@tandryukha/aidemo 0.15.0 → 0.15.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.
- package/docs/AUTHORING.md +16 -1
- package/package.json +3 -2
- package/src/captions.ts +80 -2
- package/src/cli.ts +6 -1
- package/src/distribute.ts +3 -0
- package/src/inspect.ts +22 -1
- package/src/mcp/server.ts +3 -1
- package/src/player.ts +45 -30
- package/src/redact.ts +72 -0
- package/src/safe-regex.ts +15 -2
package/docs/AUTHORING.md
CHANGED
|
@@ -70,6 +70,12 @@ scan inside them). Use it instead of reading source or guessing: copy the
|
|
|
70
70
|
first selector of the element you mean. It writes `logs/inspect-<ts>.json`
|
|
71
71
|
and a screenshot next to it. Elements below the fold are listed too (marked
|
|
72
72
|
`↓` on the CLI); a `scrollTo` on them works as usual.
|
|
73
|
+
The result includes `target.status` (`available` or `blocked`). Common
|
|
74
|
+
security-verification and login pages return `blocked` with a reason: their
|
|
75
|
+
selectors describe the interstitial, not the requested product. Inspection
|
|
76
|
+
still writes its JSON and screenshot for diagnosis; `init --from-url` stops
|
|
77
|
+
instead of drafting scenes from a blocked page. Open the page with an
|
|
78
|
+
authenticated profile or use supported setup seeding, then inspect again.
|
|
73
79
|
|
|
74
80
|
`inspect` runs the storyboard's **`setup`** block by default — preflight hook,
|
|
75
81
|
`storageState`, cookies — so a gated app scans as the logged-in user instead of
|
|
@@ -763,7 +769,7 @@ for UI that re-renders under the cursor) and `anchor?` (land this action on a
|
|
|
763
769
|
out of the way before a reveal, or point at what the narration names.
|
|
764
770
|
- `{op:"assert", target?, textMatches?, url?, timeoutMs?=5000}` — **prove the
|
|
765
771
|
payoff happened**: polls until `target` is visible (and its text matches
|
|
766
|
-
`textMatches`,
|
|
772
|
+
`textMatches`, RE2-compatible regex, no inline flags — use `[Cc]onfirmed`) and/or the
|
|
767
773
|
page URL matches `url`; otherwise **fails the take with a named error**
|
|
768
774
|
(`assert failed after 5000ms: text "…" does not match /…/`). Put one after
|
|
769
775
|
the moment the demo exists to show (order confirmed, item added) so a
|
|
@@ -1319,6 +1325,15 @@ all) using each scene's measured duration from `voice.json`; only the timing
|
|
|
1319
1325
|
measured speech rhythm). `captions` logs a one-line reminder of this whenever
|
|
1320
1326
|
the resolved language isn't English.
|
|
1321
1327
|
|
|
1328
|
+
The STT caption step compares each scene's recognized words with its known
|
|
1329
|
+
script and warns when at least 20% differ. Review those captions before
|
|
1330
|
+
publishing. `aidemo captions <dir> --align-script` uses the script's spelling
|
|
1331
|
+
and punctuation while retaining the measured STT word times; words missing
|
|
1332
|
+
from the transcript are placed between their nearest timed neighbours. This
|
|
1333
|
+
is useful for misheard names and inflected words. If the spoken narration
|
|
1334
|
+
actually differs from the script, correct the script or use the original STT
|
|
1335
|
+
captions instead.
|
|
1336
|
+
|
|
1322
1337
|
**Cue segmentation.** Cues are broken at sentence ends first, then at clause
|
|
1323
1338
|
punctuation, then before a conjunction, and only then on length — so a cue
|
|
1324
1339
|
stops reading like it was cut mid-thought ("we open the" / "settings panel").
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tandryukha/aidemo",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "AI Demo Engine — turn a storyboard into a narrated, captioned product demo video",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
"@modelcontextprotocol/sdk": "1.30.0",
|
|
37
37
|
"commander": "^12.1.0",
|
|
38
38
|
"openai": "^4.67.0",
|
|
39
|
-
"playwright": "^1.
|
|
39
|
+
"playwright": "^1.55.1",
|
|
40
|
+
"re2js": "2.8.6",
|
|
40
41
|
"tsx": "^4.23.1",
|
|
41
42
|
"zod": "^3.23.8",
|
|
42
43
|
"zod-to-json-schema": "3.25.2"
|
package/src/captions.ts
CHANGED
|
@@ -96,6 +96,8 @@ const MAX_PROMPT_CHARS = 900;
|
|
|
96
96
|
export interface CaptionsSttOptions extends SceneProgress {
|
|
97
97
|
/** ISO-639-1/BCP-47 language hint for Whisper, overriding --lang / storyboard.language. */
|
|
98
98
|
language?: string;
|
|
99
|
+
/** Use storyboard spelling/punctuation while retaining STT word timing. */
|
|
100
|
+
alignScript?: boolean;
|
|
99
101
|
}
|
|
100
102
|
|
|
101
103
|
/**
|
|
@@ -168,6 +170,7 @@ export async function generateCaptions(
|
|
|
168
170
|
config,
|
|
169
171
|
prompt,
|
|
170
172
|
language,
|
|
173
|
+
...(opts.alignScript ? { alignScript: true } : {}),
|
|
171
174
|
});
|
|
172
175
|
const prior = await readCaptionsManifest(project);
|
|
173
176
|
if (prior && prior.mode === "stt" && prior.inputHash === inputHash) {
|
|
@@ -197,13 +200,32 @@ export async function generateCaptions(
|
|
|
197
200
|
if (words.length === 0) {
|
|
198
201
|
log("no word timestamps returned; captions may be empty");
|
|
199
202
|
}
|
|
200
|
-
const
|
|
203
|
+
const sceneWords = splitWordsByScene(words, ids, sceneEnds, config.gapMs);
|
|
204
|
+
let captionWords = words;
|
|
205
|
+
if (storyboard && sceneEnds.length === storyboard.scenes.length) {
|
|
206
|
+
const aligned: Word[] = [];
|
|
207
|
+
let sceneStartMs = 0;
|
|
208
|
+
for (let i = 0; i < storyboard.scenes.length; i++) {
|
|
209
|
+
const scene = storyboard.scenes[i];
|
|
210
|
+
const timed = sceneWords[i]?.words ?? [];
|
|
211
|
+
const result = alignCaptionWords(scene.narration, timed, sceneEnds[i] - sceneStartMs);
|
|
212
|
+
if (result.driftPct >= 20) {
|
|
213
|
+
log(`⚠ caption drift in scene ${scene.id}: ${result.driftPct}% of script words differ from STT; ${opts.alignScript ? "script text aligned to STT timing — review against the audio" : "review captions or use --align-script"}`);
|
|
214
|
+
}
|
|
215
|
+
for (const w of result.words) aligned.push({ ...w, start: w.start + sceneStartMs / 1000, end: w.end + sceneStartMs / 1000 });
|
|
216
|
+
sceneStartMs = sceneEnds[i] + config.gapMs;
|
|
217
|
+
}
|
|
218
|
+
if (opts.alignScript) captionWords = aligned;
|
|
219
|
+
} else if (opts.alignScript) {
|
|
220
|
+
throw new Error("--align-script requires a storyboard and matching voice scene timings");
|
|
221
|
+
}
|
|
222
|
+
const cues = groupWords(captionWords, sceneEnds, seg);
|
|
201
223
|
await writeCaptionFiles(project, cues);
|
|
202
224
|
await writeCaptionsManifest(project, {
|
|
203
225
|
mode: "stt",
|
|
204
226
|
inputHash,
|
|
205
227
|
config,
|
|
206
|
-
scenes: splitWordsByScene(
|
|
228
|
+
scenes: opts.alignScript ? splitWordsByScene(captionWords, ids, sceneEnds, config.gapMs) : sceneWords,
|
|
207
229
|
cues,
|
|
208
230
|
});
|
|
209
231
|
reportDone();
|
|
@@ -245,6 +267,62 @@ function splitWordsByScene(
|
|
|
245
267
|
return out;
|
|
246
268
|
}
|
|
247
269
|
|
|
270
|
+
/** Align written words to measured STT timestamps. Inserted script words share
|
|
271
|
+
* the space between their nearest timed neighbours; STT-only words are dropped. */
|
|
272
|
+
export function alignCaptionWords(
|
|
273
|
+
narration: string,
|
|
274
|
+
spoken: Word[],
|
|
275
|
+
durationMs: number
|
|
276
|
+
): { words: Word[]; driftPct: number } {
|
|
277
|
+
const script = narration.split(/\s+/).filter(Boolean);
|
|
278
|
+
if (!script.length) return { words: [], driftPct: spoken.length ? 100 : 0 };
|
|
279
|
+
if (!spoken.length) return { words: deriveSceneWords(narration, durationMs), driftPct: 100 };
|
|
280
|
+
const norm = (s: string) => s.normalize("NFKC").toLocaleLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
|
|
281
|
+
const n = script.length, m = spoken.length;
|
|
282
|
+
const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
|
|
283
|
+
for (let i = 0; i <= n; i++) dp[i][0] = i;
|
|
284
|
+
for (let j = 0; j <= m; j++) dp[0][j] = j;
|
|
285
|
+
for (let i = 1; i <= n; i++) for (let j = 1; j <= m; j++) {
|
|
286
|
+
dp[i][j] = Math.min(
|
|
287
|
+
dp[i - 1][j - 1] + (norm(script[i - 1]) === norm(spoken[j - 1].word) ? 0 : 1),
|
|
288
|
+
dp[i - 1][j] + 1,
|
|
289
|
+
dp[i][j - 1] + 1
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const matched: Array<number | undefined> = Array(n).fill(undefined);
|
|
293
|
+
let i = n, j = m;
|
|
294
|
+
while (i && j) {
|
|
295
|
+
const cost = norm(script[i - 1]) === norm(spoken[j - 1].word) ? 0 : 1;
|
|
296
|
+
if (dp[i][j] === dp[i - 1][j - 1] + cost) {
|
|
297
|
+
matched[--i] = --j;
|
|
298
|
+
} else if (dp[i][j] === dp[i - 1][j] + 1) {
|
|
299
|
+
i--;
|
|
300
|
+
} else {
|
|
301
|
+
j--;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
const duration = Math.max(0, durationMs / 1000);
|
|
305
|
+
const words: Word[] = script.map((word, k) => {
|
|
306
|
+
const at = matched[k];
|
|
307
|
+
return at === undefined ? { word, start: 0, end: 0 } : {
|
|
308
|
+
word, start: Math.max(0, spoken[at].start), end: Math.min(duration, spoken[at].end),
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
for (let k = 0; k < n;) {
|
|
312
|
+
if (matched[k] !== undefined) { k++; continue; }
|
|
313
|
+
const first = k;
|
|
314
|
+
while (k < n && matched[k] === undefined) k++;
|
|
315
|
+
const start = first ? words[first - 1].end : 0;
|
|
316
|
+
const end = k < n ? words[k].start : duration;
|
|
317
|
+
const span = Math.max(0, end - start);
|
|
318
|
+
for (let t = first; t < k; t++) {
|
|
319
|
+
words[t].start = start + span * (t - first) / (k - first);
|
|
320
|
+
words[t].end = start + span * (t - first + 1) / (k - first);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return { words, driftPct: Math.round(100 * dp[n][m] / Math.max(n, m)) };
|
|
324
|
+
}
|
|
325
|
+
|
|
248
326
|
/** Scene-relative words for a scene; script-timed fallback when the manifest has none. */
|
|
249
327
|
export async function sceneWordsFor(
|
|
250
328
|
project: Project,
|
package/src/cli.ts
CHANGED
|
@@ -664,6 +664,9 @@ program
|
|
|
664
664
|
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
665
665
|
return;
|
|
666
666
|
}
|
|
667
|
+
if (res.target.status === "blocked") {
|
|
668
|
+
process.stdout.write(`⚠ inspect blocked: ${res.target.reason}\n`);
|
|
669
|
+
}
|
|
667
670
|
process.stdout.write(`${res.title || "(untitled)"} — ${res.finalUrl}\n`);
|
|
668
671
|
if (res.headings.length) {
|
|
669
672
|
process.stdout.write(
|
|
@@ -967,6 +970,7 @@ program
|
|
|
967
970
|
.option("--param <kv>", PARAM_OPT_DESC, collectKv, [])
|
|
968
971
|
.option("--lang <code>", LANG_OPT_DESC)
|
|
969
972
|
.option("--langs <codes>", LANGS_OPT_DESC)
|
|
973
|
+
.option("--align-script", "restore storyboard spelling and punctuation using STT word timing", false)
|
|
970
974
|
.option(
|
|
971
975
|
"--stt-lang <code>",
|
|
972
976
|
"override the Whisper STT language hint (ISO-639-1, e.g. et) — defaults to " +
|
|
@@ -982,6 +986,7 @@ program
|
|
|
982
986
|
lang?: string;
|
|
983
987
|
langs?: string;
|
|
984
988
|
sttLang?: string;
|
|
989
|
+
alignScript?: boolean;
|
|
985
990
|
}
|
|
986
991
|
) => {
|
|
987
992
|
const langs = langsFrom(opts);
|
|
@@ -998,7 +1003,7 @@ program
|
|
|
998
1003
|
if (opts.offline) {
|
|
999
1004
|
await generateCaptionsOffline(project, sb);
|
|
1000
1005
|
} else {
|
|
1001
|
-
await generateCaptions(project, sb, { language: opts.sttLang });
|
|
1006
|
+
await generateCaptions(project, sb, { language: opts.sttLang, alignScript: opts.alignScript });
|
|
1002
1007
|
}
|
|
1003
1008
|
}
|
|
1004
1009
|
}
|
package/src/distribute.ts
CHANGED
|
@@ -266,6 +266,9 @@ export async function scaffoldDemo(
|
|
|
266
266
|
screenshotPath: project.p("logs", "inspect-init.png"),
|
|
267
267
|
});
|
|
268
268
|
await fs.writeFile(project.p("logs", "inspect-init.json"), JSON.stringify(res, null, 2));
|
|
269
|
+
if (res.target.status === "blocked") {
|
|
270
|
+
throw new Error(`Cannot draft from ${opts.fromUrl}: ${res.target.reason}. See logs/inspect-init.json and inspect-init.png.`);
|
|
271
|
+
}
|
|
269
272
|
await fs.writeFile(project.p("input", "brief.md"), briefFromInspect(name, res));
|
|
270
273
|
await fs.writeFile(project.storyboardPath, storyboardFromInspect(name, res));
|
|
271
274
|
ok(
|
package/src/inspect.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface InspectElement {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export interface InspectResult {
|
|
39
|
+
/** Whether the requested product UI was actually available to inspect. */
|
|
40
|
+
target: { status: "available" | "blocked"; reason?: string };
|
|
39
41
|
url: string;
|
|
40
42
|
finalUrl: string;
|
|
41
43
|
title: string;
|
|
@@ -174,11 +176,12 @@ const SCAN_JS = String.raw`(() => {
|
|
|
174
176
|
src: (f.getAttribute("src") || "").slice(0, 200),
|
|
175
177
|
selector: f.id && stableId(f.id) ? "#" + cssEsc(f.id) : (f.getAttribute("name") ? 'iframe[name="' + q(f.getAttribute("name")) + '"]' : "iframe:nth-of-type(" + (i + 1) + ")"),
|
|
176
178
|
}));
|
|
177
|
-
return { title: document.title, elements: items, headings, iframes, truncated: all.length > LIMIT && items.length >= LIMIT, total: all.length };
|
|
179
|
+
return { title: document.title, bodyText: (document.body?.innerText || "").slice(0, 1200), elements: items, headings, iframes, truncated: all.length > LIMIT && items.length >= LIMIT, total: all.length };
|
|
178
180
|
})()`;
|
|
179
181
|
|
|
180
182
|
interface ScanResult {
|
|
181
183
|
title: string;
|
|
184
|
+
bodyText: string;
|
|
182
185
|
elements: InspectElement[];
|
|
183
186
|
headings: Array<{ level: number; text: string }>;
|
|
184
187
|
iframes: Array<{ name: string; src: string; selector: string }>;
|
|
@@ -186,6 +189,23 @@ interface ScanResult {
|
|
|
186
189
|
total: number;
|
|
187
190
|
}
|
|
188
191
|
|
|
192
|
+
/** Conservative signal: report a challenge without guessing how to solve it. */
|
|
193
|
+
export function inspectTargetStatus(scan: Pick<ScanResult, "title" | "headings" | "bodyText">): InspectResult["target"] {
|
|
194
|
+
const title = scan.title.trim().toLowerCase();
|
|
195
|
+
const headings = scan.headings.map((h) => h.text.toLowerCase()).join(" ");
|
|
196
|
+
const body = scan.bodyText.toLowerCase();
|
|
197
|
+
if (/^(just a moment|attention required|security verification|checking your browser)/i.test(title) ||
|
|
198
|
+
/performing security verification|verify (you are|that you are) human|checking your browser/i.test(headings) ||
|
|
199
|
+
(/security verification|verify you are human/i.test(body) && /challenge|cloudflare|captcha/i.test(body))) {
|
|
200
|
+
return { status: "blocked", reason: "security verification page; the requested product UI was not inspected" };
|
|
201
|
+
}
|
|
202
|
+
if (/^(sign in|log in|login|authentication required)$/i.test(scan.title.trim()) &&
|
|
203
|
+
/sign in|log in|email|password/i.test(body)) {
|
|
204
|
+
return { status: "blocked", reason: "login page; the requested product UI was not inspected" };
|
|
205
|
+
}
|
|
206
|
+
return { status: "available" };
|
|
207
|
+
}
|
|
208
|
+
|
|
189
209
|
/** Scan one frame for visible interactive elements (see SCAN_JS). */
|
|
190
210
|
export async function scanInteractive(
|
|
191
211
|
target: Page | Frame,
|
|
@@ -233,6 +253,7 @@ export async function inspectPage(opts: InspectOptions): Promise<InspectResult>
|
|
|
233
253
|
screenshot = opts.screenshotPath;
|
|
234
254
|
}
|
|
235
255
|
return {
|
|
256
|
+
target: inspectTargetStatus(main),
|
|
236
257
|
url: opts.url,
|
|
237
258
|
finalUrl: page.url(),
|
|
238
259
|
title: main.title,
|
package/src/mcp/server.ts
CHANGED
|
@@ -1131,6 +1131,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1131
1131
|
.boolean()
|
|
1132
1132
|
.optional()
|
|
1133
1133
|
.describe("approximate captions from the script — no network/STT"),
|
|
1134
|
+
alignScript: z.boolean().optional().describe("use script spelling and punctuation with STT word timing"),
|
|
1134
1135
|
params: PARAMS_INPUT,
|
|
1135
1136
|
lang: LANG_INPUT,
|
|
1136
1137
|
},
|
|
@@ -1142,7 +1143,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1142
1143
|
if (args.offline) {
|
|
1143
1144
|
await generateCaptionsOffline(lp, sb, sceneProgress(job));
|
|
1144
1145
|
} else {
|
|
1145
|
-
await generateCaptions(lp, sb, sceneProgress(job));
|
|
1146
|
+
await generateCaptions(lp, sb, { ...sceneProgress(job), alignScript: args.alignScript });
|
|
1146
1147
|
}
|
|
1147
1148
|
return {
|
|
1148
1149
|
srt: lp.captionsSrtPath,
|
|
@@ -1375,6 +1376,7 @@ export function buildMcpServer(): { server: McpServer; jobs: JobManager } {
|
|
|
1375
1376
|
});
|
|
1376
1377
|
const file = project.p("logs", `inspect-${stamp}.json`);
|
|
1377
1378
|
await writeJson(file, res);
|
|
1379
|
+
if (res.target.status === "blocked") log(`inspect blocked: ${res.target.reason}`);
|
|
1378
1380
|
log(`inspect: ${res.elements.length} element(s), ${res.headings.length} heading(s) → ${file}`);
|
|
1379
1381
|
return { ...res, file };
|
|
1380
1382
|
})
|
package/src/player.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Page, Locator, Frame, Request, Response, Dialog } from "playwright";
|
|
2
2
|
import { scanInteractive, rankCandidates, type DriftCandidate } from "./inspect.js";
|
|
3
3
|
import { compileUserRegex } from "./safe-regex.js";
|
|
4
|
+
import { redactUrl, redactSecrets } from "./redact.js";
|
|
4
5
|
import { promises as fs } from "node:fs";
|
|
5
6
|
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
6
7
|
import { existsSync } from "node:fs";
|
|
@@ -242,39 +243,51 @@ class NetworkWatch {
|
|
|
242
243
|
private readonly page: Page,
|
|
243
244
|
private readonly t0: number
|
|
244
245
|
) {
|
|
246
|
+
// A listener that throws surfaces as an unhandled rejection on the page
|
|
247
|
+
// and can abort a take, so each one is wrapped: diagnostics are strictly
|
|
248
|
+
// best-effort and must never take the recording down with them.
|
|
245
249
|
this.onResponse = (res: Response) => {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
250
|
+
try {
|
|
251
|
+
if (res.status() < 400) return;
|
|
252
|
+
const req = res.request();
|
|
253
|
+
const entry: FailedRequest = {
|
|
254
|
+
tMs: Date.now() - t0,
|
|
255
|
+
method: req.method(),
|
|
256
|
+
url: redactUrl(res.url(), 200),
|
|
257
|
+
status: res.status(),
|
|
258
|
+
resourceType: req.resourceType(),
|
|
259
|
+
};
|
|
260
|
+
this.push(entry);
|
|
261
|
+
if (/^(xhr|fetch)$/.test(entry.resourceType)) {
|
|
262
|
+
res
|
|
263
|
+
.text()
|
|
264
|
+
.then((b) => {
|
|
265
|
+
const snippet = redactSecrets(b.replace(/\s+/g, " ").trim()).slice(0, NET_BODY_MAX);
|
|
266
|
+
if (snippet) entry.body = snippet;
|
|
267
|
+
})
|
|
268
|
+
// The body is gone once the page navigates; that's expected.
|
|
269
|
+
.catch(() => {});
|
|
270
|
+
}
|
|
271
|
+
} catch {
|
|
272
|
+
/* diagnostics only — a broken response record is not a take failure */
|
|
264
273
|
}
|
|
265
274
|
};
|
|
266
275
|
this.onFailed = (req: Request) => {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
try {
|
|
277
|
+
const err = req.failure()?.errorText ?? "failed";
|
|
278
|
+
// Navigations cancel in-flight requests; that's not an app failure.
|
|
279
|
+
if (/ERR_ABORTED/.test(err)) return;
|
|
280
|
+
this.push({
|
|
281
|
+
tMs: Date.now() - t0,
|
|
282
|
+
method: req.method(),
|
|
283
|
+
url: redactUrl(req.url(), 200),
|
|
284
|
+
status: null,
|
|
285
|
+
error: err,
|
|
286
|
+
resourceType: req.resourceType(),
|
|
287
|
+
});
|
|
288
|
+
} catch {
|
|
289
|
+
/* diagnostics only */
|
|
290
|
+
}
|
|
278
291
|
};
|
|
279
292
|
}
|
|
280
293
|
|
|
@@ -1900,7 +1913,9 @@ async function waitForChange(
|
|
|
1900
1913
|
// ---------------------------------------------------------------------------
|
|
1901
1914
|
|
|
1902
1915
|
function truncateUrl(url: string, n = 90): string {
|
|
1903
|
-
|
|
1916
|
+
// Same masking as the network watch: every URL that reaches a log or a
|
|
1917
|
+
// fail-*.json goes through redaction, not just the ones from failed requests.
|
|
1918
|
+
return redactUrl(url, n);
|
|
1904
1919
|
}
|
|
1905
1920
|
|
|
1906
1921
|
/** Never returns — enriches the error with scene/action context + diagnostics. */
|
package/src/redact.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction for diagnostics.
|
|
3
|
+
*
|
|
4
|
+
* A failed take writes `logs/fail-*.json` (and log lines) containing live URLs
|
|
5
|
+
* and, for XHR/fetch failures, a snippet of the response body. Those come from
|
|
6
|
+
* whatever app was being recorded — a magic-link callback, an OAuth redirect or
|
|
7
|
+
* an error payload can easily carry a session token. The logs are meant to be
|
|
8
|
+
* pasted into an issue, so anything that looks like a credential is masked
|
|
9
|
+
* before it is written. This is a best-effort net, not a guarantee: it never
|
|
10
|
+
* throws, and it errs toward masking.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MASK = "REDACTED";
|
|
14
|
+
|
|
15
|
+
/** Query/form parameter names whose *value* is never printed. */
|
|
16
|
+
const SECRET_PARAM =
|
|
17
|
+
/^(?:.*[-_])?(?:token|access[-_]?token|id[-_]?token|refresh[-_]?token|secret|password|passwd|pwd|api[-_]?key|apikey|key|auth|authorization|session|sig|signature|code|credential|assertion)$/i;
|
|
18
|
+
|
|
19
|
+
/** Free-text shapes that are credentials wherever they appear. */
|
|
20
|
+
const SECRET_SHAPES: Array<[RegExp, string]> = [
|
|
21
|
+
[/\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}/gi, `$1 ${MASK}`],
|
|
22
|
+
// Provider-prefixed keys (sk-…, ghp_…, xoxb-…, AKIA…).
|
|
23
|
+
[/\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}/g, MASK],
|
|
24
|
+
[/\bgh[pousr]_[A-Za-z0-9]{16,}/g, MASK],
|
|
25
|
+
[/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, MASK],
|
|
26
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, MASK],
|
|
27
|
+
// JWTs.
|
|
28
|
+
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, MASK],
|
|
29
|
+
// JSON/pair values under a secret-looking name.
|
|
30
|
+
[
|
|
31
|
+
/("?)([A-Za-z0-9_-]*(?:token|secret|password|passwd|api[-_]?key|apikey|authorization|credential)[A-Za-z0-9_-]*)\1(\s*[:=]\s*)("?)[^"\s,}&]+\4/gi,
|
|
32
|
+
`$1$2$1$3$4${MASK}$4`,
|
|
33
|
+
],
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** Mask credential-shaped substrings in free text (response bodies, errors). */
|
|
37
|
+
export function redactSecrets(text: string): string {
|
|
38
|
+
if (!text) return text;
|
|
39
|
+
let out = text;
|
|
40
|
+
for (const [re, to] of SECRET_SHAPES) out = out.replace(re, to);
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Mask secret-bearing query/fragment parameters, then truncate to `n` chars.
|
|
46
|
+
* Anything that doesn't parse as a URL still goes through {@link redactSecrets}.
|
|
47
|
+
*/
|
|
48
|
+
export function redactUrl(url: string, n = 90): string {
|
|
49
|
+
let safe: string;
|
|
50
|
+
try {
|
|
51
|
+
const u = new URL(url);
|
|
52
|
+
for (const params of [u.searchParams, new URLSearchParams(u.hash.replace(/^#/, ""))]) {
|
|
53
|
+
let touched = false;
|
|
54
|
+
for (const name of [...params.keys()]) {
|
|
55
|
+
if (SECRET_PARAM.test(name)) {
|
|
56
|
+
params.set(name, MASK);
|
|
57
|
+
touched = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (touched && params !== u.searchParams) u.hash = params.toString();
|
|
61
|
+
}
|
|
62
|
+
if (u.username || u.password) {
|
|
63
|
+
u.username = "";
|
|
64
|
+
u.password = "";
|
|
65
|
+
}
|
|
66
|
+
safe = redactSecrets(u.toString());
|
|
67
|
+
} catch {
|
|
68
|
+
// Not an absolute URL (about:blank, a relative href, a data: blob).
|
|
69
|
+
safe = redactSecrets(url);
|
|
70
|
+
}
|
|
71
|
+
return safe.length > n ? safe.slice(0, n) + "…" : safe;
|
|
72
|
+
}
|
package/src/safe-regex.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { RE2JS } from "re2js";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Storyboard-supplied regex patterns (`assert.textMatches`, `assert.url`, and
|
|
3
5
|
* the `textMatches` option the player threads through) are compiled at replay
|
|
@@ -29,8 +31,19 @@ export function isUnsafeRegexSource(pattern: string): string | null {
|
|
|
29
31
|
* Compile a storyboard-supplied pattern, or throw with `where` naming the
|
|
30
32
|
* field so the author can see which matcher to fix.
|
|
31
33
|
*/
|
|
32
|
-
export function compileUserRegex(pattern: string, flags: string, where: string):
|
|
34
|
+
export function compileUserRegex(pattern: string, flags: string, where: string): RE2JS {
|
|
35
|
+
if (typeof pattern !== "string" || pattern === "")
|
|
36
|
+
throw new Error(`${where}: regex must be a non-empty string`);
|
|
33
37
|
const unsafe = isUnsafeRegexSource(pattern);
|
|
34
38
|
if (unsafe) throw new Error(`${where}: unsafe regex — ${unsafe}`);
|
|
35
|
-
|
|
39
|
+
try {
|
|
40
|
+
// RE2JS guarantees linear-time matching for storyboard supplied patterns.
|
|
41
|
+
// The replay path only uses `test`, so its API is interchangeable here.
|
|
42
|
+
if (flags && flags !== "i") throw new Error(`unsupported regex flags ${flags}`);
|
|
43
|
+
return RE2JS.compile(pattern, flags === "i" ? RE2JS.CASE_INSENSITIVE : 0);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
// A raw SyntaxError names neither the field nor the storyboard, which is
|
|
46
|
+
// the only thing the author can act on.
|
|
47
|
+
throw new Error(`${where}: invalid regex ${JSON.stringify(pattern)} — ${(e as Error).message}`);
|
|
48
|
+
}
|
|
36
49
|
}
|