@polishd/next 0.2.3-dev.4 → 0.2.3-dev.6
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/CHANGELOG.md +63 -0
- package/README.md +12 -5
- package/bin/init.mjs +4 -1
- package/dist/ai/actions.d.ts +7 -1
- package/dist/ai/actions.d.ts.map +1 -1
- package/dist/ai/actions.js +10 -1
- package/dist/ai/actions.js.map +1 -1
- package/dist/ai/design.d.ts +5 -1
- package/dist/ai/design.d.ts.map +1 -1
- package/dist/ai/design.js +21 -4
- package/dist/ai/design.js.map +1 -1
- package/dist/ai/issues.d.ts +22 -11
- package/dist/ai/issues.d.ts.map +1 -1
- package/dist/ai/issues.js +102 -43
- package/dist/ai/issues.js.map +1 -1
- package/dist/ai/types.d.ts +3 -0
- package/dist/ai/types.d.ts.map +1 -1
- package/dist/dashboard/design-review-card.d.ts +3 -1
- package/dist/dashboard/design-review-card.d.ts.map +1 -1
- package/dist/dashboard/design-review-card.js +14 -6
- package/dist/dashboard/design-review-card.js.map +1 -1
- package/dist/dashboard/design.d.ts.map +1 -1
- package/dist/dashboard/design.js +75 -17
- package/dist/dashboard/design.js.map +1 -1
- package/dist/dashboard/file-bug-button.d.ts +14 -0
- package/dist/dashboard/file-bug-button.d.ts.map +1 -0
- package/dist/dashboard/file-bug-button.js +37 -0
- package/dist/dashboard/file-bug-button.js.map +1 -0
- package/dist/dashboard/snapshot-actions.d.ts +15 -5
- package/dist/dashboard/snapshot-actions.d.ts.map +1 -1
- package/dist/dashboard/snapshot-actions.js +69 -24
- package/dist/dashboard/snapshot-actions.js.map +1 -1
- package/dist/dashboard/snapshots.d.ts.map +1 -1
- package/dist/dashboard/snapshots.js +58 -8
- package/dist/dashboard/snapshots.js.map +1 -1
- package/dist/dashboard/summary.d.ts.map +1 -1
- package/dist/dashboard/summary.js +8 -36
- package/dist/dashboard/summary.js.map +1 -1
- package/dist/dashboard/ui.d.ts +1 -0
- package/dist/dashboard/ui.d.ts.map +1 -1
- package/dist/dashboard/ui.js +1 -0
- package/dist/dashboard/ui.js.map +1 -1
- package/dist/dashboard/value-preview.d.ts +47 -0
- package/dist/dashboard/value-preview.d.ts.map +1 -0
- package/dist/dashboard/value-preview.js +138 -0
- package/dist/dashboard/value-preview.js.map +1 -0
- package/dist/dashboard.css +170 -50
- package/dist/server/snapshots.d.ts +69 -10
- package/dist/server/snapshots.d.ts.map +1 -1
- package/dist/server/snapshots.js +407 -158
- package/dist/server/snapshots.js.map +1 -1
- package/dist/server/value-previews.d.ts +47 -0
- package/dist/server/value-previews.d.ts.map +1 -0
- package/dist/server/value-previews.js +313 -0
- package/dist/server/value-previews.js.map +1 -0
- package/dist/shared/design-values.d.ts +71 -0
- package/dist/shared/design-values.d.ts.map +1 -0
- package/dist/shared/design-values.js +98 -0
- package/dist/shared/design-values.js.map +1 -0
- package/docs/SETUP.md +10 -0
- package/package.json +1 -1
package/dist/server/snapshots.js
CHANGED
|
@@ -19,11 +19,27 @@
|
|
|
19
19
|
* optional dependencies, so a standard install needs no extra wiring. Every
|
|
20
20
|
* failure degrades to a message, never a crash — the same contract as the
|
|
21
21
|
* rest of the server layer.
|
|
22
|
+
*
|
|
23
|
+
* Alongside the full shots, each route call also pictures the Design tab's
|
|
24
|
+
* flagged values in place — a ringed crop of the element carrying a one-off
|
|
25
|
+
* type style, color, radius or off-grid padding (server/value-previews.ts) —
|
|
26
|
+
* stored beside the shots under the same disk-or-Blob rule, and listed in the
|
|
27
|
+
* snapshot as `previews` so hovering the value on the dashboard shows where
|
|
28
|
+
* it renders.
|
|
29
|
+
*
|
|
30
|
+
* Capture is chunked into one server call per route: begin → one
|
|
31
|
+
* `captureSnapshotRoute` per route → finish, with the in-flight state parked
|
|
32
|
+
* in the store between calls. A whole-site capture in a single request would
|
|
33
|
+
* have to outlive a serverless host's function time limit — which the package
|
|
34
|
+
* cannot raise on the host's behalf (`maxDuration` must be declared inline in
|
|
35
|
+
* the host's own page module), and which kills the function mid-shoot with no
|
|
36
|
+
* result at all. One route per request keeps every call short enough to fit.
|
|
22
37
|
*/
|
|
23
38
|
import { isAbsolute, join } from "node:path";
|
|
24
|
-
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
|
|
39
|
+
import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
25
40
|
import { tmpdir } from "node:os";
|
|
26
41
|
import { getMeta, setMeta, storeReady } from "./store";
|
|
42
|
+
import { captureValuePreviews } from "./value-previews";
|
|
27
43
|
const INDEX_KEY = "design_snapshots";
|
|
28
44
|
/** Snapshots kept before the oldest is pruned (files and index entry both). */
|
|
29
45
|
const MAX_SNAPSHOTS = 6;
|
|
@@ -32,6 +48,19 @@ export const MAX_SNAPSHOT_ROUTES = 12;
|
|
|
32
48
|
/** The device/theme matrix every snapshot shoots. */
|
|
33
49
|
const SNAPSHOT_DEVICES = ["phone", "desktop"];
|
|
34
50
|
const SNAPSHOT_THEMES = ["light", "dark"];
|
|
51
|
+
/**
|
|
52
|
+
* Where a flagged value is looked for, in order: the gallery's default view
|
|
53
|
+
* first, so most pictures match the shot the user is already looking at.
|
|
54
|
+
*/
|
|
55
|
+
const PREVIEW_RENDERINGS = [
|
|
56
|
+
{ device: "desktop", theme: "light" },
|
|
57
|
+
{ device: "desktop", theme: "dark" },
|
|
58
|
+
{ device: "phone", theme: "light" },
|
|
59
|
+
{ device: "phone", theme: "dark" },
|
|
60
|
+
];
|
|
61
|
+
/** Values pictured per route, and the time the pass may add to its call. */
|
|
62
|
+
const MAX_PREVIEWS_PER_ROUTE = 16;
|
|
63
|
+
const PREVIEW_BUDGET_MS = 15_000;
|
|
35
64
|
/**
|
|
36
65
|
* True when shots should upload to Vercel Blob rather than land on disk.
|
|
37
66
|
* `BLOB_READ_WRITE_TOKEN` is injected automatically once a Blob store is
|
|
@@ -68,26 +97,32 @@ export async function listSnapshots() {
|
|
|
68
97
|
return (await loadIndex()).snapshots;
|
|
69
98
|
}
|
|
70
99
|
/**
|
|
71
|
-
* Read one shot's image as a data URL, or null
|
|
72
|
-
* filename is only accepted when the index lists it
|
|
73
|
-
* caller can never read outside a snapshot directory —
|
|
74
|
-
* are only ever fetched from the URL the index recorded
|
|
100
|
+
* Read one shot's — or one value preview's — image as a data URL, or null
|
|
101
|
+
* when it doesn't exist. The filename is only accepted when the index lists it
|
|
102
|
+
* for that snapshot, so a caller can never read outside a snapshot directory —
|
|
103
|
+
* and Blob-stored images are only ever fetched from the URL the index recorded
|
|
104
|
+
* at capture time.
|
|
75
105
|
*/
|
|
76
106
|
export async function readSnapshotImage(snapshotId, file) {
|
|
77
107
|
const index = await loadIndex();
|
|
78
108
|
const snapshot = index.snapshots.find((s) => s.id === snapshotId);
|
|
79
|
-
|
|
80
|
-
|
|
109
|
+
if (!snapshot || file.length === 0)
|
|
110
|
+
return null;
|
|
111
|
+
const shot = snapshot.shots.find((s) => s.file === file);
|
|
112
|
+
const preview = shot ? undefined : snapshot.previews?.find((p) => p.file === file);
|
|
113
|
+
const image = shot ?? preview;
|
|
114
|
+
if (!image)
|
|
81
115
|
return null;
|
|
82
116
|
try {
|
|
83
|
-
if (
|
|
84
|
-
const res = await fetch(
|
|
117
|
+
if (image.url) {
|
|
118
|
+
const res = await fetch(image.url);
|
|
85
119
|
if (!res.ok)
|
|
86
120
|
return null;
|
|
87
121
|
const bytes = Buffer.from(await res.arrayBuffer());
|
|
88
122
|
return `data:image/png;base64,${bytes.toString("base64")}`;
|
|
89
123
|
}
|
|
90
|
-
const
|
|
124
|
+
const dir = shot ? "shots" : "previews";
|
|
125
|
+
const bytes = await readFile(join(snapshotsRoot(), snapshot.id, dir, image.file));
|
|
91
126
|
return `data:image/png;base64,${bytes.toString("base64")}`;
|
|
92
127
|
}
|
|
93
128
|
catch {
|
|
@@ -96,43 +131,129 @@ export async function readSnapshotImage(snapshotId, file) {
|
|
|
96
131
|
}
|
|
97
132
|
// ── Storage ──────────────────────────────────────────────────────────────────
|
|
98
133
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
134
|
+
* Move one route's staged PNGs to their real home — Vercel Blob when a token
|
|
135
|
+
* is present, the snapshots directory otherwise — under a per-route filename
|
|
136
|
+
* prefix: allpages slugs are only unique within a single run, and each route
|
|
137
|
+
* is its own run now. Blob pathnames get a random suffix on top —
|
|
138
|
+
* public-but-unguessable is the only access model Blob offers, and the
|
|
139
|
+
* dashboard still serves images through the auth-gated action rather than
|
|
140
|
+
* handing these URLs to the browser. On a failed upload the pieces already
|
|
141
|
+
* uploaded are deleted, so a half-stored route never survives.
|
|
105
142
|
*/
|
|
106
|
-
async function
|
|
107
|
-
|
|
143
|
+
async function storeRouteShots(id, routeIndex, stageDir, captured) {
|
|
144
|
+
const prefix = `r${routeIndex}-`;
|
|
145
|
+
const shots = [];
|
|
146
|
+
const toShot = (s, file, url) => ({
|
|
147
|
+
route: s.route.path,
|
|
148
|
+
device: s.device.name,
|
|
149
|
+
theme: s.theme,
|
|
150
|
+
file,
|
|
151
|
+
...(url ? { url } : {}),
|
|
152
|
+
...(s.skipped ? { skipped: s.skipped } : {}),
|
|
153
|
+
});
|
|
154
|
+
if (!blobConfigured()) {
|
|
155
|
+
const shotsDir = join(snapshotsRoot(), id, "shots");
|
|
156
|
+
await mkdir(shotsDir, { recursive: true });
|
|
157
|
+
for (const s of captured) {
|
|
158
|
+
if (!s.file) {
|
|
159
|
+
shots.push(toShot(s, ""));
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
await copyFile(join(stageDir, "shots", s.file), join(shotsDir, prefix + s.file));
|
|
163
|
+
shots.push(toShot(s, prefix + s.file));
|
|
164
|
+
}
|
|
165
|
+
return { shots };
|
|
166
|
+
}
|
|
167
|
+
const blob = await loadBlobSdk();
|
|
168
|
+
if ("message" in blob)
|
|
169
|
+
return blob;
|
|
170
|
+
const uploaded = [];
|
|
171
|
+
try {
|
|
172
|
+
for (const s of captured) {
|
|
173
|
+
if (!s.file) {
|
|
174
|
+
shots.push(toShot(s, ""));
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const bytes = await readFile(join(stageDir, "shots", s.file));
|
|
178
|
+
const { url } = await blob.put(`polishd/snapshots/${id}/${prefix}${s.file}`, bytes, {
|
|
179
|
+
access: "public",
|
|
180
|
+
contentType: "image/png",
|
|
181
|
+
addRandomSuffix: true,
|
|
182
|
+
});
|
|
183
|
+
uploaded.push(url);
|
|
184
|
+
shots.push(toShot(s, prefix + s.file, url));
|
|
185
|
+
}
|
|
186
|
+
return { shots };
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
if (uploaded.length > 0)
|
|
190
|
+
await blob.del(uploaded).catch(() => { });
|
|
191
|
+
return {
|
|
192
|
+
message: `Couldn't upload screenshots to Vercel Blob: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** The Blob SDK, or why it couldn't load. */
|
|
197
|
+
async function loadBlobSdk() {
|
|
108
198
|
try {
|
|
109
|
-
|
|
199
|
+
return await import("@vercel/blob");
|
|
110
200
|
}
|
|
111
201
|
catch {
|
|
112
|
-
return
|
|
113
|
-
"
|
|
114
|
-
|
|
202
|
+
return {
|
|
203
|
+
message: "Blob storage is configured (BLOB_READ_WRITE_TOKEN is set) but @vercel/blob " +
|
|
204
|
+
"isn't installed. It ships as an optional dependency of @polishd/next — " +
|
|
205
|
+
"reinstall without `--omit=optional`.",
|
|
206
|
+
};
|
|
115
207
|
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Store one route's value previews next to its shots — the snapshot's
|
|
211
|
+
* `previews/` directory, or Blob under the same rules as the shots. Names are
|
|
212
|
+
* `v-r<route>-<n>-<device>-<theme>.png`, unique within the snapshot and never
|
|
213
|
+
* mistakable for a shot's. All or nothing, like the shots: a failed upload
|
|
214
|
+
* deletes the pieces already sent.
|
|
215
|
+
*/
|
|
216
|
+
async function storeRoutePreviews(id, routeIndex, route, crops) {
|
|
217
|
+
const named = crops.map((c, n) => ({
|
|
218
|
+
...c,
|
|
219
|
+
file: `v-r${routeIndex}-${n}-${c.device}-${c.theme}.png`,
|
|
220
|
+
}));
|
|
221
|
+
const toPreview = (c, url) => ({
|
|
222
|
+
key: c.key,
|
|
223
|
+
route,
|
|
224
|
+
device: c.device,
|
|
225
|
+
theme: c.theme,
|
|
226
|
+
file: c.file,
|
|
227
|
+
...(url ? { url } : {}),
|
|
228
|
+
});
|
|
229
|
+
if (!blobConfigured()) {
|
|
230
|
+
const dir = join(snapshotsRoot(), id, "previews");
|
|
231
|
+
await mkdir(dir, { recursive: true });
|
|
232
|
+
for (const c of named)
|
|
233
|
+
await writeFile(join(dir, c.file), c.bytes);
|
|
234
|
+
return named.map((c) => toPreview(c));
|
|
235
|
+
}
|
|
236
|
+
const blob = await loadBlobSdk();
|
|
237
|
+
if ("message" in blob)
|
|
238
|
+
return [];
|
|
116
239
|
const uploaded = [];
|
|
117
240
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const bytes = await readFile(join(outDir, "shots", shot.file));
|
|
122
|
-
const { url } = await blob.put(`polishd/snapshots/${id}/${shot.file}`, bytes, {
|
|
241
|
+
const previews = [];
|
|
242
|
+
for (const c of named) {
|
|
243
|
+
const { url } = await blob.put(`polishd/snapshots/${id}/previews/${c.file}`, c.bytes, {
|
|
123
244
|
access: "public",
|
|
124
245
|
contentType: "image/png",
|
|
125
246
|
addRandomSuffix: true,
|
|
126
247
|
});
|
|
127
|
-
shot.url = url;
|
|
128
248
|
uploaded.push(url);
|
|
249
|
+
previews.push(toPreview(c, url));
|
|
129
250
|
}
|
|
130
|
-
return
|
|
251
|
+
return previews;
|
|
131
252
|
}
|
|
132
|
-
catch
|
|
253
|
+
catch {
|
|
133
254
|
if (uploaded.length > 0)
|
|
134
255
|
await blob.del(uploaded).catch(() => { });
|
|
135
|
-
return
|
|
256
|
+
return [];
|
|
136
257
|
}
|
|
137
258
|
}
|
|
138
259
|
/**
|
|
@@ -142,7 +263,7 @@ async function uploadShots(id, outDir, shots) {
|
|
|
142
263
|
* blob costs cents, a prune that crashed the capture would cost the feature.
|
|
143
264
|
*/
|
|
144
265
|
async function deleteSnapshotImages(snapshot) {
|
|
145
|
-
const urls = snapshot.shots.flatMap((s) =>
|
|
266
|
+
const urls = [...snapshot.shots, ...(snapshot.previews ?? [])].flatMap((s) => s.url ? [s.url] : []);
|
|
146
267
|
if (urls.length > 0) {
|
|
147
268
|
try {
|
|
148
269
|
const { del } = await import("@vercel/blob");
|
|
@@ -154,6 +275,251 @@ async function deleteSnapshotImages(snapshot) {
|
|
|
154
275
|
}
|
|
155
276
|
await rm(join(snapshotsRoot(), snapshot.id), { recursive: true, force: true }).catch(() => { });
|
|
156
277
|
}
|
|
278
|
+
const PENDING_KEY = "design_snapshot_pending";
|
|
279
|
+
async function loadPending() {
|
|
280
|
+
const raw = await getMeta(PENDING_KEY);
|
|
281
|
+
if (!raw)
|
|
282
|
+
return null;
|
|
283
|
+
try {
|
|
284
|
+
const parsed = JSON.parse(raw);
|
|
285
|
+
if (parsed?.v === 1 && typeof parsed.id === "string") {
|
|
286
|
+
// Records parked by a version without value previews.
|
|
287
|
+
parsed.targets ??= [];
|
|
288
|
+
parsed.previews ??= [];
|
|
289
|
+
parsed.previewMisses ??= [];
|
|
290
|
+
return parsed;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
// A corrupt pending record is simply abandoned.
|
|
295
|
+
}
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
/** Persist the in-flight capture; null clears it. */
|
|
299
|
+
async function savePending(pending) {
|
|
300
|
+
return setMeta(PENDING_KEY, pending ? JSON.stringify(pending) : "");
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Start a capture: run the environment preflight, sweep any unfinished
|
|
304
|
+
* predecessor's images, and park the plan in the store. The returned routes
|
|
305
|
+
* are what the caller should then feed to `captureSnapshotRoute`, one call
|
|
306
|
+
* each, before `finishSnapshot` records the run.
|
|
307
|
+
*/
|
|
308
|
+
export async function beginSnapshot(input) {
|
|
309
|
+
if (!(await storeReady())) {
|
|
310
|
+
return {
|
|
311
|
+
ok: false,
|
|
312
|
+
message: "The analytics store isn't writable here, so a snapshot couldn't be recorded. " +
|
|
313
|
+
"Run locally or configure a database — see DATABASE.md.",
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
// The same preflight the dashboard runs before enabling the capture button.
|
|
317
|
+
// Actions are reachable without the dashboard ever rendering, so a doomed
|
|
318
|
+
// environment must fail here too — with the diagnosis, not a minute of
|
|
319
|
+
// capture ending in an EROFS.
|
|
320
|
+
const issue = await snapshotCaptureIssue();
|
|
321
|
+
if (issue)
|
|
322
|
+
return { ok: false, message: issue };
|
|
323
|
+
const routes = input.routes.slice(0, MAX_SNAPSHOT_ROUTES);
|
|
324
|
+
if (routes.length === 0)
|
|
325
|
+
return { ok: false, message: "No routes to capture." };
|
|
326
|
+
// A predecessor that never finished (tab closed mid-run) left images
|
|
327
|
+
// behind; sweep them before its record is overwritten.
|
|
328
|
+
const stale = await loadPending();
|
|
329
|
+
if (stale)
|
|
330
|
+
await deleteSnapshotImages(stale);
|
|
331
|
+
const pending = {
|
|
332
|
+
v: 1,
|
|
333
|
+
id: Date.now().toString(36),
|
|
334
|
+
origin: input.origin,
|
|
335
|
+
fingerprint: input.fingerprint,
|
|
336
|
+
startedAt: Date.now(),
|
|
337
|
+
routes,
|
|
338
|
+
done: [],
|
|
339
|
+
shots: [],
|
|
340
|
+
authWalled: [],
|
|
341
|
+
noDarkMode: true,
|
|
342
|
+
targets: input.targets ?? [],
|
|
343
|
+
previews: [],
|
|
344
|
+
previewMisses: [],
|
|
345
|
+
};
|
|
346
|
+
if (!(await savePending(pending))) {
|
|
347
|
+
return { ok: false, message: "The store refused to record the capture in progress." };
|
|
348
|
+
}
|
|
349
|
+
return { ok: true, id: pending.id, routes };
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Shoot one route of the capture `beginSnapshot` started — both devices, both
|
|
353
|
+
* themes — store its images, and append the results to the pending record.
|
|
354
|
+
* Bounded work per call by design: this is the request that has to fit inside
|
|
355
|
+
* a serverless host's function time limit.
|
|
356
|
+
*/
|
|
357
|
+
export async function captureSnapshotRoute(id, route) {
|
|
358
|
+
const pending = await loadPending();
|
|
359
|
+
if (!pending || pending.id !== id) {
|
|
360
|
+
return {
|
|
361
|
+
ok: false,
|
|
362
|
+
message: "This capture is no longer in progress — start a new one.",
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (!pending.routes.includes(route)) {
|
|
366
|
+
return { ok: false, message: `${route} isn't part of this capture.` };
|
|
367
|
+
}
|
|
368
|
+
if (pending.done.includes(route)) {
|
|
369
|
+
// A retry after a lost response — the shots are already recorded.
|
|
370
|
+
return { ok: true, shots: pending.shots.filter((s) => s.route === route) };
|
|
371
|
+
}
|
|
372
|
+
let run;
|
|
373
|
+
try {
|
|
374
|
+
({ allpages: run } = await import("allpages"));
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
message: `The allpages screenshot engine failed to load: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
const launched = await launchBrowser();
|
|
383
|
+
if ("message" in launched)
|
|
384
|
+
return { ok: false, message: launched.message };
|
|
385
|
+
// Every route stages into its own temp directory — the one location
|
|
386
|
+
// serverless hosts keep writable — and the files only exist for the moments
|
|
387
|
+
// between capture and `storeRouteShots` moving them home.
|
|
388
|
+
let stageDir;
|
|
389
|
+
try {
|
|
390
|
+
stageDir = await mkdtemp(join(tmpdir(), "polishd-snapshot-"));
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
await launched.browser.close().catch(() => { });
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
message: `Couldn't create the snapshot staging directory (${err instanceof Error ? err.message : "unknown error"}) — is the filesystem writable?`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const result = await run({
|
|
401
|
+
url: pending.origin,
|
|
402
|
+
routes: [route],
|
|
403
|
+
outDir: stageDir,
|
|
404
|
+
devices: [...SNAPSHOT_DEVICES],
|
|
405
|
+
themes: [...SNAPSHOT_THEMES],
|
|
406
|
+
// The dashboard composes its own grid; the stitched contact sheet would
|
|
407
|
+
// only duplicate every image on disk.
|
|
408
|
+
skipSheet: true,
|
|
409
|
+
browser: launched.browser,
|
|
410
|
+
});
|
|
411
|
+
const stored = await storeRouteShots(id, pending.routes.indexOf(route), stageDir, result.shots);
|
|
412
|
+
if ("message" in stored)
|
|
413
|
+
return { ok: false, message: stored.message };
|
|
414
|
+
const pictured = await pictureRouteValues(launched.browser, pending, route, stored.shots);
|
|
415
|
+
pending.done.push(route);
|
|
416
|
+
pending.shots.push(...stored.shots);
|
|
417
|
+
pending.previews.push(...pictured.previews);
|
|
418
|
+
pending.previewMisses.push(...pictured.missed);
|
|
419
|
+
for (const walled of result.authWalled) {
|
|
420
|
+
if (!pending.authWalled.includes(walled))
|
|
421
|
+
pending.authWalled.push(walled);
|
|
422
|
+
}
|
|
423
|
+
// A route that produced no images says nothing about dark mode.
|
|
424
|
+
if (stored.shots.some((s) => s.file)) {
|
|
425
|
+
pending.noDarkMode = pending.noDarkMode && result.noDarkMode;
|
|
426
|
+
}
|
|
427
|
+
if (!(await savePending(pending))) {
|
|
428
|
+
await deleteSnapshotImages({ id, shots: stored.shots, previews: pictured.previews });
|
|
429
|
+
return { ok: false, message: "The route was shot but the store refused to record it." };
|
|
430
|
+
}
|
|
431
|
+
return { ok: true, shots: stored.shots };
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
return {
|
|
435
|
+
ok: false,
|
|
436
|
+
message: `Screenshot capture failed: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
finally {
|
|
440
|
+
await launched.browser.close().catch(() => { });
|
|
441
|
+
await rm(stageDir, { recursive: true, force: true }).catch(() => { });
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Picture the capture's flagged values that were measured on `route` and
|
|
446
|
+
* aren't pictured yet, in the renderings this route actually produced shots
|
|
447
|
+
* for (an auth-walled or failed route has none, so it's skipped). Never fails
|
|
448
|
+
* the route: any error leaves the route with its full shots and no previews.
|
|
449
|
+
*/
|
|
450
|
+
async function pictureRouteValues(browser, pending, route, shots) {
|
|
451
|
+
const none = { previews: [], missed: [] };
|
|
452
|
+
const pictured = new Set(pending.previews.map((p) => p.key));
|
|
453
|
+
const targets = pending.targets
|
|
454
|
+
.filter((t) => t.pages.includes(route) && !pictured.has(t.key))
|
|
455
|
+
.slice(0, MAX_PREVIEWS_PER_ROUTE);
|
|
456
|
+
const renderings = PREVIEW_RENDERINGS.filter((r) => shots.some((s) => s.file && s.device === r.device && s.theme === r.theme));
|
|
457
|
+
if (targets.length === 0 || renderings.length === 0)
|
|
458
|
+
return none;
|
|
459
|
+
try {
|
|
460
|
+
const result = await captureValuePreviews(browser, pending.origin, route, targets, renderings, PREVIEW_BUDGET_MS);
|
|
461
|
+
const previews = result.crops.length > 0
|
|
462
|
+
? await storeRoutePreviews(pending.id, pending.routes.indexOf(route), route, result.crops)
|
|
463
|
+
: [];
|
|
464
|
+
return { previews, missed: result.missed };
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return none;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Close out the capture: record the accumulated shots in the snapshot index
|
|
472
|
+
* and prune old snapshots past `MAX_SNAPSHOTS`, images included. Called once
|
|
473
|
+
* every route has been attempted — routes whose calls failed outright are
|
|
474
|
+
* simply absent from the snapshot.
|
|
475
|
+
*/
|
|
476
|
+
export async function finishSnapshot(id) {
|
|
477
|
+
const pending = await loadPending();
|
|
478
|
+
if (!pending || pending.id !== id) {
|
|
479
|
+
return {
|
|
480
|
+
ok: false,
|
|
481
|
+
message: "This capture is no longer in progress — start a new one.",
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
const snapshot = {
|
|
485
|
+
id: pending.id,
|
|
486
|
+
capturedAt: Date.now(),
|
|
487
|
+
origin: pending.origin,
|
|
488
|
+
fingerprint: pending.fingerprint,
|
|
489
|
+
shots: pending.shots,
|
|
490
|
+
authWalled: pending.authWalled,
|
|
491
|
+
noDarkMode: pending.noDarkMode,
|
|
492
|
+
previews: pending.previews,
|
|
493
|
+
// A value missed on one of its pages may have been pictured on another.
|
|
494
|
+
previewMisses: [...new Set(pending.previewMisses)].filter((key) => !pending.previews.some((p) => p.key === key)),
|
|
495
|
+
};
|
|
496
|
+
if (snapshot.shots.every((s) => !s.file)) {
|
|
497
|
+
await deleteSnapshotImages(snapshot);
|
|
498
|
+
await savePending(null);
|
|
499
|
+
return {
|
|
500
|
+
ok: false,
|
|
501
|
+
message: "Every page failed to capture — is the site reachable at " +
|
|
502
|
+
`${pending.origin} from its own server?`,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const index = await loadIndex();
|
|
506
|
+
index.snapshots.unshift(snapshot);
|
|
507
|
+
const pruned = index.snapshots.slice(MAX_SNAPSHOTS);
|
|
508
|
+
index.snapshots = index.snapshots.slice(0, MAX_SNAPSHOTS);
|
|
509
|
+
if (!(await setMeta(INDEX_KEY, JSON.stringify(index)))) {
|
|
510
|
+
await deleteSnapshotImages(snapshot);
|
|
511
|
+
await savePending(null);
|
|
512
|
+
return {
|
|
513
|
+
ok: false,
|
|
514
|
+
message: "The snapshot was shot but the store refused to record it.",
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
await savePending(null);
|
|
518
|
+
for (const old of pruned) {
|
|
519
|
+
await deleteSnapshotImages(old);
|
|
520
|
+
}
|
|
521
|
+
return { ok: true, snapshot };
|
|
522
|
+
}
|
|
157
523
|
/**
|
|
158
524
|
* Launch a Chromium the way the allpages CLI does — the user's own Chrome or
|
|
159
525
|
* Edge first, Playwright's downloaded Chromium next — but never auto-download
|
|
@@ -164,7 +530,15 @@ async function deleteSnapshotImages(snapshot) {
|
|
|
164
530
|
* dependency so production needs no setup step.
|
|
165
531
|
*/
|
|
166
532
|
async function launchBrowser() {
|
|
167
|
-
|
|
533
|
+
let chromium;
|
|
534
|
+
try {
|
|
535
|
+
({ chromium } = await import("playwright-core"));
|
|
536
|
+
}
|
|
537
|
+
catch (err) {
|
|
538
|
+
return {
|
|
539
|
+
message: `The browser driver (playwright-core) failed to load: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
168
542
|
const args = ["--force-color-profile=srgb", "--hide-scrollbars"];
|
|
169
543
|
for (const channel of ["chrome", "msedge", undefined]) {
|
|
170
544
|
try {
|
|
@@ -262,129 +636,4 @@ export async function snapshotCaptureIssue() {
|
|
|
262
636
|
}
|
|
263
637
|
return null;
|
|
264
638
|
}
|
|
265
|
-
/**
|
|
266
|
-
* Shoot the given routes and record the run in the snapshot index. Old
|
|
267
|
-
* snapshots past `MAX_SNAPSHOTS` are pruned, images included.
|
|
268
|
-
*/
|
|
269
|
-
export async function captureSnapshot(input) {
|
|
270
|
-
if (!(await storeReady())) {
|
|
271
|
-
return {
|
|
272
|
-
ok: false,
|
|
273
|
-
message: "The analytics store isn't writable here, so a snapshot couldn't be recorded. " +
|
|
274
|
-
"Run locally or configure a database — see DATABASE.md.",
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
|
-
// The same preflight the dashboard runs before enabling the capture button.
|
|
278
|
-
// Actions are reachable without the dashboard ever rendering, so a doomed
|
|
279
|
-
// environment must fail here too — with the diagnosis, not a minute of
|
|
280
|
-
// capture ending in an EROFS.
|
|
281
|
-
const issue = await snapshotCaptureIssue();
|
|
282
|
-
if (issue)
|
|
283
|
-
return { ok: false, message: issue };
|
|
284
|
-
let run;
|
|
285
|
-
try {
|
|
286
|
-
({ allpages: run } = await import("allpages"));
|
|
287
|
-
}
|
|
288
|
-
catch (err) {
|
|
289
|
-
return {
|
|
290
|
-
ok: false,
|
|
291
|
-
message: `The allpages screenshot engine failed to load: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
const launched = await launchBrowser();
|
|
295
|
-
if ("message" in launched)
|
|
296
|
-
return { ok: false, message: launched.message };
|
|
297
|
-
const id = `${Date.now().toString(36)}`;
|
|
298
|
-
const toBlob = blobConfigured();
|
|
299
|
-
// In Blob mode allpages still writes to disk first, but into the system
|
|
300
|
-
// temp directory — the one location serverless hosts keep writable — and
|
|
301
|
-
// the files only exist for the moments between capture and upload.
|
|
302
|
-
let outDir;
|
|
303
|
-
try {
|
|
304
|
-
if (toBlob) {
|
|
305
|
-
outDir = await mkdtemp(join(tmpdir(), "polishd-snapshot-"));
|
|
306
|
-
}
|
|
307
|
-
else {
|
|
308
|
-
outDir = join(snapshotsRoot(), id);
|
|
309
|
-
await mkdir(outDir, { recursive: true });
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
catch (err) {
|
|
313
|
-
await launched.browser.close().catch(() => { });
|
|
314
|
-
return {
|
|
315
|
-
ok: false,
|
|
316
|
-
message: `Couldn't create the snapshot directory (${err instanceof Error ? err.message : "unknown error"}) — is the filesystem writable?`,
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
try {
|
|
320
|
-
const result = await run({
|
|
321
|
-
url: input.origin,
|
|
322
|
-
routes: input.routes.slice(0, MAX_SNAPSHOT_ROUTES),
|
|
323
|
-
outDir,
|
|
324
|
-
devices: [...SNAPSHOT_DEVICES],
|
|
325
|
-
themes: [...SNAPSHOT_THEMES],
|
|
326
|
-
// The dashboard composes its own grid; the stitched contact sheet would
|
|
327
|
-
// only duplicate every image on disk.
|
|
328
|
-
skipSheet: true,
|
|
329
|
-
browser: launched.browser,
|
|
330
|
-
});
|
|
331
|
-
const snapshot = {
|
|
332
|
-
id,
|
|
333
|
-
capturedAt: Date.now(),
|
|
334
|
-
origin: input.origin,
|
|
335
|
-
fingerprint: input.fingerprint,
|
|
336
|
-
shots: result.shots.map((s) => ({
|
|
337
|
-
route: s.route.path,
|
|
338
|
-
device: s.device.name,
|
|
339
|
-
theme: s.theme,
|
|
340
|
-
file: s.file ?? "",
|
|
341
|
-
...(s.skipped ? { skipped: s.skipped } : {}),
|
|
342
|
-
})),
|
|
343
|
-
authWalled: result.authWalled,
|
|
344
|
-
noDarkMode: result.noDarkMode,
|
|
345
|
-
};
|
|
346
|
-
if (snapshot.shots.every((s) => !s.file)) {
|
|
347
|
-
await rm(outDir, { recursive: true, force: true }).catch(() => { });
|
|
348
|
-
return {
|
|
349
|
-
ok: false,
|
|
350
|
-
message: "Every page failed to capture — is the site reachable at " +
|
|
351
|
-
`${input.origin} from this process?`,
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
if (toBlob) {
|
|
355
|
-
const failure = await uploadShots(id, outDir, snapshot.shots);
|
|
356
|
-
if (failure)
|
|
357
|
-
return { ok: false, message: failure };
|
|
358
|
-
}
|
|
359
|
-
const index = await loadIndex();
|
|
360
|
-
index.snapshots.unshift(snapshot);
|
|
361
|
-
const pruned = index.snapshots.slice(MAX_SNAPSHOTS);
|
|
362
|
-
index.snapshots = index.snapshots.slice(0, MAX_SNAPSHOTS);
|
|
363
|
-
if (!(await setMeta(INDEX_KEY, JSON.stringify(index)))) {
|
|
364
|
-
await deleteSnapshotImages(snapshot);
|
|
365
|
-
return {
|
|
366
|
-
ok: false,
|
|
367
|
-
message: "The snapshot was shot but the store refused to record it.",
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
for (const old of pruned) {
|
|
371
|
-
await deleteSnapshotImages(old);
|
|
372
|
-
}
|
|
373
|
-
return { ok: true, snapshot };
|
|
374
|
-
}
|
|
375
|
-
catch (err) {
|
|
376
|
-
await rm(outDir, { recursive: true, force: true }).catch(() => { });
|
|
377
|
-
return {
|
|
378
|
-
ok: false,
|
|
379
|
-
message: `Screenshot capture failed: ${err instanceof Error ? err.message : "unknown error"}`,
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
finally {
|
|
383
|
-
await launched.browser.close().catch(() => { });
|
|
384
|
-
// The temp staging directory never outlives the capture; the disk-mode
|
|
385
|
-
// directory is the storage itself and stays.
|
|
386
|
-
if (toBlob)
|
|
387
|
-
await rm(outDir, { recursive: true, force: true }).catch(() => { });
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
639
|
//# sourceMappingURL=snapshots.js.map
|