diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c6dcc8b..51bf4b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### Added +- #329:手机打开 RoleWeave 时进入原生手机壳(组织 / 指令 / 桌面 / 设置),而不是缩过的桌面工作台。组织页只读预览 `examples/oss-maintainer`;回合仍在已打开的电脑桌面执行。 + - #328 R2:新增 `semantic-runtime.v1alpha1` 合同切片(BusinessObjectRef / EvidenceRef / DecisionRecord / ActionProposal / ExecutionReceipt)与 Ontology Runtime 术语表;github-ops 示例给出只读分析与可写 squash-merge 轨迹;纯函数测试覆盖「未批准不可执行、非法过期时间 fail-closed、幂等重试绑定目标版本、目标版本失效、运行前状态不可直接失败、indeterminate 不能变成 succeeded」。不是 live GitHub 执行,也不是 Sales Workbench 核心词汇。设计说明见 `docs/design/ontology-runtime-r2.md`。 - #309 后续:控制面拒绝超大请求体时,drain 或读取被中止(2 秒截止、10 MiB drain 上限、对端断开)会向 stderr 写一行原因与字节数,现场 EPIPE / 连接复位事故从此可归因;同步 docs/api-contract-v0.md:1 MiB 上限补记先读后拒行为、drain 上限与截止、server requestTimeout / headersTimeout,以及拒绝响应携带 Connection: close。附测试:stall 的超大上传在 drain 与 read 两条路径都断言中止行。 @@ -141,6 +143,8 @@ ### Added +- #329:手机打开 RoleWeave 时进入原生手机壳(组织 / 指令 / 桌面 / 设置),而不是缩过的桌面工作台。组织页只读预览 `examples/oss-maintainer`;回合仍在已打开的电脑桌面执行。 + - #127 AC-004 跨平台布局一致性证据:新增 layout smoke 模式(macOS arm64 / Windows x64 双平台,全应用渲染两栏组织工作区并由 main 进程度量列矩形写报告),verify.yml 新增 layout-parity job 下载双平台报告比对(per-platform bottomDelta ≤2px、per-platform 两列高差 ≤2px、跨平台宽差 ≤4px、跨平台 chrome overhead 差 ≤8px,阈值声明在 scripts/check-layout-parity.mjs)。跨平台一项自 #190 起比的是 chrome overhead(`viewport.innerHeight - 列高`)而非绝对列高:runner 给两侧的窗口高度本就不同,比绝对高度量到的是 runner 而不是布局,#190 之前的「高差 ≤8px」写法已随之作废。顺带修 #150 打包缺口:doc-plane.js 未登记 SERVER_RUNTIME_FILES 导致打包 server 启动即崩、main CI 红。 - #146 国际化骨架与全量迁移:`@org-workbench/ui` 新增 `OwbI18nProvider` / `useT` / `zhText` 与 zh-CN/en 双目录(440 key,parity 门强制 key 集合一致);标题栏新增语言切换钮(恰好两态,持久化,默认 zh-CN,antd ConfigProvider locale 同步切换);renderer 与 ui 包全部用户可见文案迁入目录,`i18n-cjk-gate` 测试扫描源码字符串字面量内的 CJK 防绕过;数据层(turn 原文、信封、组织文件、裁决输入)不翻译。 - #167 组织图画布化收尾:视口 overflow:hidden 零滚动条,平移纯拖拽(transform translate + pointer capture,4px 点击阈值保留),缩放(按钮/捏合)以光标为锚;选中岗位 translate 居中替代 scrollIntoView;布局不再随滚动条跳动。描述语精简到标题:图表头部只留标题、空态只留标题行、composer 空闲提示行移除(运行态/禁用原因保留)。 @@ -258,6 +262,8 @@ ### Added +- #329:手机打开 RoleWeave 时进入原生手机壳(组织 / 指令 / 桌面 / 设置),而不是缩过的桌面工作台。组织页只读预览 `examples/oss-maintainer`;回合仍在已打开的电脑桌面执行。 + - 壳-服务分离:Electron main 拉起 `apps/server`(Node,仅 127.0.0.1,每启动随机 boot-token);控制面可脱离壳独立运行。 - 引擎消费:spawn 钉版 `digital-employee` CLI(ADR-0002);`/health` 报告引擎可用性与下一步。 - `org apply`:staging 暂存 → 引擎校验 → rename 原子发布;失败留档 `rejected/`,裁撤归档 `archive/`,全程不硬删除(ADR-0003)。 diff --git a/apps/mobile-web/app.css b/apps/mobile-web/app.css new file mode 100644 index 00000000..75b931fa --- /dev/null +++ b/apps/mobile-web/app.css @@ -0,0 +1,213 @@ +:root { + color-scheme: dark; + --bg: #12141b; + --bg-elev: #1b1d26; + --ink: #f3f4f8; + --muted: #b0b5c4; + --line: #323644; + --accent: #7aa2ff; + --accent-ink: #101116; + --danger: #f0a8a8; + --radius: 16px; + --pad: 16px; + --tab: 64px; + font-family: Inter, "PingFang SC", "Noto Sans SC", system-ui, sans-serif; +} + +* { box-sizing: border-box; } +html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--ink); } +body { + min-height: 100dvh; + padding: + max(12px, env(safe-area-inset-top)) + max(var(--pad), env(safe-area-inset-right)) + calc(var(--tab) + env(safe-area-inset-bottom)) + max(var(--pad), env(safe-area-inset-left)); + overscroll-behavior: contain; + touch-action: manipulation; +} + +.skip { + position: absolute; + left: -999px; + top: 8px; +} +.skip:focus { + left: 12px; + z-index: 20; + padding: 8px 12px; + background: var(--accent); + color: var(--accent-ink); + border-radius: 8px; +} + +.top { margin-bottom: 18px; } +.brand { display: flex; gap: 12px; align-items: center; } +.mark { width: 36px; height: 36px; flex: none; } +.eyebrow { + margin: 0; + color: var(--accent); + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; +} +h1 { + margin: 2px 0 0; + font-size: 22px; + line-height: 1.25; + letter-spacing: -0.02em; +} +.lead, #command-status { color: var(--muted); line-height: 1.5; font-size: 15px; } + +.panel[hidden] { display: none; } +.panel.is-active { display: block; } + +.roles { + list-style: none; + margin: 16px 0 0; + padding: 0; + display: grid; + gap: 10px; +} +.role { + width: 100%; + text-align: left; + min-height: 72px; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-elev); + color: inherit; + font: inherit; + display: grid; + gap: 4px; +} +.role:focus-visible, .button:focus-visible, .tab:focus-visible, input:focus-visible, textarea:focus-visible, button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.role strong { font-size: 16px; } +.role span { color: var(--muted); font-size: 13px; } +.role:active { transform: scale(0.99); } + +.detail { + margin-top: 14px; + padding: 16px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--bg-elev); + display: grid; + gap: 10px; +} +.detail pre { + white-space: pre-wrap; + margin: 0; + color: var(--muted); + font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; +} +.meta { display: flex; gap: 8px; flex-wrap: wrap; } +.chip { + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + color: var(--muted); +} + +.stack { display: grid; gap: 10px; margin: 20px 0; } +label { font-size: 14px; } +input, textarea, button, .button { + width: 100%; + font: inherit; + color: var(--ink); + border: 1px solid var(--line); + border-radius: 12px; + background: var(--bg-elev); +} +input, textarea { padding: 14px; } +input { letter-spacing: 0.35em; font-size: 22px; text-align: center; } +button, .button { + min-height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--accent); + color: var(--accent-ink); + border: 0; + font-weight: 650; + text-decoration: none; + cursor: pointer; +} +button:disabled { opacity: 0.55; cursor: not-allowed; } +.button.ghost { + background: transparent; + color: var(--ink); + border: 1px solid var(--line); +} +#command-summary { + white-space: pre-wrap; + background: var(--bg-elev); + border: 1px solid var(--line); + border-radius: 12px; + padding: 14px; + line-height: 1.45; +} + +.settings { + list-style: none; + margin: 0 0 16px; + padding: 0; + border: 1px solid var(--line); + border-radius: var(--radius); + overflow: hidden; +} +.settings li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + min-height: 48px; + border-bottom: 1px solid var(--line); +} +.settings li:last-child { border-bottom: 0; } +.settings span { color: var(--muted); } +.settings strong { font-weight: 600; text-align: right; } + +.tabs { + position: fixed; + left: 0; + right: 0; + bottom: 0; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px; + padding: 6px 8px calc(6px + env(safe-area-inset-bottom)); + background: rgb(18 20 27 / 94%); + border-top: 1px solid var(--line); + backdrop-filter: blur(12px); +} +.tab { + min-height: 48px; + border: 0; + background: transparent; + color: var(--muted); + display: grid; + justify-items: center; + gap: 2px; + font-size: 11px; + font-weight: 600; + width: auto; + border-radius: 12px; +} +.tab svg { width: 22px; height: 22px; fill: currentColor; } +.tab.is-active { color: var(--ink); background: #262936; } + +.error { color: var(--danger); } + +@media (prefers-reduced-motion: reduce) { + .role:active { transform: none; } +} + +@media (min-width: 720px) { + main, .top { max-width: 42rem; margin-left: auto; margin-right: auto; } +} diff --git a/apps/mobile-web/app.mjs b/apps/mobile-web/app.mjs new file mode 100644 index 00000000..5407f7bd --- /dev/null +++ b/apps/mobile-web/app.mjs @@ -0,0 +1,193 @@ +const workspaceName = document.getElementById("workspace-name"); +const orgLead = document.getElementById("org-lead"); +const roleList = document.getElementById("role-list"); +const roleDetail = document.getElementById("role-detail"); +const pairForm = document.getElementById("pair-form"); +const commandForm = document.getElementById("command-form"); +const commandStatus = document.getElementById("command-status"); +const commandSummary = document.getElementById("command-summary"); +const storageKey = "roleweave-phone-link-v1"; + +let socket = null; +let deviceToken = localStorage.getItem(storageKey); +let selectedRoleId = null; + +function setCommandStatus(text) { + commandStatus.textContent = text; +} + +function showSummary(text) { + if (!text) { + commandSummary.hidden = true; + commandSummary.textContent = ""; + return; + } + commandSummary.hidden = false; + commandSummary.textContent = text; +} + +function formatBudget(role) { + const tokens = role.budget?.perTask?.tokens; + return typeof tokens === "number" ? `每任务 ${tokens.toLocaleString("zh-CN")} tokens` : "预算未声明"; +} + +function renderDetail(role, names) { + const reports = role.reportTo ? `汇报给 ${names.get(role.reportTo) ?? role.reportTo}` : "组织负责人"; + roleDetail.hidden = false; + roleDetail.innerHTML = ` +

