stitch2 0.22.1 → 0.22.2

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 (2) hide show
  1. package/dist/report.js +99 -11
  2. package/package.json +1 -1
package/dist/report.js CHANGED
@@ -2,8 +2,10 @@
2
2
  * Consistency report: every shared component cropped from every active screen, next to the component
3
3
  * catalog's version, with what differs. Writes <design>/consistency/index.html, which the canvas opens from
4
4
  * its sidebar. Usage: stitch2 consistency [kind…] (kinds: tab-bar, header, icon-button, chip, …)
5
+ * Signatures and crops are cached in <design>/consistency/known.json: reruns only visit screens that are
6
+ * new, changed, or lack crops for the requested kinds, and check the issues against every screen.
5
7
  */
6
- import { mkdirSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
7
9
  import { join } from 'node:path';
8
10
  import { chromium } from '@playwright/test';
9
11
  import { config } from './config.js';
@@ -21,19 +23,66 @@ const esc = (s) => String(s ?? '')
21
23
  .replaceAll('<', '&lt;')
22
24
  .replaceAll('>', '&gt;')
23
25
  .replaceAll('"', '&quot;');
26
+ const KNOWN_FILE = join(out, 'known.json');
27
+ const cached = existsSync(KNOWN_FILE)
28
+ ? JSON.parse(readFileSync(KNOWN_FILE, 'utf8'))
29
+ : {};
30
+ // A screen needs a visit when it is new, changed, or lacks crops for the requested kinds.
31
+ const needsVisit = (path) => {
32
+ const hit = cached[path];
33
+ if (!hit)
34
+ return true;
35
+ try {
36
+ if (statSync(join(ROOT, path)).mtimeMs !== hit.mtime)
37
+ return true;
38
+ }
39
+ catch {
40
+ return true;
41
+ }
42
+ if (!kinds.length)
43
+ return !hit.kinds.includes('all');
44
+ return !kinds.every((k) => hit.kinds.includes(k) || hit.kinds.includes('all'));
45
+ };
46
+ const todo = screens.filter((s) => needsVisit(s.path));
47
+ const todoPaths = new Set(todo.map((s) => s.path));
24
48
  const server = await startServer(0);
25
- let browser = await chromium.launch();
26
- let page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
49
+ const CONCURRENCY = 4;
27
50
  const known = {};
28
51
  const crops = {}; // kind -> path -> image files
29
- for (const [i, s] of screens.entries()) {
30
- if (i > 0 && i % 50 === 0) {
31
- // Chromium gets fragile after hundreds of page loads in one browser; a fresh one keeps long runs alive.
32
- await page.close().catch(() => { });
33
- await browser.close().catch(() => { });
34
- browser = await chromium.launch();
35
- page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
52
+ // Reuse the cached signatures and crops of unchanged screens; only changed screens get a visit.
53
+ for (const s of screens) {
54
+ const hit = cached[s.path];
55
+ if (hit && !todoPaths.has(s.path)) {
56
+ known[s.path] = hit.components;
57
+ for (const [kind, files] of Object.entries(hit.crops)) {
58
+ if (kinds.length && !kinds.includes(kind.split(':')[0]))
59
+ continue;
60
+ crops[kind] ??= {};
61
+ crops[kind][s.path] = files;
62
+ }
63
+ }
64
+ }
65
+ const fresh = async () => {
66
+ const browser = await chromium.launch();
67
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2 });
68
+ return { browser, page, visits: 0 };
69
+ };
70
+ const pool = await Promise.all(Array.from({ length: Math.min(CONCURRENCY, todo.length) }, fresh));
71
+ let nextWorker = 0;
72
+ /** Component families cropped per visited screen, merged into the cache. */
73
+ const visitedKinds = {};
74
+ const visit = async (s) => {
75
+ const worker = pool[nextWorker++ % pool.length];
76
+ const { page } = worker;
77
+ if (worker.visits > 0 && worker.visits % 50 === 0) {
78
+ // Chromium gets fragile after hundreds of page loads; a fresh browser keeps long runs alive.
79
+ await worker.page.close().catch(() => { });
80
+ await worker.browser.close().catch(() => { });
81
+ const next = await fresh();
82
+ pool[pool.indexOf(worker)] = next;
83
+ return visit(s);
36
84
  }
85
+ worker.visits++;
37
86
  await page.setViewportSize({ width: s.width, height: 844 });
38
87
  await page
39
88
  .goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 })
@@ -41,9 +90,11 @@ for (const [i, s] of screens.entries()) {
41
90
  await page.waitForTimeout(600);
42
91
  const found = await page.evaluate(collectComponents, collectOptions());
43
92
  known[s.path] = found.components;
93
+ visitedKinds[s.path] = new Set();
44
94
  for (const [kind, sigs] of Object.entries(found.components)) {
45
95
  if (kinds.length && !kinds.includes(kind.split(':')[0]))
46
96
  continue;
97
+ visitedKinds[s.path].add(kind.split(':')[0]);
47
98
  const files = [];
48
99
  for (const [i, sig] of sigs.entries()) {
49
100
  const file = `${safe(kind)}/${safe(s.path)}-${i}.png`;
@@ -80,8 +131,45 @@ for (const [i, s] of screens.entries()) {
80
131
  crops[kind] ??= {};
81
132
  crops[kind][s.path] = files;
82
133
  }
134
+ visitedKinds[s.path].add('all');
135
+ };
136
+ await Promise.all(todo.map((s) => visit(s)));
137
+ // Remember what this run saw: visited screens with fresh mtimes, the rest untouched. Screens that are
138
+ // gone leave the cache; their rows leave the report with them.
139
+ const live = new Set(screens.map((s) => s.path));
140
+ const next = {};
141
+ for (const [path, hit] of Object.entries(cached)) {
142
+ if (live.has(path) && !todoPaths.has(path))
143
+ next[path] = hit;
144
+ }
145
+ for (const s of todo) {
146
+ const screenCrops = {};
147
+ for (const [kind, byPath] of Object.entries(crops)) {
148
+ if (byPath[s.path])
149
+ screenCrops[kind] = byPath[s.path];
150
+ }
151
+ const prev = cached[s.path];
152
+ const families = new Set([...(prev?.kinds ?? []), ...(visitedKinds[s.path] ?? [])]);
153
+ if (!kinds.length)
154
+ families.add('all');
155
+ else
156
+ for (const k of kinds)
157
+ families.add(k);
158
+ try {
159
+ next[s.path] = {
160
+ mtime: statSync(join(ROOT, s.path)).mtimeMs,
161
+ components: known[s.path],
162
+ kinds: [...families],
163
+ crops: { ...(prev?.crops ?? {}), ...screenCrops },
164
+ };
165
+ }
166
+ catch { }
83
167
  }
84
- await browser.close();
168
+ writeFileSync(KNOWN_FILE, JSON.stringify(next));
169
+ await Promise.all(pool.map(async (w) => {
170
+ await w.page.close().catch(() => { });
171
+ await w.browser.close().catch(() => { });
172
+ }));
85
173
  server.close();
86
174
  const issues = consistencyIssues(known, Object.keys(known));
87
175
  const ORDER = ['tab-bar', 'header', 'icon-button', 'button:primary:md', 'chip', 'section'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stitch2",
3
- "version": "0.22.1",
3
+ "version": "0.22.2",
4
4
  "description": "A design canvas, layout and type checks, component consistency and screen versioning for agent-built UI, on Google's DESIGN.md format.",
5
5
  "license": "MIT",
6
6
  "author": "ifokeev",