create-pmos 0.188.0 → 0.189.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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/c3-report-setup-test.js +1 -1
- package/workspace/c3-scan-fixture.js +247 -5
- package/workspace/c3-scan-test.js +453 -106
- package/workspace/guide/horizon-scan.md +45 -36
- package/workspace/lib/headless.js +19 -5
- package/workspace/lib/pmos-verbs.js +2 -2
- package/workspace/lib/route-modules.js +1 -1
- package/workspace/lib/routes-boards.js +29 -12
- package/workspace/lib/routes-scan.js +224 -44
- package/workspace/lib/scan-cli.js +143 -45
- package/workspace/lib/scan-run.js +242 -87
- package/workspace/lib/scan.js +623 -79
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-doc-sheet.js +7 -1
- package/workspace/public/pm/c3-initiatives.js +5 -4
- package/workspace/public/pm/c3-scan-api.js +167 -0
- package/workspace/public/pm/c3-scan-next.js +248 -0
- package/workspace/public/pm/c3-scan-setup.js +317 -0
- package/workspace/public/pm/c3-scan-words.js +34 -0
- package/workspace/public/pm/c3-scan.css +58 -9
- package/workspace/public/pm/c3-scan.js +36 -208
- package/workspace/public/pm/c3-toast.js +1 -0
- package/workspace/server.js +7 -4
- package/workspace/skills/horizon-scan-repo.skill +26 -10
- package/workspace/skills/horizon-scan-research.skill +44 -0
- package/workspace/skills/horizon-scan-web.skill +12 -5
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
|
-
/* c3-scan-test.js — the Horizon scan on console3 (initiative horizon-scan; PRD
|
|
4
|
-
* LIVE mode: a real server and a scratch repository
|
|
5
|
-
*
|
|
3
|
+
/* c3-scan-test.js — the portfolio Horizon scan on console3 (initiative horizon-scan-portfolio; PRD sections 1 to 4 and 7; the approved mockup 42-hscan).
|
|
4
|
+
* LIVE mode: a real server and a scratch repository (c3-scan-fixture.js) and a real Chromium driving /c3.html. The first part routes the page's
|
|
5
|
+
* /api/scan/* calls to a model that answers with the REAL view functions and presses of lib/scan.js and lib/routes-scan.js over a ledger of its own
|
|
6
|
+
* (portfolioStub), so a test can move a scan one step at a time. The last part (E) is end to end: no routing at all, the server's own scan routes over a
|
|
7
|
+
* seeded state folder, the runner stubbed, a real initiative created. The report documents are real markdown files read through GET /api/doc.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* pure the client's readers: portfolio shape, front matter, evidence links, the summary line
|
|
10
|
+
* portfolio the Horizon scan section of Initiatives > Portfolio: Scan now and "Horizon scan setup" visible in the first-time state, the board count
|
|
11
|
+
* setup the sheet: last and next scan, Scan now (also in the header), How often, repository and report types, the watch list with kind and
|
|
12
|
+
* board chips, Keep all, Keep, Edit, Remove with Undo and Add; every press posts to /api/scan/* with no writer field
|
|
13
|
+
* picker Scan now: items, repository and report types, a plain warning when nothing is chosen, the server's refusal in words
|
|
14
|
+
* running the steps with Stop; Stop posts; a finished scan lists its reports
|
|
15
|
+
* document a report opens in the existing document sheet by its state-folder path, with the Proposed next steps panel under the header and
|
|
16
|
+
* tabs: create, research and attach; Approve, Change first, Dismiss (a reason is required), Undo and the toasts
|
|
17
|
+
* portfolio after a scan: the latest scan's reports and "Waiting for you: N proposals"
|
|
18
|
+
* briefing the Horizon scan card; Open and Scan now
|
|
19
|
+
* board Board settings holds one pointer line, no scan block
|
|
20
|
+
* E end to end on the real server routes: Scan now, setup and Keep, a seeded report with its proposals panel, Change first then Approve
|
|
21
|
+
* (a real initiative in the first stage), Dismiss with a reason then Undo
|
|
22
|
+
* triage the asks an earlier per-board scan found still read "Found by agent" (c3-ib-triage.js)
|
|
23
|
+
* phone at 400 wide none of these has a horizontal scroll
|
|
24
|
+
* Set SCAN_SHOTS=<dir> to save screenshots. Every wait polls the page. No script error may be raised.
|
|
14
25
|
*/
|
|
15
26
|
const fs = require('fs');
|
|
16
27
|
const path = require('path');
|
|
@@ -18,7 +29,7 @@ const { findBrowser } = require('./lib/find-browser');
|
|
|
18
29
|
const F = require('./c3-scan-fixture');
|
|
19
30
|
|
|
20
31
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
21
|
-
const until = async (fn, ms = 20000, step =
|
|
32
|
+
const until = async (fn, ms = 20000, 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); } };
|
|
22
33
|
const results = [];
|
|
23
34
|
const ok = (n, cond, extra = '') => { results.push({ n, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${n}${!cond && extra !== '' ? ' — ' + String(extra).slice(0, 500) : ''}`); };
|
|
24
35
|
let fx = null; let browser = null;
|
|
@@ -37,134 +48,470 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
|
37
48
|
let chromium;
|
|
38
49
|
try { ({ chromium } = require('playwright-core')); } catch { console.log('· playwright-core not installed — c3-scan suite skipped'); process.exit(0); }
|
|
39
50
|
fx = await F.start({ name: 'c3-scan' });
|
|
40
|
-
const { BASE, ws
|
|
41
|
-
const state = () => S.fold(S.readLines(ledger));
|
|
51
|
+
const { BASE, ws } = fx;
|
|
42
52
|
browser = await chromium.launch({ executablePath: exe });
|
|
53
|
+
require('./lib/c3-full-console').fullConsole(browser); // the desktop layout at phone width (c3-phone-companion)
|
|
43
54
|
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
|
44
55
|
const page = await ctx.newPage();
|
|
45
56
|
const errors = []; page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
|
|
46
|
-
const
|
|
57
|
+
const docReads = []; page.on('request', (r) => { if (r.url().includes('/api/doc?')) docReads.push(new URL(r.url()).searchParams.get('path') || ''); });
|
|
58
|
+
const P = fx.portfolio(); await P.install(page);
|
|
59
|
+
const posts = () => P.calls.filter((c) => c.method === 'POST');
|
|
60
|
+
const lastPost = (p) => posts().filter((c) => c.path === p).pop();
|
|
47
61
|
const st = (f, a) => page.evaluate(f, a);
|
|
48
|
-
const sel = (s) => until(() => page.$(s));
|
|
62
|
+
const sel = (s, ms) => until(() => page.$(s), ms);
|
|
49
63
|
const shots = process.env.SCAN_SHOTS || '';
|
|
50
64
|
if (shots) fs.mkdirSync(shots, { recursive: true });
|
|
51
65
|
const shot = async (name) => { if (shots) await page.screenshot({ path: path.join(shots, name + '.png'), fullPage: false }); };
|
|
52
|
-
const noScroll = () => st(() => document.documentElement.scrollWidth <= window.innerWidth + 1);
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
const noScroll = () => st(() => document.documentElement.scrollWidth <= window.innerWidth + 1 && [...document.querySelectorAll('.sheet .sb')].every((n) => n.scrollWidth <= n.clientWidth + 1));
|
|
67
|
+
const text = (s) => st((q) => { const n = document.querySelector(q); return n ? n.textContent : ''; }, s);
|
|
68
|
+
const toastText = () => st(() => (document.querySelector('[data-c3-toast]') || {}).textContent || '');
|
|
69
|
+
const portfolio = async () => { await page.goto(BASE + '/c3.html?tab=init', { waitUntil: 'load' }); await sel('[data-scan-section="portfolio"] [data-scan-act=scan]'); };
|
|
70
|
+
|
|
71
|
+
/* ── pure parts, read in the page ── */
|
|
72
|
+
await portfolio();
|
|
73
|
+
const pure = await st(async () => {
|
|
74
|
+
const A = await import('/pm/c3-scan-api.js'); const N = await import('/pm/c3-scan-next.js');
|
|
75
|
+
const real = { ok: true, setting: { cadence: 'weekly', repo: false, types: ['benchmark'] }, switched_off: false, next: '2026-10-19T09:00:00.000Z',
|
|
76
|
+
items: [{ id: 'w-0000000a', kind: 'weird', name: 'A', why: '', ini: 'IDPF-1', added: false, state: 'suggested', boards: ['b1'], ids: ['w-0000000a'] }, { id: 'w-0000000b', kind: 'domain', name: 'B', why: 'y', ini: null, added: true, state: 'kept', boards: [], ids: ['w-0000000b'] }],
|
|
77
|
+
last: { scan: 'scan-1', at: 'a', end: 'b', ok: true, reason: null, found: 3, skipped: 1, pages: 12, manual: false, stopped: false }, manual: { limit: 3, used: 2, left: 1, ok: true },
|
|
78
|
+
open: { scan: 'scan-2', mode: 'scan', manual: true, start: 'c', stopping: false, steps: [{ id: 'web', label: 'Searching the public web', state: 'running' }, { id: 'repo', label: 'Reading the repository', state: 'pending' }], reports: [] },
|
|
79
|
+
reports: [{ id: 'r-1', scan: 'scan-1', type: 'benchmark', label: 'Benchmark report', title: 'T', path: 'pmos/state/reports/scan-benchmark/2026-10-13.md', written: true, parent: null, proposals: { open: 1, total: 3 } }],
|
|
80
|
+
proposals_waiting: 2,
|
|
81
|
+
proposals: [{ id: 'p-1', report: 'r-1', report_title: 'T', kind: 'attach', title: 'X', why: 'w', finding: 'F', evidence: [{ label: 'L', ref: 'IDPF-1' }, { label: 'x', ref: '' }, { label: 'W', ref: 'https://a.example/b' }, { label: 'P', ref: 'src/a.js' }],
|
|
82
|
+
fields: { initiative: 'IDPF-1' }, state: 'approved', changed: true, dismissed_why: null, result: { initiative: 'IDPF-1' }, at: 'x', decided_at: 'y' },
|
|
83
|
+
{ id: 'p-2', report: 'r-1', report_title: 'T', kind: 'create', title: 'Y', why: 'w', finding: 'G', evidence: [], fields: { board: 'b1', lane: 'feature', anchor: 'health:tech', intent: 'i' }, state: 'dismissed', changed: false, dismissed_why: 'no', result: null }] };
|
|
84
|
+
const d = A.readPortfolio(real); const p0 = d.proposals[0]; const p1 = d.proposals[1];
|
|
85
|
+
return { cad: d.setting.cadence, repo: d.setting.repo, types: d.setting.types.join(), items: d.items.map((w) => w.id + w.kind + w.state + (w.added ? '+' : '-') + w.ini).join(), waiting: d.waiting, open: d.open.scan + d.open.steps.map((x) => x.id).join(), left: d.manual.left,
|
|
86
|
+
rep: d.reports[0].open + '/' + d.reports[0].total + d.reports[0].written, kind: p0.kind, fields: JSON.stringify(p0.fields), from: p0.from, ev: p0.evidence.map((e) => e.kind).join(), state: p0.state, out: JSON.stringify(A.readOut({ initiative: { id: 'X-1' }, board: 'b', stage: 'Discover' })), outStr: p0.out.id, changed: p0.changed,
|
|
87
|
+
reason: p1.reason, create: JSON.stringify(p1.fields), state2: p1.state,
|
|
88
|
+
words: [A.scanNowWords({ why: 'running' }), A.scanNowWords({ why: 'quiet-hours' }), A.scanNowWords({ why: 'manual-limit', limit: 3 })].join('|'),
|
|
89
|
+
front: A.frontOf('---\ntype: Reference\nsource: scan:portfolio\nreport: r-1\n---\n# T').source, none: Object.keys(A.frontOf('# T')).length,
|
|
90
|
+
js: N.evidenceHref({ kind: 'web', ref: 'javascript:alert(1)' }), host: N.evidenceHref({ kind: 'web', ref: 'docs.stripe.com/changelog' }), jira: N.evidenceHref({ kind: 'jira', ref: 'IDPF-1' }), outOf: JSON.stringify(N.outOf({ result: { initiative: { id: 'X-1' }, board: 'b', stage: 'Discover' } })),
|
|
91
|
+
sum: N.sumLine([{ kind: 'create', state: 'open' }, { kind: 'attach', state: 'open' }, { kind: 'attach', state: 'done' }]), all: N.sumLine([]) };
|
|
92
|
+
});
|
|
93
|
+
ok('U1 readPortfolio reads the server\'s names: items (state suggested or kept, ini, added), proposals_waiting, open.scan and steps[].id, manual.left, reports[].proposals.open, an unknown kind as product',
|
|
94
|
+
pure.cad === 'weekly' && pure.repo === false && pure.types === 'benchmark' && pure.items === 'w-0000000aproductsuggested-IDPF-1,w-0000000bdomainkept+' && pure.waiting === 2 && pure.open === 'scan-2web,repo' && pure.left === 1 && pure.rep === '1/3true', JSON.stringify(pure));
|
|
95
|
+
ok('U2 readProposal reads approved as done, finding as from, evidence { label, ref } with a kind taken from the ref (an empty ref dropped), result.initiative as out, dismissed_why as reason, and the create fields by the server\'s names',
|
|
96
|
+
pure.kind === 'attach' && pure.fields === '{"initiative":"IDPF-1"}' && pure.from === 'F' && pure.ev === 'jira,web,repo' && pure.state === 'done' && pure.outStr === 'IDPF-1' && pure.changed === true && pure.reason === 'no' && pure.state2 === 'dismissed'
|
|
97
|
+
&& pure.create === '{"title":"Y","board":"b1","lane":"feature","anchor":"health:tech","intent":"i"}' && pure.out === '{"id":"X-1","board":"b","stage":"Discover","scan":"","report":""}' && pure.outOf === '{"id":"X-1","board":"b","stage":"Discover"}', JSON.stringify(pure));
|
|
98
|
+
ok('U3 frontOf, evidenceHref, sumLine and the words of a refused Scan now: a javascript: reference opens nothing, a bare host opens https, a Jira key opens nothing; running, quiet hours and the daily limit each have their own sentence',
|
|
99
|
+
pure.front === 'scan:portfolio' && pure.none === 0 && pure.js === '' && pure.host === 'https://docs.stripe.com/changelog' && pure.jira === '' && pure.sum === '1 create initiative, 1 add to an existing initiative' && pure.all === 'All handled.'
|
|
100
|
+
&& /^A scan is running now\. .*\|Quiet hours hold.*\|You used all 3 manual scans today/.test(pure.words), JSON.stringify(pure));
|
|
101
|
+
|
|
102
|
+
/* ── portfolio, first time ── */
|
|
103
|
+
const sec = await text('[data-scan-section="portfolio"]');
|
|
104
|
+
ok('P1 Initiatives > Portfolio has a Horizon scan section that names the whole portfolio and its board count', /Horizon scan/i.test(sec) && /Whole portfolio: 1 board/.test(sec), sec);
|
|
105
|
+
ok('P2 in the first-time state the section says the agent suggests 4 things to watch', /The agent suggests 4 things to watch/.test(sec), sec);
|
|
106
|
+
ok('P3 Scan now is visible and primary, and "Horizon scan setup" is beside it', await st(() => { const s = document.querySelector('[data-scan-section="portfolio"]'); const a = s.querySelector('[data-scan-act=scan]'); const b = s.querySelector('[data-scan-act=setup]');
|
|
107
|
+
return a && b && a.offsetWidth > 0 && /primary/.test(a.className) && /Scan now/.test(a.textContent) && /Horizon scan setup/.test(b.textContent); }));
|
|
108
|
+
ok('P4 the section has no report rows and no "Waiting for you" line before a scan', !(await page.$('[data-scan-section] [data-scan-report]')) && !(await page.$('[data-scan="waiting"]')));
|
|
109
|
+
ok('P5 the page read GET /api/scan/portfolio with the workspace', P.calls.some((c) => c.method === 'GET' && c.path === '/api/scan/portfolio' && c.query.ws === ws));
|
|
110
|
+
ok('P6 every control in the section is a real button (the primitive)', await st(() => [...document.querySelectorAll('[data-scan-section] button')].every((b) => /pmc-btn/.test(b.className))));
|
|
111
|
+
await shot('portfolio-first');
|
|
112
|
+
|
|
113
|
+
/* ── setup ── */
|
|
114
|
+
await page.click('[data-scan-section] [data-scan-act=setup]');
|
|
115
|
+
await sel('[data-scan-setup] [data-scan-item]');
|
|
116
|
+
const set0 = await text('[data-scan-setup]');
|
|
117
|
+
ok('S1 the sheet has Last scan and next scan, How often, Watch list (4), Sources and Report types', /Last scan and next scan/i.test(set0) && /How often/i.test(set0) && /Watch list\s*4/i.test(set0) && /Sources/i.test(set0) && /Report types/i.test(set0), set0.slice(0, 300));
|
|
118
|
+
ok('S2 Last scan says none yet, the schedule says Every working day, 09:00, and the first scan waits for the list', /Last scan: none yet/.test(set0) && /Every working day, 09:00/.test(set0) && /first scheduled scan runs after you decide on the watch list/.test(set0), set0.slice(0, 400));
|
|
119
|
+
ok('S3 Scan now is in the sheet header and in the status block', (await page.$$('[data-scan-setup] [data-scan-act=scan]')).length === 1 && !!(await page.$('.sheet [data-scan-head]')));
|
|
120
|
+
ok('S4 Daily is pressed; Weekly and Off are not', await st(() => ['daily', 'weekly', 'off'].map((k) => document.querySelector(`[data-scan-freq=${k}]`).getAttribute('aria-pressed')).join() === 'true,false,false'));
|
|
121
|
+
ok('S5 the banner says the agent suggests 4 things, 4 still to decide, with Keep all', /The agent suggests 4 things to watch/.test(await text('[data-scan="banner"]')) && /4 still to decide/.test(await text('[data-scan="banner"]')) && !!(await page.$('[data-scan-act=keepall]')));
|
|
122
|
+
const items = () => st(() => [...document.querySelectorAll('[data-scan-item]')].map((n) => ({ id: n.dataset.scanItem, state: n.dataset.scanState, kind: n.dataset.scanKind, text: n.textContent, keep: !!n.querySelector('[data-scan-act=keep]'), edit: !!n.querySelector('[data-scan-act=edit]'), remove: !!n.querySelector('[data-scan-act=remove]') })));
|
|
67
123
|
const its = await items();
|
|
68
|
-
ok('
|
|
69
|
-
|
|
70
|
-
await shot('
|
|
71
|
-
|
|
72
|
-
await
|
|
73
|
-
|
|
74
|
-
await
|
|
75
|
-
ok('T7 Keep posts op keep with the item and no writer field; the ledger holds the keep by the PM; the row reads Kept', posts.some((p) => p.path === '/api/scan/watch' && p.body.op === 'keep' && p.body.id === 'w-00000001' && !('by' in p.body))
|
|
76
|
-
&& S.readLines(ledger).some((l) => l.ev === 'watch-keep' && l.id === 'w-00000001' && l.by === 'pm:wawan') && /Kept/.test((await items())[0].text));
|
|
77
|
-
await page.click('[data-scan-item="w-00000002"] [data-scan-act=edit]');
|
|
124
|
+
ok('S6 four items, each with a kind tag, board chips, where it came from, and Keep, Edit and Remove', its.length === 4 && its.every((i) => i.keep && i.edit && i.remove && /BOARDS/.test(i.text) && /team/.test(i.text) && /from IDPF-/.test(i.text)) && its[0].kind === 'product' && its[2].kind === 'domain' && its[3].kind === 'approach', JSON.stringify(its.map((i) => i.text)));
|
|
125
|
+
ok('S7 the first-time items show why the agent suggests them', /usual reference/.test(its[0].text));
|
|
126
|
+
await shot('setup-first');
|
|
127
|
+
await page.click('[data-scan-item=w-00000011] [data-scan-act=keep]');
|
|
128
|
+
await until(() => st(() => document.querySelector('[data-scan-item=w-00000011]').dataset.scanState === 'kept'));
|
|
129
|
+
ok('S8 Keep posts op keep with the item, no writer field; the row reads Kept', JSON.stringify(lastPost('/api/scan/watch').body) === JSON.stringify({ ws, op: 'keep', id: 'w-00000011' }) && /Kept/.test((await items())[0].text));
|
|
130
|
+
await page.click('[data-scan-item=w-00000012] [data-scan-act=edit]');
|
|
78
131
|
await sel('[data-scan-edit-in]');
|
|
79
|
-
await page.fill('[data-scan-edit-in]', '
|
|
132
|
+
await page.fill('[data-scan-edit-in]', 'Twilio onboarding');
|
|
80
133
|
await page.click('[data-scan-act=save]');
|
|
81
|
-
await until(() =>
|
|
82
|
-
ok('
|
|
83
|
-
await page.click('[data-scan-item=
|
|
134
|
+
await until(() => st(() => /Twilio onboarding/.test(document.querySelector('[data-scan-item=w-00000012]').textContent) && document.querySelector('[data-scan-item=w-00000012]').dataset.scanState === 'kept'));
|
|
135
|
+
ok('S9 Edit then Save posts op edit with the new name, and the item is kept', lastPost('/api/scan/watch').body.op === 'edit' && lastPost('/api/scan/watch').body.name === 'Twilio onboarding');
|
|
136
|
+
await page.click('[data-scan-item=w-00000013] [data-scan-act=edit]');
|
|
84
137
|
await sel('[data-scan-edit-in]');
|
|
85
138
|
await page.click('[data-scan-act=cancel]');
|
|
86
|
-
ok('
|
|
87
|
-
await page.click('[data-scan-item=
|
|
88
|
-
await until(() =>
|
|
139
|
+
ok('S10 Cancel leaves the item as it was', !(await page.$('[data-scan-edit-in]')) && (await items())[2].state === 'suggested');
|
|
140
|
+
await page.click('[data-scan-item=w-00000013] [data-scan-act=remove]');
|
|
141
|
+
await until(async () => (await items()).length === 3);
|
|
89
142
|
await sel('[data-c3-toast-action]');
|
|
90
|
-
ok('
|
|
143
|
+
ok('S11 Remove posts op remove, the row leaves, and a toast offers Undo', lastPost('/api/scan/watch').body.op === 'remove' && /Removed API rate limits/.test(await toastText()));
|
|
91
144
|
await page.click('[data-c3-toast-action]');
|
|
92
|
-
await until(() =>
|
|
93
|
-
|
|
94
|
-
ok('T11 Undo posts keep with restore and the item is back as it was (suggested)', posts.some((p) => p.body.op === 'keep' && p.body.restore === true && p.body.id === 'w-00000003') && (await items()).length === 3);
|
|
145
|
+
await until(async () => (await items()).length === 4);
|
|
146
|
+
ok('S12 Undo posts keep with restore and the item is back as it was', posts().some((c) => c.path === '/api/scan/watch' && c.body.op === 'keep' && c.body.restore === true && c.body.id === 'w-00000013') && (await items())[2].state === 'suggested');
|
|
95
147
|
await page.click('[data-scan-act=add]');
|
|
96
|
-
ok('
|
|
148
|
+
ok('S13 Add with nothing typed says so in plain words and posts nothing', /Write what to watch first/.test(await text('[data-scan="add-error"]')) && !posts().some((c) => c.body.op === 'add'));
|
|
97
149
|
await page.fill('[data-scan-add-in]', 'Partner SDK trends');
|
|
98
150
|
await page.selectOption('[data-scan-add-kind]', 'domain');
|
|
151
|
+
await page.fill('[data-scan-add-why]', 'Partners ask which SDKs we support');
|
|
152
|
+
await page.selectOption('[data-scan-add-board]', ws);
|
|
99
153
|
await page.click('[data-scan-act=add]');
|
|
100
|
-
await until(() =>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
154
|
+
await until(async () => (await items()).length === 5);
|
|
155
|
+
const added = lastPost('/api/scan/watch').body;
|
|
156
|
+
ok('S14 Add posts op add with the name, kind, why and the board it relates to; the item is kept, says "added by you", and carries that board and that why', added.op === 'add' && added.name === 'Partner SDK trends' && added.kind === 'domain' && added.why === 'Partners ask which SDKs we support' && JSON.stringify(added.boards) === JSON.stringify([ws])
|
|
157
|
+
&& /added by you/.test((await items())[4].text) && /Partners ask which SDKs/.test((await items())[4].text) && (await items())[4].state === 'kept' && /team/.test((await items())[4].text), JSON.stringify(added));
|
|
158
|
+
await page.fill('[data-scan-add-in]', 'Webhook retries');
|
|
159
|
+
await page.click('[data-scan-act=add]');
|
|
160
|
+
await until(async () => (await items()).length === 6);
|
|
161
|
+
ok('S14b Add with the board left on "Whole portfolio" posts an empty boards list and an empty why', JSON.stringify(lastPost('/api/scan/watch').body.boards) === '[]' && lastPost('/api/scan/watch').body.why === '' && /BOARDS\s*none yet/.test((await items())[5].text), JSON.stringify(lastPost('/api/scan/watch').body));
|
|
162
|
+
await page.click('[data-scan-item=' + (await items())[5].id + '] [data-scan-act=remove]');
|
|
163
|
+
await until(async () => (await items()).length === 5);
|
|
164
|
+
await page.click('[data-scan-act=keepall]');
|
|
165
|
+
await until(async () => (await items()).every((i) => i.state === 'kept'));
|
|
166
|
+
const keeps = posts().filter((c) => c.path === '/api/scan/watch' && c.body.op === 'keep' && !c.body.restore).map((c) => c.body.id);
|
|
167
|
+
ok('S15 Keep all keeps what is still undecided (the third and the fourth) and the banner then says you decided on all of them', keeps.includes('w-00000013') && keeps.includes('w-00000014') && /You decided on all of them/.test(await text('[data-scan="banner"]')) && !(await page.$('[data-scan-act=keepall]')));
|
|
104
168
|
await page.click('[data-scan-freq=weekly]');
|
|
105
|
-
await until(() => S.cadenceOf(state(), ws) === 'weekly');
|
|
106
169
|
await until(() => st(() => document.querySelector('[data-scan-freq=weekly]').getAttribute('aria-pressed') === 'true'));
|
|
107
|
-
ok('
|
|
170
|
+
ok('S16 Weekly posts the cadence for the workspace; the line says Every Monday, 09:00 and a toast says so', JSON.stringify(lastPost('/api/scan/setting').body) === JSON.stringify({ ws, cadence: 'weekly' }) && /Every Monday, 09:00/.test(await text('[data-scan="schedule"]')) && /every monday/i.test(await toastText()));
|
|
108
171
|
await page.click('[data-scan-freq=off]');
|
|
109
|
-
await until(() =>
|
|
110
|
-
|
|
111
|
-
ok('T15 Off says that no scan runs and shows no next scan', await st(() => /Off\. No scan runs on this board/.test(document.querySelector('[data-scan="schedule"]').textContent) && !document.querySelector('[data-scan="next"]')));
|
|
172
|
+
await until(() => st(() => document.querySelector('[data-scan-freq=off]').getAttribute('aria-pressed') === 'true'));
|
|
173
|
+
ok('S17 Off says no scan runs by itself, and that Scan now still works', /Off\. No scan runs by itself/.test(await text('[data-scan="schedule"]')) && /Scan now still works/.test(await text('[data-scan="next"]')));
|
|
112
174
|
await page.click('[data-scan-freq=daily]');
|
|
113
|
-
await until(() => S.cadenceOf(state(), ws) === 'daily');
|
|
114
175
|
await until(() => st(() => document.querySelector('[data-scan-freq=daily]').getAttribute('aria-pressed') === 'true'));
|
|
115
|
-
ok('
|
|
116
|
-
await page.
|
|
117
|
-
await
|
|
118
|
-
await
|
|
119
|
-
await
|
|
120
|
-
ok('
|
|
121
|
-
|
|
176
|
+
ok('S18 Daily brings a next scan back', /Next scan/.test(await text('[data-scan="next"]')) && /Every working day, 09:00/.test(await text('[data-scan="schedule"]')));
|
|
177
|
+
await page.uncheck('[data-scan-repo]');
|
|
178
|
+
await until(() => lastPost('/api/scan/setting').body.repo === false);
|
|
179
|
+
await page.uncheck('[data-scan-type=insight]');
|
|
180
|
+
await until(() => Array.isArray(lastPost('/api/scan/setting').body.types));
|
|
181
|
+
ok('S19 the repository switch posts repo:false and a report type posts the list of types still on', lastPost('/api/scan/setting').body.types.join() === 'benchmark,competitive,productivity');
|
|
182
|
+
ok('S20 the productivity report is marked repository only; the web is marked topics only', /repository only/.test(await text('[data-scan="types"]')) && /topics only/.test(await text('[data-scan="sources"]')));
|
|
183
|
+
await shot('setup-kept');
|
|
184
|
+
await page.check('[data-scan-repo]'); await page.check('[data-scan-type=insight]');
|
|
185
|
+
await until(() => { const l = lastPost('/api/scan/setting').body; return Array.isArray(l.types) && l.types.length === 4; });
|
|
186
|
+
P.fail = { path: '/api/scan/watch', error: 'the state folder is read only' };
|
|
187
|
+
await page.click('[data-scan-item=w-00000011] [data-scan-act=remove]');
|
|
188
|
+
await until(async () => /the state folder is read only/.test(await toastText()));
|
|
189
|
+
ok('S21 a press the server refuses shows the server\'s reason in plain words and the item stays', /the state folder is read only/.test(await toastText()) && (await items()).some((i) => i.id === 'w-00000011'));
|
|
190
|
+
|
|
191
|
+
/* ── the picker ── */
|
|
192
|
+
await page.click('[data-scan-setup] [data-scan-act=scan]:not([data-scan-head])');
|
|
193
|
+
await sel('[data-scan-pick] [data-scan-pi]');
|
|
194
|
+
const pick0 = await st(() => ({ items: [...document.querySelectorAll('[data-scan-pi]')].map((i) => i.checked), repo: document.querySelector('[data-scan-prepo]').checked, types: [...document.querySelectorAll('[data-scan-pt]')].map((i) => i.checked), t: document.querySelector('[data-scan-pick]').textContent }));
|
|
195
|
+
ok('K1 the picker lists the 5 kept items (all ticked), the repository and the 4 report types, and says how many manual scans are left today', pick0.items.length === 5 && pick0.items.every(Boolean) && pick0.repo && pick0.types.length === 4 && pick0.types.every(Boolean) && /does not change your schedule/.test(pick0.t) && /3 of 3 manual scans left today/.test(pick0.t), JSON.stringify(pick0));
|
|
196
|
+
await shot('picker');
|
|
197
|
+
await page.click('[data-scan-act=pnone]');
|
|
198
|
+
await page.uncheck('[data-scan-prepo]');
|
|
199
|
+
ok('K2 with no item and no repository the picker says so and Start is disabled', /Choose at least one watch item or the repository/.test(await text('[data-scan-pick]')) && await st(() => document.querySelector('[data-scan-act=start]').disabled));
|
|
200
|
+
await page.check('[data-scan-prepo]');
|
|
201
|
+
ok('K3 with the repository only, it warns that only the Productivity report can run', /Only the Productivity report can run/.test(await text('[data-scan-pick]')));
|
|
202
|
+
await page.click('[data-scan-act=pall]');
|
|
203
|
+
await page.uncheck('[data-scan-pt=competitive]');
|
|
204
|
+
P.fail = { path: '/api/scan/now', status: 409, why: 'quiet-hours', error: 'Quiet hours hold. A manual scan does not start inside them.' };
|
|
205
|
+
await page.click('[data-scan-act=start]');
|
|
206
|
+
await sel('[data-scan="start-error"]');
|
|
207
|
+
ok('K4a a 409 for quiet hours shows in the picker in plain words and the picker stays', /Quiet hours hold, so a manual scan does not start now/.test(await text('[data-scan="start-error"]')) && !!(await page.$('[data-scan-pick]')));
|
|
208
|
+
P.fail = { path: '/api/scan/now', status: 429, why: 'manual-limit', error: 'At most 3 manual scans a day. Today\'s 3 are used.' };
|
|
209
|
+
await page.click('[data-scan-act=start]');
|
|
210
|
+
await until(async () => /You used all 3 manual scans today/.test(await text('[data-scan="start-error"]')));
|
|
211
|
+
ok('K4 a 429 (the 3-a-day limit) shows in the picker as "You used all 3 manual scans today" and says the scheduled scan still runs', /You used all 3 manual scans today/.test(await text('[data-scan="start-error"]')) && /scheduled scan still runs/.test(await text('[data-scan="start-error"]')) && !!(await page.$('[data-scan-pick]')));
|
|
212
|
+
await page.click('[data-scan-act=start]');
|
|
213
|
+
await sel('[data-scan-run] [data-scan-act=stop]');
|
|
214
|
+
const now = lastPost('/api/scan/now').body;
|
|
215
|
+
ok('K5 Start posts items, repository and the chosen report types', now.ws === ws && now.items.length === 5 && now.items.slice(0, 4).join() === 'w-00000011,w-00000012,w-00000013,w-00000014' && /^w-[0-9a-f]{8}$/.test(now.items[4]) && now.repo === true && now.types.join() === 'benchmark,insight,productivity', JSON.stringify(now));
|
|
216
|
+
|
|
217
|
+
/* ── running ── */
|
|
218
|
+
const run0 = await st(() => ({ t: document.querySelector('[data-scan-run]').textContent, steps: [...document.querySelectorAll('[data-scan-step]')].map((n) => n.dataset.scanStep + ':' + n.dataset.scanStepState) }));
|
|
219
|
+
ok('R1 the running sheet shows the server\'s 3 steps (web, repo, reports) with the first running, Step 1 of 3 and Stop', run0.steps.join() === 'web:running,repo:pending,reports:pending' && /Step 1 of 3/.test(run0.t) && /Stopping keeps what was already filed/.test(run0.t), JSON.stringify(run0));
|
|
220
|
+
await shot('running');
|
|
221
|
+
P.advance(); P.advance();
|
|
222
|
+
await until(async () => /Step 3 of 3/.test(await text('[data-scan-run]')));
|
|
223
|
+
ok('R2 the sheet follows the server: two steps done, the third running', /Step 3 of 3/.test(await text('[data-scan-run]')) && (await text('[data-scan-step="web"]')).includes('done') && (await text('[data-scan-step="reports"]')).includes('now'));
|
|
224
|
+
await page.click('[data-scan-act=stop]');
|
|
225
|
+
await sel('[data-scan="stopped"]');
|
|
226
|
+
ok('R3 Stop posts /api/scan/stop and the sheet says it stopped and that nothing was sent', !!lastPost('/api/scan/stop') && /Stopped at step 3 of 3/.test(await text('[data-scan="stopped"]')) && /Nothing was sent/.test(await text('[data-scan="stopped"]')) && !fx.S.openScans(P.state()).some((x) => x.mode !== 'suggest') && P.state().scans.get(P.state().scanOrder[P.state().scanOrder.length - 1]).stopped === true);
|
|
227
|
+
await page.click('[data-scan-run] [data-scan-act=scan]');
|
|
228
|
+
await sel('[data-scan-pick] [data-scan-act=start]');
|
|
229
|
+
await page.click('[data-scan-act=start]');
|
|
230
|
+
await sel('[data-scan-run] [data-scan-act=stop]');
|
|
231
|
+
for (let i = 0; i < 4; i++) P.advance();
|
|
232
|
+
await sel('[data-scan="finished"]');
|
|
233
|
+
const fin = await text('[data-scan-run]');
|
|
234
|
+
ok('R4 a finished scan says how many reports were written and how many proposals wait, and lists each report with Open', /Scan finished: 4 reports written/.test(fin) && /4 proposals wait for you/.test(fin) && (await page.$$('[data-scan-run] [data-scan-report]')).length === 4, fin.slice(0, 300));
|
|
235
|
+
await shot('finished');
|
|
236
|
+
const RB = P.reportOf('benchmark'); const RC = P.reportOf('competitive'); const RP = P.reportOf('productivity');
|
|
237
|
+
const propOf = (rep, kind) => [...P.state().proposals.values()].find((x) => x.report === rep.id && x.kind === kind).id;
|
|
238
|
+
const BM1 = propOf(RB, 'create'); const BM2 = propOf(RB, 'research'); const BM3 = propOf(RB, 'attach'); const CP1 = propOf(RC, 'create');
|
|
239
|
+
await page.click(`[data-scan-run] [data-scan-act=opendoc][data-id=${RB.id}]`);
|
|
240
|
+
await sel('[data-scan-next]:not([hidden]) [data-scan-prop]', 15000);
|
|
241
|
+
|
|
242
|
+
/* ── a report document ── */
|
|
243
|
+
const rel = RB.path;
|
|
244
|
+
await until(() => docReads.includes(rel));
|
|
245
|
+
ok('D1 a scan report opens in the existing document sheet by the path the server gives (reports[].path, relative to the repository)', docReads.includes(rel) && /Benchmark report: partner onboarding/.test(await text('.sheet')) && /^(planning|pmos\/state)\/reports\/scan-benchmark\/\d{4}-\d{2}-\d{2}(-\d+)?\.md$/.test(rel), docReads.join());
|
|
246
|
+
ok('D2 the document sheet shows the text of the report (a section from the file)', await until(async () => /Gap 1: 9 days to first API call/.test(await text('.sheet'))));
|
|
247
|
+
const panel = () => st(() => { const p = document.querySelector('[data-scan-next]'); const sh = p && p.closest('.sheet'); const top = sh && sh.querySelector('.ds-top');
|
|
248
|
+
return p ? { hidden: p.hidden, expanded: p.querySelector('[data-scan-act=panel]').getAttribute('aria-expanded'), head: p.querySelector('.hd').textContent, below: !!(top && (top.compareDocumentPosition(p) & Node.DOCUMENT_POSITION_FOLLOWING)), props: p.querySelectorAll('[data-scan-prop]').length } : null; });
|
|
249
|
+
const p0 = await panel();
|
|
250
|
+
ok('D3 the panel says Proposed next steps (3), summarises the kinds, says nothing happens until you click, and sits under the sheet\'s header and tabs', p0 && !p0.hidden && /Proposed next steps \(3\)/.test(p0.head) && /1 create initiative, 1 research more, 1 add to an existing initiative/.test(p0.head) && /Nothing happens until you click/.test(p0.head) && p0.below, JSON.stringify(p0));
|
|
251
|
+
await shot('doc-next');
|
|
252
|
+
const kinds = await st(() => [...document.querySelectorAll('[data-scan-prop]')].map((n) => ({ id: n.dataset.scanProp, kind: n.dataset.scanKind, t: n.textContent, kv: [...n.querySelectorAll('dt')].map((d) => d.textContent).join('|'), ev: n.querySelectorAll('[data-scan-ev]').length, from: !!n.querySelector('[data-scan-act=jump]') })));
|
|
253
|
+
ok('D4 a create proposal shows title, board, lane, anchor, intent and starts-in, with FROM and EVIDENCE chips', kinds[0].kind === 'create' && kinds[0].kv === 'Title|Board|Lane|Anchor|Intent|Starts in' && /Gap 2: no changelog/.test(kinds[0].t) && kinds[0].ev === 3 && kinds[0].from && /chosen by the agent/.test(kinds[0].t), JSON.stringify(kinds[0]));
|
|
254
|
+
ok('D5 a research proposal shows question, sources, depth and result', kinds[1].kind === 'research' && kinds[1].kv === 'Question|Sources|Depth|Result' && /Standard: About 20 public pages, 10 minutes/.test(kinds[1].t), JSON.stringify(kinds[1]));
|
|
255
|
+
ok('D6 an attach proposal shows attach-to and what-it-adds', kinds[2].kind === 'attach' && kinds[2].kv === 'Attach to|What it adds|Shown in' && /scan-ui/.test(kinds[2].t), JSON.stringify(kinds[2]));
|
|
256
|
+
ok('D7 the buttons read Approve, Approve and start, Approve and attach, each with Change first and Dismiss', await st(() => [...document.querySelectorAll('[data-scan-prop]')].map((n) => [...n.querySelectorAll('[data-scan-act=approve],[data-scan-act=change],[data-scan-act=dismiss]')].map((b) => b.textContent.trim()).join('/')).join(',')) === 'Approve/Change first/Dismiss,Approve and start/Change first/Dismiss,Approve and attach/Change first/Dismiss');
|
|
257
|
+
const links = await st(() => [...document.querySelectorAll('[data-scan-prop] a[data-scan-ev=web]')].map((a) => ({ href: a.href, target: a.target, rel: a.rel })));
|
|
258
|
+
ok('D8 a public-page evidence chip is a link that opens in a new tab with noopener; a Jira key is not a link', links.length >= 3 && links.every((l) => l.target === '_blank' && /noopener/.test(l.rel)) && links[0].href === 'https://docs.stripe.com/changelog' && !(await page.$('a[data-scan-ev=jira]')), JSON.stringify(links));
|
|
259
|
+
await page.click(`[data-scan-prop=${BM1}] [data-scan-act=jump]`);
|
|
260
|
+
ok('D9 the FROM chip takes you to that place in the text and marks it', await until(() => st(() => !!document.querySelector('.sheet .flash')), 3000));
|
|
261
|
+
|
|
262
|
+
/* approve a create, after Change first */
|
|
263
|
+
await page.click(`[data-scan-act=change][data-id=${BM1}]`);
|
|
264
|
+
await sel(`[data-scan-form=${BM1}] [data-scan-f=title]`);
|
|
265
|
+
const form = await st((id) => ({ fields: [...document.querySelectorAll(`[data-scan-form="${id}"] [data-scan-f]`)].map((f) => f.dataset.scanF).join(), t: document.querySelector(`[data-scan-prop=${id}]`).textContent, approve: document.querySelector(`[data-scan-act=approve][data-id=${id}]`).textContent }), BM1);
|
|
266
|
+
ok('D10 Change first turns the fields into a form (title, board, lane, anchor, intent) and the button says Approve with changes', form.fields === 'title,board,lane,anchor,intent' && /Approve with changes/.test(form.approve) && /agent choice/.test(form.t), JSON.stringify(form));
|
|
267
|
+
await shot('doc-change');
|
|
268
|
+
await page.fill(`[data-scan-form=${BM1}] [data-scan-f=title]`, 'Changelog and change notices');
|
|
269
|
+
await page.selectOption(`[data-scan-form=${BM1}] [data-scan-f=lane]`, 'discovery');
|
|
270
|
+
await page.click(`[data-scan-act=approve][data-id=${BM1}]`);
|
|
271
|
+
await until(() => st((id) => document.querySelector(`[data-scan-prop=${id}]`).dataset.scanState === 'done', BM1));
|
|
272
|
+
const seq = posts().filter((c) => c.path === '/api/scan/proposal' && c.body.id === BM1).map((c) => c.body.action);
|
|
273
|
+
const ch = posts().find((c) => c.path === '/api/scan/proposal' && c.body.action === 'change' && c.body.id === BM1);
|
|
274
|
+
ok('D11 Approve with changes posts only the changed fields under the server\'s names (action change), then approve, and no writer field', seq.join() === 'change,approve' && Object.keys(ch.body.fields).sort().join() === 'lane,title' && ch.body.fields.title === 'Changelog and change notices' && ch.body.fields.lane === 'discovery' && !('by' in ch.body), JSON.stringify(ch.body));
|
|
275
|
+
ok('D12 the proposal then reads Created idpf-98 with Open in the board, and a toast says the initiative starts in Discover', /Created idpf-98/.test(await text('[data-scan-result=create]')) && !!(await page.$('[data-scan-result=create] [data-scan-act=go-board]')) && /idpf-98 created on team in Discover, with the report as evidence/.test(await toastText()), await toastText());
|
|
276
|
+
ok('D13 the count falls to Proposed next steps (2)', /Proposed next steps \(2\)/.test((await panel()).head));
|
|
277
|
+
ok('D14 an approval has no Undo: not on the toast, not on the proposal, and no undo is ever posted for it', !(await page.$('[data-c3-toast-action]')) && !(await page.$(`[data-scan-prop="${BM1}"] [data-scan-act=undo]`)) && !posts().some((c) => c.body.action === 'undo' && c.body.id === BM1));
|
|
278
|
+
|
|
279
|
+
/* dismiss needs a reason, then Undo */
|
|
280
|
+
await page.click(`[data-scan-act=dismiss][data-id=${BM3}]`);
|
|
281
|
+
await sel(`[data-scan-f=why][data-id=${BM3}]`);
|
|
282
|
+
const before = posts().length;
|
|
283
|
+
await page.click(`[data-scan-act=dismiss-ok][data-id=${BM3}]`);
|
|
284
|
+
await sel('[data-scan-need-why]');
|
|
285
|
+
ok('D15 Dismiss with no reason says "Say why" and posts nothing', /Say why/.test(await text('[data-scan-need-why]')) && posts().length === before && await st((id) => document.querySelector(`[data-scan-f=why][data-id=${id}]`).classList.contains('bad'), BM3));
|
|
286
|
+
await shot('doc-dismiss');
|
|
287
|
+
await page.fill(`[data-scan-f=why][data-id=${BM3}]`, 'we already decided this');
|
|
288
|
+
await page.click(`[data-scan-act=dismiss-ok][data-id=${BM3}]`);
|
|
289
|
+
await until(() => st((id) => document.querySelector(`[data-scan-prop=${id}]`).dataset.scanState === 'dismissed', BM3));
|
|
290
|
+
const dis = lastPost('/api/scan/proposal').body;
|
|
291
|
+
ok('D16 Dismiss with a reason posts action dismiss with the why; the proposal says it was dismissed (the reason is the server\'s dismissed_why), counted as a correction, not raised again', dis.action === 'dismiss' && dis.why === 'we already decided this' && /Dismissed: we already decided this/.test(await text('[data-scan-result=dismissed]')) && /Counted as a correction/.test(await toastText()));
|
|
292
|
+
await page.click(`[data-scan-act=dismiss][data-id=${BM2}]`);
|
|
293
|
+
await page.fill(`[data-scan-f=why][data-id=${BM2}]`, 'not now');
|
|
294
|
+
await page.click(`[data-scan-act=dismiss-ok][data-id=${BM2}]`);
|
|
295
|
+
await until(() => st((id) => document.querySelector(`[data-scan-prop=${id}]`).dataset.scanState === 'dismissed', BM2));
|
|
296
|
+
await page.click('[data-c3-toast-action]');
|
|
297
|
+
await until(() => st((id) => document.querySelector(`[data-scan-prop=${id}]`).dataset.scanState === 'open', BM2));
|
|
298
|
+
ok('D16b the Undo on the dismissal toast posts undo and the proposal waits again, with no reason left', lastPost('/api/scan/proposal').body.action === 'undo' && lastPost('/api/scan/proposal').body.id === BM2 && /Proposed next steps \(1\)/.test((await panel()).head) && !(await page.$(`[data-scan-prop="${BM2}"] [data-scan-result]`)));
|
|
299
|
+
await page.click(`[data-scan-act=dismiss][data-id=${BM2}]`);
|
|
300
|
+
await page.fill(`[data-scan-f=why][data-id=${BM2}]`, 'not now');
|
|
301
|
+
await page.click(`[data-scan-act=dismiss-ok][data-id=${BM2}]`);
|
|
302
|
+
await until(() => st((id) => document.querySelector(`[data-scan-prop=${id}]`).dataset.scanState === 'dismissed', BM2));
|
|
303
|
+
await page.click(`[data-scan-prop="${BM2}"] [data-scan-act=undo]`);
|
|
304
|
+
await until(() => st((id) => document.querySelector(`[data-scan-prop=${id}]`).dataset.scanState === 'open', BM2));
|
|
305
|
+
ok('D16c the Undo button on a dismissed proposal does the same', lastPost('/api/scan/proposal').body.action === 'undo' && lastPost('/api/scan/proposal').body.id === BM2 && P.state().proposals.get(BM2).state === 'open');
|
|
306
|
+
// A change made and not approved (an approval that failed after it): the proposal shows Undo my changes, and the press posts undo.
|
|
307
|
+
fx.S.proposalChange(P.file, BM2, { depth: 'deep' }, { by: 'pm:wawan' });
|
|
308
|
+
await st(() => import('/pm/c3-scan-api.js').then((m) => m.scanChanged()));
|
|
309
|
+
await sel(`[data-scan-act=undo-change][data-id=${BM2}]`);
|
|
310
|
+
await page.click(`[data-scan-act=undo-change][data-id=${BM2}]`);
|
|
311
|
+
await until(() => !P.state().proposals.get(BM2).changed);
|
|
312
|
+
ok('D16d a proposal with a change the PM did not approve offers Undo my changes; it posts undo and the agent\'s fields come back', lastPost('/api/scan/proposal').body.action === 'undo' && P.state().proposals.get(BM2).fields.depth === 'standard' && await until(async () => !(await page.$(`[data-scan-act=undo-change][data-id=${BM2}]`))));
|
|
313
|
+
|
|
314
|
+
/* research */
|
|
315
|
+
await page.click(`[data-scan-act=approve][data-id=${BM2}]`);
|
|
316
|
+
await sel(`[data-scan-run="${BM2}"]`);
|
|
317
|
+
const steps0 = await st(() => [...document.querySelectorAll('[data-scan-run] li')].map((n) => n.textContent).join('|'));
|
|
318
|
+
ok('D17 Approve and start posts approve; the proposal shows the research running with the open scan\'s steps and Stop; no Undo', lastPost('/api/scan/proposal').body.action === 'approve' && lastPost('/api/scan/proposal').body.id === BM2 && !!(await page.$(`[data-scan-run="${BM2}"] [data-scan-act=rstop]`)) && /Research started/.test(await toastText())
|
|
319
|
+
&& /Searching the public web/.test(steps0) && !(await page.$('[data-c3-toast-action]')), steps0);
|
|
320
|
+
await shot('doc-research');
|
|
321
|
+
const fol = P.research();
|
|
322
|
+
await sel('[data-scan-result=research]', 12000);
|
|
323
|
+
ok('D18 when the research ends the proposal offers the follow-up document', /Research finished/.test(await text('[data-scan-result=research]')));
|
|
324
|
+
await page.click('[data-scan-act=follow-up]');
|
|
325
|
+
await until(() => docReads.includes(fol.path));
|
|
326
|
+
ok('D19 Open the follow-up document opens the child report (its parent is the benchmark) in the sheet', docReads.includes(fol.path) && P.state().reports.get(fol.id).parent === RB.id && await until(async () => /Research: Which rate-limit headers/.test(await text('.sheet'))));
|
|
327
|
+
|
|
328
|
+
/* ── the Portfolio lists the reports and what waits ── */
|
|
329
|
+
await portfolio();
|
|
330
|
+
await sel('[data-scan-section] [data-scan-report]');
|
|
331
|
+
const sec2 = await text('[data-scan-section="portfolio"]');
|
|
332
|
+
ok('L1 the section lists the latest scan\'s reports (4, not the follow-up)', (await page.$$('[data-scan-section] [data-scan-report]')).length === 4 && /Benchmark report: partner onboarding/.test(sec2) && /Competitive analysis/.test(sec2) && /Productivity report/.test(sec2) && !/Research: /.test(sec2), sec2);
|
|
333
|
+
ok('L2 it says "Waiting for you: 1 proposal" (the competitive one; one created, one researched, one dismissed)', /Waiting for you: 1 proposal(?!s)/.test(await text('[data-scan="waiting"]')), await text('[data-scan="waiting"]'));
|
|
334
|
+
ok('L2b it says how many manual scans are left today (1 of 3 after the two above)', /1 of 3 manual scans left today/.test(sec2) && !(await st(() => document.querySelector('[data-scan-section] [data-scan-act=scan]').disabled)), sec2);
|
|
335
|
+
ok('L3 the section says the schedule, last scan and next scan', /Daily\. Last scan/.test(sec2) && /Next scan/.test(sec2), sec2);
|
|
336
|
+
await shot('portfolio-after');
|
|
337
|
+
await page.click(`[data-scan-section] [data-scan-report="${RC.id}"] .tt`);
|
|
338
|
+
await sel('[data-scan-next]:not([hidden]) [data-scan-prop]');
|
|
339
|
+
ok('L4 opening a report with proposals waiting opens its panel expanded', (await panel()).expanded === 'true' && (await panel()).props === 1);
|
|
340
|
+
await page.click('[data-scan-act=panel]');
|
|
341
|
+
ok('L5 the toggle closes the panel', (await panel()).expanded === 'false' && !(await page.$('[data-scan-prop]')));
|
|
342
|
+
await page.keyboard.press('Escape');
|
|
343
|
+
await page.click(`[data-scan-section] [data-scan-report="${RP.id}"] .tt`);
|
|
344
|
+
await sel('.sheet [data-doc-meta]');
|
|
345
|
+
await sleep(500);
|
|
346
|
+
ok('L6 a report with no proposals shows no panel', await st(() => { const p = document.querySelector('[data-scan-next]'); return !p || p.hidden; }));
|
|
347
|
+
await page.keyboard.press('Escape');
|
|
348
|
+
|
|
349
|
+
/* ── Briefing ── */
|
|
350
|
+
await page.goto(BASE + '/c3.html?tab=brief', { waitUntil: 'load' });
|
|
351
|
+
await sel('[data-c3pg="scan"] [data-scan-report]');
|
|
352
|
+
const card = await text('[data-c3pg="scan"]');
|
|
353
|
+
ok('B1 the Briefing has a Horizon scan card: when it ran, the boards scanned, the reports of the day', /Horizon scan/.test(card) && /today, \d\d:\d\d/.test(card) && /1 board scanned/.test(card) && /4 reports today/.test(card), card);
|
|
354
|
+
ok('B2 one row per report with the proposals waiting on it, and an Open', (await page.$$('[data-c3pg="scan"] [data-scan-report]')).length === 4 && /0 proposals waiting/.test(await text(`[data-scan-report="${RB.id}"]`)) && /1 proposal waiting/.test(await text(`[data-scan-report="${RC.id}"]`)) && /0 proposals waiting/.test(await text(`[data-scan-report="${RP.id}"]`)));
|
|
355
|
+
ok('B3 the card says 1 proposal waiting for you and that nothing was sent, 31 public pages were read and 2 findings skipped', /1 proposal waiting for you/.test(card) && /Nothing was sent\. The agent read 31 public pages, by topic only\. It skipped 2 findings you dismissed before\./.test(card), card);
|
|
356
|
+
await shot('briefing');
|
|
357
|
+
await page.click(`[data-c3pg="scan"] [data-scan-act=open-report][data-id=${RC.id}]`);
|
|
358
|
+
await sel('[data-scan-next]:not([hidden]) [data-scan-prop]');
|
|
359
|
+
ok('B4 Open on a report opens it in the document sheet with its proposals showing', (await panel()).expanded === 'true');
|
|
360
|
+
await page.keyboard.press('Escape');
|
|
361
|
+
await page.click('[data-c3pg="scan"] [data-scan-act=scan]');
|
|
362
|
+
ok('B5 Scan now on the card opens the picker', !!(await sel('[data-scan-pick] [data-scan-act=start]')));
|
|
363
|
+
await page.keyboard.press('Escape');
|
|
364
|
+
|
|
365
|
+
/* ── Board settings ── */
|
|
366
|
+
await page.goto(BASE + `/c3.html?tab=init&init=board:${ws}:summary`, { waitUntil: 'load' });
|
|
367
|
+
await (await sel('[data-ini=board-settings]')).click();
|
|
368
|
+
await sel('[data-scan-ptr]');
|
|
369
|
+
const ptr = await text('[data-scan-ptr]');
|
|
370
|
+
ok('G1 Board settings has one line: "Horizon scan runs for the whole portfolio" with "Open the Horizon scan setup"', /Horizon scan runs for the whole portfolio\./.test(ptr) && /Open the Horizon scan setup/.test(ptr) && /It is not set per board/.test(ptr), ptr);
|
|
371
|
+
ok('G2 the per-board watch list, schedule and Keep, Edit, Remove are gone from Board settings', !(await page.$('[data-scan-set]')) && !(await page.$('.sheet [data-scan-item]')) && !(await page.$('.sheet [data-scan-freq]')));
|
|
372
|
+
await shot('board-settings');
|
|
373
|
+
await page.click('[data-scan-ptr] [data-scan-act=setup]');
|
|
374
|
+
await sel('[data-scan-setup] [data-scan-freq=daily]');
|
|
375
|
+
ok('G3 "Open the Horizon scan setup" opens the portfolio setup sheet', !!(await page.$('[data-scan-setup]')) && !(await page.$('[data-scan-ptr]')));
|
|
376
|
+
const set1 = await until(async () => { const t = await text('[data-scan-setup]'); return /Last scan: today/.test(t) ? t : ''; });
|
|
377
|
+
ok('G4 once a scan has run the setup shows the last scan, its reports and the proposals waiting in Reports', /Last scan: today/.test(set1) && /4 reports written/.test(set1) && /1 proposal waiting in Reports/.test(set1) && !!(await page.$('[data-scan-setup] [data-scan-act=reports]')), set1.slice(0, 400));
|
|
378
|
+
ok('G5 the dismissed proposal is listed with its reason (read from the reports that hold it) as not raised again', await until(async () => /we already decided this/.test(await text('[data-scan="dismissed"]'))) && /not raised again/.test(await text('[data-scan="dismissed"]')) && !/not now/.test(await text('[data-scan="dismissed"]')));
|
|
379
|
+
await page.keyboard.press('Escape');
|
|
122
380
|
|
|
123
|
-
/* ── triage ── */
|
|
381
|
+
/* ── triage: an ask an earlier per-board scan found ── */
|
|
124
382
|
const seed = fx.seedScan();
|
|
125
383
|
await page.goto(BASE + `/c3.html?tab=init&init=board:${ws}:triage`, { waitUntil: 'load' });
|
|
126
384
|
await sel(`[data-ibt-ask="${seed.asks[0]}"]`);
|
|
127
|
-
const row = (i) => st((id) => { const r = document.querySelector(`[data-ibt-ask="${id}"]`); return { text: r.textContent, agent: !!r.querySelector('[data-scan="found-by-agent"]'), links: [...r.querySelectorAll('a[data-scan-src=web]')].map((a) => ({
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
ok('R2 a public page source is a link that opens in a new tab with noopener; a repository path and a Jira key are chips', r0.links.length === 2 && r0.links.every((l) => l.target === '_blank' && /noopener/.test(l.rel)) && /rivalnotes\.example\/blog\/pdf-export/.test(r0.links[0].href) && r2.links.length === 0 && r2.srcs === 2 && /src\/importer\.js/.test(r2.text) && /IDPF-1204/.test(r2.text), JSON.stringify([r0.links, r2.srcs]));
|
|
132
|
-
ok('R3 the match chip names the initiative; an ask with no match says "no initiative yet"', /matches scan-ui/.test(r1.text) && !r1.noMatch && r0.noMatch && r2.noMatch);
|
|
133
|
-
ok('R4 each has Accept, Ask for more and Decline with the shared attributes', [r0, r1, r2].every((r) => r.accept && r.more && r.decline) && /Accept into/.test(r0.text) && /Ask for more/.test(r0.text) && /Decline/.test(r0.text));
|
|
134
|
-
ok('R5 the agent is named by page name with no token', !/0a1b2c3d/.test(await st(() => document.body.innerHTML)));
|
|
135
|
-
await shot('triage-1280');
|
|
136
|
-
await page.setViewportSize({ width: 400, height: 900 });
|
|
137
|
-
await sleep(300);
|
|
138
|
-
await st((id) => document.querySelector(`[data-ibt-ask="${id}"]`).scrollIntoView({ block: 'start' }), seed.asks[0]);
|
|
139
|
-
await shot('triage-400');
|
|
140
|
-
ok('R6 at 400 wide Triage has no horizontal scroll', await noScroll(), await st(() => document.documentElement.scrollWidth));
|
|
141
|
-
await page.setViewportSize({ width: 1280, height: 900 });
|
|
385
|
+
const row = (i) => st((id) => { const r = document.querySelector(`[data-ibt-ask="${id}"]`); return { text: r.textContent, agent: !!r.querySelector('[data-scan="found-by-agent"]'), links: [...r.querySelectorAll('a[data-scan-src=web]')].map((a) => ({ target: a.target, rel: a.rel })), accept: !!r.querySelector('[data-ibt-accept]'), more: !!r.querySelector('[data-ibt-more]'), decline: !!r.querySelector('[data-ibt-decline]') }; }, seed.asks[i]);
|
|
386
|
+
const r0 = await row(0); const r2 = await row(2);
|
|
387
|
+
ok('T1 an ask an earlier scan found still reads "Found by agent", with the reason, source links that open in a new tab, and Accept, Ask for more and Decline',
|
|
388
|
+
r0.agent && /Horizon scan/.test(r0.text) && /A rival added PDF export/.test(r0.text) && r0.links.length === 2 && r0.links.every((l) => l.target === '_blank' && /noopener/.test(l.rel)) && r2.links.length === 0 && r0.accept && r0.more && r0.decline, JSON.stringify(r0));
|
|
142
389
|
|
|
143
|
-
/* ──
|
|
144
|
-
await page.goto(BASE + '/c3.html?tab=brief', { waitUntil: 'load' });
|
|
145
|
-
await sel('[data-c3pg="scan"] [data-scan-find]');
|
|
146
|
-
const card = await st(() => document.querySelector('[data-c3pg="scan"]').textContent);
|
|
147
|
-
ok('B1 the Horizon scan card counts the day: 3 asks found, 1 matches an initiative, 2 new, the board scanned', /Horizon scan/.test(card) && /3 asks found/.test(card) && /1 matches an initiative/.test(card) && /2 new/.test(card) && /1 board scanned/.test(card), card.slice(0, 400));
|
|
148
|
-
ok('B2 one row per finding, in the order they were found', await st(() => [...document.querySelectorAll('[data-scan-find]')].map((n) => n.dataset.scanFind).join()) === seed.findings.join());
|
|
149
|
-
ok('B3 the foot says nothing was sent, 12 public pages were read and 1 finding was skipped', /Nothing was sent\. The agent read 12 public pages\. It skipped 1 finding from earlier scans\./.test(card), card);
|
|
150
|
-
await shot('briefing-1280');
|
|
151
|
-
await page.click('[data-scan-find] [data-scan-go]');
|
|
152
|
-
await until(() => page.$('[data-ibt-ask]'));
|
|
153
|
-
ok('B4 "Open in Triage" on a finding opens that board\'s Triage', !!(await page.$(`[data-ibt-ask="${seed.asks[0]}"]`)) && /board:.*triage/.test(decodeURIComponent(page.url())), page.url());
|
|
154
|
-
await page.goto(BASE + '/c3.html?tab=brief', { waitUntil: 'load' });
|
|
155
|
-
await sel('[data-scan-settings]');
|
|
156
|
-
await page.click('[data-scan-settings]');
|
|
157
|
-
await until(() => page.$('[data-scan-set] [data-scan-item]'));
|
|
158
|
-
ok('B5 "Scan settings" opens the board with its settings open at the Horizon scan block', !!(await page.$('[data-scan-set] [data-scan-freq=daily]')));
|
|
159
|
-
ok('B6 the block now shows the last scan', /Last scan: today/.test(await st(() => document.querySelector('[data-scan="last"]').textContent)), await st(() => document.querySelector('[data-scan="last"]').textContent));
|
|
160
|
-
await page.goto(BASE + '/c3.html?tab=brief', { waitUntil: 'load' });
|
|
161
|
-
await sel('[data-c3pg="scan"] [data-scan-find]');
|
|
390
|
+
/* ── the phone ── */
|
|
162
391
|
await page.setViewportSize({ width: 400, height: 900 });
|
|
392
|
+
await portfolio(); await sel('[data-scan-section] [data-scan-report]');
|
|
163
393
|
await sleep(300);
|
|
164
|
-
await
|
|
394
|
+
await shot('portfolio-400');
|
|
395
|
+
ok('M1 at 400 wide the Portfolio has no horizontal scroll', await noScroll(), await st(() => document.documentElement.scrollWidth));
|
|
396
|
+
await page.click('[data-scan-section] [data-scan-act=setup]'); await sel('[data-scan-setup] [data-scan-item]'); await sleep(300);
|
|
397
|
+
await shot('setup-400');
|
|
398
|
+
ok('M2 at 400 wide the setup sheet has no horizontal scroll', await noScroll());
|
|
399
|
+
await page.keyboard.press('Escape');
|
|
400
|
+
await page.click('[data-scan-section] [data-scan-act=scan]'); await sel('[data-scan-pick] [data-scan-pi]'); await sleep(300);
|
|
401
|
+
ok('M3 at 400 wide the picker has no horizontal scroll', await noScroll());
|
|
402
|
+
await page.keyboard.press('Escape');
|
|
403
|
+
await page.click(`[data-scan-section] [data-scan-report="${RC.id}"] .tt`); await sel('[data-scan-next]:not([hidden]) [data-scan-prop]'); await sleep(500);
|
|
404
|
+
await shot('doc-next-400');
|
|
405
|
+
ok('M4 at 400 wide a report with its proposals open has no horizontal scroll', await noScroll());
|
|
406
|
+
await page.click(`[data-scan-act=change][data-id=${CP1}]`); await sleep(300);
|
|
407
|
+
ok('M5 at 400 wide the Change first form has no horizontal scroll', await noScroll());
|
|
408
|
+
await page.keyboard.press('Escape');
|
|
409
|
+
await page.goto(BASE + '/c3.html?tab=brief', { waitUntil: 'load' });
|
|
410
|
+
await sel('[data-c3pg="scan"] [data-scan-report]'); await sleep(300);
|
|
165
411
|
await shot('briefing-400');
|
|
166
|
-
ok('
|
|
412
|
+
ok('M6 at 400 wide the Briefing has no horizontal scroll', await noScroll());
|
|
413
|
+
|
|
414
|
+
/* Scan now is off once the day's three manual scans are used: the buttons are disabled and the server's sentence says why */
|
|
415
|
+
P.tools.w({ ev: 'scan-start', board: 'portfolio', scan: 'scan-00000000ff', mode: 'scan', watch: [], manual: true, repo: true, types: ['productivity'], plan: { web: false, repo: true }, by: 'pm:wawan' }, Date.now() - 60000);
|
|
416
|
+
P.tools.w({ ev: 'scan-end', board: 'portfolio', scan: 'scan-00000000ff', ok: false, reason: 'the repository run failed', pages: 0, found: 0, skipped: 0, runs: 1 }, Date.now() - 30000);
|
|
417
|
+
await page.setViewportSize({ width: 1280, height: 900 });
|
|
418
|
+
await portfolio();
|
|
419
|
+
await until(() => st(() => { const b = document.querySelector('[data-scan-section] [data-scan-act=scan]'); return b && b.disabled; }));
|
|
420
|
+
ok('X1 after three manual scans Scan now is disabled in the Portfolio section and the line says "You used all 3 manual scans today"', await st(() => document.querySelector('[data-scan-section] [data-scan-act=scan]').disabled) && /You used all 3 manual scans today/.test(await text('[data-scan-section] [data-scan="manual"]')), await text('[data-scan-section]'));
|
|
421
|
+
await page.click('[data-scan-section] [data-scan-act=setup]'); await sel('[data-scan-setup] [data-scan-item]');
|
|
422
|
+
ok('X2 the setup sheet disables Scan now in its header and in its status block', await st(() => [...document.querySelectorAll('.sheet [data-scan-act=scan]')].length === 2 && [...document.querySelectorAll('.sheet [data-scan-act=scan]')].every((b) => b.disabled)));
|
|
423
|
+
await page.keyboard.press('Escape');
|
|
167
424
|
|
|
168
425
|
ok('Z1 the page raised no script error', errors.length === 0, errors.join(' | '));
|
|
426
|
+
ok('Z2 no request carried a writer field (by, via, at)', posts().every((c) => !('by' in c.body) && !('via' in c.body) && !('at' in c.body)));
|
|
427
|
+
|
|
428
|
+
/* ── E · end to end on the real server routes ──────────────────────────────────────────────────────────
|
|
429
|
+
* A second server and repository with a seeded state folder (three suggestions, one kept and scanned, a finished scan of today with four reports and
|
|
430
|
+
* four proposals). No route is replaced: the page calls the server's own /api/scan/* routes. The runner is stubbed: PMOS_DRIVER=off, and a `claude`
|
|
431
|
+
* on the PATH that only records a call. Nothing may call it. */
|
|
432
|
+
fx.stop();
|
|
433
|
+
fx = await F.start({ name: 'c3-scan-e2e', clean: true, guard: true });
|
|
434
|
+
const E = fx.seedPortfolio();
|
|
435
|
+
const eBase = fx.BASE; const eWs = fx.ws; const eS = fx.S;
|
|
436
|
+
const eState = () => eS.fold(eS.readLines(fx.ledger));
|
|
437
|
+
const eLines = () => eS.readLines(fx.ledger);
|
|
438
|
+
const page2 = await (await browser.newContext({ viewport: { width: 1280, height: 900 } })).newPage();
|
|
439
|
+
const eErrors = []; page2.on('pageerror', (e) => eErrors.push(String((e && e.message) || e)));
|
|
440
|
+
const eDocs = []; page2.on('request', (r) => { if (r.url().includes('/api/doc?')) eDocs.push(new URL(r.url()).searchParams.get('path') || ''); });
|
|
441
|
+
const eSel = (q, ms) => until(() => page2.$(q), ms);
|
|
442
|
+
const eText = (q) => page2.evaluate((x) => { const n = document.querySelector(x); return n ? n.textContent : ''; }, q);
|
|
443
|
+
const eToast = () => page2.evaluate(() => (document.querySelector('[data-c3-toast]') || {}).textContent || '');
|
|
444
|
+
const eRb = E.reports.find((r) => r.type === 'benchmark'); const eRc = E.reports.find((r) => r.type === 'competitive');
|
|
445
|
+
const eProp = (rep, kind) => [...eState().proposals.values()].find((p) => p.report === rep.id && p.kind === kind);
|
|
446
|
+
await page2.goto(eBase + '/c3.html?tab=init', { waitUntil: 'load' });
|
|
447
|
+
await eSel('[data-scan-section="portfolio"] [data-scan-act=scan]');
|
|
448
|
+
await eSel('[data-scan-section] [data-scan-report]');
|
|
449
|
+
const eSec = await eText('[data-scan-section="portfolio"]');
|
|
450
|
+
ok('E1 the real portfolio route fills the Horizon scan section: Scan now is enabled and says 3 of 3 manual scans are left, the latest scan\'s 4 reports are listed, and 4 proposals wait',
|
|
451
|
+
/3 of 3 manual scans left today/.test(eSec) && !(await page2.evaluate(() => document.querySelector('[data-scan-section] [data-scan-act=scan]').disabled)) && (await page2.$$('[data-scan-section] [data-scan-report]')).length === 4 && /Waiting for you: 4 proposals/.test(eSec), eSec);
|
|
452
|
+
await page2.click('[data-scan-section] [data-scan-act=setup]');
|
|
453
|
+
await eSel('[data-scan-setup] [data-scan-item]');
|
|
454
|
+
const eItems = () => page2.evaluate(() => [...document.querySelectorAll('[data-scan-item]')].map((n) => ({ id: n.dataset.scanItem, state: n.dataset.scanState, text: n.textContent })));
|
|
455
|
+
const i0 = await eItems();
|
|
456
|
+
ok('E2 the setup sheet lists the 3 items of the real answer: one kept, two suggested with where they came from', i0.length === 3 && i0[0].state === 'kept' && i0[1].state === 'suggested' && /from scan-ui/.test(i0[1].text) && /Kept/.test(i0[0].text), JSON.stringify(i0.map((i) => i.state)));
|
|
457
|
+
await page2.click('[data-scan-item=w-00000022] [data-scan-act=keep]');
|
|
458
|
+
await until(async () => (await eItems()).find((i) => i.id === 'w-00000022').state === 'kept');
|
|
459
|
+
const keepLine = eLines().filter((l) => l.ev === 'watch-keep' && l.id === 'w-00000022').pop();
|
|
460
|
+
ok('E3 Keep writes a watch-keep line for that item, by the PM, to the real ledger', !!keepLine && keepLine.by === 'pm:wawan' && eState().itemById.get('w-00000022').state === 'kept', JSON.stringify(keepLine));
|
|
461
|
+
await page2.fill('[data-scan-add-in]', 'Webhook retries');
|
|
462
|
+
await page2.fill('[data-scan-add-why]', 'Partners lose events');
|
|
463
|
+
await page2.selectOption('[data-scan-add-board]', eWs);
|
|
464
|
+
await page2.click('[data-scan-act=add]');
|
|
465
|
+
await until(async () => (await eItems()).length === 4);
|
|
466
|
+
const addLine = eLines().filter((l) => l.ev === 'watch-add').pop();
|
|
467
|
+
ok('E4 Add writes a watch-add line with the name, the why and the board', !!addLine && addLine.name === 'Webhook retries' && addLine.why === 'Partners lose events' && JSON.stringify(addLine.boards) === JSON.stringify([eWs]) && addLine.by === 'pm:wawan', JSON.stringify(addLine));
|
|
468
|
+
await page2.keyboard.press('Escape');
|
|
469
|
+
// the seeded report opens in the document sheet by the path the server gave, and /api/doc serves it
|
|
470
|
+
const eRel = eRb.path;
|
|
471
|
+
const served = await fetch(eBase + `/api/doc?ws=${eWs}&path=${encodeURIComponent(eRel)}`);
|
|
472
|
+
ok('E5 the path the server gives for a report (reports[].path) is one that /api/doc serves, and the file says source: scan:portfolio', served.status === 200 && /^source: scan:portfolio$/m.test(await served.text()) && !path.isAbsolute(eRel), eRel);
|
|
473
|
+
await page2.click(`[data-scan-section] [data-scan-report="${eRb.id}"] .tt`);
|
|
474
|
+
await eSel('[data-scan-next]:not([hidden]) [data-scan-prop]', 15000);
|
|
475
|
+
const eProps = await page2.evaluate(() => [...document.querySelectorAll('[data-scan-prop]')].map((n) => ({ id: n.dataset.scanProp, kind: n.dataset.scanKind })));
|
|
476
|
+
ok('E6 the report opens in the document sheet and the docAbove hook shows its proposals panel (source: scan:*): 3 proposals, expanded because proposals wait', eDocs.includes(eRel) && eProps.map((p) => p.kind).join() === 'create,research,attach' && /Proposed next steps \(3\)/.test(await eText('[data-scan-next] .hd')) && /Gap 2: no changelog/.test(await eText('.sheet')), eDocs.join());
|
|
477
|
+
// Change first on the create proposal, then Approve
|
|
478
|
+
const create = eProp(eRb, 'create');
|
|
479
|
+
await page2.click(`[data-scan-act=change][data-id=${create.id}]`);
|
|
480
|
+
await eSel(`[data-scan-form=${create.id}] [data-scan-f=title]`);
|
|
481
|
+
await page2.fill(`[data-scan-form=${create.id}] [data-scan-f=title]`, 'Changelog and change notices');
|
|
482
|
+
await page2.fill(`[data-scan-form=${create.id}] [data-scan-f=intent]`, 'Partners read one dated page and subscribe by email.');
|
|
483
|
+
await page2.selectOption(`[data-scan-form=${create.id}] [data-scan-f=lane]`, 'discovery');
|
|
484
|
+
await page2.click(`[data-scan-act=approve][data-id=${create.id}]`);
|
|
485
|
+
await eSel(`[data-scan-result=create]`, 30000);
|
|
486
|
+
const ap = eState().proposals.get(create.id);
|
|
487
|
+
const boardsAns = (await fx.get(`/api/boards?ws=${eWs}`)).body;
|
|
488
|
+
const first = boardsAns.boards.find((b) => b.id === eWs).workflow[0].name;
|
|
489
|
+
const bl = fs.readFileSync(fx.boards, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
490
|
+
const moved = bl.find((l) => l.kind === 'move' && l.id === ap.result.initiative);
|
|
491
|
+
const eSeq = eLines().filter((l) => l.id === create.id).map((l) => l.ev);
|
|
492
|
+
ok('E7 Change first then Approve: the ledger has proposal-change then proposal-approve, and the proposal carries the changed title, lane and intent', eSeq.join() === 'proposal,proposal-change,proposal-approve' && ap.state === 'approved' && ap.title === 'Changelog and change notices' && ap.fields.lane === 'discovery' && /^Partners read/.test(ap.fields.intent), eSeq.join());
|
|
493
|
+
ok('E8 Approve made a real initiative in the first stage of the board\'s workflow: the file is in the repository, the boards ledger has the assign and the move into the first stage, and an evidence line links the report', !!ap.result.initiative && fs.existsSync(path.join(fx.team, ap.result.path)) && ap.result.stage === first && !!moved && moved.to === first && moved.from === null
|
|
494
|
+
&& eS.evidenceOf(eState(), ap.result.initiative).some((x) => x.report === eRb.id && x.proposal === create.id), JSON.stringify([ap.result, first, moved]));
|
|
495
|
+
const resTxt = await eText('[data-scan-result=create]');
|
|
496
|
+
ok('E9 the proposal reads Created <id> in the first stage with Open in the board; the toast says what happened and offers no Undo', new RegExp('Created ' + ap.result.initiative).test(resTxt) && resTxt.includes(first) && !!(await page2.$('[data-scan-result=create] [data-scan-act=go-board]')) && !(await page2.$('[data-c3-toast-action]')) && !(await page2.$(`[data-scan-prop="${create.id}"] [data-scan-act=undo]`))
|
|
497
|
+
&& new RegExp(ap.result.initiative + ' created on team in ' + first).test(await eToast()), resTxt + ' | ' + await eToast());
|
|
498
|
+
// Dismiss with a reason, then Undo
|
|
499
|
+
const attach = eProp(eRb, 'attach');
|
|
500
|
+
await page2.click(`[data-scan-act=dismiss][data-id=${attach.id}]`);
|
|
501
|
+
await page2.click(`[data-scan-act=dismiss-ok][data-id=${attach.id}]`);
|
|
502
|
+
ok('E10 Dismiss without a reason is refused in the panel and writes nothing', /Say why/.test(await eText('[data-scan-need-why]')) && !eLines().some((l) => l.ev === 'proposal-dismiss'));
|
|
503
|
+
await page2.fill(`[data-scan-f=why][data-id=${attach.id}]`, 'we already track this');
|
|
504
|
+
await page2.click(`[data-scan-act=dismiss-ok][data-id=${attach.id}]`);
|
|
505
|
+
await eSel(`[data-scan-prop="${attach.id}"][data-scan-state=dismissed]`);
|
|
506
|
+
const dl = eLines().filter((l) => l.ev === 'proposal-dismiss').pop();
|
|
507
|
+
ok('E11 Dismiss with a reason writes proposal-dismiss with that why, and the panel shows the reason', !!dl && dl.id === attach.id && dl.why === 'we already track this' && dl.by === 'pm:wawan' && eState().proposals.get(attach.id).state === 'dismissed' && /Dismissed: we already track this/.test(await eText('[data-scan-result=dismissed]')), JSON.stringify(dl));
|
|
508
|
+
await page2.click(`[data-scan-prop="${attach.id}"] [data-scan-act=undo]`);
|
|
509
|
+
await eSel(`[data-scan-prop="${attach.id}"][data-scan-state=open]`);
|
|
510
|
+
const ul = eLines().filter((l) => l.ev === 'proposal-undo').pop();
|
|
511
|
+
ok('E12 Undo appends a proposal-undo line (the dismiss line is still in the file) and the proposal waits for the PM again; the report route says open with no reason', !!ul && ul.id === attach.id && eLines().filter((l) => l.ev === 'proposal-dismiss' && l.id === attach.id).length === 1
|
|
512
|
+
&& eState().proposals.get(attach.id).state === 'open' && (await fx.get(`/api/scan/report?ws=${eWs}&id=${eRb.id}`)).body.proposals.find((p) => p.id === attach.id).dismissed_why === null && /Proposed next steps \(2\)/.test(await eText('[data-scan-next] .hd')), JSON.stringify(ul));
|
|
513
|
+
ok('E13 an approved proposal cannot be undone on the server (409), which is why the page offers no Undo for it', (await fx.post('/api/scan/proposal', { ws: eWs, id: create.id, action: 'undo' })).status === 409);
|
|
514
|
+
ok('E14 the runner never ran: no call to `claude`, and the ledger has no scan other than the seeded ones', fx.calls().length === 0 && eState().scanOrder.length === 2, fx.calls().join());
|
|
515
|
+
ok('E15 the end-to-end page raised no script error', eErrors.length === 0, eErrors.join(' | '));
|
|
169
516
|
await finish();
|
|
170
517
|
})().catch((e) => { console.error(e); finish(1); });
|