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, /