diff --git a/lab/internal/ui/analytics_test.go b/lab/internal/ui/analytics_test.go index 58dc131..4454def 100644 --- a/lab/internal/ui/analytics_test.go +++ b/lab/internal/ui/analytics_test.go @@ -344,7 +344,7 @@ func TestAnalyticsIsGuardedReadOnlyAndSampleOnlyInDemo(t *testing.T) { // and the Unavailable cost-per-merged-PR card. func TestAnalyticsOverviewRendersTextOnly(t *testing.T) { h := testHandler(t, Options{LabDir: t.TempDir()}) - js := do(t, h, authed("/app.js")).Body.String() + js := servedJS(t) for _, want := range []string{ "api('/api/analytics'", "token !== state.analyticsToken", diff --git a/lab/internal/ui/evidence_test.go b/lab/internal/ui/evidence_test.go index 04c77e4..cb91d41 100644 --- a/lab/internal/ui/evidence_test.go +++ b/lab/internal/ui/evidence_test.go @@ -319,7 +319,7 @@ func TestUIAtomPanelSeparatesPlannedFromObserved(t *testing.T) { if d.Atoms[0].Provenance != nil { t.Fatalf("intake provenance inferred: %+v", d.Atoms[0].Provenance) } - js := do(t, h, authed("/app.js")).Body.String() + js := servedJS(t) for _, want := range []string{ "'PLANNED CAPABILITIES · DECLARED, NOT OBSERVED'", "'ACTIONS TAKEN · OBSERVED'", diff --git a/lab/internal/ui/gates_test.go b/lab/internal/ui/gates_test.go index 95b60f1..d47198c 100644 --- a/lab/internal/ui/gates_test.go +++ b/lab/internal/ui/gates_test.go @@ -668,7 +668,7 @@ func TestUIGateJobRegistryIsBoundedAndStops(t *testing.T) { // submissions, reads canonical state back instead of retrying, and builds // everything from text nodes. func TestUIGateServedJSSubmitsOnceAndReadsBack(t *testing.T) { - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() + js := servedJS(t) for _, want := range []string{ "method: 'POST'", "'Content-Type': 'application/json'", @@ -693,13 +693,13 @@ func TestUIGateServedJSSubmitsOnceAndReadsBack(t *testing.T) { } } // An ambiguous response is read back and never resubmitted. - submit := jsFunction(t, js, "submitGate") + submit := moduleFunction(t, js, "submitGate") for _, want := range []string{"if (!res || res.status >= 500) {", "readBack(gate);", "return null;"} { if !strings.Contains(submit, want) { t.Errorf("submitGate lacks %q", want) } } - if strings.Contains(submit, "gatePost(gate.id, action, payload);\n res = await gatePost") { + if strings.Count(submit, "await gatePost(") != 1 { t.Error("submitGate posts twice") } for _, gone := range []string{"approvals are not available in this UI", "/merge", "/deploy"} { diff --git a/lab/internal/ui/inbox_test.go b/lab/internal/ui/inbox_test.go index b970d07..33b011c 100644 --- a/lab/internal/ui/inbox_test.go +++ b/lab/internal/ui/inbox_test.go @@ -186,8 +186,10 @@ func TestUIInboxTextHasNoRawCodes(t *testing.T) { // The browser renders only those plain fields: the Inbox code never turns a // phase, status, Atom, Formula or digest into text, and ids reach only // links' href and dataset. - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() - inbox := between(t, js, "// ---- Inbox ----", "showView(viewFromURL());\n restoreSelection();") + inbox := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/js/inbox.js")).Body.String() + if !strings.Contains(inbox, "// ---- Inbox ----") || !strings.Contains(inbox, "function renderInbox(") { + t.Fatal("/js/inbox.js is not the Inbox module") + } for _, forbidden := range []string{"m.phase", "m.status", "m.currentAtom", "m.formulaId", "m.lane", ".digest", "short("} { if strings.Contains(inbox, forbidden) { t.Errorf("the Inbox renders %s", forbidden) @@ -369,14 +371,14 @@ func TestUIInboxSummaryScrubsOnlyRealIDs(t *testing.T) { // the resident all count it, so a Lab with only a blocked Molecule never reads // Nothing needs you or All quiet. The fixture's blocked Molecule is one of them. func TestUIInboxOneNeedsYouDefinition(t *testing.T) { - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() + js := servedJS(t) if !strings.Contains(js, "const NEEDS_KINDS = ['scope', 'publish', 'blocked'];") { t.Error("Needs you does not cover scope, publish and blocked") } - if needs := jsFunction(t, js, "needsYou"); !strings.Contains(needs, "NEEDS_KINDS.includes(v.kind)") || !strings.Contains(needs, "live(m) && inboxOf(m)") { + if needs := moduleFunction(t, js, "needsYou"); !strings.Contains(needs, "NEEDS_KINDS.includes(v.kind)") || !strings.Contains(needs, "live(m) && inboxOf(m)") { t.Errorf("needsYou does not list the Needs-you cards: %s", needs) } - shell, inbox := jsFunction(t, js, "renderShell"), jsFunction(t, js, "renderInbox") + shell, inbox := moduleFunction(t, js, "renderShell"), moduleFunction(t, js, "renderInbox") for body, wants := range map[string][]string{ shell: {"const waiting = needsYou().length;", "badge.hidden = waiting === 0;", "residentPose(waiting, counts)", "RESIDENT_TITLES[pose](waiting, counts)"}, inbox: {"const needs = needsYou();", "const waiting = needs.length,", "waiting ? needsYouText(waiting) : 'Nothing needs you'", "setText($('#inbox-live'), waiting ? needsYouText(waiting) + '.' : 'Nothing needs you right now.');"}, @@ -387,7 +389,7 @@ func TestUIInboxOneNeedsYouDefinition(t *testing.T) { } } } - for name, body := range map[string]string{"renderShell": shell, "renderInbox": inbox, "residentPose": jsFunction(t, js, "residentPose")} { + for name, body := range map[string]string{"renderShell": shell, "renderInbox": inbox, "residentPose": moduleFunction(t, js, "residentPose")} { if strings.Contains(body, "counts.decision") { t.Errorf("%s counts the decision lane instead of Needs you", name) } @@ -453,7 +455,7 @@ func TestUIInboxEmptyStateAndIncompleteInventory(t *testing.T) { if !strings.Contains(page, `
All quiet
`) { t.Error("the resident is not titled All quiet") } - js := do(t, h, authed("/app.js")).Body.String() + js := servedJS(t) for _, want := range []string{ "$('#inbox-needs-empty').hidden = needs.length > 0;", "setText($('#inbox-live'), waiting ? needsYouText(waiting) + '.' : 'Nothing needs you right now.');", @@ -499,7 +501,7 @@ func loopBody(mode, value string, n int) string { // engine receives exactly that selector and bound; Start intake posts {issue}. // A selector the server refuses never reaches the engine. func TestUIInboxRunTheLoopFormSendsTheSameEngineRequest(t *testing.T) { - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() + js := servedJS(t) for _, want := range []string{ "function loopSelector(mode, value) { return mode === 'oldest' ? 'oldest' : mode + ':' + String(value).trim(); }", "const loop = await submitDrive('/api/loop/start', JSON.stringify({ select: String(select).trim(), maxConcurrent: n }));", @@ -512,13 +514,13 @@ func TestUIInboxRunTheLoopFormSendsTheSameEngineRequest(t *testing.T) { t.Errorf("app.js lacks %q", want) } } - submit := jsFunction(t, js, "submitLoop") + submit := moduleFunction(t, js, "submitLoop") for _, want := range []string{"e.preventDefault();", "startLoop(loopSelector(drive.mode, c.value.value), c.max.value);", "driveMessage('Enter a ' + LOOP_MODES[drive.mode].field.toLowerCase() + ', or choose Oldest.');"} { if !strings.Contains(submit, want) { t.Errorf("submitLoop lacks %q", want) } } - if run := jsFunction(t, js, "submitRun"); !strings.Contains(run, "e.preventDefault();") || !strings.Contains(run, "startRun(drive.controls.issue.value);") { + if run := moduleFunction(t, js, "submitRun"); !strings.Contains(run, "e.preventDefault();") || !strings.Contains(run, "startRun(drive.controls.issue.value);") { t.Errorf("submitRun does not post the typed Issue: %s", run) } // The forms submit on Enter through a submit button; the CSP's form-action @@ -570,8 +572,8 @@ func TestUIInboxRunTheLoopFormSendsTheSameEngineRequest(t *testing.T) { // never refilled, and links are real ?view=work&m= links opened through // history.pushState. func TestUIInboxPollingPatchesCardsInPlace(t *testing.T) { - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() - patch := jsFunction(t, js, "patchList") + js := servedJS(t) + patch := moduleFunction(t, js, "patchList") for _, want := range []string{ "const existing = new Map([...container.children].map((node) => [node.dataset.inboxId, node]));", "let node = existing.get(m.id);", @@ -585,12 +587,12 @@ func TestUIInboxPollingPatchesCardsInPlace(t *testing.T) { t.Errorf("patchList lacks %q", want) } } - for _, gone := range []string{"fill(", "replaceChildren", "create();\n update"} { + for _, gone := range []string{"fill(", "replaceChildren", "create();\n update"} { if strings.Contains(patch, gone) { t.Errorf("patchList rebuilds with %q", gone) } } - render := jsFunction(t, js, "renderInbox") + render := moduleFunction(t, js, "renderInbox") for _, want := range []string{ "patchList($('#inbox-needs'), needs.slice(0, MAX_ENTRIES), createCard, updateCard);", "patchList($('#inbox-progress'), running.slice(0, MAX_ENTRIES), createRow, updateRow);", @@ -609,7 +611,7 @@ func TestUIInboxPollingPatchesCardsInPlace(t *testing.T) { "setLink": {"setAttr(a, 'href', moleculeHref(m.id));", "if (a.dataset.molecule !== m.id) a.dataset.molecule = m.id;"}, "setAttr": {"if (node.getAttribute(name) !== value) node.setAttribute(name, value);"}, } { - body := jsFunction(t, js, name) + body := moduleFunction(t, js, name) for _, want := range wants { if !strings.Contains(body, want) { t.Errorf("%s lacks %q", name, want) @@ -621,13 +623,15 @@ func TestUIInboxPollingPatchesCardsInPlace(t *testing.T) { } for _, want := range []string{ "keepFocus(renderList)", - "renderInbox();\n }", + // The list fans out to the Inbox last, and the Inbox module renders it. + "inbox.update(state);\n}", + "export function update(state) { renderInbox(); }", "function moleculeHref(id) { return '?view=work&m=' + encodeURIComponent(id); }", "if (location.search !== moleculeHref(id)) history.pushState({ view: 'work', m: id }, '', moleculeHref(id));", "const link = e.target.closest('a[data-molecule]');", "if (moleculeFromURL() !== id) history.replaceState({ view: 'work', m: id }, '', moleculeHref(id));", "const MOLECULE_ID = /^[A-Za-z0-9_-]{1,200}$/;", - "setInterval(() => { if (!document.hidden && state.list) renderInbox(); }, 30000);", + "setInterval(() => { if (!document.hidden && state.list) inbox.update(state); }, 30000);", "el('ol', { class: 'inbox-steps', 'aria-label': 'Progress' }, STEPS.map(", "const STEPS = ['Plan', 'Your approval', 'Coding', 'Checks', 'Review', 'Draft PR', 'Merged'];", "el('a', { class: 'btn btn-primary inbox-action', dataset: { slot: 'action' } })", @@ -637,7 +641,7 @@ func TestUIInboxPollingPatchesCardsInPlace(t *testing.T) { } } // One primary action per card: createCard builds exactly one .btn. - if card := jsFunction(t, js, "createCard"); strings.Count(card, "btn") != 2 || strings.Count(card, "btn-primary") != 1 || strings.Contains(card, "btn-danger") { + if card := moduleFunction(t, js, "createCard"); strings.Count(card, "btn") != 2 || strings.Count(card, "btn-primary") != 1 || strings.Contains(card, "btn-danger") { t.Errorf("a card does not have exactly one primary action: %s", card) } } @@ -645,19 +649,25 @@ func TestUIInboxPollingPatchesCardsInPlace(t *testing.T) { // The Inbox CSS stacks cards with full-width primary buttons on phones, gives // coarse pointers 44px targets and honours forced colors. func TestUIInboxServedCSS(t *testing.T) { - css := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/style.css")).Body.String() + css := servedCSS(t) rules := cssRules(css) wantDecls(t, rules, ".inbox-card", "display: flex;", "gap: 20px;") wantDecls(t, rules, ".inbox-steps", "grid-template-columns: repeat(7, minmax(0, 1fr));") wantDecls(t, rules, ".inbox-steps li.is-current .step-bar", "background: linear-gradient(") wantDecls(t, rules, ".seg[aria-pressed=true]", "background: var(--paper);") wantDecls(t, rules, ".inbox-counts", "font-variant-numeric: tabular-nums;") - mobile := cssRules(mediaBlock(t, css, "@media (max-width: 720px)")) + mobile := cssRules(mediaBlocks(t, css, "@media (max-width: 720px)")) wantDecls(t, mobile, ".inbox-card", "flex-direction: column;") wantDecls(t, mobile, ".inbox-action", "width: 100%;", "min-height: 48px;") wantDecls(t, mobile, ".inbox-greeting > .btn", "width: 100%;") - wantDecls(t, cssRules(mediaBlock(t, css, "@media (pointer: coarse)")), ".seg, .stepper-button, .inbox-watch, .inbox-recent-row", "min-height: 44px;") - forced := cssRules(mediaBlock(t, css, "@media (forced-colors: active)")) + coarse := cssRules(mediaBlocks(t, css, "@media (pointer: coarse)")) + wantDecls(t, coarse, ".seg, .stepper-button", "min-height: 44px;") + wantDecls(t, coarse, ".inbox-watch, .inbox-recent-row", "min-height: 44px;") + // The Watch link keeps the .btn height it always had; base.css sets it. + if strings.Contains(rules[".inbox-watch"], "height") { + t.Errorf(".inbox-watch overrides the .btn height: %s", rules[".inbox-watch"]) + } + forced := cssRules(mediaBlocks(t, css, "@media (forced-colors: active)")) wantDecls(t, forced, ".seg[aria-pressed=true]", "background: Highlight;") wantDecls(t, forced, ".inbox-steps li.is-done .step-bar, .inbox-steps li.is-current .step-bar", "background: Highlight;") } diff --git a/lab/internal/ui/molecule_view_test.go b/lab/internal/ui/molecule_view_test.go index 5368046..9cf930f 100644 --- a/lab/internal/ui/molecule_view_test.go +++ b/lab/internal/ui/molecule_view_test.go @@ -340,8 +340,8 @@ func TestMoleculeViewServedPage(t *testing.T) { // the Molecule and apart from any primary, routes ?m= and Back through // history, and never rewrites the Publish title while polling. func TestMoleculeViewServedJS(t *testing.T) { - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() - build := jsFunction(t, js, "ensureMoleculeView") + js := servedJS(t) + build := moduleFunction(t, js, "ensureMoleculeView") decision := between(t, build, "fill($('#mv-decision'),", ");") danger := between(t, build, "c.stopOpen =", "fill($('#mv-danger-slot'), c.runnerNote, c.danger);") if strings.Contains(decision, "stop") || strings.Contains(decision, "retire") || strings.Contains(decision, "danger") { @@ -399,7 +399,7 @@ func TestMoleculeViewServedJS(t *testing.T) { "viewFromURL": {"if (!view && moleculeFromURL()) return 'work';"}, "restoreSelection": {"else if (!id && state.selectedId) overview();", "if (VIEWS.includes(from)) mv.from = from;"}, "openMolecule": {"rememberFrom(from);"}, - "navigate": {"if (moleculeFromURL()) history.pushState({ view: view }, '', '?view=' + view);", "restoreSelection();"}, + "navigate": {"if (moleculeFromURL()) history.pushState({ view: view }, '', '?view=' + view);", "ctx.restoreSelection();"}, "renderHeader": {"whenText(plan.plannedAt)", "dateTitle(plan.plannedAt)"}, "renderPlanDetails": {"el('code', null, digest.slice(0, 7))", "setText($('#mv-digest-short'), digest ? short(digest) : 'Not recorded');"}, "renderTimeline": {"if (panel.contains(document.activeElement) && mv.timelineId === d.id) { mv.timelineDirty = true; return; }"}, @@ -408,7 +408,7 @@ func TestMoleculeViewServedJS(t *testing.T) { "showMoleculeView": {"$('.shelf').hidden = open;"}, "renderPlan": {"if (key === mv.planKey) return;"}, } { - body := jsFunction(t, js, name) + body := moduleFunction(t, js, name) for _, want := range wants { if !strings.Contains(body, want) { t.Errorf("%s lacks %q", name, want) @@ -416,7 +416,7 @@ func TestMoleculeViewServedJS(t *testing.T) { } } // A poll never rewrites a typed value or rebuilds the card. - render := jsFunction(t, js, "renderDecision") + render := moduleFunction(t, js, "renderDecision") for _, gone := range []string{"title.value", "reasonInput.value", "acceptance.value", "fill($('#mv-decision')", "short(", "innerHTML"} { if strings.Contains(render, gone) { t.Errorf("renderDecision has %q", gone) @@ -424,7 +424,7 @@ func TestMoleculeViewServedJS(t *testing.T) { } // No raw digest or timestamp text is set outside Plan details. for _, name := range []string{"renderHeader", "renderDecision", "renderPlan", "fileItem", "groupItem", "renderRoles"} { - body := jsFunction(t, js, name) + body := moduleFunction(t, js, name) if regexp.MustCompile(`(setText|el)\([^;]*(\.digest\b[^)]*\)|plannedAt\))`).MatchString(strings.ReplaceAll(strings.ReplaceAll(body, "whenText(plan.plannedAt)", ""), "dateTitle(plan.plannedAt)", "")) { t.Errorf("%s renders a digest or a raw time", name) } @@ -441,7 +441,7 @@ func TestMoleculeViewServedJS(t *testing.T) { // sticky, stacks the view on narrow screens and keeps 44px targets and forced // colors for its controls. func TestMoleculeViewServedCSS(t *testing.T) { - css := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/style.css")).Body.String() + css := servedCSS(t) rules := cssRules(css) wantDecls(t, rules, ".btn-decision", "height: 48px;", "border-radius: 14px;") wantDecls(t, rules, ".mv-aside", "position: sticky;") @@ -450,12 +450,12 @@ func TestMoleculeViewServedCSS(t *testing.T) { wantDecls(t, rules, ".danger-zone", "border: 1px solid #eccfc6;") wantDecls(t, rules, ".state-pill[data-tone=needs]", "background: var(--needs-bg);") wantDecls(t, rules, ".shell.has-molecule .side-panel", "display: none;") - wantDecls(t, cssRules(mediaBlock(t, css, "@media (max-width: 1199px)")), ".mv-layout", "grid-template-columns: minmax(0, 1fr);") - mobile := cssRules(mediaBlock(t, css, "@media (max-width: 720px)")) + wantDecls(t, cssRules(mediaBlocks(t, css, "@media (max-width: 1199px)")), ".mv-layout", "grid-template-columns: minmax(0, 1fr);") + mobile := cssRules(mediaBlocks(t, css, "@media (max-width: 720px)")) wantDecls(t, mobile, ".check-grid", "grid-template-columns: minmax(0, 1fr);") wantDecls(t, mobile, ".file", "grid-template-columns: minmax(0, 1fr);") - wantDecls(t, cssRules(mediaBlock(t, css, "@media (pointer: coarse)")), ".mv-tab, .btn-small, .breadcrumb a, .accept-label, .preview-info .text-button", "min-height: 44px;") - forced := cssRules(mediaBlock(t, css, "@media (forced-colors: active)")) + wantDecls(t, cssRules(mediaBlocks(t, css, "@media (pointer: coarse)")), ".mv-tab, .btn-small, .breadcrumb a, .accept-label, .preview-info .text-button", "min-height: 44px;") + forced := cssRules(mediaBlocks(t, css, "@media (forced-colors: active)")) wantDecls(t, forced, ".mv-tab[aria-selected=true]", "background: Highlight;") wantDecls(t, forced, ".mv-step.is-done .step-bar, .mv-step.is-current .step-bar, .mv-step.is-blocked .step-bar", "background: Highlight;") if bad := uncoveredOutlineResets(css); len(bad) != 0 { diff --git a/lab/internal/ui/retire_test.go b/lab/internal/ui/retire_test.go index 60405c1..9734f29 100644 --- a/lab/internal/ui/retire_test.go +++ b/lab/internal/ui/retire_test.go @@ -221,7 +221,7 @@ func TestUIRetireAction(t *testing.T) { // The served script has the Retired lane and filter, keeps retired Molecules out // of All and the headline counts, and posts the retire reason as JSON. func TestUIServedJSRetire(t *testing.T) { - js := do(t, testHandler(t, Options{LabDir: t.TempDir()}), authed("/app.js")).Body.String() + js := servedJS(t) for _, want := range []string{ "['retired', 'Retired']", "state.filter === 'all' ? live(m) : m.lane === state.filter", diff --git a/lab/internal/ui/served_test.go b/lab/internal/ui/served_test.go new file mode 100644 index 0000000..fc2bd82 --- /dev/null +++ b/lab/internal/ui/served_test.go @@ -0,0 +1,380 @@ +package ui + +import ( + "net/http" + "regexp" + "strings" + "testing" +) + +// servedModules are the entry and every script module it loads, in the fixed +// order servedJS concatenates them. +var servedModules = []string{ + "/app.js", + "/js/core.js", + "/js/core-gates.js", + "/js/inbox.js", + "/js/molecule.js", + "/js/molecule-plan.js", + "/js/molecule-decision.js", + "/js/molecule-timeline.js", + "/js/work.js", + "/js/issues.js", + "/js/insights.js", + "/js/formula.js", + "/js/loop.js", +} + +// servedStylesheets are the stylesheets index.html links, in link order: +// tokens, base, then one per view. +var servedStylesheets = []string{ + "/css/tokens.css", + "/css/base.css", + "/css/inbox.css", + "/css/molecule.css", + "/css/work.css", + "/css/issues.css", + "/css/insights.css", + "/css/formula.css", + "/css/loop.css", +} + +// viewModules are the view modules; each exports mount(root, ctx) and +// update(state), and imports only the core and its own sub-modules. +var viewModules = []string{"inbox", "molecule", "work", "issues", "insights", "formula", "loop"} + +// maxModuleLines bounds every script module and stylesheet. +const maxModuleLines = 600 + +func servedAll(t *testing.T, paths []string) string { + t.Helper() + h := testHandler(t, Options{LabDir: t.TempDir()}) + var b strings.Builder + for _, path := range paths { + w := do(t, h, authed(path)) + if w.Code != http.StatusOK { + t.Fatalf("%s: %d", path, w.Code) + } + b.WriteString(w.Body.String()) + b.WriteString("\n") + } + return b.String() +} + +// servedJS is every served script module, concatenated in servedModules order. +func servedJS(t *testing.T) string { + t.Helper() + return servedAll(t, servedModules) +} + +// servedCSS is every linked stylesheet, concatenated in link order. +func servedCSS(t *testing.T) string { + t.Helper() + return servedAll(t, servedStylesheets) +} + +// moduleFunction returns the body of the top-level function name in module +// code, which sits at column 0: up to its closing brace at column 0, or the +// rest of its line for a one-line function. +func moduleFunction(t *testing.T, js, name string) string { + t.Helper() + m := regexp.MustCompile(`(?m)^(?:export )?(?:async )?function ` + regexp.QuoteMeta(name) + `\([^)]*\) \{(.*)$`).FindStringSubmatchIndex(js) + if m == nil { + t.Fatalf("the served JS has no top-level function %s", name) + } + if rest := js[m[2]:m[3]]; rest != "" { + return rest + } + end := strings.Index(js[m[1]:], "\n}\n") + if end < 0 { + t.Fatalf("function %s is not closed", name) + } + return js[m[1] : m[1]+end] +} + +// moduleTable returns the body of the top-level table "const name = …" in +// module code, up to its closing bracket at column 0. +func moduleTable(t *testing.T, js, name, closing string) string { + t.Helper() + m := regexp.MustCompile(`(?s)\nconst ` + regexp.QuoteMeta(name) + ` = [\[{](.*?)\n` + regexp.QuoteMeta(closing) + `;`).FindStringSubmatch(js) + if m == nil { + t.Fatalf("the served JS has no %s table", name) + } + return m[1] +} + +// mediaBlocks joins the bodies of every top-level at-rule that starts with +// head, across all stylesheets: each view keeps its own part of a media query. +func mediaBlocks(t *testing.T, css, head string) string { + t.Helper() + var parts []string + for rest := css; ; { + i := strings.Index(rest, head+" {") + if i < 0 { + break + } + rest = rest[i+len(head)+2:] + j := strings.Index(rest, "\n}\n") + if j < 0 { + t.Fatalf("%s block is not closed", head) + } + parts = append(parts, rest[:j]) + rest = rest[j:] + } + if len(parts) == 0 { + t.Fatalf("the served CSS has no %s block", head) + } + return strings.Join(parts, "\n") +} + +// importRe matches a module's static imports. +var importRe = regexp.MustCompile(`(?m)^import (?:\{[^}]*\}|\* as [A-Za-z]+) from '([^']+)';$`) + +// dynamicImportRe matches a dynamic import( call, and reExportRe an +// "export … from" re-export, in any form or spacing. +var ( + dynamicImportRe = regexp.MustCompile(`\bimport\s*\(`) + reExportRe = regexp.MustCompile(`\bexport\s*(?:\*|\{[^}]*\})[^;'"]*?\bfrom\s*['"]`) +) + +// Every module and stylesheet is named in the allowlist and served with its +// type, no-store, the security headers and the version mark replaced; each +// needs the cookie and GET. index.html links exactly the stylesheets in order +// and loads the entry as a module, and every import resolves to a served file. +func TestUIServesEveryModuleAndStylesheet(t *testing.T) { + h := testHandler(t, Options{LabDir: t.TempDir(), Version: "v1.4.0-rc.1"}) + index := do(t, h, authed("/")) + want := map[string]bool{} + for _, path := range append(append([]string{}, servedModules...), servedStylesheets...) { + want[path] = true + contentType := "text/javascript; charset=utf-8" + if strings.HasSuffix(path, ".css") { + contentType = "text/css; charset=utf-8" + } + w := do(t, h, authed(path)) + if w.Code != http.StatusOK || w.Header().Get("Content-Type") != contentType || w.Header().Get("Cache-Control") != "no-store" || w.Body.Len() == 0 { + t.Errorf("%s: %d %q %q %d bytes", path, w.Code, w.Header().Get("Content-Type"), w.Header().Get("Cache-Control"), w.Body.Len()) + } + if strings.Contains(w.Body.String(), assetVersionMark) { + t.Errorf("%s leaks the version placeholder", path) + } + for _, name := range []string{"Content-Security-Policy", "X-Content-Type-Options", "Referrer-Policy", "X-Frame-Options", "Cross-Origin-Opener-Policy", "Cross-Origin-Resource-Policy"} { + if got := w.Header().Get(name); got == "" || got != index.Header().Get(name) { + t.Errorf("%s %s %q, the index sends %q", path, name, got, index.Header().Get(name)) + } + } + if w := do(t, h, request{path: path}); w.Code != http.StatusForbidden || w.Header().Get("Cache-Control") != "no-store" { + t.Errorf("%s without the cookie: %d %q", path, w.Code, w.Header().Get("Cache-Control")) + } + post := authed(path) + post.method, post.origin = http.MethodPost, "http://"+testHost + if w := do(t, h, post); w.Code != http.StatusMethodNotAllowed || w.Header().Get("Allow") != http.MethodGet { + t.Errorf("POST %s: %d", path, w.Code) + } + // Each file is listed explicitly, by its own path under web/. + if staticFiles[path] != strings.TrimPrefix(path, "/") { + t.Errorf("%s is served from %q", path, staticFiles[path]) + } + } + // The allowlist holds no other script or stylesheet, and no /style.css. + for path := range staticFiles { + if (strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css")) && path != "/session.js" && !want[path] { + t.Errorf("staticFiles serves the unlisted %s", path) + } + } + + page := index.Body.String() + var links []string + for _, m := range regexp.MustCompile(``).FindAllStringSubmatch(page, -1) { + links = append(links, m[1]) + } + if strings.Join(links, " ") != strings.Join(servedStylesheets, " ") || strings.Count(page, "`) { + t.Error("index.html does not load the entry as its one module script") + } + + // Every import resolves, from the importing file's directory, to a served + // module, and every served module is imported. + imported := map[string]bool{"/app.js": true} + for _, path := range servedModules { + body := do(t, h, authed(path)).Body.String() + dir := path[:strings.LastIndex(path, "/")+1] + for _, m := range importRe.FindAllStringSubmatch(body, -1) { + target := dir + strings.TrimPrefix(m[1], "./") + if !strings.HasPrefix(m[1], "./") || !want[target] { + t.Errorf("%s imports %q, which is not a served module", path, m[1]) + } + imported[target] = true + } + if n := strings.Count("\n"+body, "\nimport "); n != len(importRe.FindAllString(body, -1)) { + t.Errorf("%s has %d import statements the check does not read", path, n) + } + } + for _, path := range servedModules { + if !imported[path] { + t.Errorf("no module imports %s", path) + } + } +} + +// Any path not named in the allowlist is not found and never cached: the +// removed /style.css, unlisted or directory paths under /js/ and /css/, +// traversal, a trailing slash and the wrong case. +func TestUIUnlistedStaticPathsAreNotFound(t *testing.T) { + h := testHandler(t, Options{LabDir: t.TempDir()}) + for _, path := range []string{ + "/style.css", "/js/nope.js", "/css/nope.css", "/js/", "/js", "/css/", "/css", + "/css/../app.js", "/js/../app.js", "/js/%2e%2e/app.js", "/css/%2e%2e/css/base.css", "/js/core", "/js/core.js/", "/css/base.css/", + "/js/CORE.js", "/CSS/base.css", "/web/js/core.js", "/web/css/base.css", "/js/style.css", "/css/core.js", "/js/core.js.map", + } { + if w := do(t, h, authed(path)); w.Code != http.StatusNotFound || w.Header().Get("Cache-Control") != "no-store" { + t.Errorf("%s: %d %q", path, w.Code, w.Header().Get("Cache-Control")) + } + } +} + +// Each view module exports mount(root, ctx) and update(state) and imports only +// the core and its own sub-modules, never another view; only the entry imports +// the views. No module or stylesheet exceeds the line bound. +func TestUIViewModulesImportOnlyTheCore(t *testing.T) { + h := testHandler(t, Options{LabDir: t.TempDir()}) + views := map[string]bool{} + for _, v := range viewModules { + views[v] = true + } + for _, path := range servedModules { + body := do(t, h, authed(path)).Body.String() + name := strings.TrimSuffix(path[strings.LastIndex(path, "/")+1:], ".js") + owner, _, _ := strings.Cut(name, "-") + // Only the static imports read below load code: no dynamic import( + // and no "export … from" re-export can reach around the check. + if dynamicImportRe.MatchString(body) { + t.Errorf("%s uses a dynamic import(", path) + } + if reExportRe.MatchString(body) { + t.Errorf("%s re-exports with export … from", path) + } + for _, m := range importRe.FindAllStringSubmatch(body, -1) { + target := strings.TrimSuffix(strings.TrimPrefix(m[1], "./"), ".js") + if path == "/app.js" { + continue + } + targetOwner, _, _ := strings.Cut(target, "-") + if target != "core" && !strings.HasPrefix(target, "core-") && targetOwner != owner { + t.Errorf("%s imports %s: a view imports only the core and its own sub-modules", path, m[1]) + } + if views[target] && target != name && owner != target { + t.Errorf("%s imports the view %s", path, target) + } + } + if views[name] { + if !strings.Contains(body, "\nexport function mount(root, ctx) {") || !strings.Contains(body, "\nexport function update(state) {") { + t.Errorf("%s does not export mount(root, ctx) and update(state)", path) + } + } + } + // Sub-modules of a view are imported only by that view. + entry := do(t, h, authed("/app.js")).Body.String() + for _, v := range viewModules { + if !strings.Contains(entry, "import * as "+v+" from './js/"+v+".js';") { + t.Errorf("the entry does not import the view %s", v) + } + } + if strings.Contains(entry, "molecule-") { + t.Error("the entry imports a view's sub-module") + } + // The two patterns catch every form they guard against, and no plain code. + for _, bad := range []string{"import('./inbox.js')", "await import (\n'./js/work.js')", "x = import(`./${v}.js`);"} { + if !dynamicImportRe.MatchString(bad) { + t.Errorf("dynamicImportRe misses %q", bad) + } + } + for _, bad := range []string{"export * from './inbox.js';", "export * as inbox from './inbox.js';", "export { renderInbox } from './inbox.js';", "export {\n a,\n b,\n} from \"./work.js\";", "export{a}from'./loop.js'"} { + if !reExportRe.MatchString(bad) { + t.Errorf("reExportRe misses %q", bad) + } + } + for _, good := range []string{"import { el } from './core.js';", "export function mount(root, ctx) {", "export const mv = {};", "const from = 'x'; // imported"} { + if dynamicImportRe.MatchString(good) || reExportRe.MatchString(good) { + t.Errorf("the import-form check rejects plain code %q", good) + } + } + for _, path := range append(append([]string{}, servedModules...), servedStylesheets...) { + data, err := web.ReadFile("web" + path) + if err != nil { + t.Fatalf("embedded %s: %v", path, err) + } + if n := strings.Count(string(data), "\n"); n > maxModuleLines { + t.Errorf("%s has %d lines, more than %d", path, n, maxModuleLines) + } + } +} + +// The concatenations follow the fixed orders, so a contract test sees every +// module and every sheet. +func TestUIServedJSAndCSSConcatenateEveryFile(t *testing.T) { + js, css := servedJS(t), servedCSS(t) + for _, want := range []string{"export function el(", "export async function submitGate(", "function renderInbox(", "export function ensureMoleculeView(", "export function renderPlan(", "export function renderTimeline(", "function renderGrid(", "function renderOverview(", "function renderFormula(", "function renderLoopPill(", "function renderShell("} { + if !strings.Contains(js, want) { + t.Errorf("servedJS lacks %q", want) + } + } + if strings.Index(js, "async function loadLab(") > strings.Index(js, "export const $ =") { + t.Error("servedJS does not start with the entry") + } + for _, want := range []string{":root {", "* { box-sizing: border-box; }", ".inbox-card {", ".mv-layout {", ".lane-bar {", ".placeholder {", ".review-row {", ".active-formula {", ".drive-form {"} { + if !strings.Contains(css, want) { + t.Errorf("servedCSS lacks %q", want) + } + } + if !(strings.Index(css, ":root {") < strings.Index(css, "* { box-sizing") && strings.Index(css, "* { box-sizing") < strings.Index(css, ".inbox {")) { + t.Error("servedCSS is not in link order") + } + // Each view keeps its own part of a shared media query; mediaBlocks finds + // them all. + forced := cssRules(mediaBlocks(t, css, "@media (forced-colors: active)")) + for _, selector := range []string{".glass, .card", ".inbox-row, .inbox-recent, .inbox-icon, .inbox-badge", ".atom-tab, .atom-detail", ".cube-button, .micro-cube", ".review-row", ".active-formula", ".drive-panel"} { + wantDecls(t, forced, selector, "background: Canvas;", "border: 1px solid CanvasText;") + } +} + +// In forced-colors mode the shelf's gradient gives way to Canvas: base.css's +// ".glass, .card" Canvas rule now loads before work.css, so the shelf needs +// its own forced-colors rule after its gradient, or the gradient wins. +func TestUIServedCSSShelfIsCanvasInForcedColors(t *testing.T) { + css := servedCSS(t) + gradient := strings.Index(css, "\n.shelf {") + if gradient < 0 { + t.Fatal("the served CSS has no top-level .shelf rule") + } + if rule, _, _ := strings.Cut(css[gradient+1:], "\n"); !strings.Contains(rule, "gradient(") { + t.Fatalf("the top-level .shelf rule has no gradient: %s", rule) + } + head := "@media (forced-colors: active) {" + found := false + for at := 0; ; { + i := strings.Index(css[at:], head) + if i < 0 { + break + } + start := at + i + len(head) + end := strings.Index(css[start:], "\n}\n") + if end < 0 { + t.Fatal("a forced-colors block is not closed") + } + block := css[start : start+end] + if decls, ok := cssRules(block)[".shelf"]; ok { + if start < gradient { + t.Error("a forced-colors .shelf rule loads before the .shelf gradient, which overrides it") + } else if strings.Contains(decls, "background: Canvas;") { + found = true + } + } + at = start + end + } + if !found { + t.Error("no forced-colors block after the .shelf gradient sets .shelf { background: Canvas; }") + } +} diff --git a/lab/internal/ui/server.go b/lab/internal/ui/server.go index 66f0aeb..741123b 100644 --- a/lab/internal/ui/server.go +++ b/lab/internal/ui/server.go @@ -200,10 +200,35 @@ type handler struct { } // staticFiles are the only embedded files served, with their content types. +// Every script module and stylesheet is named here: there is no wildcard or +// prefix match, so any other /js/ or /css/ path is not found. var staticFiles = map[string]string{ - "/": "index.html", - "/app.js": "app.js", - "/style.css": "style.css", + "/": "index.html", + "/app.js": "app.js", + // The entry's modules: the shared core and one module per view, with the + // Molecule view's sub-modules. + "/js/core.js": "js/core.js", + "/js/core-gates.js": "js/core-gates.js", + "/js/inbox.js": "js/inbox.js", + "/js/molecule.js": "js/molecule.js", + "/js/molecule-plan.js": "js/molecule-plan.js", + "/js/molecule-decision.js": "js/molecule-decision.js", + "/js/molecule-timeline.js": "js/molecule-timeline.js", + "/js/work.js": "js/work.js", + "/js/issues.js": "js/issues.js", + "/js/insights.js": "js/insights.js", + "/js/formula.js": "js/formula.js", + "/js/loop.js": "js/loop.js", + // The stylesheets index.html links, in its order: tokens, base, then views. + "/css/tokens.css": "css/tokens.css", + "/css/base.css": "css/base.css", + "/css/inbox.css": "css/inbox.css", + "/css/molecule.css": "css/molecule.css", + "/css/work.css": "css/work.css", + "/css/issues.css": "css/issues.css", + "/css/insights.css": "css/insights.css", + "/css/formula.css": "css/formula.css", + "/css/loop.css": "css/loop.css", // session.js only copies the exchange page's value into sessionStorage; it // holds no secret, so it is served without the cookie. "/session.js": "session.js", @@ -220,8 +245,9 @@ var staticFiles = map[string]string{ // change within a release; every other response stays no-store. const assetCache = "private, max-age=31536000, immutable" -// assetVersionMark is the placeholder index.html, app.js and style.css put in -// every asset URL's ?v= query; file replaces it with assetVersion, so an +// assetVersionMark is the placeholder index.html, the scripts and the +// stylesheets (css/tokens.css and css/base.css today) put in every asset URL's +// ?v= query; file replaces it with assetVersion, so an // upgrade changes each asset URL and the immutable cache never serves stale art // or fonts. const assetVersionMark = "__ASSET_VERSION__" diff --git a/lab/internal/ui/server_test.go b/lab/internal/ui/server_test.go index 3ff1378..048b913 100644 --- a/lab/internal/ui/server_test.go +++ b/lab/internal/ui/server_test.go @@ -144,7 +144,7 @@ func TestUITokenExchangeSetsStrictHttpOnlyCookie(t *testing.T) { if w := do(t, h, nav); w.Code != http.StatusForbidden { t.Fatalf("cross-site navigation to the API: %d", w.Code) } - if w := do(t, h, authed("/")); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "") { + if w := do(t, h, authed("/")); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "") { t.Fatalf("index with cookie: %d", w.Code) } } @@ -184,7 +184,7 @@ func TestUIAPIRequiresTheTabSessionHeader(t *testing.T) { func TestUIRefusesRequestsWithoutTheCookie(t *testing.T) { h := testHandler(t, Options{LabDir: t.TempDir()}) - for _, path := range []string{"/", "/app.js", "/api/lab", "/api/molecules", "/api/molecules/m1"} { + for _, path := range []string{"/", "/app.js", "/js/core.js", "/css/base.css", "/api/lab", "/api/molecules", "/api/molecules/m1"} { if w := do(t, h, request{path: path}); w.Code != http.StatusForbidden { t.Errorf("%s without cookie: %d", path, w.Code) } @@ -634,7 +634,7 @@ func TestUINeverWritesTheLab(t *testing.T) { h := testHandler(t, Options{LabDir: lab, SourceRoot: "/src/checkout"}) engine := refuseEngine(t, h) drive := installDrive(h, &fakeDrive{}) - for _, path := range []string{"/", "/app.js", "/style.css", "/api/lab", "/api/loop", "/api/runs/7", "/api/molecules", "/api/molecules/" + r.ID, "/api/molecules/" + r.ID + "?yaml=1", "/api/molecules/" + r.ID + "/job", "/api/runs", "/api/loop/start", "/api/loop/stop", "/api/molecules/" + r.ID + "/stop"} { + for _, path := range []string{"/", "/app.js", "/js/core.js", "/css/base.css", "/style.css", "/api/lab", "/api/loop", "/api/runs/7", "/api/molecules", "/api/molecules/" + r.ID, "/api/molecules/" + r.ID + "?yaml=1", "/api/molecules/" + r.ID + "/job", "/api/runs", "/api/loop/start", "/api/loop/stop", "/api/molecules/" + r.ID + "/stop"} { do(t, h, authed(path)) post := authed(path) post.method, post.origin = http.MethodPost, "http://"+testHost @@ -667,23 +667,25 @@ func TestUINeverWritesTheLab(t *testing.T) { // page has no inline script, style or handler for data to reach. func TestUIServedJSNeverAssignsDataToInnerHTML(t *testing.T) { h := testHandler(t, Options{LabDir: t.TempDir()}) - js := do(t, h, authed("/app.js")) - if js.Code != http.StatusOK || !strings.HasPrefix(js.Header().Get("Content-Type"), "text/javascript") { - t.Fatalf("app.js: %d %v", js.Code, js.Header()) - } - for _, sink := range []string{"innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "DOMParser", "createContextualFragment", "eval(", "new Function", "srcdoc", "javascript:"} { - if strings.Contains(js.Body.String(), sink) { - t.Errorf("app.js uses %s", sink) + res := do(t, h, authed("/app.js")) + if res.Code != http.StatusOK || !strings.HasPrefix(res.Header().Get("Content-Type"), "text/javascript") { + t.Fatalf("app.js: %d %v", res.Code, res.Header()) + } + // Every module, so no sink hides in a file the entry imports. + js := servedJS(t) + for _, sink := range []string{"innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "DOMParser", "createContextualFragment", "eval(", "new Function", "srcdoc", "javascript:", "import("} { + if strings.Contains(js, sink) { + t.Errorf("the served JS uses %s", sink) } } - if !strings.Contains(js.Body.String(), "document.createTextNode") || !strings.Contains(js.Body.String(), "textContent") { - t.Fatal("app.js does not render text through text nodes") + if !strings.Contains(js, "document.createTextNode") || !strings.Contains(js, "textContent") { + t.Fatal("the served JS does not render text through text nodes") } page := do(t, h, authed("/")).Body.String() if regexp.MustCompile(`| +