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.
Files changed (39) hide show
  1. package/README.md +65 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/workspace/README.md +50 -13
  5. package/workspace/bin/pmos.js +204 -51
  6. package/workspace/design-parity.js +358 -0
  7. package/workspace/doctor.js +54 -0
  8. package/workspace/lib/find-browser.js +60 -0
  9. package/workspace/lib/four-panel.js +345 -0
  10. package/workspace/lib/git-info.js +28 -1
  11. package/workspace/lib/mobile-nav.js +32 -2
  12. package/workspace/lib/server-discovery.js +127 -0
  13. package/workspace/package-lock.json +29 -2
  14. package/workspace/package.json +13 -5
  15. package/workspace/public/index.html +79 -409
  16. package/workspace/public/pm/app.js +242 -0
  17. package/workspace/public/pm/data.js +905 -0
  18. package/workspace/public/pm/dom.js +116 -0
  19. package/workspace/public/pm/fixture.js +979 -0
  20. package/workspace/public/pm/mobile.css +200 -0
  21. package/workspace/public/pm/mobile.js +403 -0
  22. package/workspace/public/pm/modes.js +1076 -0
  23. package/workspace/public/pm/panel-detail.js +648 -0
  24. package/workspace/public/pm/panel-initiatives.js +426 -0
  25. package/workspace/public/pm/panel-repos.js +553 -0
  26. package/workspace/public/pm/panel-tabs.js +487 -0
  27. package/workspace/public/pm/state.js +168 -0
  28. package/workspace/public/pm/tab-doc.js +279 -0
  29. package/workspace/public/pm/tab-task.js +83 -0
  30. package/workspace/public/pm/tab-term.js +560 -0
  31. package/workspace/public/pm/tokens.css +73 -0
  32. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  33. package/workspace/server.js +174 -11
  34. package/workspace/focus.test.js +0 -650
  35. package/workspace/handoff.test.js +0 -443
  36. package/workspace/profiles.test.js +0 -362
  37. package/workspace/public/app.js +0 -5560
  38. package/workspace/public/styles.css +0 -2258
  39. 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); });
@@ -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 };