${escapeHtml(role.name)}

+

${escapeHtml(role.description)}

+
+ ${escapeHtml(reports)} + ${escapeHtml(formatBudget(role))} +
+
${escapeHtml(role.skillExcerpt || "这份岗位还没有说明书摘录。")}
+ + `; + document.getElementById("talk")?.addEventListener("click", () => selectTab("command")); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function renderRoles(snapshot) { + workspaceName.textContent = snapshot.name; + orgLead.textContent = snapshot.description || "当前是示例组织的只读预览。选一个岗位,看说明书和预算。"; + const names = new Map(snapshot.roles.map((role) => [role.id, role.name])); + roleList.replaceChildren(); + for (const role of snapshot.roles) { + const item = document.createElement("li"); + const button = document.createElement("button"); + button.type = "button"; + button.className = "role"; + button.innerHTML = `${escapeHtml(role.name)}${escapeHtml(role.description)}`; + button.addEventListener("click", () => { + selectedRoleId = role.id; + renderDetail(role, names); + roleDetail.scrollIntoView({ block: "nearest" }); + }); + item.append(button); + roleList.append(item); + } + const initial = snapshot.roles.find((role) => role.id === selectedRoleId) ?? snapshot.roles[0]; + if (initial) renderDetail(initial, names); +} + +async function loadWorkspace() { + try { + const response = await fetch("/api/mobile/workspace", { headers: { accept: "application/json" } }); + if (!response.ok) throw new Error("workspace unavailable"); + renderRoles(await response.json()); + } catch { + orgLead.textContent = "组织预览暂时读不到。桌面工作台仍可用。"; + orgLead.classList.add("error"); + } +} + +function selectTab(id) { + for (const panel of document.querySelectorAll(".panel")) { + const active = panel.dataset.panel === id; + panel.classList.toggle("is-active", active); + panel.hidden = !active; + } + for (const tab of document.querySelectorAll(".tab")) { + const active = tab.dataset.tab === id; + tab.classList.toggle("is-active", active); + if (active) tab.setAttribute("aria-current", "page"); + else tab.removeAttribute("aria-current"); + } +} + +function connect() { + if (!deviceToken) return; + socket?.close(); + const url = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/phone-link/phone`; + socket = new WebSocket(url); + socket.addEventListener("open", () => { + socket.send(JSON.stringify({ v: 1, type: "phone.hello", deviceToken })); + }); + socket.addEventListener("message", (event) => { + let message; + try { + message = JSON.parse(String(event.data)); + } catch { + return; + } + if (message.type === "phone.accepted") { + pairForm.hidden = true; + commandForm.hidden = false; + setCommandStatus("已连上电脑,可以发指令。"); + return; + } + if (message.type === "command.status") { + const labels = { + accepted: "电脑已接到", + running: "员工正在处理", + completed: "完成", + failed: "失败", + busy: "该员工正在忙", + needs_approval: "请在电脑上确认", + }; + setCommandStatus(labels[message.state] ?? message.state); + if (message.summary) showSummary(message.summary); + return; + } + if (message.type === "error") { + if (message.code === "unauthorized" || message.code === "revoked") { + localStorage.removeItem(storageKey); + deviceToken = null; + pairForm.hidden = false; + commandForm.hidden = true; + } + setCommandStatus(message.message ?? "出错了"); + } + }); + socket.addEventListener("close", () => { + if (deviceToken) setCommandStatus("连接断开,正在重试…"); + }); +} + +pairForm.addEventListener("submit", async (event) => { + event.preventDefault(); + const code = document.getElementById("code").value.trim(); + setCommandStatus("正在配对…"); + const response = await fetch("/phone-link/v1/pair", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code }), + }); + const body = await response.json(); + if (!response.ok || !body.deviceToken) { + setCommandStatus(body.message ?? "配对失败"); + return; + } + deviceToken = body.deviceToken; + localStorage.setItem(storageKey, deviceToken); + connect(); +}); + +commandForm.addEventListener("submit", (event) => { + event.preventDefault(); + const text = document.getElementById("text").value.trim(); + if (!text || !socket || socket.readyState !== WebSocket.OPEN) { + setCommandStatus("还没连上电脑"); + return; + } + showSummary(""); + const commandId = crypto.randomUUID(); + socket.send(JSON.stringify({ v: 1, type: "command.submit", commandId, text })); + setCommandStatus("已发出,等电脑受理…"); +}); + +document.querySelector(".tabs").addEventListener("click", (event) => { + const tab = event.target.closest("[data-tab]"); + if (tab) selectTab(tab.dataset.tab); +}); + +if (deviceToken) connect(); +void loadWorkspace(); diff --git a/apps/mobile-web/index.html b/apps/mobile-web/index.html new file mode 100644 index 00000000..f5724569 --- /dev/null +++ b/apps/mobile-web/index.html @@ -0,0 +1,87 @@ + + + + + + + + + RoleWeave + + + + +
+
+ +
+

