import test from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; const ROOT = path.resolve(process.cwd(), "web/storyforge-web-v4"); const HTML = fs.readFileSync(path.join(ROOT, "index.html"), "utf8"); const APP = fs.readFileSync(path.join(ROOT, "assets/app.js"), "utf8"); const CSS = fs.readFileSync(path.join(ROOT, "assets/styles.css"), "utf8"); function extractBetween(source, startToken, endToken) { const start = source.indexOf(startToken); assert.notEqual(start, -1, `Missing token: ${startToken}`); const end = source.indexOf(endToken, start); assert.notEqual(end, -1, `Missing token: ${endToken}`); return source.slice(start, end); } test("settings navigation and screen are real routes", () => { assert.match(HTML, /data-screen-target="settings"/); assert.match(HTML, /data-screen="settings"/); assert.match(APP, /function renderSettingsScreen\(/); assert.match(APP, /screenMap\.settings\.innerHTML = renderSettingsScreen\(\);/); assert.match(APP, /window\.addEventListener\("hashchange"/); }); test("mobile shell includes a native-like header, drawer toggle, and bottom tab bar", () => { assert.match(HTML, / { assert.match(CSS, /padding-top:\s*max\(12px,\s*env\(safe-area-inset-top\)\)/); assert.match(CSS, /\.mobile-shell-bar\s*\{[\s\S]*position:\s*sticky/); assert.match(CSS, /\.mobile-tabbar\s*\{[\s\S]*position:\s*fixed/); assert.match(CSS, /\.mobile-sidebar-backdrop\s*\{[\s\S]*position:\s*fixed/); assert.match(CSS, /\.mobile-sidebar-backdrop\s*\{[\s\S]*z-index:\s*46/); assert.match(CSS, /\.mobile-sidebar-open\s+\.sidebar\s*\{[\s\S]*transform:\s*translateX\(0\)/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.sidebar\s*\{[\s\S]*z-index:\s*47/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.mobile-sidebar-open \.mobile-tabbar,[\s\S]*\.mobile-sidebar-open \.mobile-shell-bar\s*\{[\s\S]*pointer-events:\s*none/); assert.match(CSS, /\.content\s*\{[\s\S]*padding-bottom:\s*calc\(110px \+ env\(safe-area-inset-bottom\)\)/); assert.match(CSS, /\.oneliner-fab\s*\{[\s\S]*bottom:\s*calc\(96px \+ env\(safe-area-inset-bottom\)\)/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.mobile-workspace-strip\s*\{[\s\S]*display:\s*grid/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.workspace-switch span\s*\{[\s\S]*display:\s*none/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.auth-status\s*\{[\s\S]*display:\s*none/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.oneliner-fab-text\s*\{[\s\S]*display:\s*none/); }); test("mobile shell javascript syncs drawer state and active labels with the current screen", () => { const shell = extractBetween(APP, "function renderAuthUi()", "function openAuthModal()"); const clicks = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); assert.match(APP, /function setMobileSidebarOpen\(next\)/); assert.match(APP, /function getScreenLabel\(screenId = appState\.screen\)/); assert.match(APP, /function syncMobileShell\(\)/); assert.match(APP, /const mobileWorkspaceProject = document\.querySelector\('\[data-role="mobile-workspace-project"\]'\)/); assert.match(APP, /const mobileWorkspacePlatforms = document\.querySelector\('\[data-role="mobile-workspace-platforms"\]'\)/); assert.match(shell, /syncMobileShell\(\);/); assert.match(APP, /setMobileSidebarOpen\(false\);[\s\S]*appState\.screen = resolvedId;/); assert.match(clicks, /name === "open-mobile-sidebar"/); assert.match(clicks, /name === "close-mobile-sidebar"/); assert.match(clicks, /action\.closest\("\.sidebar"\)/); }); test("mobile workspace strip stays available for project and platform switching", () => { const topbar = extractBetween(APP, "function renderTopbar()", "function syncRoleGatedNav()"); assert.match(topbar, /data-role="mobile-workspace-project"/); assert.match(topbar, /data-role="mobile-workspace-platforms"/); assert.match(topbar, /mobileWorkspaceProject\.dataset\.action = "open-dashboard-project-switcher"/); assert.match(topbar, /data-action="select-platform"/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.mobile-workspace-strip\s*\{[\s\S]*display:\s*grid/); }); test("mobile layout turns screen actions and page tabs into native-like horizontal rails", () => { assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.action-row\s*\{[\s\S]*flex-wrap:\s*nowrap/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.action-row\s*\{[\s\S]*overflow-x:\s*auto/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.page-detail-tabs\s*\{[\s\S]*overflow-x:\s*auto/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.page-detail-tabs\s*\{[\s\S]*scroll-snap-type:\s*x proximity/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.page-detail-tabs \.tab\s*\{[\s\S]*flex:\s*0 0 auto/); }); test("mobile shell removes duplicated desktop topbar and collapses the main agent fab", () => { assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.topbar\s*\{[\s\S]*display:\s*none/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.oneliner-fab\s*\{[\s\S]*width:\s*52px/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.oneliner-fab\s*\{[\s\S]*justify-content:\s*center/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.oneliner-fab-text\s*\{[\s\S]*display:\s*none/); }); test("mobile action sheets and oneliner runtime behave like bottom sheets", () => { assert.match(APP, /class="sheet-handle"/); assert.match(APP, /document\.body\.classList\.add\("sheet-open", "auth-sheet-open"\)/); assert.match(APP, /document\.body\.classList\.remove\("sheet-open", "auth-sheet-open"\)/); assert.match(APP, /document\.body\.classList\.add\("sheet-open", "action-sheet-open"\)/); assert.match(APP, /document\.body\.classList\.remove\("sheet-open", "action-sheet-open"\)/); assert.match(APP, /document\.body\.classList\.add\("sheet-open", "oneliner-open"\)/); assert.match(APP, /document\.body\.classList\.remove\("sheet-open", "oneliner-open"\)/); assert.match(CSS, /\.sheet-handle\s*\{/); assert.match(CSS, /\.sheet-open \.mobile-tabbar\s*\{[\s\S]*opacity:\s*0/); assert.match(CSS, /\.oneliner-open \.oneliner-fab\s*\{[\s\S]*opacity:\s*0/); assert.match(CSS, /body\.sheet-open,\s*body\.mobile-sidebar-open\s*\{[\s\S]*overflow:\s*hidden/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.auth-modal,\s*[\s\S]*\.action-modal,\s*[\s\S]*\.oneliner-panel\s*\{[\s\S]*border-radius:\s*24px 24px 0 0/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.auth-actions\s*\{[\s\S]*position:\s*sticky/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.oneliner-composer\s*\{[\s\S]*position:\s*sticky/); }); test("opening OneLiner clears the transient loading state after the panel is hydrated", () => { const actions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); assert.match(actions, /name === "open-oneliner"[\s\S]*setBusy\(true,\s*"正在打开 OneLiner\.\.\."\)/); assert.match(actions, /name === "open-oneliner"[\s\S]*finally \{[\s\S]*setBusy\(false,\s*""\);[\s\S]*renderAll\(\);[\s\S]*\}/); }); test("project creation and switching use in-app sheets instead of browser prompts", () => { const createProject = extractBetween(APP, "async function createProject()", "function openPreferredModelAction()"); const projectSwitcher = extractBetween(APP, "function openDashboardProjectSwitcher()", "function openDashboardActionReasonAction("); assert.match(createProject, /openActionModal\(\{/); assert.doesNotMatch(createProject, /window\.prompt/); assert.doesNotMatch(createProject, /alert\(/); assert.match(projectSwitcher, /type: "html"/); assert.match(projectSwitcher, /最近任务/); assert.match(projectSwitcher, /切换到这个项目/); }); test("mobile project sheets support direct project picking and zoom-safe form controls", () => { const projectSwitcher = extractBetween(APP, "function openDashboardProjectSwitcher()", "function openDashboardActionReasonAction("); const applySelectedProject = extractBetween(APP, "async function applySelectedProject(projectId = \"\")", "function openDashboardProjectSwitcher()"); const clickHandler = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); const createProject = extractBetween(APP, "async function createProject()", "function openPreferredModelAction()"); assert.match(APP, /async function applySelectedProject\(projectId = ""\)/); assert.match(APP, /function focusDashboardWorkspace\(anchorId = "dashboard-workspace-anchor"\)/); assert.match(projectSwitcher, /data-project-choice=/); assert.match(projectSwitcher, /hidden:\s*isMobileViewport/); assert.match(projectSwitcher, /onOpen:\s*\(/); assert.match(projectSwitcher, /select\.value = nextProjectId/); assert.match(projectSwitcher, /window\.matchMedia\?\.\("\(max-width: 760px\)"\)\?\.matches/); assert.match(projectSwitcher, /submit\.hidden = true/); assert.match(projectSwitcher, /closeActionModal\(\);/); assert.match(projectSwitcher, /await applySelectedProject\(nextProjectId\);/); assert.match(APP, /if \(field\.hidden\) \{\s*return "";/); assert.match(applySelectedProject, /loadStorageStatus\(appState\.selectedProjectId \|\| ""\)/); assert.match(applySelectedProject, /loadAgentControlSurfaces\(appState\.selectedProjectId \|\| ""\)/); assert.match(applySelectedProject, /focusDashboardWorkspace\("dashboard-workspace-anchor"\)/); assert.match(clickHandler, /name === "select-project"[\s\S]*await applySelectedProject\(action\.dataset\.projectId \|\| ""\)/); assert.match(APP, /id="dashboard-workspace-anchor"/); assert.match(createProject, /onOpen:\s*\(/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.field-stack input,\s*[\s\S]*\.field-stack textarea,\s*[\s\S]*\.field-stack select\s*\{[\s\S]*min-height:\s*46px/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.field-stack input,\s*[\s\S]*\.field-stack textarea,\s*[\s\S]*\.field-stack select\s*\{[\s\S]*font-size:\s*16px/); }); test("mobile touch targets raise tappable buttons, tabs, and action tags closer to native sizes", () => { assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.btn,\s*[\s\S]*\.tab,\s*[\s\S]*\.tag\.clickable-tag\s*\{[\s\S]*min-height:\s*44px/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.btn,\s*[\s\S]*\.tab,\s*[\s\S]*\.tag\.clickable-tag\s*\{[\s\S]*display:\s*inline-flex/); }); test("mobile compact summaries scroll horizontally instead of stacking into a second row", () => { assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.compact-summary-row\s*\{[\s\S]*flex-wrap:\s*nowrap/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.compact-summary-row\s*\{[\s\S]*overflow-x:\s*auto/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.compact-summary-row::-webkit-scrollbar\s*\{[\s\S]*display:\s*none/); assert.match(CSS, /@media \(max-width: 760px\)[\s\S]*\.compact-summary-row \.tag\s*\{[\s\S]*flex:\s*0 0 auto/); }); test("action tags render interactive controls as buttons instead of passive spans", () => { const actionTagSource = extractBetween(APP, "function actionTag(", "function renderPipelineButton("); assert.match(actionTagSource, /if \(targetAction\) \{/); assert.match(actionTagSource, /