state-scenarios-cli 0.2.0 → 0.4.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/README.md +10 -1
- package/dist/browser.d.ts +27 -0
- package/dist/chunk-GCYOLAFU.js +40 -0
- package/dist/chunk-GCYOLAFU.js.map +1 -0
- package/dist/chunk-O6QTNAAZ.js +67 -0
- package/dist/chunk-O6QTNAAZ.js.map +1 -0
- package/dist/chunk-YFNROT3R.js +115 -0
- package/dist/chunk-YFNROT3R.js.map +1 -0
- package/dist/cli.js +260 -43
- package/dist/cli.js.map +1 -1
- package/dist/doctor-URGIFA3W.js +200 -0
- package/dist/doctor-URGIFA3W.js.map +1 -0
- package/dist/doctor.d.ts +21 -0
- package/dist/errors-G7ODRBLT.js +11 -0
- package/dist/errors-G7ODRBLT.js.map +1 -0
- package/dist/errors.d.ts +33 -0
- package/dist/init-MRK4F5SV.js +96 -0
- package/dist/init-MRK4F5SV.js.map +1 -0
- package/dist/init.d.ts +6 -0
- package/dist/inspect-LN4EACWA.js +13 -0
- package/dist/inspect-LN4EACWA.js.map +1 -0
- package/dist/inspect.d.ts +63 -0
- package/dist/mcp-HRQIWAIR.js +342 -0
- package/dist/mcp-HRQIWAIR.js.map +1 -0
- package/dist/mcp-protocol.d.ts +51 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/promote-GSF5UXJL.js +78 -0
- package/dist/promote-GSF5UXJL.js.map +1 -0
- package/dist/promote.d.ts +12 -0
- package/dist/record-WEZRD2VT.js +59 -0
- package/dist/record-WEZRD2VT.js.map +1 -0
- package/dist/record.d.ts +41 -0
- package/dist/skills-NQRJHKHG.js +80 -0
- package/dist/skills-NQRJHKHG.js.map +1 -0
- package/dist/skills.d.ts +13 -0
- package/package.json +5 -4
- package/skills/create-scenario-map/SKILL.md +88 -0
- package/skills/state-scenarios/SKILL.md +91 -0
- package/skills/state-scenarios/references/auth.md +52 -0
- package/skills/state-scenarios/references/capture.md +45 -0
- package/skills/state-scenarios/references/debugging.md +52 -0
- package/skills/state-scenarios/references/mcp.md +39 -0
- package/skills/state-scenarios/references/media.md +50 -0
- package/skills/state-scenarios/references/network.md +67 -0
- package/skills/state-scenarios/references/setup.md +94 -0
- package/skills/state-scenarios/references/verify.md +47 -0
- package/skills/state-scenarios/references/writing-scenarios.md +55 -0
package/README.md
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
# state-scenarios-cli
|
|
2
2
|
|
|
3
3
|
```sh
|
|
4
|
+
npx state-scenarios init src/scenarios # scaffold, install agent skills, register the MCP server
|
|
5
|
+
npx state-scenarios doctor scenarios --base-url http://localhost:5173
|
|
6
|
+
npx state-scenarios guide [topic]
|
|
7
|
+
npx state-scenarios mcp scenarios --base-url http://localhost:5173
|
|
4
8
|
npx state-scenarios validate scenarios --rules rules.ts
|
|
5
9
|
npx state-scenarios list scenarios --base-url http://localhost:5173 --json
|
|
6
10
|
npx state-scenarios shoot scenarios --base-url http://localhost:5173 --out shots
|
|
11
|
+
npx state-scenarios shoot scenarios --base-url http://localhost:5173 --no-panel --viewport 1440x900 --scale 2 --color-scheme dark --locale de-DE --timezone Europe/Berlin
|
|
12
|
+
npx state-scenarios record scenarios --base-url http://localhost:5173 --steps steps.ts --format mp4
|
|
7
13
|
npx state-scenarios suggest scenarios --src src --schema scenario.schema.json
|
|
8
14
|
```
|
|
9
15
|
|
|
10
|
-
- `shoot` opens every scenario in Chromium, waits until it's ready, and saves a screenshot. It fails on unmocked requests, uncaught exceptions and `console.error` calls. It needs `@playwright/test`.
|
|
16
|
+
- `shoot` opens every scenario in Chromium, waits until it's ready, and saves a screenshot. It fails on unmocked requests, uncaught exceptions and `console.error` calls, and writes `<out>/<name>.md` for each failure with the fix. It needs `@playwright/test`.
|
|
17
|
+
- `doctor` checks the wiring one step at a time and prints each fix. `mcp` lets a coding agent open, drive and save scenarios. `guide` prints the shipped agent skill.
|
|
18
|
+
- Exit codes: `1` a scenario failed, `2` bad arguments or scenarios, `3` something needed is missing or down. Errors print a stable code, e.g. `[DEV_SERVER_DOWN]`.
|
|
19
|
+
- `record` opens each scenario named in `--steps`, runs its steps with a visible cursor, and saves an MP4, GIF or WebM, optionally cropped to `--element`. MP4, GIF and cropping need ffmpeg.
|
|
11
20
|
- `suggest` asks Claude, or another model behind `ANTHROPIC_BASE_URL` via `--model`, which UI states lack scenarios, and drafts them for review.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Browser, LaunchOptions } from '@playwright/test';
|
|
2
|
+
import type { Scenario } from 'state-scenarios';
|
|
3
|
+
/** The CLI flags that shape the browser, shared by shoot, record, doctor and mcp. */
|
|
4
|
+
export interface BrowserFlags {
|
|
5
|
+
viewport?: string;
|
|
6
|
+
scale?: string;
|
|
7
|
+
'color-scheme'?: string;
|
|
8
|
+
locale?: string;
|
|
9
|
+
timezone?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Playwright context options from the browser flags, with the scenario's own viewport first. */
|
|
12
|
+
export declare function contextOptions(scenario: Scenario | undefined, flags: BrowserFlags): {
|
|
13
|
+
viewport: {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
};
|
|
17
|
+
deviceScaleFactor: number | undefined;
|
|
18
|
+
colorScheme: "dark" | "light" | "no-preference" | undefined;
|
|
19
|
+
locale: string | undefined;
|
|
20
|
+
timezoneId: string | undefined;
|
|
21
|
+
};
|
|
22
|
+
/** Chromium from @playwright/test, with the install command when it's missing. */
|
|
23
|
+
export declare function launchChromium(options?: LaunchOptions): Promise<Browser>;
|
|
24
|
+
/** Fail fast, with the fix, when nothing answers at the app's URL. */
|
|
25
|
+
export declare function checkServer(baseURL: string): Promise<void>;
|
|
26
|
+
/** The link that opens a scenario in strict mode, as shoot and agents use it. */
|
|
27
|
+
export declare const scenarioLink: (baseURL: string, scenario: Scenario) => string;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var ERROR_CLASSES = {
|
|
3
|
+
// A scenario ran and failed (exit 1).
|
|
4
|
+
SESSION_NOT_STARTED: "failed",
|
|
5
|
+
SCENARIO_NOT_IN_APP: "failed",
|
|
6
|
+
READY_TIMEOUT: "failed",
|
|
7
|
+
UNMOCKED_REQUEST: "failed",
|
|
8
|
+
PAGE_ERROR: "failed",
|
|
9
|
+
CONSOLE_ERROR: "failed",
|
|
10
|
+
ELEMENT_NOT_FOUND: "failed",
|
|
11
|
+
STEPS_FAILED: "failed",
|
|
12
|
+
RULE_VIOLATION: "failed",
|
|
13
|
+
// The command or the scenarios are wrong (exit 2).
|
|
14
|
+
INVALID_ARGUMENT: "config",
|
|
15
|
+
INVALID_SCENARIO: "config",
|
|
16
|
+
UNKNOWN_SCENARIO: "config",
|
|
17
|
+
// Something the command needs is missing or down (exit 3).
|
|
18
|
+
DEV_SERVER_DOWN: "infra",
|
|
19
|
+
BROWSER_MISSING: "infra",
|
|
20
|
+
FFMPEG_MISSING: "infra",
|
|
21
|
+
WORKER_NOT_SERVED: "infra"
|
|
22
|
+
};
|
|
23
|
+
var EXIT_CODES = { failed: 1, config: 2, infra: 3 };
|
|
24
|
+
var CliError = class extends Error {
|
|
25
|
+
code;
|
|
26
|
+
constructor(code, message) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.code = code;
|
|
29
|
+
}
|
|
30
|
+
get exitCode() {
|
|
31
|
+
return EXIT_CODES[ERROR_CLASSES[this.code]];
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
ERROR_CLASSES,
|
|
37
|
+
EXIT_CODES,
|
|
38
|
+
CliError
|
|
39
|
+
};
|
|
40
|
+
//# sourceMappingURL=chunk-GCYOLAFU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/errors.ts"],"sourcesContent":["/**\n * Stable error codes. The class sets the exit code, so scripts and agents can branch on\n * either without reading messages: 1 a scenario failed, 2 configuration, 3 infrastructure.\n */\nexport const ERROR_CLASSES = {\n // A scenario ran and failed (exit 1).\n SESSION_NOT_STARTED: 'failed',\n SCENARIO_NOT_IN_APP: 'failed',\n READY_TIMEOUT: 'failed',\n UNMOCKED_REQUEST: 'failed',\n PAGE_ERROR: 'failed',\n CONSOLE_ERROR: 'failed',\n ELEMENT_NOT_FOUND: 'failed',\n STEPS_FAILED: 'failed',\n RULE_VIOLATION: 'failed',\n // The command or the scenarios are wrong (exit 2).\n INVALID_ARGUMENT: 'config',\n INVALID_SCENARIO: 'config',\n UNKNOWN_SCENARIO: 'config',\n // Something the command needs is missing or down (exit 3).\n DEV_SERVER_DOWN: 'infra',\n BROWSER_MISSING: 'infra',\n FFMPEG_MISSING: 'infra',\n WORKER_NOT_SERVED: 'infra',\n} as const;\n\nexport type ErrorCode = keyof typeof ERROR_CLASSES;\n\nexport const EXIT_CODES = { failed: 1, config: 2, infra: 3 } as const;\n\nexport class CliError extends Error {\n readonly code: ErrorCode;\n\n constructor(code: ErrorCode, message: string) {\n super(message);\n this.code = code;\n }\n\n get exitCode() {\n return EXIT_CODES[ERROR_CLASSES[this.code]];\n }\n}\n"],"mappings":";AAIO,IAAM,gBAAgB;AAAA;AAAA,EAE3B,qBAAqB;AAAA,EACrB,qBAAqB;AAAA,EACrB,eAAe;AAAA,EACf,kBAAkB;AAAA,EAClB,YAAY;AAAA,EACZ,eAAe;AAAA,EACf,mBAAmB;AAAA,EACnB,cAAc;AAAA,EACd,gBAAgB;AAAA;AAAA,EAEhB,kBAAkB;AAAA,EAClB,kBAAkB;AAAA,EAClB,kBAAkB;AAAA;AAAA,EAElB,iBAAiB;AAAA,EACjB,iBAAiB;AAAA,EACjB,gBAAgB;AAAA,EAChB,mBAAmB;AACrB;AAIO,IAAM,aAAa,EAAE,QAAQ,GAAG,QAAQ,GAAG,OAAO,EAAE;AAEpD,IAAM,WAAN,cAAuB,MAAM;AAAA,EACzB;AAAA,EAET,YAAY,MAAiB,SAAiB;AAC5C,UAAM,OAAO;AACb,SAAK,OAAO;AAAA,EACd;AAAA,EAEA,IAAI,WAAW;AACb,WAAO,WAAW,cAAc,KAAK,IAAI,CAAC;AAAA,EAC5C;AACF;","names":[]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CliError
|
|
3
|
+
} from "./chunk-GCYOLAFU.js";
|
|
4
|
+
|
|
5
|
+
// src/browser.ts
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
var ColorScheme = z.enum(["light", "dark", "no-preference"]).optional();
|
|
8
|
+
function contextOptions(scenario, flags) {
|
|
9
|
+
const viewport = scenario?.viewport ?? parseViewport(flags.viewport ?? "1280x720");
|
|
10
|
+
const scale = flags.scale === void 0 ? void 0 : Number(flags.scale);
|
|
11
|
+
if (scale !== void 0 && !(scale > 0)) {
|
|
12
|
+
throw new CliError("INVALID_ARGUMENT", `--scale must be a positive number, not "${flags.scale}"`);
|
|
13
|
+
}
|
|
14
|
+
const colorScheme = ColorScheme.safeParse(flags["color-scheme"]);
|
|
15
|
+
if (!colorScheme.success) {
|
|
16
|
+
throw new CliError(
|
|
17
|
+
"INVALID_ARGUMENT",
|
|
18
|
+
`--color-scheme must be light, dark or no-preference, not "${flags["color-scheme"]}"`
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
viewport,
|
|
23
|
+
deviceScaleFactor: scale,
|
|
24
|
+
colorScheme: colorScheme.data,
|
|
25
|
+
locale: flags.locale,
|
|
26
|
+
timezoneId: flags.timezone
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function parseViewport(text) {
|
|
30
|
+
const match = /^(\d+)x(\d+)$/.exec(text);
|
|
31
|
+
if (!match) throw new CliError("INVALID_ARGUMENT", `--viewport must look like 1440x900, not "${text}"`);
|
|
32
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
33
|
+
}
|
|
34
|
+
async function launchChromium(options) {
|
|
35
|
+
const hint = "Install it: npm i -D @playwright/test && npx playwright install chromium";
|
|
36
|
+
let chromium;
|
|
37
|
+
try {
|
|
38
|
+
({ chromium } = await import("@playwright/test"));
|
|
39
|
+
} catch {
|
|
40
|
+
throw new CliError("BROWSER_MISSING", `@playwright/test isn't installed. ${hint}`);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return await chromium.launch(options);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
const message = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
46
|
+
throw new CliError("BROWSER_MISSING", `Chromium didn't start (${message}). ${hint}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function checkServer(baseURL) {
|
|
50
|
+
try {
|
|
51
|
+
await fetch(baseURL, { signal: AbortSignal.timeout(5e3) });
|
|
52
|
+
} catch {
|
|
53
|
+
throw new CliError(
|
|
54
|
+
"DEV_SERVER_DOWN",
|
|
55
|
+
`Nothing answers at ${baseURL}. Start the dev server first, or pass the --base-url it prints.`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
var scenarioLink = (baseURL, scenario) => `${baseURL.replace(/\/$/, "")}${scenario.path ?? "/"}?scenario=${encodeURIComponent(scenario.name)}&scenario-strict`;
|
|
60
|
+
|
|
61
|
+
export {
|
|
62
|
+
contextOptions,
|
|
63
|
+
launchChromium,
|
|
64
|
+
checkServer,
|
|
65
|
+
scenarioLink
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=chunk-O6QTNAAZ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/browser.ts"],"sourcesContent":["import type { Browser, BrowserType, LaunchOptions } from '@playwright/test';\nimport type { Scenario } from 'state-scenarios';\nimport { z } from 'zod';\nimport { CliError } from './errors.js';\n\n/** The CLI flags that shape the browser, shared by shoot, record, doctor and mcp. */\nexport interface BrowserFlags {\n viewport?: string;\n scale?: string;\n 'color-scheme'?: string;\n locale?: string;\n timezone?: string;\n}\n\nconst ColorScheme = z.enum(['light', 'dark', 'no-preference']).optional();\n\n/** Playwright context options from the browser flags, with the scenario's own viewport first. */\nexport function contextOptions(scenario: Scenario | undefined, flags: BrowserFlags) {\n const viewport = scenario?.viewport ?? parseViewport(flags.viewport ?? '1280x720');\n const scale = flags.scale === undefined ? undefined : Number(flags.scale);\n\n if (scale !== undefined && !(scale > 0)) {\n throw new CliError('INVALID_ARGUMENT', `--scale must be a positive number, not \"${flags.scale}\"`);\n }\n\n const colorScheme = ColorScheme.safeParse(flags['color-scheme']);\n\n if (!colorScheme.success) {\n throw new CliError(\n 'INVALID_ARGUMENT',\n `--color-scheme must be light, dark or no-preference, not \"${flags['color-scheme']}\"`,\n );\n }\n\n return {\n viewport,\n deviceScaleFactor: scale,\n colorScheme: colorScheme.data,\n locale: flags.locale,\n timezoneId: flags.timezone,\n };\n}\n\nfunction parseViewport(text: string) {\n const match = /^(\\d+)x(\\d+)$/.exec(text);\n\n if (!match) throw new CliError('INVALID_ARGUMENT', `--viewport must look like 1440x900, not \"${text}\"`);\n\n return { width: Number(match[1]), height: Number(match[2]) };\n}\n\n/** Chromium from @playwright/test, with the install command when it's missing. */\nexport async function launchChromium(options?: LaunchOptions): Promise<Browser> {\n const hint = 'Install it: npm i -D @playwright/test && npx playwright install chromium';\n let chromium: BrowserType;\n\n try {\n ({ chromium } = await import('@playwright/test'));\n } catch {\n throw new CliError('BROWSER_MISSING', `@playwright/test isn't installed. ${hint}`);\n }\n\n try {\n return await chromium.launch(options);\n } catch (err) {\n const message = err instanceof Error ? err.message.split('\\n')[0] : String(err);\n throw new CliError('BROWSER_MISSING', `Chromium didn't start (${message}). ${hint}`);\n }\n}\n\n/** Fail fast, with the fix, when nothing answers at the app's URL. */\nexport async function checkServer(baseURL: string) {\n try {\n await fetch(baseURL, { signal: AbortSignal.timeout(5000) });\n } catch {\n throw new CliError(\n 'DEV_SERVER_DOWN',\n `Nothing answers at ${baseURL}. Start the dev server first, or pass the --base-url it prints.`,\n );\n }\n}\n\n/** The link that opens a scenario in strict mode, as shoot and agents use it. */\nexport const scenarioLink = (baseURL: string, scenario: Scenario) =>\n `${baseURL.replace(/\\/$/, '')}${scenario.path ?? '/'}?scenario=${\n encodeURIComponent(scenario.name)\n }&scenario-strict`;\n"],"mappings":";;;;;AAEA,SAAS,SAAS;AAYlB,IAAM,cAAc,EAAE,KAAK,CAAC,SAAS,QAAQ,eAAe,CAAC,EAAE,SAAS;AAGjE,SAAS,eAAe,UAAgC,OAAqB;AAClF,QAAM,WAAW,UAAU,YAAY,cAAc,MAAM,YAAY,UAAU;AACjF,QAAM,QAAQ,MAAM,UAAU,SAAY,SAAY,OAAO,MAAM,KAAK;AAExE,MAAI,UAAU,UAAa,EAAE,QAAQ,IAAI;AACvC,UAAM,IAAI,SAAS,oBAAoB,2CAA2C,MAAM,KAAK,GAAG;AAAA,EAClG;AAEA,QAAM,cAAc,YAAY,UAAU,MAAM,cAAc,CAAC;AAE/D,MAAI,CAAC,YAAY,SAAS;AACxB,UAAM,IAAI;AAAA,MACR;AAAA,MACA,6DAA6D,MAAM,cAAc,CAAC;AAAA,IACpF;AAAA,EACF;AAEA,SAAO;AAAA,IACL;AAAA,IACA,mBAAmB;AAAA,IACnB,aAAa,YAAY;AAAA,IACzB,QAAQ,MAAM;AAAA,IACd,YAAY,MAAM;AAAA,EACpB;AACF;AAEA,SAAS,cAAc,MAAc;AACnC,QAAM,QAAQ,gBAAgB,KAAK,IAAI;AAEvC,MAAI,CAAC,MAAO,OAAM,IAAI,SAAS,oBAAoB,4CAA4C,IAAI,GAAG;AAEtG,SAAO,EAAE,OAAO,OAAO,MAAM,CAAC,CAAC,GAAG,QAAQ,OAAO,MAAM,CAAC,CAAC,EAAE;AAC7D;AAGA,eAAsB,eAAe,SAA2C;AAC9E,QAAM,OAAO;AACb,MAAI;AAEJ,MAAI;AACF,KAAC,EAAE,SAAS,IAAI,MAAM,OAAO,kBAAkB;AAAA,EACjD,QAAQ;AACN,UAAM,IAAI,SAAS,mBAAmB,qCAAqC,IAAI,EAAE;AAAA,EACnF;AAEA,MAAI;AACF,WAAO,MAAM,SAAS,OAAO,OAAO;AAAA,EACtC,SAAS,KAAK;AACZ,UAAM,UAAU,eAAe,QAAQ,IAAI,QAAQ,MAAM,IAAI,EAAE,CAAC,IAAI,OAAO,GAAG;AAC9E,UAAM,IAAI,SAAS,mBAAmB,0BAA0B,OAAO,MAAM,IAAI,EAAE;AAAA,EACrF;AACF;AAGA,eAAsB,YAAY,SAAiB;AACjD,MAAI;AACF,UAAM,MAAM,SAAS,EAAE,QAAQ,YAAY,QAAQ,GAAI,EAAE,CAAC;AAAA,EAC5D,QAAQ;AACN,UAAM,IAAI;AAAA,MACR;AAAA,MACA,sBAAsB,OAAO;AAAA,IAC/B;AAAA,EACF;AACF;AAGO,IAAM,eAAe,CAAC,SAAiB,aAC5C,GAAG,QAAQ,QAAQ,OAAO,EAAE,CAAC,GAAG,SAAS,QAAQ,GAAG,aAClD,mBAAmB,SAAS,IAAI,CAClC;","names":[]}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/inspect.ts
|
|
2
|
+
import { openScenario, unhandledRequests } from "state-scenarios/playwright";
|
|
3
|
+
var firstLine = (err) => (err instanceof Error ? err.message : err).split("\n")[0] ?? "";
|
|
4
|
+
function watchErrors(page) {
|
|
5
|
+
const pageErrors = [];
|
|
6
|
+
const consoleErrors = [];
|
|
7
|
+
page.on("pageerror", (err) => pageErrors.push(err.message));
|
|
8
|
+
page.on("console", (m) => {
|
|
9
|
+
const text = m.text();
|
|
10
|
+
const known = text.startsWith("Failed to load resource") || /^\[state-scenarios\] ".*" has no handler for /.test(text);
|
|
11
|
+
if (m.type() === "error" && !known) consoleErrors.push(text);
|
|
12
|
+
});
|
|
13
|
+
return { pageErrors, consoleErrors };
|
|
14
|
+
}
|
|
15
|
+
async function inspectScenario(page, scenario, { link, strict = true, panel = true }) {
|
|
16
|
+
const errors = watchErrors(page);
|
|
17
|
+
const codes = [];
|
|
18
|
+
let error = null;
|
|
19
|
+
let ready = null;
|
|
20
|
+
try {
|
|
21
|
+
await openScenario(page, scenario.name, { path: scenario.path, strict, panel });
|
|
22
|
+
} catch (err) {
|
|
23
|
+
error = firstLine(err instanceof Error ? err : String(err));
|
|
24
|
+
const active = await page.locator("html").getAttribute("data-scenario").catch(() => null);
|
|
25
|
+
const running = await page.evaluate(() => !!globalThis.stateScenariosPage).catch(() => false);
|
|
26
|
+
if (active !== scenario.name) codes.push(running ? "SCENARIO_NOT_IN_APP" : "SESSION_NOT_STARTED");
|
|
27
|
+
else if (scenario.ready) {
|
|
28
|
+
codes.push("READY_TIMEOUT");
|
|
29
|
+
const report = await page.evaluate((s) => globalThis.stateScenariosPage?.checkReady(s), scenario.ready);
|
|
30
|
+
if (report) ready = { selector: scenario.ready, ...report };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const unhandled = await unhandledRequests(page).catch(() => []);
|
|
34
|
+
if (unhandled.length) codes.push("UNMOCKED_REQUEST");
|
|
35
|
+
if (errors.pageErrors.length) codes.push("PAGE_ERROR");
|
|
36
|
+
if (errors.consoleErrors.length) codes.push("CONSOLE_ERROR");
|
|
37
|
+
return { name: scenario.name, ok: !codes.length, codes, link, unhandled, ...errors, error, ready };
|
|
38
|
+
}
|
|
39
|
+
function networkEntryFor(label, appOrigin) {
|
|
40
|
+
const [method = "GET", href = ""] = label.split(" ");
|
|
41
|
+
const url = new URL(href);
|
|
42
|
+
const entry = {
|
|
43
|
+
method,
|
|
44
|
+
path: url.origin === appOrigin ? url.pathname : url.origin + url.pathname,
|
|
45
|
+
response: { status: 200, body: {} }
|
|
46
|
+
};
|
|
47
|
+
if (url.search) entry.query = Object.fromEntries(url.searchParams);
|
|
48
|
+
return entry;
|
|
49
|
+
}
|
|
50
|
+
function failurePage(r, { screenshot, appOrigin }) {
|
|
51
|
+
const out = [
|
|
52
|
+
`# ${r.name}: ${r.codes.join(", ")}`,
|
|
53
|
+
"",
|
|
54
|
+
`- Reopen: ${r.link}`,
|
|
55
|
+
`- Screenshot: ${screenshot}`
|
|
56
|
+
];
|
|
57
|
+
if (r.error) out.push(`- Error: ${r.error}`);
|
|
58
|
+
if (r.codes.includes("SESSION_NOT_STARTED")) {
|
|
59
|
+
out.push(
|
|
60
|
+
"",
|
|
61
|
+
"## The scenario never became active",
|
|
62
|
+
"",
|
|
63
|
+
"`<html data-scenario>` never named this scenario. Check that `startScenarios` runs and is awaited before the app",
|
|
64
|
+
"renders, outside any production-only branch, and that the router is created after it (import it dynamically",
|
|
65
|
+
"if it is created when its module loads). `state-scenarios doctor` checks the wiring."
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (r.codes.includes("SCENARIO_NOT_IN_APP")) {
|
|
69
|
+
out.push(
|
|
70
|
+
"",
|
|
71
|
+
"## The app doesn't have this scenario",
|
|
72
|
+
"",
|
|
73
|
+
"The session runs, but its catalog has no scenario by this name. The app loads scenarios from its own",
|
|
74
|
+
"folder or list: check that this file is in it (Next.js lists imports by hand), and reload or restart the",
|
|
75
|
+
"dev server if the file is new."
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (r.ready) {
|
|
79
|
+
const found = r.ready.status === "match" ? `matches ${r.ready.count} element(s) but none became visible` : r.ready.status === "none" ? "matches nothing on the page" : r.ready.status === "invalid" ? `is not a valid selector (${r.ready.error})` : "uses a form this check can't evaluate; Playwright still waited and timed out";
|
|
80
|
+
out.push("", "## Ready selector", "", `\`${r.ready.selector}\` ${found}.`);
|
|
81
|
+
if (r.ready.suggestions.length) {
|
|
82
|
+
out.push(
|
|
83
|
+
"",
|
|
84
|
+
"Distinctive selectors on the page now (if the screenshot shows the right state, use one as `ready`):",
|
|
85
|
+
""
|
|
86
|
+
);
|
|
87
|
+
for (const s of r.ready.suggestions) out.push(`- \`${s}\``);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (r.unhandled.length) {
|
|
91
|
+
out.push(
|
|
92
|
+
"",
|
|
93
|
+
"## Unmocked requests",
|
|
94
|
+
"",
|
|
95
|
+
"Add these to the scenario's `network` (fill in real bodies, or record them with `?scenario-record`),",
|
|
96
|
+
"or `extends` a scenario that has them. Third-party SDKs belong in `startScenarios({ ignore })`.",
|
|
97
|
+
"",
|
|
98
|
+
"```json",
|
|
99
|
+
JSON.stringify(r.unhandled.map((u) => networkEntryFor(u, appOrigin)), null, 2),
|
|
100
|
+
"```"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (r.pageErrors.length) out.push("", "## Uncaught exceptions", "", ...r.pageErrors.map((e) => `- ${e}`));
|
|
104
|
+
if (r.consoleErrors.length) out.push("", "## console.error", "", ...r.consoleErrors.map((e) => `- ${e}`));
|
|
105
|
+
return `${out.join("\n")}
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export {
|
|
110
|
+
watchErrors,
|
|
111
|
+
inspectScenario,
|
|
112
|
+
networkEntryFor,
|
|
113
|
+
failurePage
|
|
114
|
+
};
|
|
115
|
+
//# sourceMappingURL=chunk-YFNROT3R.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/inspect.ts"],"sourcesContent":["import type { Page } from '@playwright/test';\nimport type { ReadyReport, Scenario } from 'state-scenarios';\nimport { openScenario, unhandledRequests } from 'state-scenarios/playwright';\nimport type { ErrorCode } from './errors.js';\n\nexport interface Inspection {\n name: string;\n ok: boolean;\n /** Why it failed, as stable codes; empty when ok. */\n codes: ErrorCode[];\n /** Reopens this state in strict mode. */\n link: string;\n unhandled: string[];\n pageErrors: string[];\n consoleErrors: string[];\n error: string | null;\n /** Set when the ready selector didn't show: what it matched and what would. */\n ready: (ReadyReport & { selector: string; }) | null;\n}\n\nconst firstLine = (err: Error | string) => (err instanceof Error ? err.message : err).split('\\n')[0] ?? '';\n\n/**\n * Collect uncaught exceptions and console errors from now on. Browser network logs\n * (\"Failed to load resource\") are left out: error scenarios cause them on purpose, and\n * requests the scenario didn't mock are reported as unhandled instead.\n */\nexport function watchErrors(page: Page) {\n const pageErrors: string[] = [];\n const consoleErrors: string[] = [];\n page.on('pageerror', (err) => pageErrors.push(err.message));\n page.on('console', (m) => {\n const text = m.text();\n // Strict mode logs each unmocked request; those are reported as unhandled already.\n\n const known = text.startsWith('Failed to load resource')\n || /^\\[state-scenarios\\] \".*\" has no handler for /.test(text);\n\n if (m.type() === 'error' && !known) consoleErrors.push(text);\n });\n\n return { pageErrors, consoleErrors };\n}\n\n/** Open a scenario and report everything an agent needs to fix it. */\nexport async function inspectScenario(\n page: Page,\n scenario: Scenario,\n { link, strict = true, panel = true }: { link: string; strict?: boolean; panel?: boolean; },\n): Promise<Inspection> {\n const errors = watchErrors(page);\n const codes: ErrorCode[] = [];\n let error: string | null = null;\n let ready: Inspection['ready'] = null;\n\n try {\n await openScenario(page, scenario.name, { path: scenario.path, strict, panel });\n } catch (err) {\n error = firstLine(err instanceof Error ? err : String(err));\n const active = await page.locator('html').getAttribute('data-scenario').catch(() => null);\n // A running session that didn't open the scenario was started with a different catalog.\n const running = await page.evaluate(() => !!globalThis.stateScenariosPage).catch(() => false);\n\n if (active !== scenario.name) codes.push(running ? 'SCENARIO_NOT_IN_APP' : 'SESSION_NOT_STARTED');\n else if (scenario.ready) {\n codes.push('READY_TIMEOUT');\n const report = await page.evaluate((s) => globalThis.stateScenariosPage?.checkReady(s), scenario.ready);\n\n if (report) ready = { selector: scenario.ready, ...report };\n }\n }\n\n const unhandled = await unhandledRequests(page).catch(() => []);\n\n if (unhandled.length) codes.push('UNMOCKED_REQUEST');\n\n if (errors.pageErrors.length) codes.push('PAGE_ERROR');\n\n if (errors.consoleErrors.length) codes.push('CONSOLE_ERROR');\n\n return { name: scenario.name, ok: !codes.length, codes, link, unhandled, ...errors, error, ready };\n}\n\ninterface DraftEntry {\n method: string;\n path: string;\n query?: Record<string, string>;\n response: { status: number; body: Record<string, never>; };\n}\n\ninterface DraftEntry {\n method: string;\n path: string;\n query?: Record<string, string>;\n response: { status: number; body: Record<string, never>; };\n}\n\n/** A network entry that answers an unmocked request, ready to paste and fill in. */\nexport function networkEntryFor(label: string, appOrigin: string) {\n const [method = 'GET', href = ''] = label.split(' ');\n const url = new URL(href);\n\n const entry: DraftEntry = {\n method,\n path: url.origin === appOrigin ? url.pathname : url.origin + url.pathname,\n response: { status: 200, body: {} },\n };\n\n if (url.search) entry.query = Object.fromEntries(url.searchParams);\n\n return entry;\n}\n\n/**\n * The page an agent reads when a scenario fails: what went wrong, the link that reopens it,\n * and the fix written out where one can be.\n */\nexport function failurePage(\n r: Inspection,\n { screenshot, appOrigin }: { screenshot: string; appOrigin: string; },\n) {\n const out = [\n `# ${r.name}: ${r.codes.join(', ')}`,\n '',\n `- Reopen: ${r.link}`,\n `- Screenshot: ${screenshot}`,\n ];\n\n if (r.error) out.push(`- Error: ${r.error}`);\n\n if (r.codes.includes('SESSION_NOT_STARTED')) {\n out.push(\n '',\n '## The scenario never became active',\n '',\n '`<html data-scenario>` never named this scenario. Check that `startScenarios` runs and is awaited before the app',\n 'renders, outside any production-only branch, and that the router is created after it (import it dynamically',\n 'if it is created when its module loads). `state-scenarios doctor` checks the wiring.',\n );\n }\n\n if (r.codes.includes('SCENARIO_NOT_IN_APP')) {\n out.push(\n '',\n \"## The app doesn't have this scenario\",\n '',\n 'The session runs, but its catalog has no scenario by this name. The app loads scenarios from its own',\n 'folder or list: check that this file is in it (Next.js lists imports by hand), and reload or restart the',\n 'dev server if the file is new.',\n );\n }\n\n if (r.ready) {\n const found = r.ready.status === 'match'\n ? `matches ${r.ready.count} element(s) but none became visible`\n : r.ready.status === 'none'\n ? 'matches nothing on the page'\n : r.ready.status === 'invalid'\n ? `is not a valid selector (${r.ready.error})`\n : \"uses a form this check can't evaluate; Playwright still waited and timed out\";\n\n out.push('', '## Ready selector', '', `\\`${r.ready.selector}\\` ${found}.`);\n\n if (r.ready.suggestions.length) {\n out.push(\n '',\n 'Distinctive selectors on the page now (if the screenshot shows the right state, use one as `ready`):',\n '',\n );\n\n for (const s of r.ready.suggestions) out.push(`- \\`${s}\\``);\n }\n }\n\n if (r.unhandled.length) {\n out.push(\n '',\n '## Unmocked requests',\n '',\n \"Add these to the scenario's `network` (fill in real bodies, or record them with `?scenario-record`),\",\n 'or `extends` a scenario that has them. Third-party SDKs belong in `startScenarios({ ignore })`.',\n '',\n '```json',\n JSON.stringify(r.unhandled.map((u) => networkEntryFor(u, appOrigin)), null, 2),\n '```',\n );\n }\n\n if (r.pageErrors.length) out.push('', '## Uncaught exceptions', '', ...r.pageErrors.map((e) => `- ${e}`));\n\n if (r.consoleErrors.length) out.push('', '## console.error', '', ...r.consoleErrors.map((e) => `- ${e}`));\n\n return `${out.join('\\n')}\\n`;\n}\n"],"mappings":"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","names":[]}
|