RoleWeave

+

工作区

+
+
+
+
+
+

正在读取组织…

+
    + +
    + + + +
    + + + + diff --git a/apps/mobile-web/surface.mjs b/apps/mobile-web/surface.mjs new file mode 100644 index 00000000..566eafdc --- /dev/null +++ b/apps/mobile-web/surface.mjs @@ -0,0 +1,100 @@ +import fs from "node:fs"; +import path from "node:path"; + +const MOBILE_UA = /Android|iPhone|iPod|iPad|Mobile|ASteamApp|webOS|BlackBerry|IEMobile|Opera Mini/i; +const SKILL_EXCERPT_CHARS = 480; + +export function isMobileUserAgent(userAgent) { + return typeof userAgent === "string" && MOBILE_UA.test(userAgent); +} + +export function chooseSurface({ userAgent, searchParams }) { + const requested = searchParams instanceof URLSearchParams ? searchParams.get("surface") : null; + if (requested === "desktop" || requested === "mobile") return requested; + return isMobileUserAgent(userAgent) ? "mobile" : "desktop"; +} + +function safeJoin(root, relative) { + const candidate = path.resolve(root, relative); + if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) return null; + return candidate; +} + +export function resolvePublicAsset(urlPath, { webDir, userAgent, searchParams }) { + const surface = chooseSurface({ userAgent, searchParams }); + if (urlPath === "/" || urlPath === "/mobile" || urlPath === "/mobile/") { + if (urlPath === "/" && surface === "desktop") return null; + return path.join(webDir, "index.html"); + } + if (urlPath === "/app.css") return path.join(webDir, "app.css"); + if (urlPath === "/app.mjs") return path.join(webDir, "app.mjs"); + return null; +} + +function findNamedDirectory(root, name) { + if (!fs.existsSync(root)) return null; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + let entries; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const full = path.join(current, entry.name); + if (entry.name === name) return full; + stack.push(full); + } + } + return null; +} + +function skillExcerpt(positionDir) { + const skillPath = path.join(positionDir, "SKILL.md"); + if (!fs.existsSync(skillPath)) return ""; + const text = fs.readFileSync(skillPath, "utf8").replace(/\r\n/g, "\n").trim(); + if (text.length <= SKILL_EXCERPT_CHARS) return text; + return `${text.slice(0, SKILL_EXCERPT_CHARS).trimEnd()}…`; +} + +export function loadWorkspaceSnapshot(workspaceDir) { + const workspaceFile = path.join(workspaceDir, "workspace.json"); + const orgFile = path.join(workspaceDir, "organization.v1alpha1.json"); + if (!fs.existsSync(workspaceFile) || !fs.existsSync(orgFile)) { + throw new Error("directory is not a RoleWeave workspace"); + } + const workspace = JSON.parse(fs.readFileSync(workspaceFile, "utf8")); + const org = JSON.parse(fs.readFileSync(orgFile, "utf8")); + if (!workspace || typeof workspace.name !== "string" || !org || !Array.isArray(org.roles)) { + throw new Error("directory is not a RoleWeave workspace"); + } + const positionsRoot = path.join(workspaceDir, "positions"); + const roles = org.roles.map((role) => { + const id = typeof role.id === "string" ? role.id : ""; + const positionDir = findNamedDirectory(positionsRoot, id); + return { + id, + name: typeof role.name === "string" ? role.name : id, + description: typeof role.description === "string" ? role.description : "", + reportTo: role.reportTo ?? null, + budget: role.budget && typeof role.budget === "object" ? { + perTask: role.budget.perTask ?? null, + perDay: role.budget.perDay ?? null, + } : null, + skillExcerpt: positionDir ? skillExcerpt(positionDir) : "", + }; + }); + return { + name: workspace.name, + description: typeof workspace.description === "string" ? workspace.description : "", + owner: typeof org.owner === "string" ? org.owner : null, + roles, + }; +} + +export function defaultExampleDir(productDir) { + return safeJoin(productDir, path.join("examples", "oss-maintainer")); +} diff --git a/docs/design/mobile-shell.md b/docs/design/mobile-shell.md new file mode 100644 index 00000000..12aaede8 --- /dev/null +++ b/docs/design/mobile-shell.md @@ -0,0 +1,32 @@ +# RoleWeave mobile shell + +Date: 2026-09-17 +Status: first slice + +## Problem + +Opening RoleWeave on a phone currently presents a desktop workspace. Targets are too small, typing is hostile, and the org chart plus conversation are not designed for a thumb. + +## Decision + +Phones get a native web shell. The desktop app remains where hiring, org edits, and turns actually run. + +Bottom navigation, four entries at most: + +1. Organization: read-only preview of the example workspace roles. +2. Command: send one instruction to an already-running desktop host. +3. Desktop: remind the user that the full workbench stays on the computer. +4. Settings: data boundary (preview is read-only; turns execute on the desktop). + +## Constraints + +- Touch targets at least 44px, including the tab bar, with safe-area insets. +- Keep the existing RoleWeave palette (`#12141b` / `#7aa2ff`). +- `/api/mobile/workspace` returns names, descriptions, budgets, and skill excerpts only. No host filesystem paths. +- The control plane stays on loopback. The phone never receives the boot token. + +## Non-goals + +- Hiring, rewriting the org tree, or editing budgets on the phone. +- Exposing the control-plane port on a LAN. +- Rebuilding the Electron four-zone layout in this slice. diff --git a/scripts/mobile-web.mjs b/scripts/mobile-web.mjs new file mode 100644 index 00000000..7c897aae --- /dev/null +++ b/scripts/mobile-web.mjs @@ -0,0 +1,78 @@ +import { createReadStream } from "node:fs"; +import { stat } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + defaultExampleDir, + loadWorkspaceSnapshot, + resolvePublicAsset, +} from "../apps/mobile-web/surface.mjs"; + +const port = Number.parseInt(process.env.PORT ?? "4173", 10); +const host = process.env.PORT ? "0.0.0.0" : "127.0.0.1"; +const productDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const webDir = path.join(productDir, "apps", "mobile-web"); +const exampleDir = defaultExampleDir(productDir); +const types = new Map([ + [".css", "text/css; charset=utf-8"], + [".html", "text/html; charset=utf-8"], + [".mjs", "text/javascript; charset=utf-8"], +]); + +function json(response, status, body) { + const payload = JSON.stringify(body); + response.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + "content-length": Buffer.byteLength(payload), + }); + response.end(payload); +} + +const server = createServer(async (request, response) => { + const url = new URL(request.url ?? "/", "http://localhost"); + if (url.pathname === "/healthz") { + json(response, 200, { ok: true, app: "RoleWeave" }); + return; + } + if (url.pathname === "/api/mobile/workspace" && request.method === "GET") { + try { + json(response, 200, loadWorkspaceSnapshot(exampleDir)); + } catch { + json(response, 503, { code: "unavailable", message: "mobile workspace preview is not available" }); + } + return; + } + const asset = resolvePublicAsset(url.pathname, { + webDir, + userAgent: request.headers["user-agent"] ?? "", + searchParams: url.searchParams, + }); + if (!asset) { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("Not found\n"); + return; + } + try { + const details = await stat(asset); + if (!details.isFile()) throw new Error("not a file"); + response.writeHead(200, { + "content-type": types.get(path.extname(asset)) ?? "application/octet-stream", + "content-length": details.size, + "cache-control": "no-store", + }); + createReadStream(asset).pipe(response); + } catch { + response.writeHead(404, { "content-type": "text/plain; charset=utf-8" }); + response.end("Not found\n"); + } +}); + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + server.listen(port, host, () => { + process.stdout.write(`RoleWeave mobile web listening on ${host}:${port}\n`); + }); +} + +export { server }; diff --git a/scripts/test/mobile-web.test.mjs b/scripts/test/mobile-web.test.mjs new file mode 100644 index 00000000..007c6eed --- /dev/null +++ b/scripts/test/mobile-web.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import net from "node:net"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { + chooseSurface, + isMobileUserAgent, + loadWorkspaceSnapshot, + resolvePublicAsset, +} from "../../apps/mobile-web/surface.mjs"; + +const productDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const exampleDir = path.join(productDir, "examples", "oss-maintainer"); + +test("classifies phone and ASteam app user agents as mobile", () => { + assert.equal(isMobileUserAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)"), true); + assert.equal(isMobileUserAgent("Mozilla/5.0 ASteamApp/0.15"), true); + assert.equal(isMobileUserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"), false); +}); + +test("surface query overrides user agent", () => { + assert.equal(chooseSurface({ userAgent: "iPhone", searchParams: new URLSearchParams("surface=desktop") }), "desktop"); + assert.equal(chooseSurface({ userAgent: "Macintosh", searchParams: new URLSearchParams("surface=mobile") }), "mobile"); +}); + +test("desktop surface falls through instead of serving the mobile shell", () => { + const asset = resolvePublicAsset("/", { + webDir: path.join(productDir, "apps", "mobile-web"), + userAgent: "iPhone", + searchParams: new URLSearchParams("surface=desktop"), + }); + assert.equal(asset, null); +}); + +test("workspace snapshot lists oss-maintainer roles without leaking host paths", () => { + const snapshot = loadWorkspaceSnapshot(exampleDir); + assert.equal(snapshot.name, "oss-maintainer"); + const ids = snapshot.roles.map((role) => role.id).sort(); + assert.deepEqual(ids, ["community-operator", "issue-researcher", "release-engineer", "repo-owner"]); + assert.equal(JSON.stringify(snapshot).includes("/workspace/positions"), false); +}); + +function unusedPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + server.close((error) => (error ? reject(error) : resolve(address.port))); + }); + server.on("error", reject); + }); +} + +test("phone user agents receive the mobile shell", async (t) => { + const port = await unusedPort(); + const child = spawn(process.execPath, [path.join(productDir, "scripts", "mobile-web.mjs")], { + cwd: productDir, + env: { ...process.env, PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + }); + t.after(() => child.kill("SIGTERM")); + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("server did not start")), 8000); + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + if (String(chunk).includes("listening")) { + clearTimeout(timeout); + resolve(); + } + }); + child.on("exit", (code) => { + clearTimeout(timeout); + reject(new Error(`exited ${code}`)); + }); + }); + const phone = await fetch(`http://127.0.0.1:${port}/`, { + headers: { "user-agent": "Mozilla/5.0 (iPhone) Mobile" }, + }); + const html = await phone.text(); + assert.equal(phone.status, 200); + assert.match(html, /data-surface="mobile"/); + const workspace = await fetch(`http://127.0.0.1:${port}/api/mobile/workspace`); + assert.equal(workspace.status, 200); + const body = await workspace.json(); + assert.equal(body.roles.length, 4); +});