create-pmos 0.494.0 → 0.496.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/package.json +75 -27
  2. package/payload/AGENTS.md +2 -0
  3. package/payload/KIT-VERSION +2 -2
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/payload/state/log.md +1 -1
  8. package/workspace/adler-moments-test.js +3 -1
  9. package/workspace/board-programs-test.js +6 -6
  10. package/workspace/board-shared-ui-test.js +2 -2
  11. package/workspace/briefing-premium-adddoc-test.js +3 -49
  12. package/workspace/briefing-premium-board-test.js +23 -23
  13. package/workspace/briefing-premium-initiative-test.js +2 -1
  14. package/workspace/changelog.json +74 -0
  15. package/workspace/guide/adler.md +1 -1
  16. package/workspace/guide/briefing-premium.md +3 -1
  17. package/workspace/guide/claude-proposes.md +6 -2
  18. package/workspace/guide/data-freshness.md +2 -0
  19. package/workspace/guide/home.md +1 -1
  20. package/workspace/guide/initiatives-screen.md +1 -1
  21. package/workspace/guide/published-board.md +1 -1
  22. package/workspace/guide/report-slides.md +6 -6
  23. package/workspace/guide/reports.md +1 -1
  24. package/workspace/guide/workflows.md +18 -5
  25. package/workspace/ini-workflow-test.js +2 -1
  26. package/workspace/lib/board-premium.js +17 -2
  27. package/workspace/lib/engine/workflows.js +19 -6
  28. package/workspace/lib/initiative-chat.js +4 -1
  29. package/workspace/lib/initiative-cli.js +15 -1
  30. package/workspace/lib/open-mission.js +14 -0
  31. package/workspace/lib/proposals.js +18 -4
  32. package/workspace/lib/report-export.js +5 -3
  33. package/workspace/lib/report-pyramid.js +134 -33
  34. package/workspace/lib/report-templates.js +33 -3
  35. package/workspace/lib/routes-board-premium.js +2 -2
  36. package/workspace/lib/routes-boards.js +35 -2
  37. package/workspace/lib/routes-brief-page.js +2 -1
  38. package/workspace/lib/routes-initiatives.js +1 -0
  39. package/workspace/lib/routes-proposals.js +14 -2
  40. package/workspace/lib/routes-report-design.js +1 -1
  41. package/workspace/lib/routes-report-flow.js +11 -4
  42. package/workspace/lib/routes-report-setups.js +3 -2
  43. package/workspace/lib/routes-workflows.js +6 -1
  44. package/workspace/lib/split-merge-suggest.js +37 -3
  45. package/workspace/lib/split-merge.js +10 -4
  46. package/workspace/lib/task-cli.js +5 -1
  47. package/workspace/lib/workflow-cli.js +70 -15
  48. package/workspace/lib/workflows-store.js +15 -1
  49. package/workspace/metric-drafts-ui-test.js +2 -1
  50. package/workspace/package-lock.json +2 -2
  51. package/workspace/package.json +1 -1
  52. package/workspace/premium-board-actions-test.js +9 -10
  53. package/workspace/premium-core-doc-issue-test.js +9 -38
  54. package/workspace/premium-vw-backlog-test.js +3 -2
  55. package/workspace/premium-vw-item-test.js +3 -2
  56. package/workspace/premium-vw-jira-test.js +3 -2
  57. package/workspace/program-panel-test.js +2 -1
  58. package/workspace/public/pm/cozy-board.css +17 -5
  59. package/workspace/public/pm/cozy-home.css +1 -0
  60. package/workspace/public/pm/home-app.js +15 -5
  61. package/workspace/public/pm/home-chat.js +49 -8
  62. package/workspace/public/pm/home-today.js +11 -4
  63. package/workspace/public/pm/ini-view.js +2 -2
  64. package/workspace/public/pm/load-shapes.css +57 -0
  65. package/workspace/public/pm/load-shapes.js +57 -0
  66. package/workspace/public/pm/premium-board.js +82 -57
  67. package/workspace/public/pm/premium-shell.js +57 -6
  68. package/workspace/public/pm/premium.css +1 -0
  69. package/workspace/public/pm/readonly.js +3 -3
  70. package/workspace/public/pm/split-merge.js +3 -2
  71. package/workspace/public/pm/wf-page.css +8 -0
  72. package/workspace/public/pm/wf-page.js +33 -5
  73. package/workspace/public/premium-board.html +7 -10
  74. package/workspace/public/premium.html +1 -0
  75. package/workspace/skills/report-template.skill +4 -2
  76. package/workspace/split-merge-ui-test.js +4 -3
  77. package/workspace/stage-card-test.js +2 -1
  78. package/workspace/task-press-test.js +2 -1
  79. package/workspace/test-lists/page-open-speed.json +1 -1
  80. package/workspace/tk-tasks-test.js +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.494.0",
3
+ "version": "0.496.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -32,6 +32,80 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.496.0",
37
+ "at": "2026-11-03T00:20:24.723Z",
38
+ "f": 3,
39
+ "prs": [
40
+ {
41
+ "n": 1172,
42
+ "t": "Initiative chats find the initiative's workflow through pmos, never a lane",
43
+ "s": "Initiative chats find the initiative's workflow through pmos, never a lane",
44
+ "k": "fix"
45
+ },
46
+ {
47
+ "n": 1170,
48
+ "t": "Keep a rejected ✦ suggestion rejected while its facts hold",
49
+ "s": "Keep a rejected ✦ suggestion rejected while its facts hold",
50
+ "k": "fix"
51
+ },
52
+ {
53
+ "n": 1171,
54
+ "t": "Tasks keep their mapped stage after a workflow switch on Home and Board",
55
+ "s": "Tasks keep their mapped stage after a workflow switch on Home and Board",
56
+ "k": "fix"
57
+ },
58
+ {
59
+ "n": 1173,
60
+ "t": "Calmer Board top bar (batch 24 item 7, design B)",
61
+ "s": "Calmer Board top bar (batch 24 item 7, design B)",
62
+ "k": "new"
63
+ },
64
+ {
65
+ "n": 1165,
66
+ "t": "Pages show something at once while they read",
67
+ "s": "Pages show something at once while they read",
68
+ "k": "new"
69
+ }
70
+ ]
71
+ },
72
+ {
73
+ "v": "0.495.0",
74
+ "at": "2026-11-02T23:26:48.050Z",
75
+ "f": 3,
76
+ "prs": [
77
+ {
78
+ "n": 1164,
79
+ "t": "Report fixes from the live check of 0.494.0",
80
+ "s": "Report fixes from the live check of 0.494.0",
81
+ "k": "fix"
82
+ },
83
+ {
84
+ "n": 1167,
85
+ "t": "Keep a live New workflow draft on the Workflows list",
86
+ "s": "Keep a live New workflow draft on the Workflows list",
87
+ "k": "fix"
88
+ },
89
+ {
90
+ "n": 1166,
91
+ "t": "Initiative telemetry card starts collapsed (batch 24)",
92
+ "s": "Initiative telemetry card starts collapsed (batch 24)",
93
+ "k": "fix"
94
+ },
95
+ {
96
+ "n": 1168,
97
+ "t": "A sent chat message shows at once",
98
+ "s": "A sent chat message shows at once",
99
+ "k": "fix"
100
+ },
101
+ {
102
+ "n": 1169,
103
+ "t": "A default workflow, set with a star in Settings › Workflows (canvas DfA)",
104
+ "s": "A default workflow, set with a star in Settings › Workflows (canvas DfA)",
105
+ "k": "new"
106
+ }
107
+ ]
108
+ },
35
109
  {
36
110
  "v": "0.494.0",
37
111
  "at": "2026-11-02T15:32:51.920Z",
@@ -317,32 +391,6 @@
317
391
  "k": "fix"
318
392
  }
