stitch2 0.22.1 → 0.22.3
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/dist/report.js +156 -53
- 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,67 +23,168 @@ const esc = (s) => String(s ?? '')
|
|
|
21
23
|
.replaceAll('<', '<')
|
|
22
24
|
.replaceAll('>', '>')
|
|
23
25
|
.replaceAll('"', '"');
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
}
|
|
36
63
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
/** Component families cropped per visited screen, merged into the cache. */
|
|
72
|
+
const visitedKinds = {};
|
|
73
|
+
let cursor = 0;
|
|
74
|
+
// One screen per worker at a time: workers pull from the queue, so a page is never shared.
|
|
75
|
+
const runOne = async (s, worker) => {
|
|
76
|
+
try {
|
|
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
|
+
worker.browser = next.browser;
|
|
83
|
+
worker.page = next.page;
|
|
84
|
+
}
|
|
85
|
+
worker.visits++;
|
|
86
|
+
const page = worker.page;
|
|
87
|
+
await page.setViewportSize({ width: s.width, height: 844 });
|
|
88
|
+
await page
|
|
89
|
+
.goto(`http://127.0.0.1:${server.port}/${s.path}`, { waitUntil: 'networkidle', timeout: 30_000 })
|
|
90
|
+
.catch(() => { });
|
|
91
|
+
await page.waitForTimeout(600);
|
|
92
|
+
const found = await page.evaluate(collectComponents, collectOptions());
|
|
93
|
+
known[s.path] = found.components;
|
|
94
|
+
visitedKinds[s.path] = new Set();
|
|
95
|
+
for (const [kind, sigs] of Object.entries(found.components)) {
|
|
96
|
+
if (kinds.length && !kinds.includes(kind.split(':')[0]))
|
|
97
|
+
continue;
|
|
98
|
+
visitedKinds[s.path].add(kind.split(':')[0]);
|
|
99
|
+
const files = [];
|
|
100
|
+
for (const [i, sig] of sigs.entries()) {
|
|
101
|
+
const file = `${safe(kind)}/${safe(s.path)}-${i}.png`;
|
|
102
|
+
mkdirSync(join(out, safe(kind)), { recursive: true });
|
|
103
|
+
try {
|
|
104
|
+
// Crop the element plus anything drawn outside it (a raised centre button above a tab bar).
|
|
105
|
+
const el = page.locator(sig.selector).first();
|
|
106
|
+
await el.scrollIntoViewIfNeeded({ timeout: 3000 });
|
|
107
|
+
const clip = await el.evaluate((node) => {
|
|
108
|
+
const r = node.getBoundingClientRect();
|
|
109
|
+
let top = r.top;
|
|
110
|
+
let bottom = r.bottom;
|
|
111
|
+
for (const c of node.querySelectorAll('*')) {
|
|
112
|
+
const q = c.getBoundingClientRect();
|
|
113
|
+
if (q.width && q.height) {
|
|
114
|
+
top = Math.min(top, q.top);
|
|
115
|
+
bottom = Math.max(bottom, q.bottom);
|
|
116
|
+
}
|
|
64
117
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
118
|
+
return {
|
|
119
|
+
x: Math.max(0, r.left),
|
|
120
|
+
y: Math.max(0, top),
|
|
121
|
+
width: r.width,
|
|
122
|
+
height: bottom - Math.max(0, top),
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
if (clip.width < 1 || clip.height < 1)
|
|
126
|
+
continue;
|
|
127
|
+
await page.screenshot({ path: join(out, file), clip });
|
|
128
|
+
files.push(file);
|
|
129
|
+
}
|
|
130
|
+
catch { }
|
|
77
131
|
}
|
|
78
|
-
|
|
132
|
+
crops[kind] ??= {};
|
|
133
|
+
crops[kind][s.path] = files;
|
|
79
134
|
}
|
|
80
|
-
|
|
81
|
-
|
|
135
|
+
visitedKinds[s.path].add('all');
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
// A dead page must not kill the other workers: the screen keeps no cache entry and is retried next run.
|
|
139
|
+
delete known[s.path];
|
|
140
|
+
delete visitedKinds[s.path];
|
|
141
|
+
console.error(`consistency: skipping ${s.path} (${String(e).split('\n')[0]})`);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const drive = async (worker) => {
|
|
145
|
+
while (cursor < todo.length)
|
|
146
|
+
await runOne(todo[cursor++], worker);
|
|
147
|
+
};
|
|
148
|
+
await Promise.all(pool.map((w) => drive(w)));
|
|
149
|
+
// Remember what this run saw: visited screens with fresh mtimes, the rest untouched. Screens that are
|
|
150
|
+
// gone leave the cache; their rows leave the report with them.
|
|
151
|
+
const live = new Set(screens.map((s) => s.path));
|
|
152
|
+
const next = {};
|
|
153
|
+
for (const [path, hit] of Object.entries(cached)) {
|
|
154
|
+
if (live.has(path) && !todoPaths.has(path))
|
|
155
|
+
next[path] = hit;
|
|
156
|
+
}
|
|
157
|
+
for (const s of todo) {
|
|
158
|
+
// Screens that failed keep no cache entry, so the next run retries them.
|
|
159
|
+
if (!known[s.path])
|
|
160
|
+
continue;
|
|
161
|
+
const screenCrops = {};
|
|
162
|
+
for (const [kind, byPath] of Object.entries(crops)) {
|
|
163
|
+
if (byPath[s.path])
|
|
164
|
+
screenCrops[kind] = byPath[s.path];
|
|
165
|
+
}
|
|
166
|
+
const prev = cached[s.path];
|
|
167
|
+
const families = new Set([...(prev?.kinds ?? []), ...(visitedKinds[s.path] ?? [])]);
|
|
168
|
+
if (!kinds.length)
|
|
169
|
+
families.add('all');
|
|
170
|
+
else
|
|
171
|
+
for (const k of kinds)
|
|
172
|
+
families.add(k);
|
|
173
|
+
try {
|
|
174
|
+
next[s.path] = {
|
|
175
|
+
mtime: statSync(join(ROOT, s.path)).mtimeMs,
|
|
176
|
+
components: known[s.path],
|
|
177
|
+
kinds: [...families],
|
|
178
|
+
crops: { ...(prev?.crops ?? {}), ...screenCrops },
|
|
179
|
+
};
|
|
82
180
|
}
|
|
181
|
+
catch { }
|
|
83
182
|
}
|
|
84
|
-
|
|
183
|
+
writeFileSync(KNOWN_FILE, JSON.stringify(next));
|
|
184
|
+
await Promise.all(pool.map(async (w) => {
|
|
185
|
+
await w.page.close().catch(() => { });
|
|
186
|
+
await w.browser.close().catch(() => { });
|
|
187
|
+
}));
|
|
85
188
|
server.close();
|
|
86
189
|
const issues = consistencyIssues(known, Object.keys(known));
|
|
87
190
|
const ORDER = ['tab-bar', 'header', 'icon-button', 'button:primary:md', 'chip', 'section'];
|
package/package.json
CHANGED