@pikku/cli 0.12.90 → 0.12.91
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 +25 -2
- package/console-app/assets/{index-C5Bd44e4.js → index-CpkvmAGb.js} +140 -140
- package/console-app/assets/{index-CSzCJzBb.css → index-D0HG8q0B.css} +1 -1
- package/console-app/index.html +2 -2
- package/dist/.pikku/agent/pikku-agent-types.gen.d.ts +1 -1
- package/dist/.pikku/channel/pikku-channel-types.gen.d.ts +1 -1
- package/dist/.pikku/channel/pikku-channel-types.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-channel.js +1 -1
- package/dist/.pikku/cli/pikku-cli-client.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-client.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-types.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-types.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.json +22 -0
- package/dist/.pikku/cli/pikku-cli-wirings.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli.gen.js +1 -1
- package/dist/.pikku/console/pikku-node-types.gen.d.ts +1 -1
- package/dist/.pikku/function/pikku-function-types.gen.d.ts +4 -4
- package/dist/.pikku/function/pikku-function-types.gen.js +1 -1
- package/dist/.pikku/function/pikku-functions-meta.gen.js +1 -1
- package/dist/.pikku/function/pikku-functions-meta.gen.json +9 -9
- package/dist/.pikku/function/pikku-functions.gen.js +1 -1
- package/dist/.pikku/http/pikku-http-types.gen.d.ts +1 -1
- package/dist/.pikku/http/pikku-http-types.gen.js +1 -1
- package/dist/.pikku/mcp/pikku-mcp-types.gen.d.ts +1 -1
- package/dist/.pikku/mcp/pikku-mcp-types.gen.js +1 -1
- package/dist/.pikku/pikku-bootstrap-scenarios.gen.d.ts +8 -0
- package/dist/.pikku/pikku-bootstrap-scenarios.gen.js +8 -0
- package/dist/.pikku/pikku-bootstrap.gen.d.ts +1 -1
- package/dist/.pikku/pikku-bootstrap.gen.js +1 -1
- package/dist/.pikku/pikku-meta-service.gen.d.ts +1 -1
- package/dist/.pikku/pikku-meta-service.gen.js +1 -1
- package/dist/.pikku/pikku-services.gen.d.ts +1 -1
- package/dist/.pikku/pikku-types.gen.d.ts +1 -1
- package/dist/.pikku/pikku-types.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-types.gen.d.ts +1 -1
- package/dist/.pikku/queue/pikku-queue-types.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings-meta.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.d.ts +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.js +1 -1
- package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.js +1 -1
- package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.d.ts +1 -0
- package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.js +10 -0
- package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.json +1 -0
- package/dist/.pikku/scenarios/pikku-scenario-functions.gen.d.ts +4 -0
- package/dist/.pikku/scenarios/pikku-scenario-functions.gen.js +1 -0
- package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.d.ts +1 -0
- package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.js +10 -0
- package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.d.ts +4 -0
- package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.js +1 -0
- package/dist/.pikku/scheduler/pikku-scheduler-types.gen.d.ts +1 -1
- package/dist/.pikku/scheduler/pikku-scheduler-types.gen.js +1 -1
- package/dist/.pikku/schemas/register.gen.js +1 -1
- package/dist/.pikku/schemas/schemas/PikkuCLIConfig.schema.json +1 -1
- package/dist/.pikku/schemas/schemas/ScenarioRunInput.schema.json +1 -1
- package/dist/.pikku/scopes/pikku-scope-types.gen.d.ts +1 -1
- package/dist/.pikku/scopes/pikku-scope-types.gen.js +1 -1
- package/dist/.pikku/scopes/pikku-scopes.gen.d.ts +1 -1
- package/dist/.pikku/secrets/pikku-secret-types.gen.d.ts +1 -1
- package/dist/.pikku/secrets/pikku-secret-types.gen.js +1 -1
- package/dist/.pikku/secrets/pikku-secrets.gen.d.ts +1 -1
- package/dist/.pikku/secrets/pikku-secrets.gen.js +1 -1
- package/dist/.pikku/trigger/pikku-trigger-types.gen.d.ts +1 -1
- package/dist/.pikku/trigger/pikku-trigger-types.gen.js +1 -1
- package/dist/.pikku/variables/pikku-variable-types.gen.d.ts +1 -1
- package/dist/.pikku/variables/pikku-variable-types.gen.js +1 -1
- package/dist/.pikku/variables/pikku-variables.gen.d.ts +1 -1
- package/dist/.pikku/variables/pikku-variables.gen.js +1 -1
- package/dist/.pikku/workflow/pikku-scenario-actors.gen.d.ts +19 -0
- package/dist/.pikku/workflow/pikku-scenario-actors.gen.js +17 -0
- package/dist/.pikku/workflow/pikku-workflow-types.gen.d.ts +152 -3
- package/dist/.pikku/workflow/pikku-workflow-types.gen.js +39 -1
- package/dist/.pikku/workflow/pikku-workflow-wirings-meta.gen.js +1 -1
- package/dist/.pikku/workflow/pikku-workflow-wirings.gen.js +1 -1
- package/dist/bin/pikku-bin.mjs +2 -2
- package/dist/src/cli.wiring.js +22 -0
- package/dist/src/functions/commands/dev.js +2 -0
- package/dist/src/functions/commands/load-user-project.d.ts +7 -0
- package/dist/src/functions/commands/load-user-project.js +21 -0
- package/dist/src/functions/commands/pikku-command-bootstrap.js +12 -0
- package/dist/src/functions/commands/scenario-browser.d.ts +69 -0
- package/dist/src/functions/commands/scenario-browser.js +60 -0
- package/dist/src/functions/commands/scenario-formatter.d.ts +81 -0
- package/dist/src/functions/commands/scenario-formatter.js +120 -0
- package/dist/src/functions/commands/scenario-ladder.d.ts +66 -0
- package/dist/src/functions/commands/scenario-ladder.js +132 -0
- package/dist/src/functions/commands/scenario-plan.d.ts +61 -0
- package/dist/src/functions/commands/scenario-plan.js +106 -0
- package/dist/src/functions/commands/scenario.d.ts +18 -0
- package/dist/src/functions/commands/scenario.js +305 -38
- package/dist/src/functions/commands/serve.js +2 -0
- package/dist/src/functions/wirings/functions/pikku-command-functions.js +26 -8
- package/dist/src/functions/wirings/functions/serialize-function-types.js +3 -3
- package/dist/src/functions/wirings/scenarios/scenario-partition.d.ts +29 -0
- package/dist/src/functions/wirings/scenarios/scenario-partition.js +58 -0
- package/dist/src/functions/wirings/scenarios/serialize-feature-meta.d.ts +14 -0
- package/dist/src/functions/wirings/scenarios/serialize-feature-meta.js +29 -0
- package/dist/src/functions/wirings/scenarios/serialize-scenario-meta.d.ts +12 -0
- package/dist/src/functions/wirings/scenarios/serialize-scenario-meta.js +56 -0
- package/dist/src/functions/wirings/scenarios/serialize-scenario-registration.d.ts +7 -0
- package/dist/src/functions/wirings/scenarios/serialize-scenario-registration.js +40 -0
- package/dist/src/functions/wirings/workflow/pikku-command-workflow.js +43 -8
- package/dist/src/functions/wirings/workflow/serialize-scenario-actors.d.ts +1 -1
- package/dist/src/functions/wirings/workflow/serialize-scenario-actors.js +13 -2
- package/dist/src/functions/wirings/workflow/serialize-scenario-step-map.d.ts +4 -0
- package/dist/src/functions/wirings/workflow/serialize-scenario-step-map.js +62 -0
- package/dist/src/functions/wirings/workflow/serialize-workflow-bootstrap-map.js +10 -3
- package/dist/src/functions/wirings/workflow/serialize-workflow-map.js +10 -3
- package/dist/src/functions/wirings/workflow/serialize-workflow-meta.js +4 -4
- package/dist/src/functions/wirings/workflow/serialize-workflow-types.d.ts +1 -1
- package/dist/src/functions/wirings/workflow/serialize-workflow-types.js +232 -3
- package/dist/src/server/server-ready.d.ts +12 -0
- package/dist/src/server/server-ready.js +12 -0
- package/dist/src/server/spawn-dev-server.d.ts +50 -0
- package/dist/src/server/spawn-dev-server.js +112 -0
- package/dist/src/services.js +12 -1
- package/dist/src/utils/meta-diff.js +5 -2
- package/dist/src/utils/pikku-cli-config.js +26 -0
- package/dist/src/utils/resolve-scenario-actors.d.ts +23 -0
- package/dist/src/utils/resolve-scenario-actors.js +74 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +15 -5
- package/skills/pikku-scenario/SKILL.md +196 -29
package/dist/src/cli.wiring.js
CHANGED
|
@@ -397,14 +397,36 @@ wireCLI({
|
|
|
397
397
|
description: 'Comma-separated flow names to run (default: all)',
|
|
398
398
|
short: 'f',
|
|
399
399
|
},
|
|
400
|
+
features: {
|
|
401
|
+
description: 'Comma-separated feature names to run (pikkuFeature exports) — the feature is the run unit, so its hooks run once around the group',
|
|
402
|
+
},
|
|
400
403
|
tags: {
|
|
401
404
|
description: 'Comma-separated tags — run flows matching any',
|
|
402
405
|
short: 't',
|
|
403
406
|
},
|
|
407
|
+
excludeTags: {
|
|
408
|
+
description: 'Comma-separated tags to hold back — flows matching any are not run, unless named directly with --flows',
|
|
409
|
+
},
|
|
404
410
|
coverage: {
|
|
405
411
|
description: 'Reset/snapshot server coverage per scenario (target must run with --coverage); writes coverage/scenario-coverage.json',
|
|
406
412
|
default: false,
|
|
407
413
|
},
|
|
414
|
+
browser: {
|
|
415
|
+
description: 'Run scenarios with browser steps. --no-browser skips them (they are reported as skipped, not failed)',
|
|
416
|
+
default: true,
|
|
417
|
+
},
|
|
418
|
+
spawn: {
|
|
419
|
+
description: "Start `pikku dev` on the environment's apiUrl for the run and stop it afterwards. Without it, the environment must already be serving",
|
|
420
|
+
default: false,
|
|
421
|
+
},
|
|
422
|
+
keepAlive: {
|
|
423
|
+
description: 'With --spawn, leave the server running after the run (dev loop)',
|
|
424
|
+
default: false,
|
|
425
|
+
},
|
|
426
|
+
trace: {
|
|
427
|
+
description: 'Keep every stack frame on a failure. Without it, only the project’s own frames are shown',
|
|
428
|
+
default: false,
|
|
429
|
+
},
|
|
408
430
|
},
|
|
409
431
|
}),
|
|
410
432
|
list: pikkuCLICommand({
|
|
@@ -14,6 +14,7 @@ import { loadUserBootstrap, loadUserModule } from './load-user-project.js';
|
|
|
14
14
|
import { startCoverageService } from './start-coverage.js';
|
|
15
15
|
import { createDevAIAgentRunner } from './dev-ai-runner.js';
|
|
16
16
|
import { resolveConsoleMount } from './serve-console.js';
|
|
17
|
+
import { serverReadyLine } from '../../server/server-ready.js';
|
|
17
18
|
export const dev = pikkuSessionlessFunc({
|
|
18
19
|
remote: true,
|
|
19
20
|
func: async ({ logger, config, getInspectorState, invalidateInspectorState, variables, devServerRunner, }, { port, watch, hmr, coverage, test }, { rpc }) => {
|
|
@@ -238,6 +239,7 @@ export const dev = pikkuSessionlessFunc({
|
|
|
238
239
|
if (consoleMount) {
|
|
239
240
|
logger.info(`Pikku Console available at http://${hostname}:${resolvedPort}${consoleMount.urlPrefix}`);
|
|
240
241
|
}
|
|
242
|
+
logger.info(serverReadyLine(hostname, resolvedPort));
|
|
241
243
|
let configWatcher;
|
|
242
244
|
let watcher;
|
|
243
245
|
process.once('SIGINT', async () => {
|
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
* `pikkuState`.
|
|
5
5
|
*/
|
|
6
6
|
export declare function loadUserBootstrap(pikkuDir: string): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Load the generated `pikku-bootstrap-scenarios.gen.{ts,js}`, which imports the
|
|
9
|
+
* app bootstrap and then registers the scenarios, features and steps that are
|
|
10
|
+
* deliberately kept out of it. Falls back to the app bootstrap for a project
|
|
11
|
+
* generated before the split existed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function loadScenarioBootstrap(pikkuDir: string): Promise<void>;
|
|
7
14
|
/**
|
|
8
15
|
* Import a user-source TypeScript file (e.g. their config or services
|
|
9
16
|
* factory) so it can be loaded from inside @pikku/cli's compiled JS.
|
|
@@ -39,6 +39,27 @@ export async function loadUserBootstrap(pikkuDir) {
|
|
|
39
39
|
ensureTsxRegistered();
|
|
40
40
|
await importUserPath(bootstrapPath);
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Load the generated `pikku-bootstrap-scenarios.gen.{ts,js}`, which imports the
|
|
44
|
+
* app bootstrap and then registers the scenarios, features and steps that are
|
|
45
|
+
* deliberately kept out of it. Falls back to the app bootstrap for a project
|
|
46
|
+
* generated before the split existed.
|
|
47
|
+
*/
|
|
48
|
+
export async function loadScenarioBootstrap(pikkuDir) {
|
|
49
|
+
const scenarioTs = join(pikkuDir, 'pikku-bootstrap-scenarios.gen.ts');
|
|
50
|
+
const scenarioJs = join(pikkuDir, 'pikku-bootstrap-scenarios.gen.js');
|
|
51
|
+
const scenarioPath = existsSync(scenarioTs)
|
|
52
|
+
? scenarioTs
|
|
53
|
+
: existsSync(scenarioJs)
|
|
54
|
+
? scenarioJs
|
|
55
|
+
: null;
|
|
56
|
+
if (!scenarioPath) {
|
|
57
|
+
await loadUserBootstrap(pikkuDir);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
ensureTsxRegistered();
|
|
61
|
+
await importUserPath(scenarioPath);
|
|
62
|
+
}
|
|
42
63
|
/**
|
|
43
64
|
* Import a user-source TypeScript file (e.g. their config or services
|
|
44
65
|
* factory) so it can be loaded from inside @pikku/cli's compiled JS.
|
|
@@ -64,5 +64,17 @@ export const pikkuBootstrap = pikkuSessionlessFunc({
|
|
|
64
64
|
})
|
|
65
65
|
.join('\n');
|
|
66
66
|
await writeFileInDir(logger, config.bootstrapFile, allBootstrapImports);
|
|
67
|
+
// The scenario bootstrap is the only entry point that registers scenarios,
|
|
68
|
+
// features and their steps. It pulls in the app bootstrap first so a runner
|
|
69
|
+
// needs to load one file, and so the meta files it merges onto are already
|
|
70
|
+
// in place however this module is reached.
|
|
71
|
+
const scenarioBootstrapImports = [
|
|
72
|
+
config.bootstrapFile,
|
|
73
|
+
config.scenarioStepsMetaFile,
|
|
74
|
+
config.scenarioStepsFile,
|
|
75
|
+
config.scenarioWiringsMetaFile,
|
|
76
|
+
config.scenarioWiringsFile,
|
|
77
|
+
].map((to) => `import '${getFileImportRelativePath(config.scenarioBootstrapFile, to, config.packageMappings, config.forceRelativeImports)}'`);
|
|
78
|
+
await writeFileInDir(logger, config.scenarioBootstrapFile, scenarioBootstrapImports.join('\n'));
|
|
67
79
|
},
|
|
68
80
|
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scenario runner's browser lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Two jobs, both kept out of the run loop so they are testable without
|
|
5
|
+
* launching a browser: deciding whether a run needs a driver at all (and
|
|
6
|
+
* failing early, with instructions, when it does and cannot have one), and
|
|
7
|
+
* making the driver's optional isolation and diagnostics safe to call
|
|
8
|
+
* unconditionally.
|
|
9
|
+
*/
|
|
10
|
+
import type { ScenarioBrowserFailure, ScenarioBrowserProvider } from '@pikku/core/workflow';
|
|
11
|
+
import type { ScenarioActorConfig } from '@pikku/core/services';
|
|
12
|
+
/** The default driver, used when a project names no other. */
|
|
13
|
+
export declare const DEFAULT_BROWSER_DRIVER = "@pikku/playwright";
|
|
14
|
+
export interface ScenarioBrowserDriverOptions {
|
|
15
|
+
secret: string;
|
|
16
|
+
actors: Record<string, ScenarioActorConfig>;
|
|
17
|
+
signInPath?: string;
|
|
18
|
+
failureDir?: string;
|
|
19
|
+
config: unknown;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* What a browser driver package exports.
|
|
23
|
+
*
|
|
24
|
+
* Nothing here is playwright-specific: a driver is anything that can hand a
|
|
25
|
+
* scenario step a `ScenarioBrowserProvider`. `@pikku/playwright` is the one
|
|
26
|
+
* pikku ships, named by `scenarios.browserDriver` only as a default.
|
|
27
|
+
*/
|
|
28
|
+
export interface ScenarioBrowserDriver {
|
|
29
|
+
createScenarioBrowserProvider?: (options: ScenarioBrowserDriverOptions) => ScenarioBrowserProvider;
|
|
30
|
+
/** The class form the bundled driver exports. */
|
|
31
|
+
PlaywrightScenarioBrowserProvider?: new (options: ScenarioBrowserDriverOptions) => ScenarioBrowserProvider;
|
|
32
|
+
/** Driver-specific config (headed, slowMo, timeouts) resolved from the env. */
|
|
33
|
+
browserConfigFromEnv?: (overrides: {
|
|
34
|
+
appUrl: string;
|
|
35
|
+
apiUrl: string;
|
|
36
|
+
}) => unknown;
|
|
37
|
+
}
|
|
38
|
+
export interface ResolveScenarioBrowserProviderOptions {
|
|
39
|
+
/** The environment name, for error messages that tell the user what to edit. */
|
|
40
|
+
environment: string;
|
|
41
|
+
apiUrl: string;
|
|
42
|
+
appUrl?: string;
|
|
43
|
+
secret: string;
|
|
44
|
+
actors: Record<string, ScenarioActorConfig>;
|
|
45
|
+
signInPath?: string;
|
|
46
|
+
/** Where failure screenshots are written. */
|
|
47
|
+
failureDir: string;
|
|
48
|
+
/** The scenarios that declared browser steps, named in the missing-driver error. */
|
|
49
|
+
browserScenarios: string[];
|
|
50
|
+
/** Package to drive the browser. Defaults to `@pikku/playwright`. */
|
|
51
|
+
driver?: string;
|
|
52
|
+
importDriver?: (specifier: string) => Promise<ScenarioBrowserDriver>;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Both failure modes here are discovered before the first scenario starts. A
|
|
56
|
+
* missing `appUrl` or an uninstalled driver found mid-run costs a whole run to
|
|
57
|
+
* learn about.
|
|
58
|
+
*/
|
|
59
|
+
export declare const resolveScenarioBrowserProvider: ({ environment, apiUrl, appUrl, secret, actors, signInPath, failureDir, browserScenarios, driver, importDriver, }: ResolveScenarioBrowserProviderOptions) => Promise<ScenarioBrowserProvider>;
|
|
60
|
+
/**
|
|
61
|
+
* The three calls the run loop makes, safe against a run with no browser and
|
|
62
|
+
* against a driver that implements neither optional member.
|
|
63
|
+
*/
|
|
64
|
+
export interface ScenarioBrowserLifecycle {
|
|
65
|
+
reset(): Promise<void>;
|
|
66
|
+
captureFailure(label: string): Promise<ScenarioBrowserFailure[]>;
|
|
67
|
+
close(): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
export declare const scenarioBrowserLifecycle: (provider: ScenarioBrowserProvider | undefined) => ScenarioBrowserLifecycle;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** The default driver, used when a project names no other. */
|
|
2
|
+
export const DEFAULT_BROWSER_DRIVER = '@pikku/playwright';
|
|
3
|
+
/**
|
|
4
|
+
* Both failure modes here are discovered before the first scenario starts. A
|
|
5
|
+
* missing `appUrl` or an uninstalled driver found mid-run costs a whole run to
|
|
6
|
+
* learn about.
|
|
7
|
+
*/
|
|
8
|
+
export const resolveScenarioBrowserProvider = async ({ environment, apiUrl, appUrl, secret, actors, signInPath, failureDir, browserScenarios, driver = DEFAULT_BROWSER_DRIVER, importDriver = (specifier) => import(specifier), }) => {
|
|
9
|
+
if (!appUrl) {
|
|
10
|
+
throw new Error(`Scenario environment '${environment}' has browser steps but no 'appUrl'. ` +
|
|
11
|
+
`Add it to scenarios.environments.${environment} in pikku.config.json, or run with --no-browser to skip them.`);
|
|
12
|
+
}
|
|
13
|
+
const module = await importDriver(driver).catch(() => {
|
|
14
|
+
const install = driver === DEFAULT_BROWSER_DRIVER
|
|
15
|
+
? `Run 'yarn add -D ${driver} @playwright/test'`
|
|
16
|
+
: `Install '${driver}', or point scenarios.browserDriver at a package that is`;
|
|
17
|
+
throw new Error(`Scenarios ${browserScenarios.join(', ')} declare browser steps but '${driver}' could not be loaded. ` +
|
|
18
|
+
`${install}, or run with --no-browser to skip them.`);
|
|
19
|
+
});
|
|
20
|
+
const options = {
|
|
21
|
+
secret,
|
|
22
|
+
actors,
|
|
23
|
+
signInPath,
|
|
24
|
+
failureDir,
|
|
25
|
+
config: module.browserConfigFromEnv?.({ appUrl, apiUrl }) ?? {
|
|
26
|
+
appUrl,
|
|
27
|
+
apiUrl,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
if (module.createScenarioBrowserProvider) {
|
|
31
|
+
return module.createScenarioBrowserProvider(options);
|
|
32
|
+
}
|
|
33
|
+
if (module.PlaywrightScenarioBrowserProvider) {
|
|
34
|
+
return new module.PlaywrightScenarioBrowserProvider(options);
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`'${driver}' is not a scenario browser driver: it exports neither ` +
|
|
37
|
+
`'createScenarioBrowserProvider' nor a provider class. A driver returns an object with ` +
|
|
38
|
+
`sessionFor() and close(), and optionally reset() and captureFailure().`);
|
|
39
|
+
};
|
|
40
|
+
export const scenarioBrowserLifecycle = (provider) => ({
|
|
41
|
+
// Deliberately NOT swallowed: a browser that cannot be reset would run the
|
|
42
|
+
// next scenario against the last one's session, and a scenario failing on
|
|
43
|
+
// its reset is far easier to diagnose than one failing on stale state.
|
|
44
|
+
reset: async () => {
|
|
45
|
+
await provider?.reset?.();
|
|
46
|
+
},
|
|
47
|
+
captureFailure: async (label) => {
|
|
48
|
+
try {
|
|
49
|
+
return (await provider?.captureFailure?.(label)) ?? [];
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// This runs while a scenario is already failing. Whatever went wrong
|
|
53
|
+
// here matters less than the error we were called to describe.
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
close: async () => {
|
|
58
|
+
await provider?.close();
|
|
59
|
+
},
|
|
60
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scenario run report, and how it is rendered.
|
|
3
|
+
*
|
|
4
|
+
* Everything a run prints goes through here, so the shape of the output is one
|
|
5
|
+
* file rather than log calls scattered through the command. The report itself
|
|
6
|
+
* is plain serialisable data — no Maps, no meta handles — which is what makes a
|
|
7
|
+
* second formatter (JSON, JUnit) a matter of writing one function rather than
|
|
8
|
+
* unpicking the runner.
|
|
9
|
+
*/
|
|
10
|
+
import type { ScenarioBrowserFailure } from '@pikku/core/workflow';
|
|
11
|
+
/** The longest gherkin keyword ("Given"), so sentences line up under each other. */
|
|
12
|
+
declare const KEYWORD_WIDTH = 5;
|
|
13
|
+
/** One step of a run, already joined to the prose that declared it. */
|
|
14
|
+
export interface ScenarioStepRow {
|
|
15
|
+
sentence: string;
|
|
16
|
+
status: string;
|
|
17
|
+
durationMs?: number;
|
|
18
|
+
error?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Everything known about why one scenario failed. */
|
|
21
|
+
export interface ScenarioFailureDetail {
|
|
22
|
+
/** The rendered sentence of the failing step; absent if no step failed. */
|
|
23
|
+
sentence?: string;
|
|
24
|
+
message: string;
|
|
25
|
+
stack?: string;
|
|
26
|
+
/**
|
|
27
|
+
* True when the failure was a deliberate one (a PikkuError). Its message is
|
|
28
|
+
* the whole story, so the stack is noise.
|
|
29
|
+
*/
|
|
30
|
+
expected?: boolean;
|
|
31
|
+
browser?: ScenarioBrowserFailure[];
|
|
32
|
+
}
|
|
33
|
+
export interface ScenarioResult {
|
|
34
|
+
name: string;
|
|
35
|
+
status: 'passed' | 'failed';
|
|
36
|
+
durationMs: number;
|
|
37
|
+
output?: unknown;
|
|
38
|
+
error?: string;
|
|
39
|
+
steps?: ScenarioStepRow[];
|
|
40
|
+
failure?: ScenarioFailureDetail;
|
|
41
|
+
}
|
|
42
|
+
/** One scenario that was not run, and why. */
|
|
43
|
+
export interface ScenarioSkip {
|
|
44
|
+
name: string;
|
|
45
|
+
reason: string;
|
|
46
|
+
}
|
|
47
|
+
export interface ScenarioRunReport {
|
|
48
|
+
environment: string;
|
|
49
|
+
results: ScenarioResult[];
|
|
50
|
+
/**
|
|
51
|
+
* Scenarios not run at all, each carrying why. A skip is only useful if the
|
|
52
|
+
* reader can tell a browser scenario held back by `--no-browser` from one the
|
|
53
|
+
* project itself quarantined, so the reason travels with the name rather than
|
|
54
|
+
* being assumed by the formatter.
|
|
55
|
+
*/
|
|
56
|
+
skipped: ScenarioSkip[];
|
|
57
|
+
/** Feature-level hook failures, which belong to no single scenario. */
|
|
58
|
+
hookFailures: string[];
|
|
59
|
+
}
|
|
60
|
+
export interface ScenarioReportLine {
|
|
61
|
+
level: 'info' | 'error';
|
|
62
|
+
text: string;
|
|
63
|
+
}
|
|
64
|
+
export interface FormatScenarioReportOptions {
|
|
65
|
+
/** Keep every stack frame, including the framework's own. */
|
|
66
|
+
trace?: boolean;
|
|
67
|
+
/** Frames under this directory are the project's own. */
|
|
68
|
+
projectRoot?: string;
|
|
69
|
+
}
|
|
70
|
+
export declare const formatScenarioReport: (report: ScenarioRunReport, options?: FormatScenarioReportOptions) => ScenarioReportLine[];
|
|
71
|
+
export declare const buildStepLadder: (steps: ScenarioStepRow[]) => string[];
|
|
72
|
+
/**
|
|
73
|
+
* The indented block printed under a failed scenario's ladder.
|
|
74
|
+
*
|
|
75
|
+
* One line of `run.error.message` is what a browser step's "Timed out waiting
|
|
76
|
+
* for selector" looks like with all of its context removed. The page's own
|
|
77
|
+
* console errors and failed API calls almost always say why the selector never
|
|
78
|
+
* appeared, and the driver has been collecting them all along.
|
|
79
|
+
*/
|
|
80
|
+
export declare const formatScenarioFailure: (failure: ScenarioFailureDetail, { trace, projectRoot }?: FormatScenarioReportOptions) => string[];
|
|
81
|
+
export { KEYWORD_WIDTH };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/** The longest gherkin keyword ("Given"), so sentences line up under each other. */
|
|
2
|
+
const KEYWORD_WIDTH = 5;
|
|
3
|
+
export const formatScenarioReport = (report, options = {}) => {
|
|
4
|
+
const lines = [];
|
|
5
|
+
const info = (text) => lines.push({ level: 'info', text });
|
|
6
|
+
const error = (text) => lines.push({ level: 'error', text });
|
|
7
|
+
for (const { name, reason } of report.skipped) {
|
|
8
|
+
info(`SKIP ${name} (${reason})`);
|
|
9
|
+
}
|
|
10
|
+
for (const result of report.results) {
|
|
11
|
+
if (result.status === 'passed') {
|
|
12
|
+
const output = result.output !== undefined ? ` → ${JSON.stringify(result.output)}` : '';
|
|
13
|
+
info(`PASS ${result.name} (${result.durationMs}ms)${output}`);
|
|
14
|
+
}
|
|
15
|
+
else {
|
|
16
|
+
error(`FAIL ${result.name} (${result.durationMs}ms): ${firstLine(result.error)}`);
|
|
17
|
+
}
|
|
18
|
+
for (const line of buildStepLadder(result.steps ?? [])) {
|
|
19
|
+
info(line);
|
|
20
|
+
}
|
|
21
|
+
if (result.failure) {
|
|
22
|
+
for (const line of formatScenarioFailure(result.failure, options)) {
|
|
23
|
+
error(line);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
for (const hookFailure of report.hookFailures) {
|
|
28
|
+
error(hookFailure);
|
|
29
|
+
}
|
|
30
|
+
const failed = report.results.filter((r) => r.status === 'failed').length;
|
|
31
|
+
const skippedSuffix = report.skipped.length
|
|
32
|
+
? `, ${report.skipped.length} skipped`
|
|
33
|
+
: '';
|
|
34
|
+
const hookSuffix = report.hookFailures.length
|
|
35
|
+
? `, ${report.hookFailures.length} feature hook failure(s)`
|
|
36
|
+
: '';
|
|
37
|
+
info(`${report.results.length - failed}/${report.results.length} scenarios passed against '${report.environment}'${skippedSuffix}${hookSuffix}`);
|
|
38
|
+
return lines;
|
|
39
|
+
};
|
|
40
|
+
export const buildStepLadder = (steps) => {
|
|
41
|
+
const width = Math.max(0, ...steps.map(({ sentence }) => sentence.length));
|
|
42
|
+
return steps.map((step) => {
|
|
43
|
+
const glyph = step.status === 'succeeded' ? '✓' : '✗';
|
|
44
|
+
const detail = step.status === 'succeeded' || !step.error
|
|
45
|
+
? formatDuration(step.durationMs)
|
|
46
|
+
: firstLine(step.error);
|
|
47
|
+
return ` ${step.sentence.padEnd(width)} ${glyph} ${detail}`;
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* The one line a summary gets. A browser timeout's message carries its whole
|
|
52
|
+
* call log; the failure block below prints all of it, so the row above only
|
|
53
|
+
* needs enough to recognise it by.
|
|
54
|
+
*/
|
|
55
|
+
const firstLine = (message) => (message ?? '').split('\n')[0] ?? '';
|
|
56
|
+
/**
|
|
57
|
+
* The indented block printed under a failed scenario's ladder.
|
|
58
|
+
*
|
|
59
|
+
* One line of `run.error.message` is what a browser step's "Timed out waiting
|
|
60
|
+
* for selector" looks like with all of its context removed. The page's own
|
|
61
|
+
* console errors and failed API calls almost always say why the selector never
|
|
62
|
+
* appeared, and the driver has been collecting them all along.
|
|
63
|
+
*/
|
|
64
|
+
export const formatScenarioFailure = (failure, { trace = false, projectRoot } = {}) => {
|
|
65
|
+
const lines = [];
|
|
66
|
+
if (failure.sentence) {
|
|
67
|
+
lines.push(` ✗ failed at: ${failure.sentence}`);
|
|
68
|
+
}
|
|
69
|
+
for (const line of failure.message.split('\n')) {
|
|
70
|
+
lines.push(` ${line}`);
|
|
71
|
+
}
|
|
72
|
+
for (const browser of failure.browser ?? []) {
|
|
73
|
+
lines.push(` browser (${browser.actor})${browser.url ? `: ${browser.url}` : ''}`);
|
|
74
|
+
const detail = (label, values) => {
|
|
75
|
+
for (const value of values) {
|
|
76
|
+
lines.push(` ${`${label}:`.padEnd(11)} ${value}`);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
detail('console', browser.consoleErrors);
|
|
80
|
+
detail('page', browser.pageErrors);
|
|
81
|
+
detail('request', browser.failedRequests);
|
|
82
|
+
detail('api', browser.apiErrors);
|
|
83
|
+
if (browser.screenshot) {
|
|
84
|
+
lines.push(` screenshot: ${browser.screenshot}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// An expected failure is a deliberate one — where it was thrown from adds
|
|
88
|
+
// nothing the message and the ladder do not already say.
|
|
89
|
+
if (failure.stack && !failure.expected) {
|
|
90
|
+
for (const frame of stackFrames(failure.stack, trace, projectRoot)) {
|
|
91
|
+
lines.push(` ${frame}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return lines;
|
|
95
|
+
};
|
|
96
|
+
/**
|
|
97
|
+
* The project's own frames — the ones a reader can act on. Framework and node
|
|
98
|
+
* internals are dropped unless `trace`, or unless dropping them would leave
|
|
99
|
+
* nothing at all: some stack always beats no stack.
|
|
100
|
+
*/
|
|
101
|
+
const stackFrames = (stack, trace, projectRoot) => {
|
|
102
|
+
const frames = stack
|
|
103
|
+
.split('\n')
|
|
104
|
+
.map((line) => line.trim())
|
|
105
|
+
.filter((line) => line.startsWith('at '));
|
|
106
|
+
if (trace || !projectRoot) {
|
|
107
|
+
return frames;
|
|
108
|
+
}
|
|
109
|
+
const own = frames.filter((frame) => frame.includes(projectRoot) && !frame.includes('node_modules'));
|
|
110
|
+
return own.length > 0 ? own : frames;
|
|
111
|
+
};
|
|
112
|
+
const formatDuration = (durationMs) => {
|
|
113
|
+
if (durationMs === undefined) {
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
return durationMs < 1000
|
|
117
|
+
? `${durationMs}ms`
|
|
118
|
+
: `${(durationMs / 1000).toFixed(1)}s`;
|
|
119
|
+
};
|
|
120
|
+
export { KEYWORD_WIDTH };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ScenarioStepPhase } from '@pikku/core/workflow';
|
|
2
|
+
import type { WorkflowStepMeta } from '@pikku/core/workflow/types';
|
|
3
|
+
import type { FunctionsMeta } from '@pikku/core';
|
|
4
|
+
import type { ScenarioFailureDetail, ScenarioStepRow } from './scenario-formatter.js';
|
|
5
|
+
export interface ScenarioStepProse {
|
|
6
|
+
phase: ScenarioStepPhase;
|
|
7
|
+
description: string;
|
|
8
|
+
template?: string;
|
|
9
|
+
actor?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ScenarioStepOutcome {
|
|
12
|
+
stepName: string;
|
|
13
|
+
status: string;
|
|
14
|
+
durationMs?: number;
|
|
15
|
+
error?: string;
|
|
16
|
+
/** The failing error's stack, as recorded on the run. */
|
|
17
|
+
stack?: string;
|
|
18
|
+
/**
|
|
19
|
+
* True when the failure was a deliberate one (a PikkuError). Its message is
|
|
20
|
+
* the whole story, so the stack is noise.
|
|
21
|
+
*/
|
|
22
|
+
expected?: boolean;
|
|
23
|
+
/** The input this step was called with, as recorded on the run. */
|
|
24
|
+
input?: unknown;
|
|
25
|
+
/** The step function that ran it, as recorded on the run. */
|
|
26
|
+
stepFunc?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* One scenario's prose, indexed the two ways a run step can be joined back to
|
|
30
|
+
* its declaration.
|
|
31
|
+
*/
|
|
32
|
+
export interface ScenarioProse {
|
|
33
|
+
byStepName: Map<string, ScenarioStepProse>;
|
|
34
|
+
/**
|
|
35
|
+
* The same prose keyed by step function, for steps whose durable name the
|
|
36
|
+
* static meta cannot predict: a step called in a loop is declared as
|
|
37
|
+
* `sees ${packageName}` and reaches the run as `sees @pikku/addon-todos`.
|
|
38
|
+
*
|
|
39
|
+
* A function called from several sites that do not agree on their prose is
|
|
40
|
+
* left out rather than guessed at — such a step falls back to its bare name,
|
|
41
|
+
* which is what it did before this index existed.
|
|
42
|
+
*/
|
|
43
|
+
byStepFunc: Map<string, ScenarioStepProse>;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Build `durable step name → prose parts` for one scenario.
|
|
47
|
+
*
|
|
48
|
+
* Description precedence matches the runtime: the call site's `description`
|
|
49
|
+
* wins, then the step's own declared `description`, then the positional step
|
|
50
|
+
* name. A step's `template` is carried alongside and, when it exists, is what
|
|
51
|
+
* actually gets rendered — filled from the input recorded on the run.
|
|
52
|
+
*/
|
|
53
|
+
export declare const collectScenarioStepProse: (workflowMeta: {
|
|
54
|
+
steps?: WorkflowStepMeta[];
|
|
55
|
+
} | undefined, functionsMeta: FunctionsMeta) => ScenarioProse;
|
|
56
|
+
/** The step functions in this scenario that declare `browser: true`. */
|
|
57
|
+
export declare const scenarioBrowserSteps: (workflowMeta: {
|
|
58
|
+
steps?: WorkflowStepMeta[];
|
|
59
|
+
} | undefined, functionsMeta: FunctionsMeta) => string[];
|
|
60
|
+
/** The run's steps as renderable rows, in the order they were recorded. */
|
|
61
|
+
export declare const scenarioStepRows: (steps: ScenarioStepOutcome[], prose: ScenarioProse) => ScenarioStepRow[];
|
|
62
|
+
/**
|
|
63
|
+
* Find the step a run died on and join it back to its declared prose, so a
|
|
64
|
+
* failure names the sentence a reader recognises rather than a durable key.
|
|
65
|
+
*/
|
|
66
|
+
export declare const scenarioFailureFromSteps: (steps: ScenarioStepOutcome[], prose: ScenarioProse) => ScenarioFailureDetail | undefined;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scenario step ladder — joining a recorded run back to the English that
|
|
3
|
+
* declared it.
|
|
4
|
+
*
|
|
5
|
+
* Cucumber parsed English into calls; this recovers English out of the typed
|
|
6
|
+
* calls the inspector already recorded, joined against the run the engine
|
|
7
|
+
* already persisted. No engine change, no step-event bus. Layout of the result
|
|
8
|
+
* belongs to `scenario-formatter.ts`; this module only decides what each step
|
|
9
|
+
* is called.
|
|
10
|
+
*/
|
|
11
|
+
import { composeStepProse } from '@pikku/core/workflow';
|
|
12
|
+
import { KEYWORD_WIDTH } from './scenario-formatter.js';
|
|
13
|
+
/**
|
|
14
|
+
* Walk a workflow's steps — including the ones nested inside branches, fanouts
|
|
15
|
+
* and parallel groups — and yield every scenario step it declares.
|
|
16
|
+
*/
|
|
17
|
+
function* walkScenarioSteps(steps) {
|
|
18
|
+
if (!Array.isArray(steps)) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
for (const step of steps) {
|
|
22
|
+
const node = step;
|
|
23
|
+
if (node?.type === 'scenarioStep') {
|
|
24
|
+
yield node;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
yield* walkScenarioSteps(node?.children);
|
|
28
|
+
yield* walkScenarioSteps(node?.body);
|
|
29
|
+
yield* walkScenarioSteps(node?.steps);
|
|
30
|
+
yield* walkScenarioSteps(node?.then);
|
|
31
|
+
yield* walkScenarioSteps(node?.else);
|
|
32
|
+
for (const branch of Object.values(node?.cases ?? {})) {
|
|
33
|
+
yield* walkScenarioSteps(branch);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Build `durable step name → prose parts` for one scenario.
|
|
39
|
+
*
|
|
40
|
+
* Description precedence matches the runtime: the call site's `description`
|
|
41
|
+
* wins, then the step's own declared `description`, then the positional step
|
|
42
|
+
* name. A step's `template` is carried alongside and, when it exists, is what
|
|
43
|
+
* actually gets rendered — filled from the input recorded on the run.
|
|
44
|
+
*/
|
|
45
|
+
export const collectScenarioStepProse = (workflowMeta, functionsMeta) => {
|
|
46
|
+
const byStepName = new Map();
|
|
47
|
+
const byStepFunc = new Map();
|
|
48
|
+
const ambiguous = new Set();
|
|
49
|
+
for (const step of walkScenarioSteps(workflowMeta?.steps)) {
|
|
50
|
+
const stepMeta = functionsMeta[step.stepFunc];
|
|
51
|
+
const parts = {
|
|
52
|
+
phase: step.phase ?? 'step',
|
|
53
|
+
description: step.options?.description ?? stepMeta?.description ?? step.stepName,
|
|
54
|
+
// A call-site description is an explicit override, so it wins over the
|
|
55
|
+
// step's template the same way it wins over its description.
|
|
56
|
+
template: step.options?.description
|
|
57
|
+
? undefined
|
|
58
|
+
: stepMeta?.scenarioStepTemplate,
|
|
59
|
+
actor: step.actor,
|
|
60
|
+
};
|
|
61
|
+
byStepName.set(step.stepName, parts);
|
|
62
|
+
const declared = byStepFunc.get(step.stepFunc);
|
|
63
|
+
if (declared && !sameProse(declared, parts)) {
|
|
64
|
+
ambiguous.add(step.stepFunc);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
byStepFunc.set(step.stepFunc, parts);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
for (const stepFunc of ambiguous) {
|
|
71
|
+
byStepFunc.delete(stepFunc);
|
|
72
|
+
}
|
|
73
|
+
return { byStepName, byStepFunc };
|
|
74
|
+
};
|
|
75
|
+
const sameProse = (a, b) => a.phase === b.phase &&
|
|
76
|
+
a.description === b.description &&
|
|
77
|
+
a.template === b.template &&
|
|
78
|
+
a.actor === b.actor;
|
|
79
|
+
/** The step functions in this scenario that declare `browser: true`. */
|
|
80
|
+
export const scenarioBrowserSteps = (workflowMeta, functionsMeta) => {
|
|
81
|
+
const names = new Set();
|
|
82
|
+
for (const step of walkScenarioSteps(workflowMeta?.steps)) {
|
|
83
|
+
if (functionsMeta[step.stepFunc]?.scenarioStepBrowser === true) {
|
|
84
|
+
names.add(step.stepFunc);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return [...names];
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Render one step's gherkin sentence.
|
|
91
|
+
*
|
|
92
|
+
* A repeated step is stored as `name#1`; its prose lives under the bare name.
|
|
93
|
+
* Only once neither name matches does the step function decide it, so a step
|
|
94
|
+
* recorded under the name it was declared with is never re-resolved by a
|
|
95
|
+
* function shared with another call site.
|
|
96
|
+
*/
|
|
97
|
+
const stepSentence = (step, prose) => {
|
|
98
|
+
const parts = prose.byStepName.get(step.stepName) ??
|
|
99
|
+
prose.byStepName.get(baseName(step.stepName)) ??
|
|
100
|
+
(step.stepFunc ? prose.byStepFunc.get(step.stepFunc) : undefined);
|
|
101
|
+
return parts
|
|
102
|
+
? composeStepProse({
|
|
103
|
+
...parts,
|
|
104
|
+
input: step.input,
|
|
105
|
+
keywordWidth: KEYWORD_WIDTH,
|
|
106
|
+
})
|
|
107
|
+
: `${''.padEnd(KEYWORD_WIDTH)} ${step.stepName}`;
|
|
108
|
+
};
|
|
109
|
+
/** The run's steps as renderable rows, in the order they were recorded. */
|
|
110
|
+
export const scenarioStepRows = (steps, prose) => steps.map((step) => ({
|
|
111
|
+
sentence: stepSentence(step, prose),
|
|
112
|
+
status: step.status,
|
|
113
|
+
durationMs: step.durationMs,
|
|
114
|
+
error: step.error,
|
|
115
|
+
}));
|
|
116
|
+
const baseName = (stepName) => stepName.replace(/#\d+$/, '');
|
|
117
|
+
/**
|
|
118
|
+
* Find the step a run died on and join it back to its declared prose, so a
|
|
119
|
+
* failure names the sentence a reader recognises rather than a durable key.
|
|
120
|
+
*/
|
|
121
|
+
export const scenarioFailureFromSteps = (steps, prose) => {
|
|
122
|
+
const failed = steps.find((step) => step.status !== 'succeeded');
|
|
123
|
+
if (!failed) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
sentence: stepSentence(failed, prose).trimEnd(),
|
|
128
|
+
message: failed.error ?? `step status: ${failed.status}`,
|
|
129
|
+
stack: failed.stack,
|
|
130
|
+
expected: failed.expected,
|
|
131
|
+
};
|
|
132
|
+
};
|