cursedops 0.21.0 → 0.22.0
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/package.json +9 -2
- package/src/smoke.ts +74 -9
- package/src/stageLook.ts +213 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedops",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "The build-and-ops answers this generation's apps wrote independently and identically: finding a generation's roots — and printing a command that runs when pasted — without knowing a path, the generation's whole-tree laws run over one repo from a checkout or a worktree, macOS launchd agent install/replace/remove and the live port a job serves, the check that a launchd job imports only what the LOCKED versions of its packages export (a lib-preview overlay's extra subpath or name is a job that dies at the next forced reinstall), the scaffolding and verdicts of a deployed smoke (origin probe, the smoke's own environment, a network that lies about DNS, a settled version), the static-serving helpers eight apps copied — the path-traversal guard among them — the API floor that keeps an unmatched /api/... from ever being answered with the app shell, the commit and dirty flag a checkout-served process reports, the Cloudflare Worker deploy toolkit four apps copied (the deploy sequence, exact-set secrets over a pipe, origin-first rollback, the curl edge fetch, the row-for-row D1 import proof, the billed-CPU tail check around a deploy's walk and smoke, and each app's worker:secrets and worker:smoke main as one function of its data), the relay a Worker fronts a Mac-bound app with (the Durable Object, the frames, the Mac's dialer and key rotation — lifted from station for roms — and the signed-in stage walk's skeleton and the relay app's whole worker:deploy), the Worker import-graph and await-port checks every Worker app's suite runs over its own source, and the public-surface ratchet three published libraries each carried a forked copy of. Mechanism only — no app knows its name from here. Bun, zero runtime dependencies (typescript is an optional peer, for public-surface only), ships source.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -158,6 +158,12 @@
|
|
|
158
158
|
"source": "./src/relayStage.ts",
|
|
159
159
|
"import": "./src/relayStage.ts"
|
|
160
160
|
},
|
|
161
|
+
"./stage-look": {
|
|
162
|
+
"types": "./src/stageLook.ts",
|
|
163
|
+
"bun": "./src/stageLook.ts",
|
|
164
|
+
"source": "./src/stageLook.ts",
|
|
165
|
+
"import": "./src/stageLook.ts"
|
|
166
|
+
},
|
|
161
167
|
"./container-deploy": {
|
|
162
168
|
"types": "./src/containerDeploy.ts",
|
|
163
169
|
"bun": "./src/containerDeploy.ts",
|
|
@@ -181,7 +187,8 @@
|
|
|
181
187
|
"public-surface": "./src/publicSurface.ts",
|
|
182
188
|
"forge-paths": "./src/paths.ts",
|
|
183
189
|
"forge-client": "./src/stagedClient.ts",
|
|
184
|
-
"forge-d1-schema": "./src/d1Schema.ts"
|
|
190
|
+
"forge-d1-schema": "./src/d1Schema.ts",
|
|
191
|
+
"stage-look": "./src/stageLook.ts"
|
|
185
192
|
},
|
|
186
193
|
"files": [
|
|
187
194
|
"src",
|
package/src/smoke.ts
CHANGED
|
@@ -932,6 +932,54 @@ export const HEALTH_READ_MAX_TIME_SEC = 10;
|
|
|
932
932
|
/** The init a smoke's `/healthz` read carries — `cursedops/edge-fetch` honors it; a plain `fetch` ignores it. */
|
|
933
933
|
const healthRead: EdgeRequestInit = { maxTimeSec: HEALTH_READ_MAX_TIME_SEC };
|
|
934
934
|
|
|
935
|
+
/** How long one address's between-versions answers are asked again before they are judged. */
|
|
936
|
+
export const BETWEEN_VERSIONS_MS = 60_000;
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* One address's allowance for answers of the edge BETWEEN VERSIONS — a 5xx, or no answer at all —
|
|
940
|
+
* after `/healthz` has settled. Shared by every question asked of that address, so a Worker that
|
|
941
|
+
* really 500s costs one window, not one per route.
|
|
942
|
+
*
|
|
943
|
+
* 🔴 Measured 2026-10-28 (forge task 3882): auth's production-first smoke settled `/healthz` on
|
|
944
|
+
* bea42b2f three reads in a row, and in the next seconds — six `secret put`s, six versions —
|
|
945
|
+
* `/account` answered 500 on the preview while the public host gave 200, and five census routes
|
|
946
|
+
* answered 500 on the public host while the preview gave 401. Judged once each, that rolled back a
|
|
947
|
+
* deploy whose stage, on the same bytes, passed all 28 checks minutes later. `/healthz` reads no
|
|
948
|
+
* session; the routes behind the D1 snapshot were still on the turning edge.
|
|
949
|
+
*
|
|
950
|
+
* Only the window's shapes are asked again. A REAL wrong answer — a private route that answers 200,
|
|
951
|
+
* a shell that is JSON — is judged on its first read, and a 5xx that outlasts the window is red.
|
|
952
|
+
*/
|
|
953
|
+
interface VersionWindow {
|
|
954
|
+
/** Ask until the answer is not between versions, or the window is spent; the LAST answer is judged. */
|
|
955
|
+
settle<T>(ask: () => Promise<T>, between: (answer: T) => boolean): Promise<{ answer: T; first: T | null; waitedMs: number }>;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function versionWindow(options: SteadyOptions = {}): VersionWindow {
|
|
959
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((done) => setTimeout(done, ms)));
|
|
960
|
+
const now = options.now ?? Date.now;
|
|
961
|
+
let opened: number | null = null;
|
|
962
|
+
return {
|
|
963
|
+
async settle(ask, between) {
|
|
964
|
+
const asked = now();
|
|
965
|
+
let first: Awaited<ReturnType<typeof ask>> | null = null;
|
|
966
|
+
for (;;) {
|
|
967
|
+
const answer = await ask();
|
|
968
|
+
if (!between(answer)) return { answer, first, waitedMs: now() - asked };
|
|
969
|
+
first ??= answer;
|
|
970
|
+
opened ??= now();
|
|
971
|
+
if (now() - opened >= BETWEEN_VERSIONS_MS) return { answer, first, waitedMs: now() - asked };
|
|
972
|
+
await sleep(options.retryMs ?? 3_000);
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** `, after <first> for Ns` — the row's note that an answer was asked again, so a settle is never silent. */
|
|
979
|
+
function settledNote(first: unknown, waitedMs: number): string {
|
|
980
|
+
return first === null ? "" : ` (${first} first, between versions — asked again for ${Math.round(waitedMs / 1000)}s)`;
|
|
981
|
+
}
|
|
982
|
+
|
|
935
983
|
/** One question a deploy must answer the NEW way — `null` when it does, else why not. */
|
|
936
984
|
export interface ConvergenceProbe {
|
|
937
985
|
name: string;
|
|
@@ -1154,14 +1202,21 @@ export async function runWorkerSmoke(spec: WorkerSmokeSpec): Promise<number> {
|
|
|
1154
1202
|
|
|
1155
1203
|
// Each probe is anonymous and refused at the gate, so it is replay-safe whatever its method,
|
|
1156
1204
|
// and one that gets no answer even then is this check's red — never a crash of the whole smoke.
|
|
1205
|
+
const versions = versionWindow(spec.steady);
|
|
1157
1206
|
const open: string[] = [];
|
|
1207
|
+
const settled: string[] = [];
|
|
1158
1208
|
for (const route of spec.census) {
|
|
1159
1209
|
const probe: EdgeRequestInit = { method: route.method, replaySafe: true };
|
|
1160
|
-
const
|
|
1161
|
-
(
|
|
1162
|
-
|
|
1210
|
+
const { answer, first, waitedMs } = await versions.settle(
|
|
1211
|
+
() =>
|
|
1212
|
+
ask(route.path, probe).then(
|
|
1213
|
+
(res): number | string => res.status,
|
|
1214
|
+
(error: Error) => `no answer (${error.message})`,
|
|
1215
|
+
),
|
|
1216
|
+
(a) => typeof a === "string" || a >= 500,
|
|
1163
1217
|
);
|
|
1164
|
-
if (
|
|
1218
|
+
if (answer !== 401) open.push(`${route.method} ${route.path} → ${answer}${settledNote(first, waitedMs)}`);
|
|
1219
|
+
else if (first !== null) settled.push(`${route.method} ${route.path}${settledNote(first, waitedMs)}`);
|
|
1165
1220
|
}
|
|
1166
1221
|
smoke.record(
|
|
1167
1222
|
`${tag} origin-gate`,
|
|
@@ -1169,7 +1224,7 @@ export async function runWorkerSmoke(spec: WorkerSmokeSpec): Promise<number> {
|
|
|
1169
1224
|
spec.census.length === 0
|
|
1170
1225
|
? "🔴 the route census is EMPTY, so this proves nothing"
|
|
1171
1226
|
: open.length === 0
|
|
1172
|
-
? `all ${spec.census.length} private routes refuse an anonymous caller`
|
|
1227
|
+
? `all ${spec.census.length} private routes refuse an anonymous caller${settled.length ? ` — ${settled.join(", ")}` : ""}`
|
|
1173
1228
|
: `🔴 ${open.join(", ")}`,
|
|
1174
1229
|
);
|
|
1175
1230
|
|
|
@@ -1177,15 +1232,25 @@ export async function runWorkerSmoke(spec: WorkerSmokeSpec): Promise<number> {
|
|
|
1177
1232
|
await spec.extraChecks?.({ tag, address, ask, health, record: (check, ok, detail) => smoke.record(check, ok, detail) });
|
|
1178
1233
|
|
|
1179
1234
|
for (const path of spec.shellPaths) {
|
|
1180
|
-
const
|
|
1235
|
+
const { answer: res, first, waitedMs } = await versions.settle(
|
|
1236
|
+
() => ask(path, { headers: { accept: "text/html" } }),
|
|
1237
|
+
(r) => r.status >= 500,
|
|
1238
|
+
);
|
|
1181
1239
|
const type = res.headers.get("content-type") ?? "";
|
|
1182
|
-
smoke.record(
|
|
1240
|
+
smoke.record(
|
|
1241
|
+
`${tag} shell ${path}`,
|
|
1242
|
+
res.status === 200 && type.includes("text/html"),
|
|
1243
|
+
`${res.status} ${type}${settledNote(first?.status ?? null, waitedMs)}`,
|
|
1244
|
+
);
|
|
1183
1245
|
}
|
|
1184
|
-
const miss = await
|
|
1246
|
+
const { answer: miss, first, waitedMs } = await versions.settle(
|
|
1247
|
+
() => ask("/assets/index-no-such-bundle.js"),
|
|
1248
|
+
(r) => r.status >= 500,
|
|
1249
|
+
);
|
|
1185
1250
|
smoke.record(
|
|
1186
1251
|
`${tag} missing-bundle`,
|
|
1187
1252
|
miss.status === 404 && !(miss.headers.get("content-type") ?? "").includes("text/html"),
|
|
1188
|
-
`a missing bundle is a miss — ${miss.status}`,
|
|
1253
|
+
`a missing bundle is a miss — ${miss.status}${settledNote(first?.status ?? null, waitedMs)}`,
|
|
1189
1254
|
);
|
|
1190
1255
|
}
|
|
1191
1256
|
return await smoke.settle();
|
package/src/stageLook.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* `cursedops/stage-look` — open `<app>-stage` in a real browser as its signed-in owner (forge task 3881).
|
|
4
|
+
*
|
|
5
|
+
* Rule 9 says every batch SEES its change on the stage, signed in, at 390 and 1440. Only collections
|
|
6
|
+
* had a tool for it (`apps/collections/scripts/stage-look.ts`), and it needed a walk's cookie jar
|
|
7
|
+
* first; batch 2026-10-28T0812 hand-built the same look twice more, for photos and family, and lost
|
|
8
|
+
* ~40 minutes to it. This is the seam those three shared:
|
|
9
|
+
*
|
|
10
|
+
* - **The host is pinned to what 1.1.1.1 says.** This Mac's resolver can hold a NEGATIVE answer for
|
|
11
|
+
* a stage hostname (`awaitLocalResolve`'s header in `relay-stage`), so Chromium is told the address.
|
|
12
|
+
* - 🔴 **Cloudflare Access headers go to the stage's own origin ONLY** ({@link isStageRequest}). Sent
|
|
13
|
+
* to binary-server too they turn every media fetch into a CORS preflight it refuses.
|
|
14
|
+
* - **Sign-in is the stage's own door**: `grantStageOwner` (auth-stage's emailed second factor) and
|
|
15
|
+
* the app's `/api/auth/sso/callback?grant=…`. Never production's accounts service.
|
|
16
|
+
* - **A reaped binary-server sandbox is SAID, not screenshotted.** Its images answer 404, or 503 with
|
|
17
|
+
* `x-bs-edge-cache: STAGE-ORIGIN-DOWN`; {@link brokenMediaOf} names them, and the CLI exits 1
|
|
18
|
+
* with the recovery (`apps/binary-server` → `bun run sandbox:up`, then upload again).
|
|
19
|
+
*
|
|
20
|
+
* cursedops has no runtime dependencies, so playwright is the CALLER's: pass `chromium`, or let the
|
|
21
|
+
* look resolve `playwright` from the working directory (the app it is run in).
|
|
22
|
+
*
|
|
23
|
+
* CLI, from an app's directory (or `bun $FORGE/tools/stage-look.ts <app> …` from anywhere):
|
|
24
|
+
*
|
|
25
|
+
* stage-look <app> [path] [--out <dir>] → <dir>/stage-<app>-390.png, …-1440.png
|
|
26
|
+
*/
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
import { grantStageOwner } from "cursedops/relay-stage";
|
|
31
|
+
import { forgeCodeRoot, requireForgeState } from "cursedops/roots";
|
|
32
|
+
|
|
33
|
+
/** `https://<app>-stage.cursedalchemy.com` — every stage's public origin. */
|
|
34
|
+
export const stageOrigin = (app: string): string => `https://${app}-stage.cursedalchemy.com`;
|
|
35
|
+
|
|
36
|
+
/** The route matcher the Access headers ride on: the stage's own origin, nothing else. */
|
|
37
|
+
export const isStageRequest =
|
|
38
|
+
(origin: string) =>
|
|
39
|
+
(url: URL): boolean =>
|
|
40
|
+
url.origin === origin;
|
|
41
|
+
|
|
42
|
+
/** `CF-Access-Client-Id` / `-Secret` from `$FORGE_STATE/secrets/cloudflare-access.env`. Throws when either is missing. */
|
|
43
|
+
export function accessHeaders(state: string): Record<string, string> {
|
|
44
|
+
const file = join(state, "secrets", "cloudflare-access.env");
|
|
45
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
46
|
+
const read = (key: string): string =>
|
|
47
|
+
(text.split("\n").find((l) => l.startsWith(`${key}=`)) ?? "")
|
|
48
|
+
.slice(key.length + 1)
|
|
49
|
+
.trim()
|
|
50
|
+
.replace(/^['"]|['"]$/g, "");
|
|
51
|
+
const id = read("CF_ACCESS_CLIENT_ID");
|
|
52
|
+
const secret = read("CF_ACCESS_CLIENT_SECRET");
|
|
53
|
+
if (!id || !secret) throw new Error(`${file} must declare CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET — the stage sits behind Cloudflare Access.`);
|
|
54
|
+
return { "CF-Access-Client-Id": id, "CF-Access-Client-Secret": secret };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** `dig +short <host> @1.1.1.1`'s output. */
|
|
58
|
+
export type Dig = (host: string) => string;
|
|
59
|
+
const realDig: Dig = (host) => spawnSync("dig", ["+short", host, "@1.1.1.1"], { encoding: "utf8", timeout: 15_000 }).stdout ?? "";
|
|
60
|
+
|
|
61
|
+
/** Chromium's `--host-resolver-rules` pinning `host` to 1.1.1.1's first IPv4 answer; none when it gave none. */
|
|
62
|
+
export function pinnedResolverArgs(host: string, dig: Dig = realDig): string[] {
|
|
63
|
+
const ip = dig(host)
|
|
64
|
+
.split("\n")
|
|
65
|
+
.map((l) => l.trim())
|
|
66
|
+
.find((l) => /^\d+\.\d+\.\d+\.\d+$/.test(l));
|
|
67
|
+
return ip ? [`--host-resolver-rules=MAP ${host} ${ip}`] : [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A response as the look reads it — the slice of playwright's `Response` it needs. */
|
|
71
|
+
export interface LookResponse {
|
|
72
|
+
url(): string;
|
|
73
|
+
status(): number;
|
|
74
|
+
headers(): Record<string, string>;
|
|
75
|
+
request(): { resourceType(): string };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** An image or media answer that says the stage's bytes are gone: a 404, or the edge's `STAGE-ORIGIN-DOWN`. */
|
|
79
|
+
export function brokenMediaOf(r: LookResponse): string | null {
|
|
80
|
+
const kind = r.request().resourceType();
|
|
81
|
+
if (kind !== "image" && kind !== "media") return null;
|
|
82
|
+
const edge = r.headers()["x-bs-edge-cache"] ?? "";
|
|
83
|
+
// The URL without its query: a media URL carries a signed token, and this line lands in logs.
|
|
84
|
+
const url = r.url().split("?")[0];
|
|
85
|
+
if (edge === "STAGE-ORIGIN-DOWN" || r.status() === 404 || r.status() === 503) return `${r.status()}${edge ? ` ${edge}` : ""} ${url}`;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The slices of playwright this drives. Structural, so cursedops needs no dependency on it. */
|
|
90
|
+
interface LookRoute {
|
|
91
|
+
request(): { headers(): Record<string, string> };
|
|
92
|
+
continue(options: { headers: Record<string, string> }): Promise<unknown>;
|
|
93
|
+
}
|
|
94
|
+
interface LookPage {
|
|
95
|
+
goto(url: string, options?: { waitUntil?: "load" | "networkidle" }): Promise<{ status(): number } | null>;
|
|
96
|
+
on(event: "response", listener: (r: LookResponse) => void): unknown;
|
|
97
|
+
}
|
|
98
|
+
interface LookContext {
|
|
99
|
+
route(match: (url: URL) => boolean, handler: (route: LookRoute) => Promise<unknown>): Promise<unknown>;
|
|
100
|
+
newPage(): Promise<LookPage>;
|
|
101
|
+
close(): Promise<unknown>;
|
|
102
|
+
}
|
|
103
|
+
interface LookBrowser {
|
|
104
|
+
newContext(options: Record<string, unknown>): Promise<LookContext>;
|
|
105
|
+
close(): Promise<unknown>;
|
|
106
|
+
}
|
|
107
|
+
export interface LookChromium {
|
|
108
|
+
launch(options: { args: string[] }): Promise<LookBrowser>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface StageLookSpec {
|
|
112
|
+
app: string;
|
|
113
|
+
/** 390 is a phone (touch, DPR 3, 844 tall); anything else a desktop 900 tall. */
|
|
114
|
+
width: number;
|
|
115
|
+
/** Defaults: the roots above the working directory. */
|
|
116
|
+
forge?: string;
|
|
117
|
+
state?: string;
|
|
118
|
+
/** playwright's `chromium`; default: `playwright` resolved from the working directory. */
|
|
119
|
+
chromium?: LookChromium;
|
|
120
|
+
/** Extra Chromium flags — collections' audio daemon, say. */
|
|
121
|
+
args?: string[];
|
|
122
|
+
/** Seams for the suite. */
|
|
123
|
+
grant?: () => { ok: boolean; grant: string };
|
|
124
|
+
dig?: Dig;
|
|
125
|
+
headers?: Record<string, string>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface StageLook<Page> {
|
|
129
|
+
page: Page;
|
|
130
|
+
/** The stage's origin, for `page.goto(`${origin}${path}`)`. */
|
|
131
|
+
origin: string;
|
|
132
|
+
/** Every broken image/media answer so far ({@link brokenMediaOf}). */
|
|
133
|
+
brokenMedia: () => string[];
|
|
134
|
+
close: () => Promise<unknown>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function defaultChromium(): Promise<LookChromium> {
|
|
138
|
+
const at = Bun.resolveSync("playwright", process.cwd());
|
|
139
|
+
return ((await import(at)) as { chromium: LookChromium }).chromium;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A page on `<app>-stage`, signed in as its owner, with Access headers on the stage's requests only.
|
|
144
|
+
* `Page` is the caller's playwright `Page` — the look builds it, so the cast is the caller's word.
|
|
145
|
+
*/
|
|
146
|
+
export async function openStagePage<Page = LookPage>(spec: StageLookSpec): Promise<StageLook<Page>> {
|
|
147
|
+
const cwd = process.cwd();
|
|
148
|
+
const forge = spec.forge ?? forgeCodeRoot(cwd);
|
|
149
|
+
if (!forge) throw new Error(`no forge.env above ${cwd} — run the look from inside the generation`);
|
|
150
|
+
const origin = stageOrigin(spec.app);
|
|
151
|
+
const access = spec.headers ?? accessHeaders(spec.state ?? requireForgeState(cwd));
|
|
152
|
+
const chromium = spec.chromium ?? (await defaultChromium());
|
|
153
|
+
const browser = await chromium.launch({ args: [...(spec.args ?? []), ...pinnedResolverArgs(new URL(origin).hostname, spec.dig)] });
|
|
154
|
+
try {
|
|
155
|
+
const phone = spec.width < 768;
|
|
156
|
+
const context = await browser.newContext({
|
|
157
|
+
viewport: { width: spec.width, height: phone ? 844 : 900 },
|
|
158
|
+
...(phone ? { isMobile: true, hasTouch: true, deviceScaleFactor: 3 } : {}),
|
|
159
|
+
});
|
|
160
|
+
await context.route(isStageRequest(origin), (route) => route.continue({ headers: { ...route.request().headers(), ...access } }));
|
|
161
|
+
const page = await context.newPage();
|
|
162
|
+
const broken: string[] = [];
|
|
163
|
+
page.on("response", (r) => {
|
|
164
|
+
const b = brokenMediaOf(r);
|
|
165
|
+
if (b) broken.push(b);
|
|
166
|
+
});
|
|
167
|
+
const granted = (spec.grant ?? (() => grantStageOwner({ forge, app: spec.app })))();
|
|
168
|
+
if (!granted.ok) throw new Error(`auth-stage gave no grant for ${spec.app} — its output above says why`);
|
|
169
|
+
const back = await page.goto(`${origin}/api/auth/sso/callback?grant=${encodeURIComponent(granted.grant)}`, { waitUntil: "load" });
|
|
170
|
+
if (back && back.status() >= 400) throw new Error(`${origin}'s sign-in callback answered ${back.status()}`);
|
|
171
|
+
return { page: page as Page, origin, brokenMedia: () => [...broken], close: () => browser.close() };
|
|
172
|
+
} catch (e) {
|
|
173
|
+
await browser.close();
|
|
174
|
+
throw e;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Shoot `path` at 390 and 1440. Exit 1 when the page or its media is broken, so a look never passes on broken tiles. */
|
|
179
|
+
async function main(argv: string[]): Promise<number> {
|
|
180
|
+
const outAt = argv.indexOf("--out");
|
|
181
|
+
const out = outAt >= 0 ? argv[outAt + 1] : undefined;
|
|
182
|
+
const [app, path = "/"] = outAt >= 0 ? argv.filter((_, i) => i !== outAt && i !== outAt + 1) : argv;
|
|
183
|
+
if (!app || (outAt >= 0 && !out)) {
|
|
184
|
+
console.error("usage: stage-look <app> [path] [--out <dir>] (run in the app's directory, or `bun $FORGE/tools/stage-look.ts <app> …`)");
|
|
185
|
+
return 2;
|
|
186
|
+
}
|
|
187
|
+
const dir = out ?? join(process.env.FORGE_BATCH_TMP ?? requireForgeState(process.cwd()), "stage-look");
|
|
188
|
+
mkdirSync(dir, { recursive: true });
|
|
189
|
+
let bad = false;
|
|
190
|
+
for (const width of [390, 1440]) {
|
|
191
|
+
type ShotPage = LookPage & { screenshot(o: { path: string; fullPage?: boolean }): Promise<unknown>; url(): string };
|
|
192
|
+
const look = await openStagePage<ShotPage>({ app, width });
|
|
193
|
+
try {
|
|
194
|
+
const res = await look.page.goto(`${look.origin}${path}`, { waitUntil: "networkidle" });
|
|
195
|
+
const shot = join(dir, `stage-${app}-${width}.png`);
|
|
196
|
+
await look.page.screenshot({ path: shot });
|
|
197
|
+
console.log(`${width}: ${res?.status() ?? "?"} ${look.page.url()} → ${shot}`);
|
|
198
|
+
if (!res || res.status() >= 400) bad = true;
|
|
199
|
+
const broken = look.brokenMedia();
|
|
200
|
+
if (broken.length) {
|
|
201
|
+
bad = true;
|
|
202
|
+
console.error(` 🔴 ${broken.length} image/media answer(s) broken — the stage's binary-server sandbox was likely reaped:`);
|
|
203
|
+
for (const b of broken.slice(0, 5)) console.error(` ${b}`);
|
|
204
|
+
console.error(" recover: cd \"$FORGE/apps/binary-server\" && bun run sandbox:up — then upload the fixture again");
|
|
205
|
+
}
|
|
206
|
+
} finally {
|
|
207
|
+
await look.close();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return bad ? 1 : 0;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (import.meta.main) process.exit(await main(process.argv.slice(2)));
|