create-pmos 0.251.0 → 0.253.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 +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/workflow-state.js +6 -1
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/briefing-premium-board-test.js +275 -0
- package/workspace/briefing-premium-initiative-test.js +304 -0
- package/workspace/briefing-premium-test.js +438 -0
- package/workspace/briefing-premium-work-test.js +281 -0
- package/workspace/fixtures/board-premium-facts.js +38 -0
- package/workspace/fixtures/briefing-premium-facts.js +32 -0
- package/workspace/fixtures/initiative-premium-facts.js +42 -0
- package/workspace/fixtures/my-work-premium-facts.js +31 -0
- package/workspace/guide/briefing-premium.md +137 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/lib/board-premium.js +133 -0
- package/workspace/lib/briefing-premium-agent.js +134 -0
- package/workspace/lib/briefing-premium-writes.js +138 -0
- package/workspace/lib/briefing-premium.js +514 -0
- package/workspace/lib/engine/workflow-state.js +6 -1
- package/workspace/lib/headless.js +1 -1
- package/workspace/lib/initiative-premium.js +147 -0
- package/workspace/lib/my-work-premium.js +151 -0
- package/workspace/lib/route-modules.js +4 -0
- package/workspace/lib/routes-board-premium.js +61 -0
- package/workspace/lib/routes-briefing-premium.js +83 -0
- package/workspace/lib/routes-initiative-premium.js +68 -0
- package/workspace/lib/routes-my-work-premium.js +31 -0
- package/workspace/lib/vendor/scripts/workflow-state.js +6 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/premium-board.js +180 -0
- package/workspace/public/pm/premium-initiative.js +336 -0
- package/workspace/public/pm/premium-shell.js +184 -0
- package/workspace/public/pm/premium-work.js +198 -0
- package/workspace/public/pm/premium.css +1198 -0
- package/workspace/public/pm/premium.js +228 -0
- package/workspace/public/premium-board.html +73 -0
- package/workspace/public/premium-initiative.html +46 -0
- package/workspace/public/premium-work.html +84 -0
- package/workspace/public/premium.html +68 -0
- package/workspace/public/vendor/geist-mono-var.woff2 +0 -0
- package/workspace/public/vendor/geist-var.woff2 +0 -0
- package/workspace/server.js +49 -10
- package/workspace/test-files.json +12 -0
- package/workspace/test-lists/briefing-premium.json +17 -0
- package/workspace/test-lists/workspace-session-restore.json +5 -0
- package/workspace/third-party/README.md +12 -0
- package/workspace/third-party/geist-OFL.LICENSE +93 -0
- package/workspace/third-party/geist-mono-OFL.LICENSE +93 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/* briefing-premium-initiative-test.js — the premium initiative page (initiative briefing-premium-initiative; planning/prd/briefing-premium-initiative-prd.md I1 to I12).
|
|
4
|
+
* Serves the real page from the real server. Most checks answer GET /api/initiative-premium with the model lib/initiative-premium.js builds from a fixture
|
|
5
|
+
* (fixtures/initiative-premium-facts.js); the last blocks read this repository's own initiative through the real routes and compare with the mockup.
|
|
6
|
+
* I1 place · I2 reading · I3 header · I4 overview · I5 checks · I6 tasks · I7 documents · I8 document sheet · I9 opening · I10 presses · I11 one count · I12 floors and fidelity */
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { spawn } = require('child_process');
|
|
10
|
+
const { findBrowser } = require('./lib/find-browser');
|
|
11
|
+
const T = require('./test-run').configure({ name: 'briefing-premium-initiative' });
|
|
12
|
+
const IP = require('./lib/initiative-premium.js');
|
|
13
|
+
const F = require('./fixtures/initiative-premium-facts.js');
|
|
14
|
+
|
|
15
|
+
const BASE = T.BASE;
|
|
16
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
17
|
+
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); } };
|
|
18
|
+
const results = [];
|
|
19
|
+
const ok = (name, cond, extra = '') => { results.push({ name, cond: !!cond }); console.log(`${cond ? '✓' : '✗'} ${name}${!cond && extra !== '' ? ' — ' + extra : ''}`); };
|
|
20
|
+
let srv = null; let browser = null;
|
|
21
|
+
const finish = async (code) => {
|
|
22
|
+
if (browser) await browser.close().catch(() => {});
|
|
23
|
+
if (srv) { try { srv.kill('SIGKILL'); } catch { /* gone */ } }
|
|
24
|
+
T.killTmux();
|
|
25
|
+
const bad = results.filter((x) => !x.cond).length;
|
|
26
|
+
console.log(`\n${results.length - bad}/${results.length} briefing-premium-initiative assertions passed`);
|
|
27
|
+
process.exit(code !== undefined ? code : (bad ? 1 : 0));
|
|
28
|
+
};
|
|
29
|
+
process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
|
|
30
|
+
|
|
31
|
+
const mk = (o) => IP.build(F.facts(o));
|
|
32
|
+
const MOCKUP = path.join(__dirname, '..', 'prototype', 'premium-ux-concept', 'briefing-artifact.html');
|
|
33
|
+
const LOG = { ok: true, path: 'planning/prd/self-serve-v2-prd.md', revisions: [{ rev: 1, why: '', by: 'baseline', at: '2026-10-16T08:00:00Z' }, { rev: 2, why: 'tightened the goal', by: 'pm', at: '2026-10-17T08:00:00Z' }], events: [{ kind: 'asked', at: '2026-10-17T08:30:00Z', prompt: 'What is missing?' }] };
|
|
34
|
+
const TEXT = '---\ntype: PRD\ntitle: "Self-serve onboarding v2"\n---\n\n# PRD: Self-serve onboarding v2\n\n## Problem\n\nA partner waits for a person.\n\n## Goal\n\n- a first call without help\n- a key shown once\n\n## Requirements\n\n```\nR1 a first call\n```\n';
|
|
35
|
+
const HEALTH = { ok: true, path: 'planning/prd/self-serve-v2-prd.md', health: { passed: 1, total: 2, checks: [{ id: 'fresh', pass: true, reason: 'no cited source changed after it' }, { id: 'traced', pass: false, reason: 'a line with a figure has no source' }, { id: 'readers', pass: null, reason: 'no input' }] } };
|
|
36
|
+
|
|
37
|
+
(async () => {
|
|
38
|
+
const exe = findBrowser();
|
|
39
|
+
if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the suite cannot run'); return finish(1); }
|
|
40
|
+
let chromium;
|
|
41
|
+
try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed'); return finish(1); }
|
|
42
|
+
srv = spawn(process.execPath, [path.join(__dirname, 'server.js')], { cwd: __dirname, stdio: 'ignore', env: T.env({ PMOS_WS_PORT: String(T.PORT), PMOS_SELF_ROOT: path.resolve(__dirname, '..') }) });
|
|
43
|
+
await T.waitForServer(BASE);
|
|
44
|
+
browser = await chromium.launch({ executablePath: exe });
|
|
45
|
+
|
|
46
|
+
const open = async ({ w = 1280, h = 900, theme = 'light', model = mk(), answers = null, qs = '?id=self-serve-v2&ws=w1&board=w1' } = {}) => {
|
|
47
|
+
const ctx = await browser.newContext({ viewport: { width: w, height: h } });
|
|
48
|
+
const page = await ctx.newPage();
|
|
49
|
+
let current = model, calls = 0; const errors = [], posts = [], gets = []; let answer = { status: 200, body: { ok: true } }; let thread = { ok: true, turns: [], working: false };
|
|
50
|
+
page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
|
|
51
|
+
await page.route('**/api/initiative-premium*', (r) => { calls++; if (answers) { const a = answers(calls); return r.fulfill({ status: a.status, contentType: 'application/json', body: JSON.stringify(a.body) }); } return r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: current, as_of: F.NOW }) }); });
|
|
52
|
+
await page.route('**/api/core-docs/**', (r) => { const rq = r.request(); posts.push({ path: new URL(rq.url()).pathname, body: JSON.parse(rq.postData() || '{}'), headers: rq.headers() }); r.fulfill({ status: answer.status, contentType: 'application/json', body: JSON.stringify(answer.body) }); });
|
|
53
|
+
await page.route('**/api/doc-sheet/**', (r) => { const rq = r.request(), u = new URL(rq.url()); (rq.method() === 'GET' ? gets : posts).push({ path: u.pathname, query: u.search, method: rq.method() }); const b = u.pathname.endsWith('/log') ? LOG : u.pathname.endsWith('/at') ? { ok: true, path: LOG.path, rev: 2, text: TEXT } : { ok: false, error: 'not in this test' }; r.fulfill({ contentType: 'application/json', body: JSON.stringify(b) }); });
|
|
54
|
+
await page.route('**/api/boards/tasks/**', (r) => { const rq = r.request(); posts.push({ path: new URL(rq.url()).pathname, body: JSON.parse(rq.postData() || '{}'), headers: rq.headers() }); r.fulfill({ status: answer.status, contentType: 'application/json', body: JSON.stringify(answer.body) }); });
|
|
55
|
+
await page.route('**/api/doc-health/check', (r) => { posts.push({ path: '/api/doc-health/check', body: JSON.parse(r.request().postData() || '{}'), method: 'POST' }); r.fulfill({ contentType: 'application/json', body: JSON.stringify(HEALTH) }); });
|
|
56
|
+
await page.route('**/api/briefing-premium/**', (r) => { const rq = r.request(); if (rq.method() === 'GET') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(thread) }); posts.push({ path: new URL(rq.url()).pathname, body: JSON.parse(rq.postData() || '{}'), headers: rq.headers() }); r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true }) }); });
|
|
57
|
+
await page.goto(BASE + '/premium-initiative.html' + qs + '&theme=' + theme);
|
|
58
|
+
const up = await until(() => page.evaluate(() => document.querySelector('#cIniTk') || /could not be read|No initiative|not on a board/.test(document.querySelector('#snap').textContent + document.querySelector('#iMain').textContent)), 20000);
|
|
59
|
+
return { ctx, page, errors, posts, gets, up, calls: () => calls, setModel: (m) => { current = m; }, set: (a) => { answer = a; }, setThread: (t) => { thread = t; } };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const A = await open();
|
|
63
|
+
ok('0 the page draws the cards', A.up && await A.page.evaluate(() => !!document.querySelector('#cIniOv') && !!document.querySelector('#cIniTk')));
|
|
64
|
+
if (!A.up) return finish(1);
|
|
65
|
+
const p = A.page, M = mk();
|
|
66
|
+
const txt = (s) => p.evaluate((q) => (document.querySelector(q) || {}).textContent || '', s);
|
|
67
|
+
const all = (s) => p.evaluate((q) => [...document.querySelectorAll(q)].map((e) => e.textContent.trim()), s);
|
|
68
|
+
|
|
69
|
+
/* I1 place */
|
|
70
|
+
const rail = await p.evaluate(() => ({ names: [...document.querySelectorAll('.rail .pl')].map((b) => b.textContent.trim()), cur: document.querySelector('.rail [aria-current="page"]').textContent.trim() }));
|
|
71
|
+
ok('I1 the rail names the four places and marks Board', rail.names.join() === 'Briefing,My work,Board,Insights' && rail.cur === 'Board');
|
|
72
|
+
ok('I1 the crumb names the board and the initiative, with a switcher for each and the sparkle', await p.evaluate(() => document.querySelector('#crBoard').textContent === 'Onboarding' && document.querySelector('#crIni').textContent === 'Self-serve onboarding v2' && !!document.querySelector('#crSw') && !!document.querySelector('#crSw2') && document.querySelector('#crAsk').getAttribute('aria-label') === 'Ask about this initiative'));
|
|
73
|
+
await p.route('**/premium-board.html*', (r) => r.fulfill({ contentType: 'text/html', body: '<title>b</title>' }));
|
|
74
|
+
await p.route('**/premium-initiative.html?id=partner*', (r) => r.fulfill({ contentType: 'text/html', body: '<title>i</title>' }));
|
|
75
|
+
await p.click('#crSw'); const bm = await p.evaluate(() => ({ open: !document.querySelector('#crMenu').hidden, items: [...document.querySelectorAll('#crMenu button')].map((b) => b.textContent + (b.hasAttribute('aria-current') ? '*' : '')).join('|'), exp: document.querySelector('#crSw').getAttribute('aria-expanded') }));
|
|
76
|
+
ok('I1 the board switcher lists the boards and marks this one', bm.open && bm.items === 'Onboarding*|Payments' && bm.exp === 'true', JSON.stringify(bm));
|
|
77
|
+
await p.keyboard.press('Escape'); ok('I1 Escape closes the menu and gives the focus back to its button', await p.evaluate(() => document.querySelector('#crMenu').hidden && document.activeElement.id === 'crSw'));
|
|
78
|
+
await p.click('#crSw2'); const im = await p.evaluate(() => [...document.querySelectorAll('#crMenu button')].map((b) => b.textContent + (b.hasAttribute('aria-current') ? '*' : '')).join('|'));
|
|
79
|
+
ok('I1 the initiative switcher lists the board\'s initiatives and marks this one', im === 'Self-serve onboarding v2*|Partner API rate limits|Usage-based billing', im);
|
|
80
|
+
await p.click('#crMenu [data-swi="partner-rate-limits"]'); await sleep(500);
|
|
81
|
+
{ const u = new URL(p.url()); ok('I1 choosing an initiative opens its page on the same board', u.pathname === '/premium-initiative.html' && u.searchParams.get('id') === 'partner-rate-limits' && u.searchParams.get('board') === 'w1', p.url()); await p.goBack(); await until(() => p.evaluate(() => !!document.querySelector('#cIniTk'))); }
|
|
82
|
+
await p.click('#crSw'); await p.click('#crMenu [data-sw="w2|w2"]'); await sleep(500);
|
|
83
|
+
{ const u = new URL(p.url()); ok('I1 choosing a board opens that board\'s page', u.pathname === '/premium-board.html' && u.searchParams.get('ws') === 'w2' && u.searchParams.get('board') === 'w2', p.url()); await p.goBack(); await until(() => p.evaluate(() => !!document.querySelector('#cIniTk'))); }
|
|
84
|
+
await p.click('#crBoard'); await sleep(500);
|
|
85
|
+
{ const u = new URL(p.url()); ok('I1 the board\'s name leads back to the Board', u.pathname === '/premium-board.html' && u.searchParams.get('board') === 'w1', p.url()); await p.goBack(); await until(() => p.evaluate(() => !!document.querySelector('#cIniTk'))); }
|
|
86
|
+
await p.click('#crAsk'); await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(500);
|
|
87
|
+
ok('I1 the sparkle opens the initiative in the dialog with the read-only agent beside it', await p.evaluate(() => document.querySelector('#dTitle').textContent === 'Self-serve onboarding v2' && /Lane\|feature/.test([...document.querySelectorAll('#oMain .kv2 span')].map((x) => x.textContent).join('|')) && !document.querySelector('#askF').hidden));
|
|
88
|
+
await p.keyboard.press('Escape'); await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
|
|
89
|
+
|
|
90
|
+
/* I3 header */
|
|
91
|
+
const head = await p.evaluate(() => ({ chip: document.querySelector('.ihead .chp').textContent.trim(), tone: document.querySelector('.ihead .chp').className, meta: document.querySelector('.ihm').textContent, segs: [...document.querySelectorAll('.iws .sg2 i')].map((i) => i.className).join('|'), cur: document.querySelector('.iws em b').textContent }));
|
|
92
|
+
ok('I3 the chip, the meta and the stage strip are the record\'s', head.chip === 'At risk' && /warn/.test(head.tone) && head.meta === 'Onboarding · Dara · planned finish 30 Oct · stage derived by the workflow engine' && head.segs === 'p|p|p|p|p|n|||' && head.cur === 'Build', JSON.stringify(head));
|
|
93
|
+
|
|
94
|
+
/* I4 overview and I5 checks */
|
|
95
|
+
const ov = await p.evaluate(() => ({ who: document.querySelector('#cIniOv .tdw').textContent, h: document.querySelector('#cIniOv .todo b').textContent, p: document.querySelector('#cIniOv .todo p').textContent, more: document.querySelector('#cIniOv .todo small').textContent, skip: document.querySelector('#cIniOv .tdk').textContent, notes: [...document.querySelectorAll('#cIniOv .note')].map((n) => n.textContent) }));
|
|
96
|
+
ok('I4 the next step, its reason, its action and what happens if you wait come from the workflow engine; the reasons are shown', ov.who === 'Waits on you' && ov.h === 'Next: contract' && /pre_run_contract/.test(ov.p) && /Agree done_criteria/.test(ov.more) && ov.skip === 'If you wait: it stays at contract.' && ov.notes.length === 2, JSON.stringify(ov));
|
|
97
|
+
const chk = await p.evaluate(() => [...document.querySelectorAll('#cIniChk > .ckl .ckr')].map((r) => r.className + ':' + r.querySelector('b').textContent + ':' + r.querySelector('span').textContent));
|
|
98
|
+
ok('I5 the six checks carry the engine\'s state, the next one marked, and no row borrows the Overview\'s box', chk.join() === 'ckr done:spec:Done,ckr pending cur:contract:Waiting,ckr pending:build:Waiting,ckr unknown:quality:Unknown,ckr na:review:Not needed,ckr pending:accept:Waiting', chk.join());
|
|
99
|
+
ok('I5 each check states its evidence', /first-appeared at or before the first build commit/.test(await txt('#cIniChk .ckr:first-child small')) && /never claims a test result it did not run/.test(await all('#cIniChk .ckr small').then((x) => x.join(' '))));
|
|
100
|
+
ok('I5 "All 7 workflow steps" lists every step of the ladder', /All 7 workflow steps/.test(await txt('#cIniChk .src summary')) && (await p.evaluate(() => document.querySelectorAll('#cIniChk .src .ckr').length)) === 7);
|
|
101
|
+
ok('I4 the description line is shown only when the record has one', await p.evaluate(() => !document.querySelector('#cIniOv .lede')));
|
|
102
|
+
/* PMOS can help: two real presses */
|
|
103
|
+
ok('I4 PMOS can help offers a draft of the first document not started and a question for the agent', (await all('#cIniOv .agc')).join('|') === 'Draft the problem statement|Summarise the review findings');
|
|
104
|
+
A.set({ status: 200, body: { ok: true, run: 'r0' } });
|
|
105
|
+
await p.locator('#cIniOv .agc').first().click();
|
|
106
|
+
ok('I10 the draft button posts the existing draft route with the initiative scope and the key', await until(() => A.posts.some((x) => x.path === '/api/core-docs/draft' && x.body.key === 'problem' && x.body.scope === 'initiative:self-serve-v2' && Object.keys(x.body).sort().join() === 'key,scope,ws')));
|
|
107
|
+
const nH = A.posts.length;
|
|
108
|
+
await p.locator('#cIniOv .agc').nth(1).click(); await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(500);
|
|
109
|
+
ok('I10 the question button opens the dialog and sends its question to the read-only agent, and nothing else', await until(() => A.posts.length > nH && A.posts[A.posts.length - 1].path === '/api/briefing-premium/ask' && A.posts[A.posts.length - 1].body.key === 'init:self-serve-v2' && /^Summarise the review findings/.test(A.posts[A.posts.length - 1].body.text) && A.posts.slice(nH).every((x) => x.path === '/api/briefing-premium/ask')));
|
|
110
|
+
await p.keyboard.press('Escape'); await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
|
|
111
|
+
|
|
112
|
+
/* I6 tasks, I11 one count */
|
|
113
|
+
ok('I6 the caption, the bar and the list read one set', await txt('#hTk + small') === '3 PRD requirements · 3 from the board · no tracker link' && /1 done · 1 in progress · 1 to do/.test(await txt('#cIniTk .stb small')) && (await all('#cIniTk .tkq .tkn b')).join('|') === 'Sandbox key step|Checklist empty state|UAT with five partners|A partner reaches a first call|The key is shown once|Errors say what to do');
|
|
114
|
+
ok('I6 another initiative\'s tracker item is not listed', !/Another initiative/.test(await txt('#cIniTk')));
|
|
115
|
+
ok('I6 a requirement says no result; a tracker item says its state; a dated item shows its due date', (await all('#cIniTk .tkp')).join('|') === 'In progress|Done|To do|No result|No result|No result' && /Due 27 Oct/.test(await txt('#cIniTk')));
|
|
116
|
+
ok('I6 the views are List, By stage and Timeline', (await all('#tkTabs button')).join('|') === 'List|By stage|Timeline');
|
|
117
|
+
await p.click('#tkTabs [data-v="stage"]');
|
|
118
|
+
const cols = await p.evaluate(() => [...document.querySelectorAll('#tkBody .isc')].map((c) => c.querySelector('.label').textContent.replace(/\s+/g, ' ').trim()));
|
|
119
|
+
ok('I6 By stage groups by the records\' states, shows an empty column as None and puts the requirements under "No result recorded"', cols.join('|') === 'To do 1|In progress 1|On hold 0|Done 1|No result recorded 3', cols.join('|'));
|
|
120
|
+
await p.click('#tkTabs [data-v="time"]');
|
|
121
|
+
const tl = await p.evaluate(() => ({ bars: document.querySelectorAll('#tkBody .tkt').length, title: (document.querySelector('#tkBody .tkt .tlt b') || {}).textContent, und: [...document.querySelectorAll('#tkBody .tkch b')].map((b) => b.textContent).join('|'), months: [...document.querySelectorAll('#tkBody .tlax i')].map((i) => i.textContent).join(), today: !!document.querySelector('#tkBody .tlax b'), legend: document.querySelectorAll('#tkBody .gleg span').length }));
|
|
122
|
+
ok('I6 the Timeline draws a bar for each task with both dates, lists the rest as "No dates set", and draws today', tl.bars === 1 && tl.title === 'UAT with five partners' && /Sandbox key step/.test(tl.und) && /A partner reaches a first call/.test(tl.und) && tl.today && tl.legend === 3 && /Oct/.test(tl.months), JSON.stringify(tl));
|
|
123
|
+
await p.click('#tkTabs [data-v="list"]');
|
|
124
|
+
await p.locator('#cIniTk .tkq').first().click(); await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(500);
|
|
125
|
+
const td = await p.evaluate(() => ({ title: document.querySelector('#dTitle').textContent, grid: [...document.querySelectorAll('#oMain .kv2 span')].map((s) => s.textContent).join('|'), ask: document.querySelector('#askF').hidden, seg: !!document.querySelector('#oMain .tkst') }));
|
|
126
|
+
ok('I6 a Jira task opens its record as a grid, read-only: no status control and no question box', td.title === 'Sandbox key step' && /Reference\|ONB-12\|Source\|Jira \(read-only\)/.test(td.grid) && td.ask === true && td.seg === false, JSON.stringify(td));
|
|
127
|
+
await p.keyboard.press('Escape'); await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
|
|
128
|
+
/* a PMOS task: its status can be changed */
|
|
129
|
+
A.set({ status: 200, body: { ok: true, item: {} } });
|
|
130
|
+
await p.locator('#cIniTk .tkq', { hasText: 'UAT with five partners' }).click(); await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(500);
|
|
131
|
+
ok('I6 a PMOS task offers the board\'s four states, with its own marked', await p.evaluate(() => [...document.querySelectorAll('#oMain [data-tks]')].map((b) => b.textContent + (b.getAttribute('aria-pressed') === 'true' ? '*' : '')).join('|')) === 'To do*|In progress|In review|Done');
|
|
132
|
+
await p.click('#oMain [data-tks="In progress"]');
|
|
133
|
+
ok('I10 a status change posts the board, the task and the state to the board\'s own route, and nothing else', await until(() => A.posts.some((x) => x.path === '/api/boards/tasks/state' && x.body.ws === 'w1' && x.body.board === 'w1' && x.body.task === 't1' && x.body.state === 'In progress' && Object.keys(x.body).sort().join() === 'board,state,task,ws')));
|
|
134
|
+
await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
|
|
135
|
+
/* add a task */
|
|
136
|
+
await p.click('[data-tkadd]'); await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(400);
|
|
137
|
+
ok('I6 Add a task opens a form with common tasks, a title, an owner and optional dates, and says what empty dates mean', await p.evaluate(() => document.querySelectorAll('#oMain .chq button').length === 6 && !!document.querySelector('#atT') && !!document.querySelector('#atO') && !!document.querySelector('#atF') && !!document.querySelector('#atD') && /from tomorrow, for a week/.test(document.querySelector('#oMain').textContent)));
|
|
138
|
+
await p.click('#atGo'); ok('I6 an empty title is refused before any post', /Give the task a title/.test(await txt('#atM')) && !A.posts.some((x) => x.path === '/api/boards/tasks/add'));
|
|
139
|
+
await p.fill('#atT', 'UAT'); await p.fill('#atF', '2026-11-05'); await p.click('#atGo'); ok('I6 a start without a due date is refused', /Add a due date too/.test(await txt('#atM')));
|
|
140
|
+
await p.fill('#atD', '2026-11-01'); await p.click('#atGo'); ok('I6 a due date before the start is refused', /due date is before the start/.test(await txt('#atM')) && !A.posts.some((x) => x.path === '/api/boards/tasks/add'));
|
|
141
|
+
await p.click('#oMain [data-pre="1"]'); ok('I6 a common task fills the title', await p.inputValue('#atT') === 'Release plan');
|
|
142
|
+
await p.fill('#atF', ''); await p.fill('#atD', '');
|
|
143
|
+
A.set({ status: 400, body: { ok: false, error: 'no initiative self-serve-v2 on this board' } }); await p.click('#atGo');
|
|
144
|
+
ok('I6 a refusal shows the route\'s reason, keeps the form and turns the button back on', await until(() => p.evaluate(() => /no initiative self-serve-v2 on this board/.test(document.querySelector('#atM').textContent) && !document.querySelector('#atGo').disabled)));
|
|
145
|
+
A.set({ status: 200, body: { ok: true, item: {} } }); await p.fill('#atO', 'Dara'); await p.fill('#atF', '2026-11-01'); await p.fill('#atD', '2026-11-05'); await p.click('#atGo');
|
|
146
|
+
ok('I10 Add task posts the workspace, the board, this initiative, the title, the owner and the dates to the board\'s own route', await until(() => A.posts.some((x) => x.path === '/api/boards/tasks/add' && x.body.ws === 'w1' && x.body.board === 'w1' && x.body.initiative === 'self-serve-v2' && x.body.title === 'Release plan' && x.body.owner === 'Dara' && x.body.start === '2026-11-01' && x.body.due === '2026-11-05' && Object.keys(x.body).sort().join() === 'board,due,initiative,owner,start,title,ws')));
|
|
147
|
+
const mint0 = await T.nonce(BASE);
|
|
148
|
+
ok('I10 both task presses carried the launch nonce and no writer field', !!mint0 && A.posts.filter((x) => /^\/api\/boards\/tasks\//.test(x.path)).every((x) => x.headers['x-pmos-nonce'] === mint0 && !['by', 'via', 'at', 'actor', 'pm', 'nonce'].some((k) => k in x.body)));
|
|
149
|
+
await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
|
|
150
|
+
ok('I6 there is no Add a document: no store for it exists', !(await p.evaluate(() => /Add a document/.test(document.body.textContent))));
|
|
151
|
+
|
|
152
|
+
/* I7 documents, I11 one count */
|
|
153
|
+
ok('I7 the counts agree: caption, summary, filter and bar', await p.evaluate(() => { const s = document.querySelector('.dsum').textContent.replace(/\s+/g, ' '); const f = [...document.querySelectorAll('#dmF button')].map((b) => b.textContent.replace(/\s+/g, '')).join(','); return document.querySelector('#hDoc + small').textContent === '5' && /1 draft to review/.test(s) && /3 not started/.test(s) && /1 up to date/.test(s) && f === 'All5,Notstarted3,Draft1,Uptodate1' && document.querySelectorAll('.dprog i').length === 5; }));
|
|
154
|
+
ok('I7 the technical documents are grouped and not counted', await p.evaluate(() => /Technical documents \(2\)/.test(document.querySelector('.dmtech summary').textContent)));
|
|
155
|
+
await p.click('#dmF [data-dmf="none"]');
|
|
156
|
+
ok('I7 the filter shows the documents not started', (await all('#dmList .dmn b')).join('|') === 'Problem statement|Research notes|Launch checklist and comms');
|
|
157
|
+
await p.click('#dmF [data-dmf="all"]'); await p.fill('#dmQ', 'risk');
|
|
158
|
+
ok('I7 the search finds a document and keeps the focus in the box', (await all('#dmList .dmn b')).join('|') === 'Risks' && await p.evaluate(() => document.activeElement.id === 'dmQ'));
|
|
159
|
+
await p.fill('#dmQ', 'zzz'); ok('I7 no match says so', /No document matches/.test(await txt('#dmList')));
|
|
160
|
+
await p.fill('#dmQ', '');
|
|
161
|
+
|
|
162
|
+
/* I10 the press */
|
|
163
|
+
A.set({ status: 200, body: { ok: true, run: 'r1' } });
|
|
164
|
+
await p.locator('#dmList [data-draft="problem"]').click();
|
|
165
|
+
ok('I10 Draft it posts the workspace, the initiative scope and the key and nothing else, and says what happens', await until(() => A.posts.some((x) => x.path === '/api/core-docs/draft' && x.body.ws === 'w1' && x.body.scope === 'initiative:self-serve-v2' && x.body.key === 'problem' && Object.keys(x.body).sort().join() === 'key,scope,ws')) && await until(() => p.evaluate(() => /drafting “Problem statement”/.test(document.querySelector('#toast').textContent))));
|
|
166
|
+
const mint = await T.nonce(BASE);
|
|
167
|
+
ok('I10 the press carried the launch nonce and no writer field', !!mint && A.posts.filter((x) => x.path === '/api/core-docs/draft').every((x) => x.headers['x-pmos-nonce'] === mint && !['by', 'via', 'at', 'actor', 'pm', 'nonce'].some((k) => k in x.body)));
|
|
168
|
+
A.set({ status: 409, body: { ok: false, error: 'a run is already drafting that document' } });
|
|
169
|
+
await p.locator('#dmList [data-draft="problem"]').click();
|
|
170
|
+
ok('I10 a refused press shows the route\'s reason and turns the button back on', await until(() => p.evaluate(() => /already drafting/.test(document.querySelector('#toast').textContent) && !document.querySelector('#dmList [data-draft="problem"]').disabled)));
|
|
171
|
+
ok('I10 Issue, Drop and editing are not on the page', !/\b(Issue it|Drop|Accept|Edit)\b/.test(await txt('#iMain')));
|
|
172
|
+
ok('I7 a running document shows Drafting and is off', await p.evaluate(() => { const b = document.querySelector('#dmList [data-draft="launch"]'); return !!b && b.disabled && /Drafting/.test(b.textContent); }));
|
|
173
|
+
|
|
174
|
+
/* the batch press: one existing draft run for each document not started */
|
|
175
|
+
ok('I7 "Draft the 2 not started" counts the documents a press can draft, not the one already running', /Draft the 2 not started/.test(await txt('.dsum .dlink')));
|
|
176
|
+
A.set({ status: 200, body: { ok: true, run: 'rb' } }); const nB = A.posts.length;
|
|
177
|
+
await p.click('.dsum .dlink');
|
|
178
|
+
ok('I10 it posts one draft for each, with the initiative scope and nothing else', await until(() => A.posts.slice(nB).filter((x) => x.path === '/api/core-docs/draft').map((x) => x.body.key).sort().join() === 'problem,research' && A.posts.slice(nB).every((x) => x.path === '/api/core-docs/draft' && x.body.scope === 'initiative:self-serve-v2' && Object.keys(x.body).sort().join() === 'key,scope,ws')));
|
|
179
|
+
|
|
180
|
+
/* I8 the document sheet */
|
|
181
|
+
const nPost0 = A.posts.length;
|
|
182
|
+
await p.locator('#dmList .dmo[data-doc="prd"]').click(); await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on') && /Self-serve onboarding v2/.test(document.querySelector('#oMain').textContent) && document.querySelector('#oMain .dsbody'))); await sleep(500);
|
|
183
|
+
const sh = await p.evaluate(() => ({ title: document.querySelector('#dTitle').textContent, path: document.querySelector('#oMain .dpath').textContent, tabs: [...document.querySelectorAll('#shT button')].map((b) => b.textContent).join(','), fm: [...document.querySelectorAll('#oMain .kv2 span')].map((s) => s.textContent).join('|'), heads: [...document.querySelectorAll('#oMain h3.d2')].map((h) => h.textContent).join('|'), outline: [...document.querySelectorAll('#oMain .dso button')].map((b) => b.textContent).join('|'), pre: [...document.querySelectorAll('#oMain pre')].map((e) => e.textContent).join('|'), li: document.querySelectorAll('#oMain li').length }));
|
|
184
|
+
ok('I8 Read shows the file\'s path, its front matter as a grid, its headings, list and code', sh.title === 'PRD' && sh.path === 'planning/prd/self-serve-v2-prd.md' && sh.tabs === 'Read,Edit log,Health' && /type\|PRD\|title\|Self-serve onboarding v2/.test(sh.fm) && sh.heads === 'PRD: Self-serve onboarding v2|Problem|Goal|Requirements' && sh.li === 2 && /R1 a first call/.test(sh.pre), JSON.stringify(sh));
|
|
185
|
+
ok('I8 Read asked for the log and the last revision, by GET, for this path', A.gets.some((g) => g.path === '/api/doc-sheet/log' && /path=planning%2Fprd%2Fself-serve-v2-prd.md/.test(g.query)) && A.gets.some((g) => g.path === '/api/doc-sheet/at' && /rev=2/.test(g.query)));
|
|
186
|
+
await p.click('#shT [data-sh="log"]');
|
|
187
|
+
const lg = await until(() => p.evaluate(() => document.querySelector('#oMain .hsr') && [...document.querySelectorAll('#oMain .hsr')].map((r) => r.textContent.replace(/\s+/g, ' ').trim())));
|
|
188
|
+
ok('I8 Edit log lists the revisions newest first with their reasons and the other entries', lg && /v2 tightened the goal · pm/.test(lg[0]) && /v1 The file as the sheet first saw it · baseline/.test(lg[1]) && /asked What is missing\?/.test(lg[2]), JSON.stringify(lg));
|
|
189
|
+
await p.click('#shT [data-sh="health"]');
|
|
190
|
+
const hl = await until(() => p.evaluate(() => document.querySelector('#oMain .ckr') && { note: document.querySelector('#oMain .note').textContent, rows: [...document.querySelectorAll('#oMain .ckr')].map((r) => r.className + ':' + r.querySelector('span').textContent) }));
|
|
191
|
+
ok('I8 Health shows each check as passed, not passed or no input, and says no input is not counted', hl && /1 of 2 checks/.test(hl.note) && hl.rows.join() === 'ckr done:Passed,ckr blocked:Not passed,ckr skipped:No input', JSON.stringify(hl));
|
|
192
|
+
ok('I8 the health check posts the path and the workspace only', A.posts.some((x) => x.path === '/api/doc-health/check' && Object.keys(x.body).sort().join() === 'path,ws' && x.body.ws === 'w1'));
|
|
193
|
+
ok('I8 the sheet wrote nothing: no edit, propose, accept, reject, why, mark or ask post went out', !A.posts.slice(nPost0).some((x) => /doc-sheet/.test(x.path)) && !A.posts.some((x) => /^\/api\/doc-sheet\/(edit|propose|accept|reject|why|mark-current|ask)/.test(x.path)));
|
|
194
|
+
ok('I8 the sheet says it is read-only', /Read-only/.test(await txt('#oMain .xn')));
|
|
195
|
+
A.setThread({ ok: true, working: false, turns: [] });
|
|
196
|
+
await p.fill('#askIn', 'What is missing?'); const nA = A.posts.length; await p.press('#askIn', 'Enter');
|
|
197
|
+
ok('I8 a question posts the document key and the text', await until(() => A.posts.length > nA && A.posts[A.posts.length - 1].path === '/api/briefing-premium/ask' && A.posts[A.posts.length - 1].body.key === 'doc:self-serve-v2:prd' && A.posts[A.posts.length - 1].body.text === 'What is missing?'));
|
|
198
|
+
await p.keyboard.press('Escape'); await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
|
|
199
|
+
ok('I8 a document with no file has no sheet to open', await p.evaluate(() => !document.querySelector('#dmList [data-doc="problem"]')));
|
|
200
|
+
ok('0 no page error', A.errors.length === 0, A.errors.join(' | '));
|
|
201
|
+
await A.ctx.close();
|
|
202
|
+
|
|
203
|
+
/* I8 a file that cannot be read */
|
|
204
|
+
{
|
|
205
|
+
const B = await open();
|
|
206
|
+
await B.page.unroute('**/api/doc-sheet/**'); await B.page.route('**/api/doc-sheet/**', (r) => r.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ ok: false, error: 'the file is not in this repository' }) }));
|
|
207
|
+
await B.page.locator('#dmList .dmo[data-doc="prd"]').click();
|
|
208
|
+
ok('I8 a file that cannot be read says why and shows no text', await until(() => B.page.evaluate(() => /the file is not in this repository/.test(document.querySelector('#oMain').textContent))));
|
|
209
|
+
await B.ctx.close();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/* I2 reading and failure */
|
|
213
|
+
{
|
|
214
|
+
let n = 0;
|
|
215
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), rp = await ctx.newPage();
|
|
216
|
+
await rp.route('**/api/initiative-premium*', (r) => { n++; return r.fulfill(n < 3 ? { status: 202, contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'reading' }) } : { contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: mk(), as_of: F.NOW }) }); });
|
|
217
|
+
await rp.goto(BASE + '/premium-initiative.html?id=self-serve-v2');
|
|
218
|
+
ok('I2 while the initiatives are read the page says so and shows no card', await until(() => rp.evaluate(() => /Reading the initiative/.test(document.querySelector('#snap').textContent) && !document.querySelector('#cIniTk'))));
|
|
219
|
+
ok('I2 it asks again and draws when the read ends', await until(() => rp.evaluate(() => !!document.querySelector('#cIniTk')), 15000) && n >= 3);
|
|
220
|
+
await ctx.close();
|
|
221
|
+
const E = await open({ answers: () => ({ status: 500, body: { ok: false, state: 'error', error: 'boom' } }) });
|
|
222
|
+
ok('I2 a failed read says why and draws no card', await E.page.evaluate(() => /The initiative could not be read/.test(document.querySelector('#snap').textContent) && /boom/.test(document.querySelector('#iMain').textContent) && !document.querySelector('#cIniTk')));
|
|
223
|
+
await E.ctx.close();
|
|
224
|
+
const N = await open({ answers: () => ({ status: 404, body: { ok: false, state: 'error', error: 'nope is not an initiative on a board' } }), qs: '?id=nope' });
|
|
225
|
+
ok('I2 an initiative that is not on a board says so and does not retry', await N.page.evaluate(() => /nope is not an initiative on a board/.test(document.querySelector('#iMain').textContent)) && N.calls() === 1);
|
|
226
|
+
await N.ctx.close();
|
|
227
|
+
const X = await open({ qs: '?x=1', answers: () => ({ status: 400, body: { ok: false, state: 'error', error: 'id must name an initiative' } }) });
|
|
228
|
+
ok('I2 no initiative named says so', await X.page.evaluate(() => /needs an initiative/.test(document.querySelector('#iMain').textContent)));
|
|
229
|
+
await X.ctx.close();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* I4 the description */
|
|
233
|
+
{
|
|
234
|
+
const L = await open({ model: mk({ row: Object.assign({}, F.ROW, { title: 'Self-serve onboarding v2 — a partner reaches a first call without help' }) }) });
|
|
235
|
+
ok('I4 a title "name — purpose" puts the name in the crumb and the purpose in the description line', await L.page.evaluate(() => document.querySelector('#crIni').textContent === 'Self-serve onboarding v2' && document.querySelector('#cIniOv .lede').textContent === 'A partner reaches a first call without help' && document.querySelector('#iH1').textContent.includes('—')));
|
|
236
|
+
await L.ctx.close();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* I3 degraded: nothing invented */
|
|
240
|
+
{
|
|
241
|
+
const D = await open({ model: mk({ row: Object.assign({}, F.ROW, { owner: null, end: null, status: null }), sheet: null, ladder: null, items: [], docs: null }) });
|
|
242
|
+
const d = await D.page.evaluate(() => ({ chip: !!document.querySelector('.ihead .chp'), meta: document.querySelector('.ihm').textContent, ov: document.querySelector('#cIniOv').textContent, chk: !!document.querySelector('#cIniChk'), tk: document.querySelector('#cIniTk').textContent, docs: document.querySelector('#cIniDoc').textContent }));
|
|
243
|
+
ok('I3 with no status, owner, finish, sheet, items or documents the page says so and draws none of them', !d.chip && d.meta === 'Onboarding · stage derived by the workflow engine' && /could not be read, so the next step is not shown/.test(d.ov) && !d.chk && /no PRD requirement list and no tracker item/.test(d.tk) && /could not be read, so none is shown/.test(d.docs), JSON.stringify(d));
|
|
244
|
+
await D.ctx.close();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* I12 floors */
|
|
248
|
+
for (const [w, h] of [[1280, 900], [390, 844]]) {
|
|
249
|
+
const R = await open({ w, h });
|
|
250
|
+
const f = await R.page.evaluate(() => { const bad = []; for (const e of document.querySelectorAll('button,a,[role=button],input')) { if (e.closest('[hidden],[inert]')) continue; const r = (e.tagName === 'INPUT' && e.closest('label') ? e.closest('label') : e).getBoundingClientRect(); if (r.width && r.height && (r.height < 32 || r.width < 32) && !e.closest('.seg,.dprog')) bad.push((e.id || e.className || e.tagName) + ' ' + Math.round(r.width) + 'x' + Math.round(r.height)); } return { sx: document.documentElement.scrollWidth - innerWidth, small: bad.slice(0, 6) }; });
|
|
251
|
+
ok(`I12 no sideways scroll and no tiny target at ${w} px`, f.sx <= 0 && f.small.length === 0, JSON.stringify(f));
|
|
252
|
+
if (w === 390) {
|
|
253
|
+
await R.page.locator('#dmList .dmo[data-doc="prd"]').click(); await until(() => R.page.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(700);
|
|
254
|
+
const g = await R.page.evaluate(() => { const r = document.querySelector('#dlg').getBoundingClientRect(); return { l: Math.round(r.left), w: Math.round(r.width), vw: innerWidth }; });
|
|
255
|
+
ok('I12 the sheet is full width on a phone', g.l <= 1 && g.w >= g.vw - 2, JSON.stringify(g));
|
|
256
|
+
}
|
|
257
|
+
await R.ctx.close();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* the real routes against this repository */
|
|
261
|
+
{
|
|
262
|
+
const get = async (q) => { for (let i = 0; i < 60; i++) { const r = await fetch(BASE + '/api/initiative-premium' + q); const b = await r.json(); if (r.status !== 202) return { status: r.status, body: b }; await sleep(3000); } return { status: 202, body: null }; };
|
|
263
|
+
const r = await get('?id=briefing-premium-board');
|
|
264
|
+
const m = r.body && r.body.model;
|
|
265
|
+
ok('I1 the real route answers ready with this initiative\'s model', r.status === 200 && r.body.state === 'ready' && m && m.id === 'briefing-premium-board' && m.key === 'init:briefing-premium-board', JSON.stringify(r.body).slice(0, 200));
|
|
266
|
+
ok('I6 its PRD requirements are the PRD\'s own (B1 to B13) and no more', m && m.tasks.reqs >= 12 && m.tasks.rows.filter((t) => t.src === 'PRD requirement').every((t) => /^B\d+$/.test(t.ref)));
|
|
267
|
+
ok('I7 its core documents come with a scope and counts that add up', m && m.docs.scope === 'initiative:briefing-premium-board' && m.docs.counts.all === m.docs.rows.filter((d) => !d.tech).length);
|
|
268
|
+
const miss = await get('?id=no-such-initiative-xyz');
|
|
269
|
+
ok('I1 an initiative that is not on a board answers 404 with a reason', miss.status === 404 && miss.body && miss.body.ok === false && /no-such-initiative-xyz/.test(miss.body.error), JSON.stringify(miss.body));
|
|
270
|
+
const bad = await fetch(BASE + '/api/initiative-premium?id=../etc/passwd'); ok('I1 an id that is not an id is refused', bad.status === 400);
|
|
271
|
+
const post = await fetch(BASE + '/api/initiative-premium?id=x', { method: 'POST' }); ok('I10 the route answers GET only', post.status === 405);
|
|
272
|
+
}
|
|
273
|
+
/* I12 fidelity against the mockup */
|
|
274
|
+
if (!fs.existsSync(MOCKUP)) ok('I12 the mockup is in the repository', false, MOCKUP);
|
|
275
|
+
else {
|
|
276
|
+
const PAIRS = [['rail', '.rail'], ['crumb', '.crumb'], ['crb', '#crBoard'], ['cri', '#crSw'], ['crask', '#crAsk'], ['lede', '#cIniOv .lede'], ['agr', '#cIniOv .agr2'], ['agc', '#cIniOv .agc'], ['tkadd', '.tkadd'], ['ckrs', '#cIniChk .ckr small'], ['chip', '.ihead .chp'], ['meta', '.ihm'], ['strip', '.iws em'], ['segs', '.iws .sg2'], ['ov', '#cIniOv'], ['ovh', '#cIniOv h2'], ['todo', '#cIniOv .todo'], ['tdw', '#cIniOv .tdw'], ['chk', '#cIniChk'], ['ckr', '#cIniChk .ckr'], ['tk', '#cIniTk'], ['tkq', '#cIniTk .tkq:last-child'], ['tki', '#cIniTk .tkq:last-child .tki'], ['tkn', '#cIniTk .tkq:last-child .tkn b'], ['tkp', '#cIniTk .tkq:last-child .tkp'], ['doc', '#cIniDoc'], ['dsum', '.dsum'], ['dprog', '.dprog'], ['dtool', '.dtool'], ['dmr', '#cIniDoc .dmr'], ['dmi', '#cIniDoc .dmi.c-Plan'], ['dock', '.dock']];
|
|
277
|
+
const SKIP_W = ['crumb', 'crb', 'lede', 'agr', 'agc', 'tkadd', 'ckrs', 'chip', 'meta', 'strip', 'segs', 'ov', 'ovh', 'todo', 'tdw', 'chk', 'ckr', 'tk', 'tkq', 'tki', 'tkn', 'tkp', 'doc', 'dsum', 'dprog', 'dtool', 'dmr', 'dmi'];
|
|
278
|
+
const SKIP_L = ['crumb', 'crb', 'cri', 'crask', 'lede', 'agr', 'agc', 'tkadd', 'ckrs', 'chip', 'meta', 'strip', 'segs', 'tdw', 'ovh', 'ckr', 'tkq', 'tki', 'tkn', 'tkp', 'dmr', 'dmi', 'dsum', 'dprog', 'dtool', 'todo'];
|
|
279
|
+
const PROPS = ['fontSize', 'fontWeight', 'fontFamily', 'color', 'backgroundColor', 'borderRadius', 'paddingTop', 'paddingLeft', 'letterSpacing', 'lineHeight', 'boxShadow', 'backdropFilter', 'borderTopColor'];
|
|
280
|
+
const grab = (pg) => pg.evaluate(([pairs, props]) => { const o = {}; for (const [n, s0] of pairs) { const s = (n === 'rail' || n === 'dock') ? s0 : 'main.stage:not([hidden]) ' + s0; const e = document.querySelector(s); if (!e) { o[n] = null; continue; } const cs = getComputedStyle(e), r = e.getBoundingClientRect(), v = { left: Math.round(r.left), width: Math.round(r.width) }; props.forEach((k) => { v[k] = cs[k]; }); o[n] = v; } return o; }, [PAIRS, PROPS]);
|
|
281
|
+
for (const [w, h] of [[1280, 900], [390, 844]]) for (const th of ['light', 'dark']) {
|
|
282
|
+
const m = await (await browser.newContext({ viewport: { width: w, height: h } })).newPage();
|
|
283
|
+
await m.goto('file://' + MOCKUP);
|
|
284
|
+
await until(() => m.evaluate(() => document.querySelectorAll('#rank .rr').length > 0));
|
|
285
|
+
await m.evaluate((t) => { document.documentElement.dataset.theme = t; document.querySelector('.pl[data-p="init"]').click(); }, th);
|
|
286
|
+
await until(() => m.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0)); await sleep(500);
|
|
287
|
+
await m.evaluate(() => { showInit('initiative', 'pmos', 0); });
|
|
288
|
+
await until(() => m.evaluate(() => !!document.querySelector('#cIniTk') && !!document.querySelector('#cIniDoc .dmr'))); await sleep(900);
|
|
289
|
+
const R = await open({ w, h, theme: th, model: mk({ row: Object.assign({}, F.ROW, { title: 'Self-serve onboarding v2 — a partner reaches a first call without help' }) }) }); await sleep(500);
|
|
290
|
+
const a = await grab(m), c = await grab(R.page), diffs = [];
|
|
291
|
+
for (const [n] of PAIRS) {
|
|
292
|
+
if (!a[n] || !c[n]) { diffs.push(n + ' missing'); continue; }
|
|
293
|
+
for (const k of Object.keys(a[n])) {
|
|
294
|
+
const geom = k === 'left' || k === 'width';
|
|
295
|
+
if (geom && ((k === 'width' && SKIP_W.includes(n)) || (k === 'left' && SKIP_L.includes(n)))) continue;
|
|
296
|
+
if (geom ? Math.abs(a[n][k] - c[n][k]) > 2 : a[n][k] !== c[n][k]) diffs.push(`${n}.${k} ${a[n][k]} | ${c[n][k]}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
ok(`I12 styles and geometry match the mockup at ${w} px, ${th}`, diffs.length === 0, diffs.join(' ; '));
|
|
300
|
+
await m.context().close(); await R.ctx.close();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return finish();
|
|
304
|
+
})().catch((e) => { console.error(e); finish(1); });
|