319
393
  ]
320
- },
321
- {
322
- "v": "0.476.0",
323
- "at": "2026-11-01T06:51:29.850Z",
324
- "f": 3,
325
- "prs": [
326
- {
327
- "n": 1132,
328
- "t": "Grill and Forecast screens: Claude's checks, grill findings, forecast ✦ line, Home line",
329
- "s": "Grill and Forecast screens: Claude's checks, grill findings, forecast ✦ line, Home line",
330
- "k": "new"
331
- }
332
- ]
333
- },
334
- {
335
- "v": "0.475.0",
336
- "at": "2026-11-01T06:12:23.972Z",
337
- "f": 3,
338
- "prs": [
339
- {
340
- "n": 1110,
341
- "t": "Set up a project from the Cozy Home in one guided conversation",
342
- "s": "Set up a project from the Cozy Home in one guided conversation",
343
- "k": "new"
344
- }
345
- ]
346
394
  }
347
395
  ]
348
396
  }
package/payload/AGENTS.md CHANGED
@@ -58,6 +58,8 @@ State files are **append-only**: never rewrite or delete an existing line or ini
58
58
 
59
59
  Every initiative carries a `type` — the lane picks what the gate requires **before** work; the kernel (anchor — a KR **or** `health:<id>` from `planning/okrs/health-budgets.md` (D59) — eval-before-work in lane-appropriate form, human Acceptance, append-only state) holds in every lane. Unknown/missing type = `feature`, the strictest lane. The enum is closed — never invent a lane; a work shape with no lane escalates to the PM.
60
60
 
61
+ **A lane is not a workflow (D99).** A lane sets what the gate requires before work. A workflow is the list of stages an initiative goes through on the PM's page (Triage, Discover, Plan…), and the PM picks it per initiative. Read workflows only through pmos: `pmos workflow of <initiative>` gives the initiative's workflow and its stages, `pmos workflow show <id>` gives one workflow's stages, and `pmos workflow list` names every workflow the PM can pick. The workflow library is not in this repository's files (Settings › Workflows saves it in the PM's home workspace), so never search the files for a workflow. When pmos cannot find a workflow, tell the PM so. Never use a lane in place of a workflow.
62
+
61
63
  | type | spec artifact the gate requires | eval form |
62
64
  |---|---|---|
63
65
  | `feature` (default) | PRD + eval rubric before build | rubric + evaluator skill (unchanged) |
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@a6bb2008
2
- assembled: 2026-11-02
1
+ kernel-source: pmos@45dcbe29
2
+ assembled: 2026-11-03
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-11-02T15:32:48Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T00:20:21Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-11-02T15:32:48Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T00:20:21Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-11-02T15:32:48Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T00:20:21Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  Newest first, ISO dates.
4
4
 
5
- ## 2026-11-02
5
+ ## 2026-11-03
6
6
 
7
7
  - Adopted OKF v0.2 (D73).
@@ -47,7 +47,9 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
47
47
  browser = await chromium.launch({ executablePath: exe });
48
48
  if (SHOTS) fs.mkdirSync(SHOTS, { recursive: true });
49
49
  const shot = async (p, name) => { if (SHOTS) { await sleep(500); await p.screenshot({ path: path.join(SHOTS, name + '.png') }); } };
50
- const BRIEF = BRP.build(BRF.facts());
50
+ // F1 is the case where the failed source leads Your day, so no stuck call may lead it: the fixture's two blocked rows are left out here
51
+ // (with them, the honest card leads on "2 pieces of work are stuck" and Jira is one card under it, home-day.js).
52
+ const BRIEF = (() => { const b = BRP.build(BRF.facts()); if (b.list && Array.isArray(b.list.rows)) b.list.rows = b.list.rows.filter((r) => r.kind !== 'blocked'); return b; })();
51
53
  const BOARD = BP.build(BF.facts());
52
54
  const SOURCES = { ok: true, ws: '', sources: [{ id: 'calendar', name: 'Calendar', state: 'fresh', as_of: new Date().toISOString() }, { id: 'jira', name: 'Jira', state: 'failed', as_of: new Date(Date.now() - 90 * 60000).toISOString() }] };
53
55
 
@@ -280,7 +280,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
280
280
  // from a place in the middle of the range (at the far end the next span's track can be shorter and stops short)
281
281
  await p.evaluate(() => { const sc = document.querySelector('#ibBody .tg-sc'); sc.scrollLeft = (sc.scrollWidth - sc.clientWidth) / 2; }); await p.waitForTimeout(200);
282
282
  const mid = (await geo()).mid;
283
- await p.click('#bdSpans [data-sp="half"]'); await p.waitForTimeout(250);
283
+ await p.click('#bdSpansBtn'); await p.click('#bdSpans [data-sp="half"]'); await p.waitForTimeout(250);
284
284
  g = await geo();
285
285
  ok('S switching span keeps the date in the middle in the middle', g.span === 'half' && Math.abs(g.mid - mid) < 3 * DAY, JSON.stringify({ mid: new Date(mid), now: new Date(g.mid) }));
286
286
  // the Monitor and Evaluate rows (batch 8 item 2) widen the range, so the middle can hold today: scroll back to the end, away from today
@@ -288,14 +288,14 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
288
288
  await p.click('#ibBody .tg-tback'); await p.waitForTimeout(800);
289
289
  g = await geo();
290
290
  ok('S the Today button scrolls back to today, a quarter of the way in, and hides', Math.abs(g.at - 0.25) < 0.03 && !g.back.length, JSON.stringify(g));
291
- await p.click('#bdSpans [data-sp="y2"]'); await p.waitForTimeout(250);
291
+ await p.click('#bdSpansBtn'); await p.click('#bdSpans [data-sp="y2"]'); await p.waitForTimeout(250);
292
292
  g = await geo();
293
293
  ok('S with today in view, a span switch keeps today in its place', g.span === 'y2' && (g.l === 0 || Math.abs(g.at - 0.25) < 0.03), JSON.stringify(g));
294
294
  const lab2 = await p.evaluate(() => [...document.querySelectorAll('#ibBody .tg-endl, #ibBody .tg-pdl')].map((e) => e.textContent));
295
295
  ok('S 2 yr labels say what lands, then when, with the year only when it is not this year (batch 13 item 4)', lab2.length > 0 && lab2.every((x) => /^[A-Z].* [A-Z][a-z]{2} \d{1,2}( ’\d\d)?$/.test(x)) && lab2.some((x) => / ’27$/.test(x)), JSON.stringify(lab2));
296
- await p.click('#bdSpans [data-sp="sprint"]'); await p.waitForTimeout(200);
296
+ await p.click('#bdSpansBtn'); await p.click('#bdSpans [data-sp="sprint"]'); await p.waitForTimeout(200);
297
297
  await p.evaluate(() => { const sc = document.querySelector('#ibBody .tg-sc'); if (sc) sc.scrollLeft = 0; });
298
- await p.click('#bdSpans [data-sp="quarter"]'); await p.waitForTimeout(250);
298
+ await p.click('#bdSpansBtn'); await p.click('#bdSpans [data-sp="quarter"]'); await p.waitForTimeout(250);
299
299
  g = await geo();
300
300
  ok('S Sprint does not scroll, and going from Sprint to Quarter opens at today', !(await p.evaluate(() => !!document.querySelector('.sp .tg-sc'))) && Math.abs(g.at - 0.25) < 0.03, JSON.stringify(g));
301
301
  // a drag on empty track scrolls; a press on a bar still opens the initiative
@@ -357,13 +357,13 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
357
357
  subs: [...document.querySelectorAll('#ibBody .bt-psb')].map((x) => x.textContent.replace(/\s+/g, ' ').trim()),
358
358
  kids: [...document.querySelectorAll('#ibBody .bt-kid .bt-nm b')].map((b) => b.textContent),
359
359
  prods: [...document.querySelectorAll('#ibBody .bt-prd .bd-prdb span:not(.bd-ti):not(.bd-gd):not(.n)')].map((b) => b.textContent),
360
- docs: getComputedStyle(document.querySelector('#tabDocs')).display === 'none', nw: document.querySelector('#newIni').hidden,
360
+ docs: !!document.querySelector('#tabDocs'), asks: !!document.querySelector('#chAsks'), nw: document.querySelector('#newIni').hidden,
361
361
  chips: [...document.querySelectorAll('#bdChipset .bd-chip')].map((c) => c.id).filter(Boolean),
362
362
  }));
