Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lab/internal/ui/analytics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion lab/internal/ui/evidence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
6 changes: 3 additions & 3 deletions lab/internal/ui/gates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand All @@ -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"} {
Expand Down
54 changes: 32 additions & 22 deletions lab/internal/ui/inbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.');"},
Expand All @@ -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)
}
Expand Down Expand Up @@ -453,7 +455,7 @@ func TestUIInboxEmptyStateAndIncompleteInventory(t *testing.T) {
if !strings.Contains(page, `<p id="resident-title" class="resident-title">All quiet</p>`) {
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.');",
Expand Down Expand Up @@ -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 }));",
Expand All @@ -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
Expand Down Expand Up @@ -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);",
Expand All @@ -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);",
Expand All @@ -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)
Expand All @@ -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' } })",
Expand All @@ -637,27 +641,33 @@ 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)
}
}

// 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;")
}
Expand Down
22 changes: 11 additions & 11 deletions lab/internal/ui/molecule_view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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; }"},
Expand All @@ -408,23 +408,23 @@ 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)
}
}
}
// 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)
}
}
// 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)
}
Expand All @@ -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;")
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion lab/internal/ui/retire_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading