create-pmos 0.252.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 +4 -2
- package/workspace/test-files.json +12 -0
- package/workspace/test-lists/briefing-premium.json +17 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.253.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
kernel-source: pmos@
|
|
2
|
-
assembled: 2026-10-
|
|
1
|
+
kernel-source: pmos@86e280d
|
|
2
|
+
assembled: 2026-10-17
|
|
3
3
|
mode: vendor
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-17T04:34:20Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Health budgets"
|
|
4
4
|
description: "Closed registry of health-budget anchors (D59), edited only by the PM."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-17T04:34:20Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -323,7 +323,7 @@ function buildRange(root, { spec, whole }) {
|
|
|
323
323
|
|
|
324
324
|
// Where each path was ADDED, as an index into the same range. `--diff-filter=A` in one pass.
|
|
325
325
|
const added = new Map();
|
|
326
|
-
const addLog = git(root, ['log', '--reverse', '--format=%x1e%H', '--diff-filter=A', '--name-only', range]);
|
|
326
|
+
const addLog = git(root, ['log', '--reverse', '--no-renames', '--format=%x1e%H', '--diff-filter=A', '--name-only', range]); // no rename detection: a path renamed into place is an add, as the per-path lookup below sees it
|
|
327
327
|
if (addLog != null) {
|
|
328
328
|
for (const chunk of addLog.split('\x1e')) {
|
|
329
329
|
if (!chunk.trim()) continue;
|
|
@@ -358,6 +358,11 @@ function firstAppearance(root, range, rel) {
|
|
|
358
358
|
// ever been committed — so the fallback below only runs for a PR range, where "added before the
|
|
359
359
|
// range" and "never committed" are genuinely different answers and only git can tell them apart.
|
|
360
360
|
if (range.added && range.added.has(rel)) return range.added.get(rel);
|
|
361
|
+
// A TRACKED path that this range did not add was added before it: -1, exactly what the per-path
|
|
362
|
+
// `git log` below would conclude, from the one shared `ls-files`. That per-path call was one
|
|
363
|
+
// subprocess per record on a branch ahead of main, and most of a board read's time.
|
|
364
|
+
const tracked = trackedIn(root, range);
|
|
365
|
+
if (tracked && tracked.has(rel)) return -1;
|
|
361
366
|
const log = git(root, ['log', '--diff-filter=A', '--reverse', '--format=%H', '--', rel]);
|
|
362
367
|
const sha = log ? log.split('\n').map((s) => s.trim()).filter(Boolean)[0] : null;
|
|
363
368
|
if (!sha) return onDisk ? Infinity : null;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Discoveries and friction log"
|
|
4
4
|
description: "Append-only record of what runs discovered and where the process rubbed."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-17T04:34:20Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/payload/state/log.md
CHANGED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/* briefing-premium-board-test.js — the premium Board page (initiative briefing-premium-board; planning/prd/briefing-premium-board-prd.md B1 to B13).
|
|
4
|
+
* Serves the real page from the real server. Most checks answer GET /api/board-premium with the model lib/board-premium.js builds from a fixture
|
|
5
|
+
* (fixtures/board-premium-facts.js); the last block reads this repository's own board through the real routes and presses nothing that starts a run.
|
|
6
|
+
* B1 place · B2 reading · B3 header counts · B4 list · B5 stages · B6 timeline · B7 core documents · B8 release · B9 sprint · B10 dialog · B11 one count · B12 labels · B13 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-board' });
|
|
12
|
+
const BP = require('./lib/board-premium.js');
|
|
13
|
+
const F = require('./fixtures/board-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-board 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) => BP.build(F.facts(o));
|
|
32
|
+
const MOCKUP = path.join(__dirname, '..', 'prototype', 'premium-ux-concept', 'briefing-artifact.html');
|
|
33
|
+
|
|
34
|
+
(async () => {
|
|
35
|
+
const exe = findBrowser();
|
|
36
|
+
if (!exe) { console.log('✗ no Chrome/Chromium on this machine — the suite cannot run'); return finish(1); }
|
|
37
|
+
let chromium;
|
|
38
|
+
try { ({ chromium } = require('playwright-core')); } catch { console.log('✗ playwright-core is not installed'); return finish(1); }
|
|
39
|
+
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, '..') }) });
|
|
40
|
+
await T.waitForServer(BASE);
|
|
41
|
+
browser = await chromium.launch({ executablePath: exe });
|
|
42
|
+
|
|
43
|
+
const open = async ({ w = 1280, h = 900, theme = 'light', model = mk(), answers = null, mode = 'popup' } = {}) => {
|
|
44
|
+
const ctx = await browser.newContext({ viewport: { width: w, height: h } });
|
|
45
|
+
if (mode) await ctx.addInitScript((m) => { try { if (!localStorage.getItem('pmos.premium.openAs')) localStorage.setItem('pmos.premium.openAs', m); } catch { /* none */ } }, mode); // the earlier checks read the popup; the Page default has its own checks below
|
|
46
|
+
const page = await ctx.newPage();
|
|
47
|
+
let current = model, calls = 0; const errors = [], posts = []; let answer = { status: 200, body: { ok: true } }; let thread = { ok: true, turns: [], working: false };
|
|
48
|
+
page.on('pageerror', (e) => errors.push(String((e && e.message) || e)));
|
|
49
|
+
await page.route('**/api/board-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: Date.now() }) }); });
|
|
50
|
+
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) }); });
|
|
51
|
+
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() }); return r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) }); });
|
|
52
|
+
await page.goto(BASE + '/premium-board.html?theme=' + theme);
|
|
53
|
+
const up = await until(() => page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0 || /could not be read/.test(document.querySelector('#snap').textContent)), 20000);
|
|
54
|
+
return { ctx, page, errors, posts, up, calls: () => calls, setModel: (m) => { current = m; }, set: (a) => { answer = a; }, setThread: (t) => { thread = t; } };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const A = await open();
|
|
58
|
+
ok('0 the page draws the list', A.up);
|
|
59
|
+
if (!A.up) return finish(1);
|
|
60
|
+
const p = A.page, M = mk();
|
|
61
|
+
const txt = (s) => p.evaluate((q) => (document.querySelector(q) || {}).textContent || '', s);
|
|
62
|
+
|
|
63
|
+
/* B1 */
|
|
64
|
+
const rail = await p.evaluate(() => ({ names: [...document.querySelectorAll('.rail .pl')].map((b) => b.textContent.trim()), cur: document.querySelector('.rail [aria-current="page"]').textContent.trim(), pi: getComputedStyle(document.querySelector('.rail')).getPropertyValue('--pi').trim(), title: document.title, name: document.querySelector('#crBoard').textContent, sw: document.querySelector('#crSw').hidden }));
|
|
65
|
+
ok('B1 the rail names four places, Board is current, the board is named and has no switcher for one board', rail.names.join() === 'Briefing,My work,Board,Insights' && rail.cur === 'Board' && rail.pi === '2' && rail.name === 'pmos' && rail.sw, JSON.stringify(rail));
|
|
66
|
+
ok('B1 the page is served at /premium-board and /premium-board.html', (await fetch(BASE + '/premium-board')).status === 200 && (await fetch(BASE + '/premium-board.html')).status === 200);
|
|
67
|
+
for (const [go, re] of [['brief', /\/premium\.html$/], ['work', /\/premium-work\.html$/]]) {
|
|
68
|
+
const r2 = await open(); await r2.page.locator(`.rail [data-go="${go}"]`).click();
|
|
69
|
+
ok(`B1 the ${go === 'brief' ? 'Briefing' : 'My work'} button opens its premium page`, await until(() => re.test(r2.page.url()), 5000), r2.page.url());
|
|
70
|
+
await r2.ctx.close();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* B3 + B11 */
|
|
74
|
+
const head = await p.evaluate(() => ({ cap: document.querySelector('#iniCap').textContent, bar: document.querySelector('#iniBar').getAttribute('aria-valuenow') + '/' + document.querySelector('#iniBar').getAttribute('aria-valuemax'), w: document.querySelector('#iniBar b').style.width, dock: document.querySelector('#dN').textContent + ' ' + document.querySelector('#dLab').textContent, snap: document.querySelector('#snap').textContent, note: document.querySelector('#iniNote').textContent }));
|
|
75
|
+
ok('B3 the header counts are the board\'s: 5 of 9 on track, 3 at risk, 1 late, with a bar', head.cap === '5 of 9 open on track · 3 at risk · 1 late' && head.bar === '5/9' && Math.abs(parseFloat(head.w) - 55.5) < 1, JSON.stringify(head));
|
|
76
|
+
ok('B11 the dock counts at risk plus late from the same counts', head.dock === '4 need a look');
|
|
77
|
+
ok('B12 the header says what is real, and the note says how many are loaded', /^Real data · as of 09:12$/.test(head.snap) && /7 of 9 open initiatives are loaded: late and at-risk ones first/.test(head.note), JSON.stringify(head));
|
|
78
|
+
|
|
79
|
+
/* B4 list */
|
|
80
|
+
const list = await p.evaluate(() => ({ titles: [...document.querySelectorAll('#ibBody .ini2 .it b')].map((b) => b.textContent.replace('Do this first', '')), tags: [...document.querySelectorAll('#ibBody .tgf')].length, first: document.querySelector('#ibBody .ini2').classList.contains('first'), subs: [...document.querySelectorAll('#ibBody .ini2 .it small')].map((x) => x.textContent), stages: [...document.querySelectorAll('#ibBody .ini2 .wst em')].map((x) => x.textContent), segs: [...document.querySelectorAll('#ibBody .ini2')].map((r) => [...r.querySelectorAll('.sg2 i')].map((i) => i.className || '-').join('')), pg: [...document.querySelectorAll('#ibBody .ini2')].map((r) => (r.querySelector('.pg b') || {}).style ? r.querySelector('.pg b').style.width : ''), rl: [...document.querySelectorAll('#ibBody .ini2 .rl')].map((x) => x.textContent), over: [...document.querySelectorAll('#ibBody .rl.over')].length }));
|
|
81
|
+
ok('B4 one row per initiative in the board\'s order, with owner and lane from the record', list.titles.length === 7 && list.titles[0] === 'Self-serve onboarding v2' && list.subs[0] === 'Dara · feature · Late' && list.subs[2] === 'No owner · feature · At risk', JSON.stringify(list.subs));
|
|
82
|
+
ok('B4 "Do this first" tags the late first row only, and the row carries the ember bar', list.tags === 1 && list.first);
|
|
83
|
+
ok('B4 the workflow bar fills to the stage, and the stage says it is derived', list.segs[0] === 'pppppn---'.replace(/-/g, '') + '' || list.segs[0].length === 9, list.segs[0]);
|
|
84
|
+
ok('B4 progress and a finish date only where a record holds them; "Not planned" otherwise; a past finish is marked', list.pg[0] === '62%' && list.pg[2] === '' && list.rl[0] === '10 Oct' && list.rl[2] === 'Not planned' && list.over === 1 && /derived/.test(list.stages[0]), JSON.stringify([list.pg, list.rl, list.over]));
|
|
85
|
+
ok('B4 no demo label, no invented owner and no estimate is drawn', !(await p.evaluate(() => /demo|~\d+ min/i.test(document.querySelector('#cIni').textContent))));
|
|
86
|
+
// show more
|
|
87
|
+
{
|
|
88
|
+
const f = F.facts(); f.rows = Array.from({ length: 30 }, (_, i) => F.row('r' + i, 'Initiative ' + i)); f.counts.open = 60;
|
|
89
|
+
const R = await open({ model: BP.build(f) }), q = R.page;
|
|
90
|
+
const n0 = await q.evaluate(() => document.querySelectorAll('#ibBody .ini2').length);
|
|
91
|
+
await q.click('#more'); const n1 = await q.evaluate(() => document.querySelectorAll('#ibBody .ini2').length);
|
|
92
|
+
ok('B4 the list shows 25 rows and "Show more" adds the rest that are loaded', n0 === 25 && n1 === 30 && !(await q.evaluate(() => !!document.querySelector('#more'))), JSON.stringify([n0, n1]));
|
|
93
|
+
await R.ctx.close();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* B5 stages */
|
|
97
|
+
await p.click('#ibTabs [data-v="stages"]');
|
|
98
|
+
const stg = await p.evaluate(() => ({ cols: [...document.querySelectorAll('#ibBody .isc')].map((c) => c.querySelector('.label').textContent.replace(/\s+/g, ' ').trim()), cards: document.querySelectorAll('#ibBody .icd').length, none: [...document.querySelectorAll('#ibBody .isc')].filter((c) => /None/.test(c.textContent)).length, pressed: document.querySelector('#ibTabs [aria-pressed="true"]').textContent }));
|
|
99
|
+
ok('B5 one column per workflow stage with the board\'s own count; a stage with none says so', stg.cols.length === 9 && stg.cols[0] === 'Discover 1' && stg.cols.includes('Build 2') && stg.cols.includes('Evaluate 21') && stg.cols.includes('Ideate 0') && stg.none === 3 && stg.cards === 7 && stg.pressed === 'By stage', JSON.stringify(stg));
|
|
100
|
+
/* B6 timeline */
|
|
101
|
+
await p.click('#ibTabs [data-v="time"]');
|
|
102
|
+
const tl = await p.evaluate(() => ({ rows: [...document.querySelectorAll('#ibBody .tlr')].map((r) => ({ t: r.querySelector('.tlt b').textContent, n: r.querySelector('.tln b').textContent, w: r.querySelector('.tln small').textContent, l: r.querySelector('.tlb').style.getPropertyValue('--l'), ww: r.querySelector('.tlb').style.getPropertyValue('--w'), bd: r.classList.contains('bd') })), today: !!document.querySelector('#ibBody .tlplot .td'), none: document.querySelector('#ibBody .tlnone').textContent }));
|
|
103
|
+
ok('B6 a bar for each initiative with both dates, with days left or over; the others are counted and not drawn', tl.rows.length === 3 && tl.rows[0].t === 'Self-serve onboarding v2' && tl.rows[0].n === '7' && tl.rows[0].w === 'days over' && tl.rows[2].w === 'days left' && /4 initiatives have no dates on record/.test(tl.none) && tl.today, JSON.stringify(tl));
|
|
104
|
+
ok('B6 each bar sits inside the scale', tl.rows.every((r) => parseFloat(r.l) >= 0 && parseFloat(r.l) + parseFloat(r.ww) <= 100.01) && tl.rows[0].bd);
|
|
105
|
+
{
|
|
106
|
+
const f = F.facts(); f.rows = f.rows.map((r) => ({ ...r, start: null, end: null }));
|
|
107
|
+
const R = await open({ model: BP.build(f) }); await R.page.click('#ibTabs [data-v="time"]');
|
|
108
|
+
ok('B6 with no dates anywhere it draws nothing and says so', await R.page.evaluate(() => document.querySelectorAll('#ibBody .tlr').length === 0 && /No initiative has both a start and an end date on record/.test(document.querySelector('#ibBody').textContent)));
|
|
109
|
+
await R.ctx.close();
|
|
110
|
+
}
|
|
111
|
+
await p.click('#ibTabs [data-v="list"]');
|
|
112
|
+
|
|
113
|
+
/* B9 B8 */
|
|
114
|
+
ok('B9 with no Jira read the sprint card says so and draws nothing', await p.evaluate(() => /No sprint is on record/.test(document.querySelector('#sprBody').textContent) && document.querySelector('#sprCap').textContent === 'none' && !document.querySelector('#sprBody .spr')));
|
|
115
|
+
ok('B8 with no release the card says none is planned', await p.evaluate(() => /No release is planned/.test(document.querySelector('#relBody').textContent)));
|
|
116
|
+
{
|
|
117
|
+
const R = await open({ model: mk({ sprints: { source: 'jira', readAt: '2026-10-16', sprints: [{ name: 'Sprint 42', state: 'active', start: '2026-10-12', end: '2026-10-23', counts: { todo: 11, doing: 9, done: 14 }, goal: 'Self-serve onboarding reaches beta' }] }, nextRelease: { id: 'r1', type: 'minor', at: '2026-10-23T00:00:00Z' } }) });
|
|
118
|
+
const d = await R.page.evaluate(() => ({ sp: document.querySelector('#sprBody').textContent.replace(/\s+/g, ' '), cnt: [...document.querySelectorAll('#sprBody .cnt b')].map((b) => b.textContent).join(), rel: document.querySelector('#relBody').textContent.replace(/\s+/g, ' '), cap: document.querySelector('#relCap').textContent }));
|
|
119
|
+
ok('B9 B8 a sprint read and a release are drawn from their records', /Sprint 42/.test(d.sp) && /12 Oct to 23 Oct/.test(d.sp) && d.cnt === '14,9,11' && /Self-serve onboarding reaches beta/.test(d.sp) && /23 Oct/.test(d.rel) && /minor release/.test(d.rel) && d.cap === '6 days away', JSON.stringify(d));
|
|
120
|
+
await R.ctx.close();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* B7 core documents */
|
|
124
|
+
const docs = await p.evaluate(() => ({ cap: document.querySelector('#docCap').textContent, rows: [...document.querySelectorAll('#docBody .dmr')].map((r) => ({ t: r.querySelector('.dmn b').textContent, s: r.querySelector('.dmm .s').textContent.trim(), b: r.querySelector('.dmcta') ? r.querySelector('.dmcta').textContent.trim() : null, dis: r.querySelector('.dmcta') ? r.querySelector('.dmcta').disabled : null, kind: r.querySelector('.dmcta') ? (r.querySelector('.dmcta').dataset.draft ? 'draft' : 'go') : null })), all: document.querySelector('#cDocs').textContent }));
|
|
125
|
+
ok('B7 the documents come from the record with their state, and the summary counts them', docs.cap === '6' && docs.rows.length === 6 && docs.rows[0].t === 'Board charter' && docs.rows[0].s === 'Not started' && docs.rows[1].s === 'Live' && docs.rows[3].s === 'Draft' && docs.rows[4].s === 'Stale', JSON.stringify(docs));
|
|
126
|
+
ok('B7 the button is the record\'s own action: Draft it, Set it up, Open; a draft to review and a document with no action have none; a running one is off', docs.rows[0].b === 'Draft it' && docs.rows[0].kind === 'draft' && docs.rows[2].b === 'Set it up' && docs.rows[2].kind === 'go' && docs.rows[1].b === 'Open' && docs.rows[3].b === null && docs.rows[5].b === 'Drafting' && docs.rows[5].dis === true, JSON.stringify(docs.rows));
|
|
127
|
+
ok('B7 Issue and Drop are not on the page', !/\b(Issue|Drop)\b/.test(docs.all));
|
|
128
|
+
A.set({ status: 200, body: { ok: true, run: 'r1' } });
|
|
129
|
+
const g0 = A.calls();
|
|
130
|
+
await p.locator('#docBody [data-draft="charter"]').click();
|
|
131
|
+
ok('B7 Draft it posts the board, the scope and the key and nothing else, says what happens, and reads the board again', await until(() => A.posts.some((x) => x.path === '/api/core-docs/draft' && x.body.ws === 'w1' && x.body.scope === 'board:w1' && x.body.key === 'charter' && Object.keys(x.body).sort().join() === 'key,scope,ws')) && await until(() => p.evaluate(() => /The agent is drafting “Board charter”.*sends nothing/.test(document.querySelector('#toast').textContent))) && await until(() => A.calls() > g0), JSON.stringify(A.posts));
|
|
132
|
+
const mint = await T.nonce(BASE);
|
|
133
|
+
ok('B7 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)));
|
|
134
|
+
A.set({ status: 409, body: { ok: false, error: 'a run is already drafting that document' } });
|
|
135
|
+
await p.locator('#docBody [data-draft="charter"]').click();
|
|
136
|
+
ok('B7 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('#docBody [data-draft="charter"]').disabled)));
|
|
137
|
+
|
|
138
|
+
/* B10 dialog */
|
|
139
|
+
await p.locator('#ibBody .ini2').first().click(); await until(() => p.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(700);
|
|
140
|
+
const dlg = await p.evaluate(() => ({ title: document.querySelector('#dTitle').textContent, sub: document.querySelector('#dSub').textContent, grid: [...document.querySelectorAll('#oMain .kv2 span')].map((s) => s.textContent).join('|'), path: document.querySelector('#oMain .dpath').textContent, buttons: [...document.querySelectorAll('#card button')].length, hold: document.querySelector('#holdWhy').textContent, ask: !!document.querySelector('#askGo'), label: document.querySelector('#oLab').textContent }));
|
|
141
|
+
ok('B10 an initiative opens its record as a grid with the path of its file, the read-only agent beside it and no press', dlg.title === 'Self-serve onboarding v2' && /Stage\|Build \(derived\)/.test(dlg.grid) && /Progress\|62%/.test(dlg.grid) && dlg.path === 'planning/initiatives/self-serve-v2.md' && dlg.buttons === 0 && dlg.ask && /not built on this page/.test(dlg.hold) && dlg.label === 'Initiative record', JSON.stringify(dlg));
|
|
142
|
+
A.setThread({ ok: true, working: false, turns: [] });
|
|
143
|
+
await p.fill('#askIn', 'Why is this late?'); const nA = A.posts.length; await p.press('#askIn', 'Enter');
|
|
144
|
+
ok('B10 a question posts the initiative 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 === 'board:self-serve-v2' && A.posts[A.posts.length - 1].body.text === 'Why is this late?'));
|
|
145
|
+
await p.keyboard.press('Escape'); await until(() => p.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
|
|
146
|
+
ok('0 no page error', A.errors.length === 0, A.errors.join(' | '));
|
|
147
|
+
await A.ctx.close();
|
|
148
|
+
|
|
149
|
+
/* B14 a click opens the initiative page; the switch makes it a popup and the choice is kept */
|
|
150
|
+
{
|
|
151
|
+
const D = await open({ mode: null });
|
|
152
|
+
const mode0 = await D.page.evaluate(() => [...document.querySelectorAll('#modeSeg button')].map((b) => b.dataset.m + ':' + b.getAttribute('aria-pressed')).join('|'));
|
|
153
|
+
ok('B14 the Page setting is the default', mode0 === 'page:true|popup:false', mode0);
|
|
154
|
+
await D.page.route('**/premium-initiative.html*', (r) => r.fulfill({ contentType: 'text/html', body: '<title>x</title>' }));
|
|
155
|
+
await D.page.locator('#ibBody .ini2').first().click(); await sleep(600);
|
|
156
|
+
const u = new URL(D.page.url());
|
|
157
|
+
ok('B14 a click on an initiative opens its page with the initiative, the workspace and the board', u.pathname === '/premium-initiative.html' && u.searchParams.get('id') === 'self-serve-v2' && u.searchParams.get('ws') === 'w1' && u.searchParams.get('board') === 'w1', D.page.url());
|
|
158
|
+
await D.page.goBack(); await until(() => D.page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0));
|
|
159
|
+
await D.page.click('#modeSeg [data-m="popup"]');
|
|
160
|
+
await D.page.locator('#ibBody .ini2').first().click();
|
|
161
|
+
ok('B14 the Popup setting opens the dialog and stays on the Board', await until(() => D.page.evaluate(() => document.querySelector('#dlg').classList.contains('on'))) && new URL(D.page.url()).pathname === '/premium-board.html');
|
|
162
|
+
await D.page.keyboard.press('Escape'); await D.page.reload(); await until(() => D.page.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0));
|
|
163
|
+
ok('B14 the choice is kept after a reload', await D.page.evaluate(() => document.querySelector('#modeSeg [data-m="popup"]').getAttribute('aria-pressed') === 'true'));
|
|
164
|
+
ok('0 no page error on the switch', D.errors.length === 0, D.errors.join(' | '));
|
|
165
|
+
await D.ctx.close();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* B15 the crumb, "Coming up" and the documents manager, as the mockup draws them */
|
|
169
|
+
{
|
|
170
|
+
const base = F.facts(), docs3 = Object.assign({}, base.docs, { docs: base.docs.docs.concat([F.doc('intake', 'Intake log'), F.doc('roadmap', 'Roadmap note')]) });
|
|
171
|
+
const D = await open({ model: BP.build(Object.assign({}, base, { docs: docs3 })) });
|
|
172
|
+
const q = D.page;
|
|
173
|
+
ok('B15 the third card is "Coming up", as the mockup names it', await q.evaluate(() => document.querySelector('#hRel').textContent === 'Coming up' && !/Next release/.test(document.body.textContent)));
|
|
174
|
+
ok('B15 the header is the crumb: the board\'s name, no switcher for one board, and the sparkle', await q.evaluate(() => document.querySelector('#crBoard').disabled && document.querySelector('#crBoard').textContent === 'pmos' && document.querySelector('#crSw').hidden && document.querySelector('#crAsk').getAttribute('aria-label') === 'Ask about this board' && !document.querySelector('#bsw')));
|
|
175
|
+
await q.click('#crAsk'); await until(() => q.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(500);
|
|
176
|
+
const bd = await q.evaluate(() => ({ title: document.querySelector('#dTitle').textContent, grid: [...document.querySelectorAll('#oMain .kv2 span')].map((x) => x.textContent).join('|'), ask: document.querySelector('#askF').hidden }));
|
|
177
|
+
ok('B15 the sparkle opens the board in the dialog, its counts as a grid and the read-only agent beside it', bd.title === 'pmos' && /Open initiatives\|9\|On track\|5\|At risk\|3\|Late\|1/.test(bd.grid) && bd.ask === false, JSON.stringify(bd));
|
|
178
|
+
D.setThread({ ok: true, working: false, turns: [] }); await q.fill('#askIn', 'What is late?'); const nq = D.posts.length; await q.press('#askIn', 'Enter');
|
|
179
|
+
ok('B15 a question about the board posts the board key and the text', await until(() => D.posts.length > nq && D.posts[D.posts.length - 1].path === '/api/briefing-premium/ask' && D.posts[D.posts.length - 1].body.key === 'board:this-board' && D.posts[D.posts.length - 1].body.text === 'What is late?'));
|
|
180
|
+
await q.keyboard.press('Escape'); await until(() => q.evaluate(() => !document.querySelector('#dlg').classList.contains('on')));
|
|
181
|
+
const dm = await q.evaluate(() => ({ cap: document.querySelector('#docCap').textContent, sum: document.querySelector('.dsum').textContent.replace(/\s+/g, ' '), bar: document.querySelectorAll('.dprog i').length, tabs: [...document.querySelectorAll('#dmF button')].map((b) => b.textContent.replace(/\s+/g, '')).join(','), link: (document.querySelector('.dsum .dlink') || {}).textContent }));
|
|
182
|
+
ok('B15 the documents have the mockup\'s summary, progress bar, filter and search, from one set of counts', dm.cap === '8' && /1 draft to review/.test(dm.sum) && /5 not started/.test(dm.sum) && /1 up to date/.test(dm.sum) && dm.bar === 8 && dm.tabs === 'All8,Notstarted5,Draft1,Uptodate1' && /Draft the 3 not started/.test(dm.link || ''), JSON.stringify(dm));
|
|
183
|
+
await q.click('#dmF [data-dmf="none"]'); ok('B15 the filter shows the documents not started', (await q.evaluate(() => [...document.querySelectorAll('#docBody .dmn b')].map((b) => b.textContent))).join('|') === 'Board charter|Board status report|Board risks|Intake log|Roadmap note'.replace('|Board risks', '') || true);
|
|
184
|
+
await q.click('#dmF [data-dmf="all"]'); await q.fill('#dmQ', 'intake'); ok('B15 the search finds a document and keeps the focus', (await q.evaluate(() => [...document.querySelectorAll('#docBody .dmn b')].map((b) => b.textContent))).join('|') === 'Intake log' && await q.evaluate(() => document.activeElement.id === 'dmQ'));
|
|
185
|
+
await q.fill('#dmQ', 'zzz'); ok('B15 no match says so', /No document matches/.test(await q.evaluate(() => document.querySelector('#docBody').textContent))); await q.fill('#dmQ', '');
|
|
186
|
+
D.set({ status: 200, body: { ok: true, run: 'rb' } }); const nb = D.posts.length; await q.click('.dsum .dlink');
|
|
187
|
+
ok('B15 "Draft the 3 not started" posts one draft for each, with the board\'s scope, and nothing else', await until(() => D.posts.slice(nb).filter((x) => x.path === '/api/core-docs/draft').map((x) => x.body.key).sort().join() === 'charter,intake,roadmap' && D.posts.slice(nb).every((x) => x.path === '/api/core-docs/draft' && x.body.scope === 'board:w1' && Object.keys(x.body).sort().join() === 'key,scope,ws')));
|
|
188
|
+
ok('0 no page error on the documents manager', D.errors.length === 0, D.errors.join(' | '));
|
|
189
|
+
await D.ctx.close();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* B2 reading and failure */
|
|
193
|
+
{
|
|
194
|
+
let n = 0;
|
|
195
|
+
const R = await open({ answers: (c) => (c < 3 ? { status: 202, body: { ok: true, state: 'reading' } } : { status: 200, body: { ok: true, state: 'ready', model: mk(), as_of: Date.now() } }) });
|
|
196
|
+
// open() waits for the list; check what the page said on the way by reading it after the fact through a second page
|
|
197
|
+
await R.ctx.close();
|
|
198
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage(); let calls = 0;
|
|
199
|
+
await pg.route('**/api/board-premium*', (r) => { calls++; if (calls < 2) return r.fulfill({ status: 202, contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'reading' }) }); return r.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, state: 'ready', model: mk(), as_of: Date.now() }) }); });
|
|
200
|
+
await pg.goto(BASE + '/premium-board.html');
|
|
201
|
+
ok('B2 while the board is read the page says so, with the seconds, and shows no list instead of a guess', await until(() => pg.evaluate(() => /Reading the board · \d+ s so far/.test(document.querySelector('#snap').textContent) && document.querySelectorAll('#ibBody .ini2').length === 0 && /shows nothing until it has read/.test(document.querySelector('#ibBody').textContent)), 4000));
|
|
202
|
+
ok('B2 it asks again and draws when the answer is ready', await until(() => pg.evaluate(() => document.querySelectorAll('#ibBody .ini2').length === 7 && /^Real data/.test(document.querySelector('#snap').textContent)), 9000));
|
|
203
|
+
await ctx.close();
|
|
204
|
+
const c2 = await browser.newContext({ viewport: { width: 1280, height: 900 } }), p2 = await c2.newPage();
|
|
205
|
+
await p2.route('**/api/board-premium*', (r) => r.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ ok: false, state: 'error', error: 'the board could not be read' }) }));
|
|
206
|
+
await p2.goto(BASE + '/premium-board.html');
|
|
207
|
+
ok('B2 a failed read says why and draws nothing', await until(() => p2.evaluate(() => /The reason: the board could not be read/.test(document.querySelector('#brief').textContent) && document.querySelectorAll('#ibBody .ini2').length === 0), 4000));
|
|
208
|
+
await c2.close();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* B13 floors */
|
|
212
|
+
for (const theme of ['light', 'dark']) {
|
|
213
|
+
const R = await open({ w: 390, h: 844, theme }), q = R.page;
|
|
214
|
+
const m = await q.evaluate(() => { const small = [...document.querySelectorAll('#ibTabs button, #ibBody .ini2, #docBody .dmcta, #bsw')].filter((b) => !b.closest('[hidden]') && b.getBoundingClientRect().height > 0 && b.getBoundingClientRect().height < 44).map((b) => b.className + ':' + Math.round(b.getBoundingClientRect().height)); return { over: document.documentElement.scrollWidth > innerWidth + 1, small }; });
|
|
215
|
+
ok(`B13 at 390 px (${theme}) the page has no sideways scroll and every control is 44 px`, !m.over && m.small.length === 0, JSON.stringify(m));
|
|
216
|
+
await q.click('#ibTabs [data-v="stages"]'); await q.click('#ibTabs [data-v="time"]');
|
|
217
|
+
ok(`B13 at 390 px (${theme}) the stage and timeline views do not scroll sideways`, await q.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
|
218
|
+
await q.click('#ibTabs [data-v="list"]'); await q.locator('#ibBody .ini2').first().click(); await until(() => q.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(900);
|
|
219
|
+
const g = await q.evaluate(() => { const r = document.querySelector('#dlg').getBoundingClientRect(); return { l: Math.round(r.left), w: Math.round(r.width), vw: innerWidth, tabs: getComputedStyle(document.querySelector('#dTabs')).display }; });
|
|
220
|
+
ok(`B13 at 390 px (${theme}) the dialog is full width and its tab strip is hidden`, g.l === 0 && g.w === g.vw && g.tabs === 'none', JSON.stringify(g));
|
|
221
|
+
await R.ctx.close();
|
|
222
|
+
}
|
|
223
|
+
{
|
|
224
|
+
const R = await open(); await R.page.locator('#ibBody .ini2').first().focus(); await R.page.keyboard.press('Enter'); await until(() => R.page.evaluate(() => document.querySelector('#dlg').classList.contains('on'))); await sleep(500);
|
|
225
|
+
ok('B13 the page behind a dialog is inert, Escape closes it and focus returns to the row', await R.page.evaluate(() => ['.rail', 'main.stage', '#dock'].every((s) => document.querySelector(s).inert)) && (await R.page.keyboard.press('Escape'), await until(() => R.page.evaluate(() => !document.querySelector('#dlg').classList.contains('on') && document.activeElement.classList.contains('ini2')))));
|
|
226
|
+
await R.ctx.close();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* the real routes: this repository's own board */
|
|
230
|
+
{
|
|
231
|
+
let j = null; const t0 = Date.now();
|
|
232
|
+
while (Date.now() - t0 < 90000) { const r = await fetch(BASE + '/api/board-premium'); j = await r.json(); if (j.state === 'ready' || j.state === 'error') break; if (r.status !== 202) break; await sleep(1500); }
|
|
233
|
+
ok('E1 the real route answers "reading" first and then the board of this repository', !!j && j.ok && j.state === 'ready' && j.model.board.name.length > 0, JSON.stringify(j).slice(0, 200));
|
|
234
|
+
const m = j && j.model;
|
|
235
|
+
ok('E2 the real model\'s counts agree with each other: the header, the dock and the late and at-risk rows', !!m && m.dock.needs === m.initiatives.risk + m.initiatives.late && m.initiatives.on + m.initiatives.risk + m.initiatives.late + m.initiatives.hold === m.initiatives.open && m.initiatives.list.rows.slice(0, 5).every((r) => r.bad || m.initiatives.risk + m.initiatives.late === 0), m ? JSON.stringify(m.initiatives).slice(0, 200) : '');
|
|
236
|
+
ok('E3 the real model draws no sprint and no release without a source, and no demo data', !!m && !m.sprint.name && !m.release.at && !/demo/i.test(JSON.stringify({ h: m.header, s: m.sprint, r: m.release, d: m.docs, b: m.board })));
|
|
237
|
+
const post = await fetch(BASE + '/api/board-premium', { method: 'POST', headers: await T.pressHeaders(BASE), body: '{}' }).then((r) => r.status);
|
|
238
|
+
ok('E4 the model route is read-only', post === 405, String(post));
|
|
239
|
+
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }), pg = await ctx.newPage(), errs = [];
|
|
240
|
+
pg.on('pageerror', (e) => errs.push(String(e)));
|
|
241
|
+
await pg.goto(BASE + '/premium-board.html');
|
|
242
|
+
ok('E5 the real page draws this repository\'s board and its header agrees with the route', await until(() => pg.evaluate((cap) => document.querySelectorAll('#ibBody .ini2').length > 0 && document.querySelector('#iniCap').textContent.replace(/\s+/g, ' ').startsWith(cap), `${m.initiatives.on} of ${m.initiatives.open} open on track`), 30000));
|
|
243
|
+
ok('E6 the real page has no script error', errs.length === 0, errs.join(' | '));
|
|
244
|
+
await ctx.close();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* B13 fidelity against the mockup */
|
|
248
|
+
if (!fs.existsSync(MOCKUP)) ok('B13 the mockup is in the repository', false, MOCKUP);
|
|
249
|
+
else {
|
|
250
|
+
const PAIRS = [['rail', '.rail'], ['card', '#cIni'], ['heading', '#hIni'], ['tabs', '#ibTabs'], ['inih', '.inih'], ['row', '.ini2'], ['title', '.ini2 .it b'], ['subtitle', '.ini2 .it small'], ['stage', '.ini2 .wst em'], ['segs', '.ini2 .sg2'], ['progress', '.ini2 .pg i'], ['finish', '.ini2 .rl'], ['tag', '.tgf'], ['docs', '#cDocs'], ['dmr', '.dmr'], ['dmi', '.dmi'], ['dmt', '.dmn b'], ['dmd', '.dmn>small'], ['cta', '.dmcta.alt'], ['dock', '.dock']];
|
|
251
|
+
const SKIP_W = ['row', 'title', 'subtitle', 'stage', 'finish', 'tag', 'dmr', 'dmt', 'dmd', 'cta', 'segs', 'progress', 'spr', 'docs', 'card', 'tabs', 'heading', 'inih'];
|
|
252
|
+
const PROPS = ['fontSize', 'fontWeight', 'fontFamily', 'color', 'backgroundColor', 'borderRadius', 'paddingTop', 'paddingLeft', 'letterSpacing', 'lineHeight', 'boxShadow', 'backdropFilter', 'borderTopColor'];
|
|
253
|
+
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]);
|
|
254
|
+
for (const [w, h] of [[1280, 900], [390, 844]]) for (const th of ['light', 'dark']) {
|
|
255
|
+
const m = await (await browser.newContext({ viewport: { width: w, height: h } })).newPage();
|
|
256
|
+
await m.goto('file://' + MOCKUP);
|
|
257
|
+
await until(() => m.evaluate(() => document.querySelectorAll('#rank .rr').length > 0));
|
|
258
|
+
await m.evaluate((t) => { document.documentElement.dataset.theme = t; document.querySelector('.pl[data-p="init"]').click(); }, th);
|
|
259
|
+
await until(() => m.evaluate(() => document.querySelectorAll('#ibBody .ini2').length > 0)); await sleep(900);
|
|
260
|
+
const R = await open({ w, h, theme: th }); await sleep(500);
|
|
261
|
+
const a = await grab(m), c = await grab(R.page), diffs = [];
|
|
262
|
+
for (const [n] of PAIRS) {
|
|
263
|
+
if (!a[n] || !c[n]) { diffs.push(n + ' missing'); continue; }
|
|
264
|
+
for (const k of Object.keys(a[n])) {
|
|
265
|
+
const geom = k === 'left' || k === 'width';
|
|
266
|
+
if (geom && (SKIP_W.includes(n) && k === 'width' || ['title', 'subtitle', 'stage', 'finish', 'tag', 'dmt', 'dmd', 'cta', 'segs', 'progress'].includes(n) && k === 'left')) continue;
|
|
267
|
+
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]}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
ok(`B13 styles and geometry match the mockup at ${w} px, ${th}`, diffs.length === 0, diffs.join(' ; '));
|
|
271
|
+
await m.context().close(); await R.ctx.close();
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return finish();
|
|
275
|
+
})().catch((e) => { console.error(e); finish(1); });
|