363
363
  ok('A All products is saved, the address stays plain and the chip says All', a.url === '' && /^All/.test(a.crumb) && a.label === 'Products: all 2' && JSON.stringify(pk.posts) === '[["idpf","microsites"]]', JSON.stringify({ a, posts: pk.posts }));
364
364
  ok('A the program comes first and holds a sub-row per product with that product\'s initiatives', /Project Ascent/.test(a.band) && a.subs.length === 2 && /IDPF/.test(a.subs[0]) && a.kids.length === 3, JSON.stringify(a));
365
365
  ok('A then one group per product with the rows no program holds', a.prods.length === 2 && a.prods[0] === 'IDPF', JSON.stringify(a));
366
- ok('A documents, New initiative, Jira and asks work across products; only the sprint waits for a product to be picked', !a.docs && !a.nw && a.chips.includes('chJira') && a.chips.includes('chAsks') && !a.chips.includes('chSprint'), JSON.stringify(a));
366
+ ok('A New initiative and Jira work across products (the Board has no Documents tab, batch 24 item 7); only the sprint waits for a product to be picked', !a.docs && !a.nw && a.chips.includes('chJira') && !a.chips.includes('chSprint'), JSON.stringify(a));
367
367
  const other = await p.evaluate(() => { const b = [...document.querySelectorAll('#ibBody .bt-kid .bt-nm')].pop(); return b ? b.dataset.key : null; });
