create-pmos 0.15.0 → 0.16.1
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/README.md +65 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +50 -13
- package/workspace/bin/pmos.js +204 -51
- package/workspace/design-parity.js +358 -0
- package/workspace/doctor.js +54 -0
- package/workspace/lib/find-browser.js +60 -0
- package/workspace/lib/four-panel.js +345 -0
- package/workspace/lib/git-info.js +28 -1
- package/workspace/lib/mobile-nav.js +32 -2
- package/workspace/lib/server-discovery.js +127 -0
- package/workspace/package-lock.json +29 -2
- package/workspace/package.json +13 -5
- package/workspace/public/index.html +79 -409
- package/workspace/public/pm/app.js +242 -0
- package/workspace/public/pm/data.js +905 -0
- package/workspace/public/pm/dom.js +116 -0
- package/workspace/public/pm/fixture.js +979 -0
- package/workspace/public/pm/mobile.css +200 -0
- package/workspace/public/pm/mobile.js +403 -0
- package/workspace/public/pm/modes.js +1076 -0
- package/workspace/public/pm/panel-detail.js +648 -0
- package/workspace/public/pm/panel-initiatives.js +426 -0
- package/workspace/public/pm/panel-repos.js +553 -0
- package/workspace/public/pm/panel-tabs.js +487 -0
- package/workspace/public/pm/state.js +168 -0
- package/workspace/public/pm/tab-doc.js +279 -0
- package/workspace/public/pm/tab-task.js +83 -0
- package/workspace/public/pm/tab-term.js +560 -0
- package/workspace/public/pm/tokens.css +73 -0
- package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
- package/workspace/server.js +174 -11
- package/workspace/focus.test.js +0 -650
- package/workspace/handoff.test.js +0 -443
- package/workspace/profiles.test.js +0 -362
- package/workspace/public/app.js +0 -5560
- package/workspace/public/styles.css +0 -2258
- package/workspace/repo-scan.test.js +0 -327
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* design-parity — decides whether the workspace matches the design the PM handed over.
|
|
3
|
+
*
|
|
4
|
+
* The PM's bar for this surface was one word: IDENTICAL. That is not a thing anyone can hold in
|
|
5
|
+
* their head across four panels, two themes and ten interaction states, so it is decided here
|
|
6
|
+
* mechanically instead. This renders the PM's own prototype and the built app side by side at the
|
|
7
|
+
* same viewport with the same font files, and diffs them on four axes:
|
|
8
|
+
*
|
|
9
|
+
* text the ordered list of rendered text nodes — a reworded label is a failure
|
|
10
|
+
* style computed styles on structurally-matched elements
|
|
11
|
+
* geometry bounding boxes, to a 2px tolerance
|
|
12
|
+
* pixels a screenshot diff, failing above 0.5% of the viewport
|
|
13
|
+
*
|
|
14
|
+
* WHY THE APP IS RENDERED IN FIXTURE MODE. A diff needs like-for-like content, and the app's real
|
|
15
|
+
* content is this repository. `?fixture=proto` loads the prototype's own seed data through the app's
|
|
16
|
+
* real renderer — so what is being compared is the rendering, which is the thing under test, and not
|
|
17
|
+
* the data, which is not. The same fixture doubles as the offline demo path: it proves the UI draws
|
|
18
|
+
* with no repository readable at all. What it deliberately does NOT prove is that the app is wired to
|
|
19
|
+
* anything; that is dimension 2 of the rubric and it is asserted in ui-test.js against real state.
|
|
20
|
+
*
|
|
21
|
+
* OFFLINE BY CONSTRUCTION. The prototype's runtime fetches React from unpkg. Both files are vendored
|
|
22
|
+
* under planning/design/four-panel/vendor/ and injected through the runtime's own `window.__resources`
|
|
23
|
+
* override, and the Google Fonts link is rewritten to the workspace's own vendored woff2 — the same
|
|
24
|
+
* byte-identical faces the app serves, so a font-fallback difference can never be mistaken for a
|
|
25
|
+
* design difference. Babel is not vendored because the prototype does not need it (verified: the
|
|
26
|
+
* render is node-for-node identical without it).
|
|
27
|
+
*
|
|
28
|
+
* node design-parity.js both themes, default state
|
|
29
|
+
* node design-parity.js --theme=light one theme
|
|
30
|
+
* node design-parity.js --states also walk the interaction states (see STATES below)
|
|
31
|
+
* node design-parity.js --state=<id> one named state
|
|
32
|
+
* node design-parity.js --keep keep the rendered PNGs for inspection
|
|
33
|
+
*
|
|
34
|
+
* THE STATE WALK. `--states` was advertised in this header for two review rounds while doing
|
|
35
|
+
* nothing — the flag was never read, so "identical across the captured interaction states" (PRD
|
|
36
|
+
* success criterion 1, and the rubric's test_method) was decided on the opening screen alone. It is
|
|
37
|
+
* implemented now: each state is a list of LABELS to click, applied to the prototype and to the app
|
|
38
|
+
* from a fresh load, and the same four axes are diffed at the end of the sequence. The steps are
|
|
39
|
+
* text, not selectors, deliberately — a selector would encode one page's DOM and quietly stop
|
|
40
|
+
* matching on the other, which is the shape a check takes when it goes decorative. A step that
|
|
41
|
+
* cannot find its label on EITHER page is a hard failure: "could not drive the app there" must
|
|
42
|
+
* never be recorded as "the app matched there".
|
|
43
|
+
*
|
|
44
|
+
* Exit 0 only when every axis is clean. Exit 3 means it could not run (no browser, no server) —
|
|
45
|
+
* which is a skip, and is reported as one rather than as a pass.
|
|
46
|
+
*/
|
|
47
|
+
'use strict';
|
|
48
|
+
const fs = require('fs');
|
|
49
|
+
const path = require('path');
|
|
50
|
+
const http = require('http');
|
|
51
|
+
const os = require('os');
|
|
52
|
+
|
|
53
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
54
|
+
const DESIGN = path.join(ROOT, 'planning', 'design', 'four-panel');
|
|
55
|
+
const FONTS = path.join(__dirname, 'public', 'vendor');
|
|
56
|
+
const APP_URL = process.env.PMOS_PARITY_APP || 'http://127.0.0.1:7433';
|
|
57
|
+
const REF_PORT = +(process.env.PMOS_PARITY_PORT || 8899);
|
|
58
|
+
const OUT = process.env.PMOS_PARITY_OUT || path.join(os.tmpdir(), 'pmos-design-parity');
|
|
59
|
+
|
|
60
|
+
const argv = process.argv.slice(2);
|
|
61
|
+
const themes = argv.some((a) => a.startsWith('--theme=')) ? [argv.find((a) => a.startsWith('--theme=')).split('=')[1]] : ['dark', 'light'];
|
|
62
|
+
const KEEP = argv.includes('--keep');
|
|
63
|
+
const W = 1600, H = 1000;
|
|
64
|
+
|
|
65
|
+
/* THE CAPTURED INTERACTION STATES.
|
|
66
|
+
*
|
|
67
|
+
* Each entry is a state the PM can put the surface into, expressed as the labels a person would
|
|
68
|
+
* click to get there — read as the element's OWN text, so "add repository" means the span holding
|
|
69
|
+
* exactly that, in either page's markup. `default` is the opening screen and always runs.
|
|
70
|
+
*
|
|
71
|
+
* Chosen because each one changes a DIFFERENT part of the layout, so the set covers the interaction
|
|
72
|
+
* model the handoff brief calls final rather than repeating one kind of change: a drawer over panel
|
|
73
|
+
* 1, a collapsed group in panel 2, an expanded task tree and an opened tab in panels 3 and 4, a
|
|
74
|
+
* collapsed column, and the panel-3 inline expansion. A state whose divergence is a DECISION rather
|
|
75
|
+
* than a defect does not belong here — it belongs in the PRD — so if one is ever excluded, the
|
|
76
|
+
* reason goes in `why` and gets printed, not left as an absence nobody can see.
|
|
77
|
+
*/
|
|
78
|
+
const STATES = [
|
|
79
|
+
{ id: 'default', steps: [] },
|
|
80
|
+
{ id: 'settings-drawer', steps: ['settings & tools'] },
|
|
81
|
+
{ id: 'group-collapsed', steps: ['health:tech'] },
|
|
82
|
+
{ id: 'phase-collapsed', steps: ['intake'] },
|
|
83
|
+
{ id: 'task-tab-open', steps: ['produce the build output'] },
|
|
84
|
+
{ id: 'column-collapsed', steps: ['⟨'] },
|
|
85
|
+
{ id: 'add-repository', steps: ['add repository'] },
|
|
86
|
+
];
|
|
87
|
+
const stateArg = argv.find((a) => a.startsWith('--state='));
|
|
88
|
+
const states = stateArg
|
|
89
|
+
? STATES.filter((s) => s.id === stateArg.split('=')[1])
|
|
90
|
+
: (argv.includes('--states') ? STATES : STATES.filter((s) => s.id === 'default'));
|
|
91
|
+
if (!states.length) {
|
|
92
|
+
console.error(`no such state: ${stateArg.split('=')[1]} — known: ${STATES.map((s) => s.id).join(', ')}`);
|
|
93
|
+
process.exit(2);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const PROPS = [
|
|
97
|
+
'display', 'gridTemplateColumns', 'gridTemplateRows', 'flexDirection', 'alignItems',
|
|
98
|
+
'justifyContent', 'gap', 'minHeight', 'padding', 'backgroundColor', 'color',
|
|
99
|
+
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth',
|
|
100
|
+
'borderLeftColor', 'borderBottomColor', 'borderRadius', 'fontFamily', 'fontSize', 'fontWeight',
|
|
101
|
+
'lineHeight', 'letterSpacing', 'textTransform', 'writingMode', 'overflowX', 'overflowY',
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
function skip(why) { console.log(`⏭ design-parity SKIPPED — ${why}`); process.exit(3); }
|
|
105
|
+
|
|
106
|
+
function requireOptional(name) { try { return require(name); } catch { return null; } }
|
|
107
|
+
|
|
108
|
+
// ── the reference page, patched offline in memory ───────────────────────────────────────────────
|
|
109
|
+
function referenceHtml() {
|
|
110
|
+
const src = fs.readFileSync(path.join(DESIGN, 'prototype-original.html'), 'utf8');
|
|
111
|
+
const map = JSON.stringify({
|
|
112
|
+
'https://unpkg.com/react@18.3.1/umd/react.production.min.js': './vendor/react.production.min.js',
|
|
113
|
+
'https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js': './vendor/react-dom.production.min.js',
|
|
114
|
+
});
|
|
115
|
+
let h = src.replace('<script src="./support.js"></script>',
|
|
116
|
+
`<script>window.__resources=${map};</script>\n<script src="./support.js"></script>`);
|
|
117
|
+
h = h.replace(/<link rel="preconnect"[^>]*>\s*/g, '');
|
|
118
|
+
h = h.replace(/<link href="https:\/\/fonts\.googleapis\.com[^>]*>/, '<link rel="stylesheet" href="./fonts.css">');
|
|
119
|
+
if (/fonts\.googleapis|unpkg\.com/.test(h.replace(map, ''))) {
|
|
120
|
+
throw new Error('reference page still carries a remote asset after patching — refusing to compare against a page that would need the network');
|
|
121
|
+
}
|
|
122
|
+
return h;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const FONTS_CSS = `
|
|
126
|
+
@font-face{font-family:"Space Grotesk";src:url("./fonts/space-grotesk-var.woff2") format("woff2-variations");font-weight:300 700;font-style:normal;font-display:block}
|
|
127
|
+
@font-face{font-family:"IBM Plex Mono";src:url("./fonts/ibm-plex-mono-400.woff2") format("woff2");font-weight:400;font-style:normal;font-display:block}
|
|
128
|
+
@font-face{font-family:"IBM Plex Mono";src:url("./fonts/ibm-plex-mono-500.woff2") format("woff2");font-weight:500;font-style:normal;font-display:block}
|
|
129
|
+
@font-face{font-family:"IBM Plex Mono";src:url("./fonts/ibm-plex-mono-700.woff2") format("woff2");font-weight:700;font-style:normal;font-display:block}
|
|
130
|
+
`;
|
|
131
|
+
|
|
132
|
+
function serveReference() {
|
|
133
|
+
const html = referenceHtml();
|
|
134
|
+
const types = { '.js': 'text/javascript', '.css': 'text/css', '.woff2': 'font/woff2', '.html': 'text/html' };
|
|
135
|
+
const server = http.createServer((req, res) => {
|
|
136
|
+
const p = decodeURIComponent(req.url.split('?')[0]);
|
|
137
|
+
if (p === '/' || p === '/index.html') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(html); }
|
|
138
|
+
if (p === '/fonts.css') { res.writeHead(200, { 'content-type': 'text/css' }); return res.end(FONTS_CSS); }
|
|
139
|
+
let file = null;
|
|
140
|
+
if (p.startsWith('/fonts/')) file = path.join(FONTS, path.basename(p));
|
|
141
|
+
else if (p === '/support.js' || p.startsWith('/vendor/')) file = path.join(DESIGN, p.replace(/^\//, ''));
|
|
142
|
+
if (file && fs.existsSync(file)) {
|
|
143
|
+
res.writeHead(200, { 'content-type': types[path.extname(file)] || 'application/octet-stream' });
|
|
144
|
+
return res.end(fs.readFileSync(file));
|
|
145
|
+
}
|
|
146
|
+
res.writeHead(404).end('not found');
|
|
147
|
+
});
|
|
148
|
+
return new Promise((resolve, reject) => {
|
|
149
|
+
server.on('error', reject);
|
|
150
|
+
server.listen(REF_PORT, '127.0.0.1', () => resolve(server));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Click the element whose OWN text is `label`, on whichever page it is given. Returns false when
|
|
155
|
+
* nothing visible carries that text — the caller turns that into a failure rather than skipping the
|
|
156
|
+
* state, because a state that could not be reached is not a state that matched. `closest('button')`
|
|
157
|
+
* is what actually gets clicked: the prototype's runtime wraps each interpolated label in a span, so
|
|
158
|
+
* the text node and the control are usually different elements. */
|
|
159
|
+
async function drive(page, steps) {
|
|
160
|
+
const applied = [];
|
|
161
|
+
for (const label of steps) {
|
|
162
|
+
const hit = await page.evaluate((lab) => {
|
|
163
|
+
const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3)
|
|
164
|
+
.map((n) => n.textContent.replace(/\s+/g, ' ').trim()).join(' ').trim();
|
|
165
|
+
const visible = (e) => {
|
|
166
|
+
const cs = getComputedStyle(e);
|
|
167
|
+
if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
|
|
168
|
+
const r = e.getBoundingClientRect();
|
|
169
|
+
return r.width > 0 && r.height > 0;
|
|
170
|
+
};
|
|
171
|
+
const target = [...document.querySelectorAll('*')].find((e) => own(e) === lab && visible(e));
|
|
172
|
+
if (!target) return false;
|
|
173
|
+
(target.closest('button,[role="button"]') || target).click();
|
|
174
|
+
return true;
|
|
175
|
+
}, label);
|
|
176
|
+
applied.push({ label, hit });
|
|
177
|
+
await page.waitForTimeout(600);
|
|
178
|
+
}
|
|
179
|
+
return applied;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function snap(browser, url, theme, tag, steps = []) {
|
|
183
|
+
const page = await browser.newPage({ viewport: { width: W, height: H }, deviceScaleFactor: 1 });
|
|
184
|
+
const errors = [];
|
|
185
|
+
page.on('pageerror', (e) => errors.push(String(e.message)));
|
|
186
|
+
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
|
|
187
|
+
await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
|
|
188
|
+
await page.waitForTimeout(1200);
|
|
189
|
+
if (theme === 'light') {
|
|
190
|
+
await page.evaluate(() => {
|
|
191
|
+
const b = [...document.querySelectorAll('button')].find((x) => /theme/i.test(x.textContent || ''));
|
|
192
|
+
if (b) b.click();
|
|
193
|
+
});
|
|
194
|
+
await page.waitForTimeout(500);
|
|
195
|
+
}
|
|
196
|
+
// The interaction steps are applied AFTER the theme is settled, so a state is diffed in the theme
|
|
197
|
+
// it was reached in rather than in whatever theme the toggle left behind.
|
|
198
|
+
const applied = await drive(page, steps);
|
|
199
|
+
await page.waitForFunction(() => document.fonts.status === 'loaded', null, { timeout: 15000 }).catch(() => {});
|
|
200
|
+
const png = path.join(OUT, `${tag}.png`);
|
|
201
|
+
await page.screenshot({ path: png });
|
|
202
|
+
const data = await page.evaluate((props) => {
|
|
203
|
+
const root = document.querySelector('[data-pm-theme]') || document.body;
|
|
204
|
+
const nodes = [], texts = [];
|
|
205
|
+
const walk = (e) => {
|
|
206
|
+
const cs = getComputedStyle(e);
|
|
207
|
+
if (cs.display === 'none') return;
|
|
208
|
+
const r = e.getBoundingClientRect();
|
|
209
|
+
const own = [...e.childNodes].filter((n) => n.nodeType === 3)
|
|
210
|
+
.map((n) => n.textContent.replace(/\s+/g, ' ').trim()).filter(Boolean).join(' ');
|
|
211
|
+
const st = {}; for (const p of props) st[p] = cs[p];
|
|
212
|
+
// `full` is the element's WHOLE visible text, used to anchor style comparison; `own` is only
|
|
213
|
+
// its own text nodes. They differ because the prototype's runtime wraps each interpolation in
|
|
214
|
+
// a span, so its label sits one element deeper than the app's.
|
|
215
|
+
nodes.push({ text: own.slice(0, 60), full: (e.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
|
|
216
|
+
box: [r.x, r.y, r.width, r.height].map(Math.round), st });
|
|
217
|
+
// Walk childNodes IN ORDER, emitting text where it actually sits rather than hoisting all of an
|
|
218
|
+
// element's own text ahead of its children. The status bar is the case that forced this: the
|
|
219
|
+
// design writes "local · persistent server · ", then a <span> holding the count, then
|
|
220
|
+
// " sessions running", so hoisting produced "…server · sessions running 3" and reported a text
|
|
221
|
+
// difference on a bar that is pixel-identical. Reading order is what the PM sees; it is what
|
|
222
|
+
// the stream has to be.
|
|
223
|
+
for (const c of e.childNodes) {
|
|
224
|
+
if (c.nodeType === 3) {
|
|
225
|
+
const s = c.textContent.replace(/\s+/g, ' ').trim();
|
|
226
|
+
if (s) texts.push(s);
|
|
227
|
+
} else if (c.nodeType === 1) walk(c);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
walk(root);
|
|
231
|
+
return { nodes, texts };
|
|
232
|
+
}, PROPS);
|
|
233
|
+
await page.close();
|
|
234
|
+
return { ...data, errors, png, applied };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function diff(A, B, theme) {
|
|
238
|
+
const problems = [];
|
|
239
|
+
if (B.errors.length) problems.push(`console errors in the app (${B.errors.length}): ${B.errors.slice(0, 5).join(' | ')}`);
|
|
240
|
+
// Compare the CONCATENATED stream, not the node list. Node boundaries are an artefact of the
|
|
241
|
+
// prototype's interpolation wrappers: its template runtime wraps every {{ }} in a <span>, so the
|
|
242
|
+
// status bar's session count is three text nodes there and one in the app. The handoff brief calls
|
|
243
|
+
// that runtime an implementation detail, so reproducing its wrapper elements would be copying the
|
|
244
|
+
// scaffolding rather than the design. What the PM reads is the sequence of words on screen, and
|
|
245
|
+
// that is what has to match — in content and in order.
|
|
246
|
+
const streamA = A.texts.join(' ').replace(/\s+/g, ' ').trim();
|
|
247
|
+
const streamB = B.texts.join(' ').replace(/\s+/g, ' ').trim();
|
|
248
|
+
let missing = [], extra = [];
|
|
249
|
+
if (streamA !== streamB) {
|
|
250
|
+
const wa = streamA.split(' '), wb = streamB.split(' ');
|
|
251
|
+
let i = 0;
|
|
252
|
+
while (i < wa.length && i < wb.length && wa[i] === wb[i]) i++;
|
|
253
|
+
missing = wa.slice(i, i + 20); extra = wb.slice(i, i + 20);
|
|
254
|
+
problems.push('the rendered text differs — first divergence at word ' + i + ':\n design: …' +
|
|
255
|
+
wa.slice(Math.max(0, i - 6), i + 14).join(' ') + '\n app: …' +
|
|
256
|
+
wb.slice(Math.max(0, i - 6), i + 14).join(' '));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Anchor on FULL text, keeping the OUTERMOST match. The walk is pre-order, so a parent is seen
|
|
260
|
+
// before its children and first-wins yields the outermost element carrying that text — pairing the
|
|
261
|
+
// design's <button> with the app's <button>, rather than pairing the design's interpolation <span>
|
|
262
|
+
// against the app's button and reporting the button's own padding and border as differences.
|
|
263
|
+
const index = (ns) => { const m = new Map(); for (const n of ns) if (n.full && !m.has(n.full)) m.set(n.full, n); return m; };
|
|
264
|
+
const ia = index(A.nodes), ib = index(B.nodes);
|
|
265
|
+
const sd = [];
|
|
266
|
+
for (const [t, na] of ia) {
|
|
267
|
+
const nb = ib.get(t); if (!nb) continue;
|
|
268
|
+
for (const p of PROPS) if (na.st[p] !== nb.st[p]) sd.push(` [${JSON.stringify(t.slice(0, 30))}] ${p}: design=${na.st[p]} app=${nb.st[p]}`);
|
|
269
|
+
const off = na.box.map((v, i) => Math.abs(v - nb.box[i]));
|
|
270
|
+
if (Math.max(...off) > 2) sd.push(` [${JSON.stringify(t.slice(0, 30))}] box: design=${na.box} app=${nb.box}`);
|
|
271
|
+
}
|
|
272
|
+
if (sd.length) problems.push(`${sd.length} computed-style / geometry difference(s):\n` + sd.slice(0, 40).join('\n'));
|
|
273
|
+
return { problems, missing, extra, styleDiffs: sd, theme };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
(async () => {
|
|
277
|
+
const pw = requireOptional('playwright-core');
|
|
278
|
+
if (!pw) skip('playwright-core is not installed (npm install in workspace/)');
|
|
279
|
+
// Discovery is lib/find-browser.js's, not a third private candidate list. This file used to hunt
|
|
280
|
+
// only under /opt/pw-browsers, so on a runner whose browser is /usr/bin/chromium-browser it found
|
|
281
|
+
// nothing and reported a SKIP — from a machine that had a browser the whole time.
|
|
282
|
+
const exe = require('./lib/find-browser').findBrowser();
|
|
283
|
+
if (!exe) skip('no Chrome/Chromium found (set PMOS_CHROME, or install one)');
|
|
284
|
+
if (!fs.existsSync(path.join(DESIGN, 'prototype-original.html'))) skip(`the design reference is missing at ${DESIGN}`);
|
|
285
|
+
|
|
286
|
+
const alive = await new Promise((r) => {
|
|
287
|
+
const req = http.get(APP_URL, (res) => { res.resume(); r(true); });
|
|
288
|
+
req.on('error', () => r(false)); req.setTimeout(3000, () => { req.destroy(); r(false); });
|
|
289
|
+
});
|
|
290
|
+
if (!alive) skip(`the workspace is not running at ${APP_URL} (node server.js)`);
|
|
291
|
+
|
|
292
|
+
fs.mkdirSync(OUT, { recursive: true });
|
|
293
|
+
const server = await serveReference();
|
|
294
|
+
const browser = await pw.chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--font-render-hinting=none'] });
|
|
295
|
+
|
|
296
|
+
let failed = 0, compared = 0;
|
|
297
|
+
const report = {};
|
|
298
|
+
for (const theme of themes) for (const st of states) {
|
|
299
|
+
const key = `${theme}/${st.id}`;
|
|
300
|
+
const A = await snap(browser, `http://127.0.0.1:${REF_PORT}/index.html`, theme, `design-${theme}-${st.id}`, st.steps);
|
|
301
|
+
const B = await snap(browser, `${APP_URL}/?fixture=proto`, theme, `app-${theme}-${st.id}`, st.steps);
|
|
302
|
+
const d = diff(A, B, theme);
|
|
303
|
+
|
|
304
|
+
// COULD THE STATE BE REACHED AT ALL? Asserted before anything is compared, and on BOTH pages: a
|
|
305
|
+
// step that found no label leaves the page in the previous state, where a clean diff would say
|
|
306
|
+
// only that two unchanged screens still match. Reported as a failure with the step named.
|
|
307
|
+
const missedD = A.applied.filter((s) => !s.hit).map((s) => s.label);
|
|
308
|
+
const missedA = B.applied.filter((s) => !s.hit).map((s) => s.label);
|
|
309
|
+
if (missedD.length || missedA.length) {
|
|
310
|
+
d.problems.push(`the state could not be reached — ` +
|
|
311
|
+
(missedD.length ? `no visible "${missedD.join('", "')}" in the DESIGN; ` : '') +
|
|
312
|
+
(missedA.length ? `no visible "${missedA.join('", "')}" in the APP; ` : '') +
|
|
313
|
+
`nothing below this line is a comparison of ${st.id}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let pixels = null;
|
|
317
|
+
const pixelmatch = requireOptional('pixelmatch'); const pngjs = requireOptional('pngjs');
|
|
318
|
+
if (pixelmatch && pngjs) {
|
|
319
|
+
const pm = pixelmatch.default || pixelmatch;
|
|
320
|
+
const a = pngjs.PNG.sync.read(fs.readFileSync(A.png)), b = pngjs.PNG.sync.read(fs.readFileSync(B.png));
|
|
321
|
+
if (a.width === b.width && a.height === b.height) {
|
|
322
|
+
const out = new pngjs.PNG({ width: a.width, height: a.height });
|
|
323
|
+
const n = pm(a.data, b.data, out.data, a.width, a.height, { threshold: 0.12 });
|
|
324
|
+
fs.writeFileSync(path.join(OUT, `diff-${theme}-${st.id}.png`), pngjs.PNG.sync.write(out));
|
|
325
|
+
pixels = n / (a.width * a.height);
|
|
326
|
+
if (pixels > 0.005) d.problems.push(`pixel diff ${(pixels * 100).toFixed(2)}% of the viewport — ${path.join(OUT, `diff-${theme}-${st.id}.png`)}`);
|
|
327
|
+
} else d.problems.push(`screenshot sizes differ: design ${a.width}x${a.height}, app ${b.width}x${b.height}`);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
report[key] = {
|
|
331
|
+
steps: st.steps,
|
|
332
|
+
designNodes: A.nodes.length, appNodes: B.nodes.length,
|
|
333
|
+
textMissing: d.missing.length, textExtra: d.extra.length,
|
|
334
|
+
styleDiffs: d.styleDiffs.length,
|
|
335
|
+
pixelPct: pixels === null ? 'not measured (pixelmatch/pngjs absent)' : +(pixels * 100).toFixed(3),
|
|
336
|
+
clean: d.problems.length === 0,
|
|
337
|
+
};
|
|
338
|
+
compared++;
|
|
339
|
+
console.log(`\n── ${key} ──`);
|
|
340
|
+
console.log(JSON.stringify(report[key], null, 1));
|
|
341
|
+
if (d.problems.length) { failed++; console.log('\n' + d.problems.join('\n\n')); }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await browser.close();
|
|
345
|
+
server.close();
|
|
346
|
+
fs.writeFileSync(path.join(OUT, 'report.json'), JSON.stringify(report, null, 1));
|
|
347
|
+
if (!KEEP && !failed) for (const f of fs.readdirSync(OUT)) if (f.endsWith('.png')) fs.unlinkSync(path.join(OUT, f));
|
|
348
|
+
|
|
349
|
+
const scope = `${themes.join(' and ')} × ${states.length} state(s) [${states.map((s) => s.id).join(', ')}]`;
|
|
350
|
+
if (failed) {
|
|
351
|
+
console.log(`\n❌ design parity FAILED in ${failed}/${compared} comparison(s). Renders kept in ${OUT}.`);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
console.log(`\n✅ design parity — the app is identical to the PM's prototype across ${scope} — ${compared} comparison(s).`);
|
|
355
|
+
if (states.length === 1 && states[0].id === 'default') {
|
|
356
|
+
console.log(' (default state only. `--states` walks all ' + STATES.length + ', which is what the PRD asks for.)');
|
|
357
|
+
}
|
|
358
|
+
})().catch((e) => { console.error('design-parity ERROR:', e.stack); process.exit(2); });
|
package/workspace/doctor.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
'use strict';
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const path = require('path');
|
|
9
|
+
const http = require('http');
|
|
10
|
+
const net = require('net');
|
|
9
11
|
const { spawnSync } = require('child_process');
|
|
10
12
|
|
|
11
13
|
const checks = [];
|
|
@@ -116,6 +118,58 @@ function binaryArch(file) {
|
|
|
116
118
|
console.log(' optional fix: install tmux (macOS `brew install tmux`; Linux `apt install tmux`) for restart-proof sessions.');
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
// 6. ports — who is running, and who holds the door. `pmos` points here after a failed start,
|
|
122
|
+
// and until now the doctor had nothing to say about the one thing that had just failed.
|
|
123
|
+
// Reports every workspace server it can find (the launcher discovers them the same way) and,
|
|
124
|
+
// when none is running, whether the port it would use is free and who holds it if not.
|
|
125
|
+
{
|
|
126
|
+
const D = require('./lib/server-discovery');
|
|
127
|
+
const STATE = path.join(__dirname, 'state');
|
|
128
|
+
const pinned = process.env.PMOS_WS_PORT ? Number(process.env.PMOS_WS_PORT) : null;
|
|
129
|
+
const ports = D.candidatePorts({ stateDir: STATE, pinned });
|
|
130
|
+
const probe = (port) => new Promise((res) => {
|
|
131
|
+
const req = http.get(`http://127.0.0.1:${port}/api/sessions`, { timeout: 2000 }, (r) => {
|
|
132
|
+
let b = ''; r.on('data', (c) => (b += c));
|
|
133
|
+
r.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} res(D.looksLikeWorkspace(j) ? j : null); });
|
|
134
|
+
});
|
|
135
|
+
req.on('error', () => res(null));
|
|
136
|
+
req.on('timeout', () => { req.destroy(); res(null); });
|
|
137
|
+
});
|
|
138
|
+
const free = (port) => new Promise((res) => {
|
|
139
|
+
const s = net.createServer();
|
|
140
|
+
s.once('error', () => res(false));
|
|
141
|
+
s.once('listening', () => s.close(() => res(true)));
|
|
142
|
+
s.listen(port, '127.0.0.1');
|
|
143
|
+
});
|
|
144
|
+
const owner = (port) => {
|
|
145
|
+
if (process.platform === 'win32') return null;
|
|
146
|
+
const r = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-F', 'cn'], { encoding: 'utf8' });
|
|
147
|
+
const lines = String(r.stdout || '').split('\n');
|
|
148
|
+
let pid = null, cmd = null;
|
|
149
|
+
for (const l of lines) {
|
|
150
|
+
if (l.startsWith('p') && pid === null) pid = Number(l.slice(1)) || null;
|
|
151
|
+
if (l.startsWith('c') && cmd === null) cmd = l.slice(1).trim() || null;
|
|
152
|
+
}
|
|
153
|
+
return pid ? `${cmd || 'unknown'} (pid ${pid})` : null;
|
|
154
|
+
};
|
|
155
|
+
const live = [];
|
|
156
|
+
for (const p of ports) if (await probe(p)) live.push(p);
|
|
157
|
+
if (live.length === 1) ok(`workspace server running on port ${live[0]} — http://127.0.0.1:${live[0]}`);
|
|
158
|
+
else if (live.length > 1) {
|
|
159
|
+
console.log(`• ${live.length} workspace servers are running (ports ${live.join(', ')}).`);
|
|
160
|
+
console.log(' Each hosts its OWN repos, and a browser tab only sees one of them. `pmos stop` shuts them all down.');
|
|
161
|
+
} else {
|
|
162
|
+
const want = pinned || D.DEFAULT_PORT;
|
|
163
|
+
if (await free(want)) ok(`no server running; port ${want} is free (start it with: pmos)`);
|
|
164
|
+
else {
|
|
165
|
+
const who = owner(want) || 'another program';
|
|
166
|
+
bad(`no workspace server is answering, but port ${want} is held by ${who}`,
|
|
167
|
+
pinned ? `stop that process, or pin a free port: PMOS_WS_PORT=7500 pmos`
|
|
168
|
+
: 'run `pmos` — it starts on the next free port when this one is taken; or stop that process');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
119
173
|
const fails = checks.filter((c) => !c).length;
|
|
120
174
|
console.log(`\n${checks.length - fails}/${checks.length} checks passed${fails ? ' — fix the ✗ items above, then re-run npm run doctor' : ' — environment healthy, npm start away'}`);
|
|
121
175
|
process.exit(fails ? 1 : 0);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/* find-browser — locate a Chrome/Chromium this machine already has.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS AS A MODULE. Three suites need a browser (ui-test, pm-shell-test, design-parity)
|
|
5
|
+
* and each had its own candidate list. Two of them listed only system paths — /usr/bin/chromium and
|
|
6
|
+
* the two macOS bundles — and so found nothing on a machine whose browser is the Playwright-managed
|
|
7
|
+
* one under a browsers root. What happens then is the part worth naming: the suite prints
|
|
8
|
+
* "no Chrome/Chromium — skipped" and exits 0. **A skip is indistinguishable from a pass in a CI
|
|
9
|
+
* summary**, which is how the browser assertions came to be believed without ever having run. The
|
|
10
|
+
* explainer for this surface found the same thing from the other end: ui-test.js is the only suite
|
|
11
|
+
* that measures real geometry, and it does not run in CI.
|
|
12
|
+
*
|
|
13
|
+
* So discovery is one function, it knows about the Playwright layout, and the callers report a skip
|
|
14
|
+
* as its own outcome rather than as success.
|
|
15
|
+
*
|
|
16
|
+
* NO DOWNLOAD, EVER. This finds a browser; it never fetches one. PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD is
|
|
17
|
+
* the environment's own stance and this module agrees with it: a local PM tool must not pull 150 MB
|
|
18
|
+
* off the network because a test ran.
|
|
19
|
+
*/
|
|
20
|
+
const fs = require('fs');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
// A Playwright browsers root holds versioned directories — chromium-1194, chromium_headless_shell-1194
|
|
24
|
+
// — each with the executable at a platform-specific path inside. Enumerated rather than hard-coded,
|
|
25
|
+
// because the version suffix changes with every Playwright bump and a pinned one silently stops
|
|
26
|
+
// matching (which is the same skip-looks-like-pass failure, arriving later).
|
|
27
|
+
function playwrightCandidates() {
|
|
28
|
+
const roots = [process.env.PLAYWRIGHT_BROWSERS_PATH, '/opt/pw-browsers',
|
|
29
|
+
path.join(process.env.HOME || '', '.cache', 'ms-playwright')].filter(Boolean);
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const root of roots) {
|
|
32
|
+
let entries = [];
|
|
33
|
+
try { entries = fs.readdirSync(root); } catch { continue; }
|
|
34
|
+
for (const dir of entries.sort().reverse()) { // newest version first
|
|
35
|
+
if (!/^chromium/.test(dir)) continue;
|
|
36
|
+
out.push(
|
|
37
|
+
path.join(root, dir, 'chrome-linux', 'chrome'),
|
|
38
|
+
path.join(root, dir, 'chrome-linux', 'headless_shell'),
|
|
39
|
+
path.join(root, dir, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
|
|
40
|
+
path.join(root, dir, 'chrome-win', 'chrome.exe'),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function findBrowser() {
|
|
48
|
+
const cands = [
|
|
49
|
+
process.env.PMOS_CHROME,
|
|
50
|
+
process.env.PMOS_CHROMIUM,
|
|
51
|
+
...playwrightCandidates(),
|
|
52
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
53
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
54
|
+
'/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
|
|
55
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
56
|
+
].filter(Boolean);
|
|
57
|
+
return cands.find((c) => { try { fs.accessSync(c, fs.constants.X_OK); return true; } catch { return false; } });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { findBrowser, playwrightCandidates };
|