368
368
  ok('A each row keeps its own product in its key', /^[a-z-]+\//.test(other || ''), other);
369
369
  await p.click('#ibTabs [data-v="list"]');
@@ -62,7 +62,7 @@ const iso = (ms) => new Date(Date.now() + ms).toISOString();
62
62
  let s = await open(1440, 900, '/premium-board.html?ws=idpf&board=idpf');
63
63
  ok('A1 Shared is a tab beside Initiatives and Documents, and has no number when there is nothing', await s.p.locator('#tabShared').isVisible() && /^Shared\s*$/.test((await s.p.locator('#tabShared').textContent()).trim()));
64
64
  ok('A2 opening the tab with nothing joined asks nothing of any host beyond the read, and shows one sentence and the invite field', await (async () => { await s.p.click('#tabShared'); await s.p.waitForSelector('#shInvite'); return /Boards your teams share show up here/.test(await s.p.locator('#cShared').textContent()) && (await s.p.locator('#cShared .bdsh-card').count()) === 0; })());
65
- ok('A3 the other tabs are hidden while it is open, and Initiatives comes back', await s.p.locator('#cIni').isHidden() && await s.p.locator('#cDocs').isHidden() && (await (async () => { await s.p.click('#tabIni'); return s.p.locator('#cShared').isHidden(); })()));
65
+ ok('A3 the other tabs are hidden while it is open, and Initiatives comes back', await s.p.locator('#cIni').isHidden() && (await (async () => { await s.p.click('#tabIni'); return s.p.locator('#cShared').isHidden(); })()));
66
66
  await s.ctx.close();
67
67
  reset({ yours: MINE, names: ['Product'] }); s = await open(1440, 900, '/premium-board.html?ws=idpf&board=idpf');
68
68
  ok('A4 at load the number is your own open links, from this computer only (no host was asked)', (await s.p.locator('#nShared').textContent()) === '2' && W.reads === 0, await s.p.locator('#nShared').textContent());
@@ -152,7 +152,7 @@ const iso = (ms) => new Date(Date.now() + ms).toISOString();
152
152
 
153
153
  console.log('G a phone');
154
154
  reset({ yours: MINE, names: ['Product'], teams: [JSON.parse(JSON.stringify(TEAM_OK))] }); s = await open(390, 844, '/premium-board.html?ws=idpf&board=idpf');
155
- ok('G1 on a phone Shared is a chip after Docs, and it opens the tab', await (async () => { await s.p.waitForSelector('#chShared'); const after = await s.p.evaluate(() => document.getElementById('chShared').previousElementSibling && document.getElementById('chShared').previousElementSibling.id); await s.p.click('#chShared'); await s.p.waitForSelector('.bdsh-list .bdsh-card'); return after === 'chDocs'; })());
155
+ ok('G1 on a phone Shared is a chip after the needs-a-look pill (no Docs chip, batch 24 item 7), and it opens the tab', await (async () => { await s.p.waitForSelector('#chShared'); const after = await s.p.evaluate(() => !document.getElementById('chDocs') && document.getElementById('chShared').previousElementSibling && document.getElementById('chShared').previousElementSibling.id); await s.p.click('#chShared'); await s.p.waitForSelector('.bdsh-list .bdsh-card'); return after === 'bdNeedw' || after === 'chJira'; })());
156
156
  const g = await s.p.evaluate(() => ({ side: document.documentElement.scrollWidth > innerWidth + 1, cards: document.querySelectorAll('#cShared .bdsh-card').length }));
157
157
  ok('G2 a row is a card that fits the phone, with no sideways scroll', g.cards === 4 && !g.side, JSON.stringify(g));
158
158
  const small = await s.p.locator('#cShared button:visible').evaluateAll((els) => els.filter((e) => e.getBoundingClientRect().height < 34).map((e) => e.className + ':' + Math.round(e.getBoundingClientRect().height)));
@@ -6,10 +6,9 @@
6
6
  const path = require('path');
7
7
  const { spawn } = require('child_process');
8
8
  const { findBrowser } = require('./lib/find-browser');
9
+ const { openMission } = require('./lib/open-mission');
9
10
  const T = require('./test-run').configure({ name: 'briefing-premium-adddoc' });
10
- const BP = require('./lib/board-premium.js');
11
11
  const IP = require('./lib/initiative-premium.js');
12
- const BF = require('./fixtures/board-premium-facts.js');
13
12
  const IF = require('./fixtures/initiative-premium-facts.js');
14
13
 
15
14
  const BASE = T.BASE;
@@ -33,7 +32,6 @@ const CUSTOM = [
33
32
  { id: 'cd-2', scope: 'board:w1', title: 'Contract', writer: 'drafts', phase: null, link: { kind: 'Confluence', url: 'https://example.atlassian.net/wiki/spaces/X/pages/1' }, at: '2026-10-18T09:05:00.000Z' },
34
33
  ];
35
34
  const PAGES = {
36
- board: { file: '/premium-board.html', api: '**/api/board-premium*', scope: 'board:w1', mk: (custom) => BP.build(BF.facts({ custom })), ready: '#ibBody .ini2', btn: '#addDoc', qs: '?theme=' },
37
35
  // the initiative is a layout inside Home (premium-agreed-design): Add a document is in its Overview, a minimal form in the Viewer (Waiting for design)
38
36
  initiative: { file: '/premium.html', api: '**/api/initiative-premium*', scope: 'initiative:self-serve-v2', mk: (custom) => IP.build(IF.facts({ custom })), ready: '#czViewer .iv-mc', btn: '#czViewer [data-hi-v="docs"]', qs: '?ini=self-serve-v2&ws=w1&board=w1&theme=' },
39
37
  };
@@ -49,8 +47,7 @@ const PAGES = {
49
47
 
50
48
  const open = async (kind, { w = 1280, h = 900, theme = 'light', custom = [], add = { status: 200, body: { ok: true } } } = {}) => {
51
49
  const P = PAGES[kind];
52
- const ctx = await browser.newContext({ viewport: { width: w, height: h } });
53
- if (kind === 'board') await ctx.addInitScript(() => { try { localStorage.setItem('pmos.premium.openAs', 'popup'); } catch { /* none */ } });
50
+ const ctx = await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
54
51
  const page = await ctx.newPage();
55
52
  let current = custom, calls = 0, reply = add; const errors = [], posts = [];
56
53
  page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
@@ -60,7 +57,6 @@ const PAGES = {
60
57
  await page.goto(BASE + P.file + P.qs + theme);
61
58
  let up = await until(() => page.evaluate((s) => !!document.querySelector(s), P.ready), 20000);
62
59
  if (up && kind === 'initiative') { /* the page holds the Tasks (batch 11 item 20) */ await docsView(page); up = await until(() => page.evaluate((s) => !!document.querySelector(s), P.btn), 20000); } // the Overview holds the button
63
- if (up && P.file === '/premium-board.html') await page.click(w <= 720 ? '#chDocs' : '#tabDocs'); // a phone reaches the documents from its Docs chip // the Agreed design shows the documents on the Documents tab (premium-agreed-design)
64
60
  return { ctx, page, errors, posts, up, calls: () => calls, setCustom: (c) => { current = c; }, reply: (a) => { reply = a; } };
65
61
  };
66
62
  const txt = (p, s) => p.evaluate((q) => (document.querySelector(q) || {}).textContent || '', s);
@@ -71,49 +67,7 @@ const PAGES = {
71
67
  // the initiative's page (canvas HbC): its Documents' "Show all N" drills into the Documents view
72
68
  async function docsView(page) { if (await until(() => page.evaluate(() => !!document.querySelector('#czViewer:not([hidden]) [data-hi-v]')), 20000)) await (async (pg) => { if (await pg.$('#czViewer [data-hi-v="page"] [data-iv-docs]')) await pg.click('#czViewer [data-iv-docs]'); else if (await pg.$('#czViewer [data-hi-view="docs"]')) await pg.click('#czViewer [data-hi-view="docs"]'); else await viaMore(pg, 'Documents'); })(page); }
73
69
 
74
- /* the Board (premium-uncovered-features, BdDocs): the Documents tab is a table with an Add a document form in the side panel;
75
- the same route, the same fields, the same refusals */
76
- {
77
- const A = await open('board'), p = A.page, P = PAGES.board;
78
- ok('board 0 the page draws', A.up);
79
- if (!A.up) return finish(1);
80
- ok('board A1 the Documents tab has an Add a document button', await p.evaluate(() => { const b = document.querySelector('#docBody #addDoc'); return !!b && b.textContent === 'Add a document'; }));
81
- await p.click('#addDoc');
82
- const form = await until(() => p.evaluate(() => { const f = document.querySelector('#czViewer form[data-form="adddoc"]'); return f ? { title: document.querySelector('#czViewer .cz-vw-title').textContent, seg: [...f.querySelectorAll('.bd-rad span')].map((x) => x.textContent), kinds: [...f.kind.options].map((o) => o.textContent), uOff: f.url.disabled, note: f.querySelector('.bd-amut').textContent, dlg: !!document.querySelector('#dlg.on') } : null; }));
83
- ok('board A1 the side panel has the dialog\'s fields, and the old dialog stays shut', !!form && form.title === 'Add a document' && form.seg.join() === 'PMOS drafts it,I write it' && form.kinds.join() === 'In PMOS,Confluence,Google Docs,Google Slides' && form.uOff && !form.dlg, JSON.stringify(form));
84
- ok('board A7 it says the document is saved and the linked page is not read', !!form && /saved on the board's record/.test(form.note) && /does not read or sync/.test(form.note), form && form.note);
85
- const go = '#czViewer form[data-form="adddoc"] [type="submit"]';
86
- await p.click(go); await sleep(120);
87
- ok('board A4 an empty title is refused in the panel and posts nothing', /Give the document a title/.test(await txt(p, '#formM')) && A.posts.length === 0);
88
- await p.fill('#czViewer input[name="title"]', 'UAT plan'); await p.selectOption('#czViewer select[name="kind"]', 'Confluence');
89
- ok('board A1 the address field opens for a link kind', await p.evaluate(() => !document.querySelector('#czViewer input[name="url"]').disabled));
90
- await p.click(go); await sleep(120);
91
- ok('board A4 a link kind with no address is refused in the panel and posts nothing', /Paste the address of the Confluence page/.test(await txt(p, '#formM')) && A.posts.length === 0);
92
- A.reply({ status: 409, body: { ok: false, error: '“UAT plan” is already on this board.' } });
93
- await p.fill('#czViewer input[name="url"]', 'https://example.atlassian.net/wiki/x'); await p.click(go); await sleep(300);
94
- ok('board A4 a refusal from the route is shown in the panel, which stays open', /already on this/.test(await txt(p, '#formM')) && await p.evaluate((g) => !document.querySelector('#czViewer').hidden && !document.querySelector(g).disabled, go));
95
- A.reply({ status: 200, body: { ok: true } }); const calls0 = A.calls();
96
- await p.check('#czViewer input[name="writer"][value="you"]'); await p.click(go); await sleep(500);
97
- const last = A.posts[A.posts.length - 1], mint = await T.nonce(BASE);
98
- const want = { ws: 'w1', scope: P.scope, title: 'UAT plan', writer: 'you', kind: 'Confluence', url: 'https://example.atlassian.net/wiki/x' };
99
- ok('board A3 the press posts the entered fields to the one route, with the launch nonce and no writer field', last && last.path === '/api/custom-docs/add' && JSON.stringify(Object.keys(last.body).sort()) === JSON.stringify(Object.keys(want).sort()) && Object.keys(want).every((k) => last.body[k] === want[k] || k === 'ws') && !!mint && last.headers['x-pmos-nonce'] === mint && !['by', 'via', 'at', 'actor', 'pm', 'nonce'].some((k) => k in last.body), JSON.stringify(last && last.body));
100
- ok('board A3 nothing else was posted', A.posts.every((x) => x.path === '/api/custom-docs/add'));
101
- ok('board A1 the panel closes, the page reads again and says it was added', A.calls() > calls0 && await p.evaluate(() => document.querySelector('#czViewer').hidden && /is added to the board's documents/.test(document.querySelector('#bdToast').textContent)));
102
- A.setCustom(CUSTOM); await p.reload(); await until(() => p.evaluate((s) => !!document.querySelector(s), P.ready), 20000); await p.click('#tabDocs'); await sleep(300);
103
- const rows = await p.evaluate(() => [...document.querySelectorAll('#docBody .bd-dt[data-docrow]')].map((r) => { const c = r.querySelectorAll('[role="cell"]'); return { id: r.dataset.docrow, t: r.querySelector('[data-opendoc]').textContent, s: c[1].textContent, when: c[2].textContent, by: c[3].textContent, btn: r.querySelectorAll('button').length }; }));
104
- const uat = rows.find((r) => r.t === 'UAT plan'), con = rows.find((r) => r.t === 'Contract');
105
- ok('board A5 an unlinked document reads Not started with the date it was added, who writes it, and no press but its title', uat && uat.s === 'Not started' && /18 Oct/.test(uat.when) && /You write it/.test(uat.by) && uat.btn === 1, JSON.stringify(uat));
106
- ok('board A5 a linked document reads Linked and has no press but its title', con && con.s === 'Linked' && con.btn === 1, JSON.stringify(con));
107
- await p.click(`#docBody [data-opendoc="${con.id}"]`);
108
- ok('board A5 a linked document opens with its address as text', await until(() => p.evaluate(() => { const o = document.querySelector('#czViewer'); return !o.hidden && /Lives in Confluence: https:\/\/example\.atlassian\.net/.test(o.textContent) && !o.querySelector('a[href^="https://example"]'); })), await txt(p, '#czViewer'));
109
- ok('board no page error', A.errors.length === 0, A.errors.join(' | '));
110
- await A.ctx.close();
111
- const N = await open('board', { w: 390, h: 844 }); await N.page.click('#addDoc'); await sleep(300);
112
- const fl = await N.page.evaluate(() => ({ over: document.documentElement.scrollWidth > innerWidth + 1, add: Math.round(document.querySelector('#addDoc').getBoundingClientRect().height), go: Math.round(document.querySelector('#czViewer form[data-form="adddoc"] [type="submit"]').getBoundingClientRect().height) }));
113
- ok('board A8 at 390 px no sideways scroll with the form open, and its buttons are 44 px', !fl.over && fl.add >= 44 && fl.go >= 44, JSON.stringify(fl));
114
- await N.ctx.close();
115
- }
116
-
70
+ /* the Board had its own Add a document on its Documents tab; batch 24 item 7 took that tab off the Board, so only the initiative's is checked */
117
71
  for (const kind of ['initiative']) {
118
72
  const P = PAGES[kind], ini = kind === 'initiative';
119
73
  const A = await open(kind), p = A.page;
@@ -16,6 +16,7 @@ const fs = require('fs');
16
16
  const path = require('path');
17
17
  const { spawn } = require('child_process');
18
18
  const { findBrowser } = require('./lib/find-browser');
19
+ const { openMission } = require('./lib/open-mission');
19
20
  const T = require('./test-run').configure({ name: 'briefing-premium-board' });
20
21
  const BP = require('./lib/board-premium.js');
21
22
  const F = require('./fixtures/board-premium-facts.js');
@@ -34,6 +35,8 @@ const four = async (p, name) => {
34
35
  }
35
36
  await p.setViewportSize(vp); await p.emulateMedia({ colorScheme: null }); await new Promise((r) => setTimeout(r, 250));
36
37
  };
38
+ /* the one pill for what needs a look (batch 24 item 7, canvas BbB): its rows are pressed after the pill opens them */
39
+ const needOpen = async (pg) => { if (await pg.$('#bdNeedMenu[hidden]')) await pg.click('#chNeed'); };
37
40
  const until = async (fn, ms = 10000, step = 100) => { const t0 = Date.now(); for (;;) { const v = await fn(); if (v) return v; if (Date.now() - t0 > ms) return v; await sleep(step); } };
38
41
  const results = [];
39
42
  const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + extra : ''}`); };
@@ -79,7 +82,7 @@ const IVREAD = () => {
79
82
  browser = await chromium.launch({ executablePath: exe });
80
83
 
81
84
  const open = async ({ w = 1440, h = 900, theme = 'light', model = mk(), answers = null, trail = null, qs = '', slowSheet = 0 } = {}) => {
82
- const ctx = await browser.newContext({ viewport: { width: w, height: h } });
85
+ const ctx = await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
83
86
  const page = await ctx.newPage();
84
87
  if (slowSheet) await page.route('**/pm/cozy-panels.css', async (r) => { await new Promise((ok) => setTimeout(ok, slowSheet)); r.continue(); }); // the panels sheet lands after the first Viewer
85
88
  let current = model, calls = 0; const errors = [], posts = []; let answer = { status: 200, body: { ok: true } }; let thread = { ok: true, turns: [], working: false };
@@ -108,7 +111,7 @@ const IVREAD = () => {
108
111
  const txt = (s) => p.evaluate((q) => (document.querySelector(q) || {}).textContent || '', s);
109
112
 
110
113
  const view = async (pg, v) => { await pg.evaluate((x) => document.querySelector(`#ibTabs [data-v="${x}"]`).click(), v); await sleep(150); };
111
- const spanTo = async (pg, s) => { await pg.click(`#bdSpans [data-sp="${s}"]`); await sleep(150); };
114
+ const spanTo = async (pg, s) => { await pg.click('#bdSpansBtn'); await pg.click(`#bdSpans [data-sp="${s}"]`); await sleep(150); }; // one span picker (batch 24 item 7)
112
115
  /* the Viewer's shell A (initiative viewer-shell-a, XvABoard): what it shows now, and a press in its ⋯ menu by label */
113
116
  const vwOn = (pg, kind) => pg.evaluate((k) => { const v = document.querySelector('#czViewer'), c = window.czViewer && window.czViewer.current(); return !!v && !v.hidden && !!c && (!k || c.kind === k); }, kind || null);
114
117
  const vwTitle = (pg) => pg.evaluate(() => { const v = document.querySelector('#czViewer'); return v && !v.hidden ? (v.querySelector('.cz-vw-title') || {}).textContent || '' : ''; });
@@ -199,16 +202,16 @@ const IVREAD = () => {
199
202
  }
200
203
  {
201
204
  const R = await open({ model: BP.build(FJ.facts()) }), q = R.page;
202
- const c = await q.evaluate(() => ({ chips: [...document.querySelectorAll('#bdChipset .bd-chip')].map((b) => b.textContent.replace(/\s+/g, ' ').trim() + (b.getAttribute('aria-pressed') === 'true' ? '*' : '')), ss: document.querySelector('.bt-l[data-row="board:self-serve-v2"] .bd-nx .tx').textContent, sso: document.querySelector('.bt-l[data-row="board:sso-partners"] .bd-nx .tx').textContent, red: !!document.querySelector('.bt-l[data-row="board:sso-partners"] .bd-nx em.red'), ssoTitle: document.querySelector('.bt-l[data-row="board:sso-partners"] .bd-nx .tx').title, st: document.querySelector('.bt-l[data-row="board:sso-partners"] .cs').textContent, ssoCount: document.querySelector('.bt-l[data-row="board:sso-partners"] .bd-nx .mu').textContent, ssCount: document.querySelector('.bt-l[data-row="board:self-serve-v2"] .bd-nx .mu').textContent }));
203
- ok('B1 the chips read Jira, the sprint and the release, the new asks, late, at risk and blocked from the records; This sprint is on by default', /^Jira · read /.test(c.chips[0]) && c.chips[1] === 'Sprint 24 · v2.4 ships Nov 4' && c.chips[2] === '1 new ask' && c.chips.includes('1 late') && c.chips.includes('3 at risk') && c.chips.includes('1 blocked*') === false && c.chips.includes('1 blocked') && c.chips[c.chips.length - 1] === 'Sprint 24 only*', JSON.stringify(c.chips));
205
+ const c = await q.evaluate(() => ({ chips: [...document.querySelectorAll('#bdChipset .bd-chip')].map((b) => b.textContent.replace(/\s+/g, ' ').trim() + (b.getAttribute('aria-pressed') === 'true' ? '*' : '')), need: document.querySelector('#chNeed').getAttribute('aria-label'), rows: [...document.querySelectorAll('#bdNeedMenu button')].map((b) => b.textContent.trim() + (b.getAttribute('aria-pressed') === 'true' ? '*' : '')).join('|'), jira: document.querySelector('#chJira').getAttribute('title'), ss: document.querySelector('.bt-l[data-row="board:self-serve-v2"] .bd-nx .tx').textContent, sso: document.querySelector('.bt-l[data-row="board:sso-partners"] .bd-nx .tx').textContent, red: !!document.querySelector('.bt-l[data-row="board:sso-partners"] .bd-nx em.red'), ssoTitle: document.querySelector('.bt-l[data-row="board:sso-partners"] .bd-nx .tx').title, st: document.querySelector('.bt-l[data-row="board:sso-partners"] .cs').textContent, ssoCount: document.querySelector('.bt-l[data-row="board:sso-partners"] .bd-nx .mu').textContent, ssCount: document.querySelector('.bt-l[data-row="board:self-serve-v2"] .bd-nx .mu').textContent }));
206
+ ok('B1 the chips read the sprint and the release, This sprint (on by default), one pill for the new asks, late, at risk and blocked, and Jira as a face whose words show on hover (batch 24 item 7)', c.chips[0] === 'Sprint 24 · v2.4 ships Nov 4' && c.chips[1] === 'Sprint 24 only*' && c.need === '1 new ask, 1 late, 3 at risk, 1 blocked. Show them' && c.rows === 'New ask1|Late1|At risk3|Blocked1' && /^Jira · read .*Press to read Jira again$/.test(c.jira) && c.chips[c.chips.length - 1] === 'Jira · read ' + c.jira.split('Jira · read ')[1].split('. Press')[0], JSON.stringify(c));
204
207
  ok('B1 Build and Deliver say the ticket that matters, counted in points or tickets; a blocked ticket comes first, in red', c.ssCount === '5/13 pts' && c.ssoCount === '1/4 tickets' && /^Finish SSO-12/.test(c.sso) && c.red && /blocked/.test(c.ssoTitle) && /^Finish ONB-\d+ · 2 more$/.test(c.ss), JSON.stringify(c));
205
208
  ok('B1 a Deliver row whose release has a date says when it ships', c.st === 'Ships Nov 4', c.st);
206
209
  // a metric chip opens its trend (BdPanel); its "Show only" filters
207
- await q.click('#bdChipset [data-f="blocked"]');
210
+ await needOpen(q); await q.click('#bdChipset [data-f="blocked"]');
208
211
  ok('B1 a metric chip opens its trend, with Show only and See in Intelligence', await until(() => q.evaluate(() => { const p = document.querySelector('#bdPop'); return !!p && !p.hidden && /Blocked · 1 now/.test(p.textContent) && !!p.querySelector('[data-trshow="blocked"]') && /See in Intelligence/.test(p.textContent); })));
209
212
  await q.click('#bdPop [data-trshow="blocked"]');
210
213
  ok('B1 a chip filters the rows to what it counts', await q.evaluate(() => [...document.querySelectorAll('#ibBody .bt-l')].map((r) => r.dataset.row).join() === 'board:sso-partners'));
211
- await q.click('#bdChipset [data-f="blocked"]');
214
+ await needOpen(q); await q.click('#bdChipset [data-f="blocked"]');
212
215
  await q.click('.bt-l[data-row="board:sso-partners"] .bt-nm');
213
216
  /* re-pointed (viewer-shell-a, XvABoard): the initiative opens in the Viewer's shell A, floating over the page's right edge: the summary card,
214
217
  * the stage bar, then the folding rows. The Ask box is gone; Discuss in chat and ⋯ take its presses */
@@ -246,7 +249,7 @@ const IVREAD = () => {
246
249
  /* the chips and tools open their designed popovers (BdAsks, BdSprint, BdFilter) and New initiative its panel (BdNew), with no "Waiting for design" tag */
247
250
  const waits = [];
248
251
  for (const [sel, name, body] of [['#chAsks', 'New asks', /1 new ask.*Finance wants a CSV of failed payouts.*Accept to Triage/], ['#chSprint', 'Sprints and releases', /Sprints · from Jira.*Sprint 24.*Releases.*Backlog/], ['#bdFind', 'Find', /Find stays in the Board/], ['#bdFilter', 'Filter', /Owner.*Dara.*Lee.*No owner.*Category.*In progress.*Done.*Applies as you pick/]]) {
249
- await q.click(sel);
252
+ if (sel === '#chAsks') await needOpen(q); await q.click(sel);
250
253
  waits.push(await until(() => q.evaluate(([nm, re]) => { const k = document.querySelector('#bdPop'); if (!k || k.hidden) return null; return k.getAttribute('aria-label') === nm && !k.querySelector('.bd-wtag') && new RegExp(re).test(k.textContent) ? nm : null; }, [name, body.source])) || 'missing ' + name);
251
254
  await q.keyboard.press('Escape');
252
255
  }
@@ -296,8 +299,8 @@ const IVREAD = () => {
296
299
  }
297
300
 
298
301
  /* B3 + B11 one count: the Initiatives tab, the track's head and the chips agree with the board's counts; B12 the status says what is real */
299
- const head = await p.evaluate(() => ({ tab: document.querySelector('#nIni').textContent, n: document.querySelector('.bt-nmh span').textContent, chips: [...document.querySelectorAll('#bdChipset .bd-chip.gh')].map((b) => b.textContent.trim()).join('|'), snap: document.querySelector('#snap').textContent }));
300
- ok('B3 B11 the counts are the board\'s, once: 9 open in the tab and the head, 1 late and 3 at risk in the chips', head.tab === '9' && head.n === '9' && head.chips === '1 late|3 at risk', JSON.stringify(head));
302
+ const head = await p.evaluate(() => ({ tab: document.querySelector('#nIni').textContent, n: document.querySelector('.bt-nmh span').textContent, chips: [...document.querySelectorAll('#bdNeedMenu [data-f]')].map((b) => b.textContent.trim()).join('|'), snap: document.querySelector('#snap').textContent }));
303
+ ok('B3 B11 the counts are the board\'s, once: 9 open in the tab and the head, 1 late and 3 at risk in the chips', head.tab === '9' && head.n === '9' && head.chips === 'Late1|At risk3', JSON.stringify(head));
301
304
  ok('B12 the status says what is real', /^Real data · as of 09:12$/.test(head.snap), JSON.stringify(head));
302
305
 
303
306
  /* B4 B5 List (BdList) and By stage (BdByStage): rows and cards open the panel */
@@ -334,7 +337,7 @@ const IVREAD = () => {
334
337
  /* B6 (premium-agreed-design B2) the timeline: plan against the Jira forecast; every span from Quarter on draws the same rows (feedback batch 7,
335
338
  * timeline spans C: TsAllSpans); the scroll itself is checked in board-programs-test.js (T) */
336
339
  await view(p, 'time');
337
- const tl = await p.evaluate(() => ({ spans: [...document.querySelectorAll('#bdSpans button')].map((b) => b.textContent + (b.getAttribute('aria-pressed') === 'true' ? '*' : '')).join(), shown: !document.querySelector('#bdSpans').hidden, rows: [...document.querySelectorAll('#ibBody .tg-r .tg-n.ini2 b')].map((b) => b.textContent), bars: document.querySelectorAll('#ibBody .tg-bar').length, none: (document.querySelector('.tg-none') || {}).textContent || '', ask: (document.querySelector('#askDates') || {}).href || '', hid: (document.querySelector('.tg-hid') || {}).textContent || '', lgd: (document.querySelector('.tg-lgd') || {}).textContent || '', today: (document.querySelector('#ibBody .tg-tpill') || {}).textContent === 'Today' }));
340
+ const tl = await p.evaluate(() => ({ spans: [...document.querySelectorAll('#bdSpans [data-sp]')].map((b) => b.textContent + (b.getAttribute('aria-checked') === 'true' ? '*' : '')).join(), shown: !document.querySelector('#bdSpans').hidden, rows: [...document.querySelectorAll('#ibBody .tg-r .tg-n.ini2 b')].map((b) => b.textContent), bars: document.querySelectorAll('#ibBody .tg-bar').length, none: (document.querySelector('.tg-none') || {}).textContent || '', ask: (document.querySelector('#askDates') || {}).href || '', hid: (document.querySelector('.tg-hid') || {}).textContent || '', lgd: (document.querySelector('.tg-lgd') || {}).textContent || '', today: (document.querySelector('#ibBody .tg-tpill') || {}).textContent === 'Today' }));
338
341
  ok('B6 the timeline has six spans and starts on Quarter, one row per initiative with dates; the others are named, with an ask to propose dates', tl.shown && tl.spans === 'Sprint,Quarter*,Half,Year,2 yr,5 yr' && tl.rows.length === 3 && tl.bars === 3 && /^4 have no dates: Usage-based billing, Audit log export, Refund reason codes, Docs search/.test(tl.none) && /\/premium\.html\?ask=board%3Aw1&q=Propose/.test(tl.ask), JSON.stringify(tl));
339
342
  ok('B6 Monitor and Evaluate show on the Quarter, with no hidden count in the footer (batch 8 item 2); the legend (PgTimeline: done, planned, the Jira forecast, Milestone (batch 11 item 3), today) and Today are drawn', tl.hid === '' && /Done in Jira/.test(tl.lgd) && /To do/.test(tl.lgd) && /Jira forecast/.test(tl.lgd) && /Milestone/.test(tl.lgd) && /Today/.test(tl.lgd) && tl.today, JSON.stringify(tl));
340
343
  ok('B6 the spans help popover went with the theme grouping (TsCGrill): the first head is a plain "Initiative"', await p.evaluate(() => !document.querySelector('#popRules') && !document.querySelector('#ibBody [data-rules]') && /^Initiative/.test(document.querySelector('#ibBody .tg-h .tg-n').textContent)));
@@ -372,12 +375,8 @@ const IVREAD = () => {
372
375
  }
373
376
  await view(p, 'track');
374
377
 
375
- /* B7 the Documents tab (BdDocs): every document on the board, by kind, with its health, when it changed and by whom, and Draft it */
376
- await p.click('#tabDocs');
377
- ok('B7 the Documents tab lists the documents with kind chips and hides the initiatives; the tab counts them', await p.evaluate(() => !document.querySelector('#cDocs').hidden && document.querySelector('#cIni').hidden && document.querySelector('#tabDocs').getAttribute('aria-selected') === 'true' && document.querySelector('#docCap').textContent === '6' && !document.querySelector('#docBody .bd-wtag') && document.querySelectorAll('#docBody .bd-dt[data-docrow]').length === 6 && document.querySelectorAll('#docBody [data-dkind]').length === 2 && document.querySelectorAll('#docBody [data-act="draft"]').length === 3 && /Board charter.*Not started/.test(document.querySelector('#docBody').textContent)));
378
- { const n2 = A.posts.length; await p.click('#docBody [data-act="draft"][data-key="charter"]');
379
- ok('B7 Draft it asks the agent to draft the document (POST /api/core-docs/draft), and says nothing is sent', await until(() => A.posts.slice(n2).some((x) => x.path === '/api/core-docs/draft' && x.body.key === 'charter' && x.body.scope === 'board:w1')) && await until(() => p.evaluate(() => /drafting it/.test(document.querySelector('#bdToast').textContent))), JSON.stringify(A.posts.slice(n2))); }
380
- await p.click('#tabIni');
378
+ /* B7 the Board has no Documents tab (batch 24 item 7, canvas BbB): an initiative's Documents fold and Search's Documents filter reach the documents */
379
+ ok('B7 the Board has no Documents tab and no Docs chip; its tabs are Initiatives and Shared', await p.evaluate(() => !document.querySelector('#tabDocs') && !document.querySelector('#cDocs') && !document.querySelector('#chDocs') && [...document.querySelectorAll('.bd-tab')].map((t) => t.id).join() === 'tabIni,tabShared'));
381
380
 
382
381
  /* B13 focus: Escape closes the panel and gives the focus back to the row; the Board never opens the old dialog */
383
382
  await p.locator('#ibBody .bt-l[data-row="board:self-serve-v2"] .bt-nm').click(); await until(() => vwOn(p, 'Initiative'));
@@ -417,13 +416,14 @@ const IVREAD = () => {
417
416
 
418
417
  /* B2 reading and failure */
419
418
  {
420
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage(); let calls = 0;
419
+ const ctx = await openMission(await browser.newContext({ viewport: { width: 1280, height: 900 } })), pg = await ctx.newPage(); let calls = 0;
421
420
  await pg.route('**/api/board-premium*', (r) => { calls++; if (calls < 2) return r.fulfill({ status: 202, contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'reading' }) }); return r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: mk(), as_of: Date.now() }) }); });
422
421
  await pg.goto(BASE + '/premium-board.html');
423
- ok('B2 while the board is read the page says so, with the seconds, and shows no row instead of a guess', await until(() => pg.evaluate(() => /Reading the board · \d+ s so far/.test(document.querySelector('#snap').textContent) && document.querySelectorAll('#ibBody .ini2').length === 0 && /shows nothing until it has read/.test(document.querySelector('#ibBody').textContent)), 4000));
422
+ // page-open-speed (SpD): grey rows that claim nothing stand in for the board; past 2 s only 'Still reading the board.' is said
423
+ ok('B2 while the board is read the page says so, with the seconds, and shows grey rows instead of a guess', await until(() => pg.evaluate(() => /Reading the board · \d+ s so far/.test(document.querySelector('#snap').textContent) && document.querySelectorAll('#ibBody .ini2').length === 0 && !!document.querySelector('#ibBody .ls-bd') && /^(Still reading the board\.)?$/.test(document.querySelector('#ibBody').textContent.trim())), 4000));
424
424
  ok('B2 it asks again and draws when the answer is ready', await until(() => pg.evaluate(() => document.querySelectorAll('#ibBody .ini2').length === 7 && /^Real data/.test(document.querySelector('#snap').textContent)), 9000));
425
425
  await ctx.close();
426
- const c2 = await browser.newContext({ viewport: { width: 1280, height: 900 } }), p2 = await c2.newPage();
426
+ const c2 = await openMission(await browser.newContext({ viewport: { width: 1280, height: 900 } })), p2 = await c2.newPage();
427
427
  await p2.route('**/api/board-premium*', (r) => r.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ ok: false, state: 'error', error: 'the board could not be read' }) }));
428
428
  await p2.goto(BASE + '/premium-board.html');
429
429
  ok('B2 a failed read says why and draws nothing', await until(() => p2.evaluate(() => /The reason: the board could not be read/.test((document.querySelector('#ibBody .bd-msg') || {}).textContent || '') && document.querySelectorAll('#ibBody .ini2').length === 0), 4000));
@@ -435,7 +435,7 @@ const IVREAD = () => {
435
435
  const R = await open({ w: 390, h: 844, theme, model: BP.build(FJ.facts()) }), q = R.page;
436
436
  const m = await q.evaluate(() => { const small = [...document.querySelectorAll('#ibTabs button, #ibBody .bt-nm, #bdChipset .bd-chip, .bd-tab, #newIni, #bdFind, #bdView')].filter((b) => !b.closest('[hidden]') && b.getBoundingClientRect().height > 0 && b.getBoundingClientRect().height < 44).map((b) => b.className + ':' + Math.round(b.getBoundingClientRect().height)); const fab = document.querySelector('#czFab').getBoundingClientRect(); return { over: document.documentElement.scrollWidth > innerWidth + 1, small, fabRight: Math.round(innerWidth - fab.right), top: Math.round(document.querySelector('.bd-hd').getBoundingClientRect().height), chips: [...document.querySelectorAll('#bdChipset .bd-chip')].map((b) => b.textContent.replace(/\s+/g, ' ').trim()), title: getComputedStyle(document.querySelector('.bd-title')).display }; });
437
437
  ok(`B13 at 390 px (${theme}) the page has no sideways scroll, the top bar is one line with "Board", the menu sits bottom right, and every control is 44 px`, !m.over && m.small.length === 0 && m.fabRight < 40 && m.top <= 56 && m.title !== 'none', JSON.stringify(m));
438
- ok(`B13 at 390 px (${theme}) the chips lead with This sprint, then the Jira read time and the new asks, and end with the documents and Shared`, m.chips[0] === 'Sprint 24 only' && /^Jira \d+ days$/.test(m.chips[1]) && m.chips[2] === '1 new ask' && m.chips[m.chips.length - 2] === 'Docs 6' && m.chips[m.chips.length - 1] === 'Shared', JSON.stringify(m.chips));
438
+ ok(`B13 at 390 px (${theme}) the chips lead with This sprint, then Jira as a face, the one pill for what needs a look, and end with Shared (no Docs chip, batch 24 item 7)`, m.chips[0] === 'Sprint 24 only' && /^Jira · read /.test(m.chips[1]) && m.chips[2] === '1131' && m.chips[m.chips.length - 1] === 'Shared' && !m.chips.some((t) => /^Docs/.test(t)), JSON.stringify(m.chips));
439
439
  const pl = await q.evaluate(() => ({ groups: [...document.querySelectorAll('#ibBody .bd-ghd')].map((g) => g.textContent.replace(/\s+/g, ' ').trim()), seg: [...document.querySelectorAll('#ibBody [data-pv]')].map((b) => b.textContent + (b.getAttribute('aria-pressed') === 'true' ? '*' : '')).join(), note: /Timeline needs a wider screen/.test(document.querySelector('#ibBody').textContent), small: [...document.querySelectorAll('#ibBody [data-pv], #ibBody .bd-lrow')].filter((b) => b.getBoundingClientRect().height < 44).length }));
440
440
  ok(`B13 at 390 px (${theme}) the phone opens on the List, grouped your turn first, with List, By stage, Track and Timeline (BdPhone, TsCPhone)`, pl.groups.join('|') === 'At risk or late4|On track3' && pl.seg === 'List*,By stage,Track,Timeline' && !pl.note && pl.small === 0, JSON.stringify(pl));
441
441
  await q.click('#bdView'); const s2 = await q.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1 && document.querySelectorAll('#ibBody .bd-scols.ph .bd-scol').length + (((document.querySelector('#ibBody .bd-sempty') || {}).textContent || '').replace(/^Nothing on screen in /, '').match(/[A-Z][a-z]+/g) || []).length === 8);
@@ -521,9 +521,9 @@ const IVREAD = () => {
521
521
  await q.fill('#czViewer input[name="title"]', 'Per-key API usage'); await q.fill('#czViewer textarea[name="why"]', 'Partners ask which key used the quota.'); await q.selectOption('#czViewer select[name="anchor"]', 'q3-2026:o1:kr1'); await q.click('#czViewer form[data-form="new2"] [type="submit"]');
522
522
  ok('T New initiative records an ask and accepts it into the first stage, anchored, with the Why as its intent and a work lane', await until(() => { const i = R.posts.findIndex((x) => x.path === '/api/boards/asks/add' && x.body.line === 'Per-key API usage'); return i >= 0 && R.posts.slice(i).some((x) => x.path === '/api/boards/asks/accept' && x.body.ask === 'n1' && x.body.anchor === 'q3-2026:o1:kr1' && typeof x.body.lane === 'string' && x.body.lane.length > 0 && x.body.intent === 'Partners ask which key used the quota.'); }), JSON.stringify(R.posts.slice(-3)));
523
523
  await q.keyboard.press('Escape');
524
- await q.click('#chAsks'); await q.click('#bdPop [data-askact="accept"][data-id="a1"]');
524
+ await needOpen(q); await q.click('#chAsks'); await q.click('#bdPop [data-askact="accept"][data-id="a1"]');
525
525
  ok('T accepting an ask with no goal yet opens New initiative from its draft', await until(() => q.evaluate(() => (document.querySelector('#czViewer input[name="title"]') || {}).value === 'Failed payouts CSV' && (document.querySelector('#czViewer .cz-vw-title') || {}).textContent === 'Accept ask')));
526
- await q.keyboard.press('Escape'); await q.click('#chAsks'); await q.click('#bdPop [data-askact="decline"][data-id="a1"]');
526
+ await q.keyboard.press('Escape'); await needOpen(q); await q.click('#chAsks'); await q.click('#bdPop [data-askact="decline"][data-id="a1"]');
527
527
  await q.click('#bdPop [data-whynot="Out of scope"]'); await q.fill('#bdPop input[name="why"]', 'Finance owns this report'); await q.click('#bdPop [data-askact="decline-go"]');
528
528
  ok('T declining an ask takes a reason, and keeps it', await until(() => R.posts.some((x) => x.path === '/api/boards/asks/decline' && x.body.ask === 'a1' && x.body.why === 'Out of scope: Finance owns this report')), JSON.stringify(R.posts.slice(-2)));
529
529
  /* Find, Filter, Metrics, cancelled and archived */
@@ -567,7 +567,7 @@ const IVREAD = () => {
567
567
  ok('E3 the real model has the eight steps and a track on every row', !!m && m.steps.length === 8 && m.initiatives.list.rows.every((r) => r.track && typeof r.track.k === 'number'));
568
568
  const post = await fetch(BASE + '/api/board-premium', { method: 'POST', headers: await T.pressHeaders(BASE), body: '{}' }).then((r) => r.status);
569
569
  ok('E4 the model route is read-only', post === 405, String(post));
570
- const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage(), errs = [];
570
+ const ctx = await openMission(await browser.newContext({ viewport: { width: 1280, height: 900 } })), pg = await ctx.newPage(), errs = [];
571
571
  pg.on('pageerror', (e) => errs.push(String(e)));
572
572
  await pg.goto(BASE + '/premium-board.html');
573
573
  ok('E5 the real page draws this repository\'s board and its count agrees with the route', await until(() => pg.evaluate((n) => document.querySelectorAll('#ibBody .ini2').length > 0 && document.querySelector('#nIni').textContent === n, String(m.initiatives.open || m.initiatives.list.rows.length)), 30000));
@@ -13,6 +13,7 @@
13
13
  const path = require('path');
14
14
  const { spawn } = require('child_process');
15
15
  const { findBrowser } = require('./lib/find-browser');
16
+ const { openMission } = require('./lib/open-mission');
16
17
  const T = require('./test-run').configure({ name: 'briefing-premium-initiative' });
17
18
  const IP = require('./lib/initiative-premium.js');
18
19
  const F = require('./fixtures/initiative-premium-facts.js');
@@ -52,7 +53,7 @@ const WRITES = /^\/api\/(?:boards\/(?:move|tasks\/(?:add|state))|lifecycle|core-
52
53
  browser = await chromium.launch({ executablePath: exe });
53
54
 
54
55
  const open = async ({ w = 1280, h = 900, theme = 'light', model = mk(), answers = null, url = '/premium.html?ini=self-serve-v2&ws=w1&board=w1' } = {}) => {
55
- const ctx = await browser.newContext({ viewport: { width: w, height: h } });
56
+ const ctx = await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
56
57
  const page = await ctx.newPage();
57
58
  let current = model, calls = 0, answer = { status: 200, body: { ok: true } };
58
59
  const errors = [], posts = [];