@solidxai/core 0.1.15 → 0.1.16-beta.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/CHANGELOG.md +642 -0
- package/dist/commands/run-tests.command.d.ts +3 -1
- package/dist/commands/run-tests.command.d.ts.map +1 -1
- package/dist/commands/run-tests.command.js +6 -2
- package/dist/commands/run-tests.command.js.map +1 -1
- package/dist/controllers/test-run.controller.d.ts +12 -0
- package/dist/controllers/test-run.controller.d.ts.map +1 -0
- package/dist/controllers/test-run.controller.js +54 -0
- package/dist/controllers/test-run.controller.js.map +1 -0
- package/dist/dtos/test-run-request.dto.d.ts +35 -0
- package/dist/dtos/test-run-request.dto.d.ts.map +1 -0
- package/dist/dtos/test-run-request.dto.js +120 -0
- package/dist/dtos/test-run-request.dto.js.map +1 -0
- package/dist/guards/access-token.guard.d.ts +5 -1
- package/dist/guards/access-token.guard.d.ts.map +1 -1
- package/dist/guards/access-token.guard.js +20 -1
- package/dist/guards/access-token.guard.js.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/active-user-data.interface.d.ts +1 -0
- package/dist/interfaces/active-user-data.interface.d.ts.map +1 -1
- package/dist/interfaces/active-user-data.interface.js.map +1 -1
- package/dist/jobs/database/test-run-queue-options-database.d.ts +8 -0
- package/dist/jobs/database/test-run-queue-options-database.d.ts.map +1 -0
- package/dist/jobs/database/test-run-queue-options-database.js +10 -0
- package/dist/jobs/database/test-run-queue-options-database.js.map +1 -0
- package/dist/jobs/database/test-run-queue-publisher-database.service.d.ts +12 -0
- package/dist/jobs/database/test-run-queue-publisher-database.service.d.ts.map +1 -0
- package/dist/jobs/database/test-run-queue-publisher-database.service.js +39 -0
- package/dist/jobs/database/test-run-queue-publisher-database.service.js.map +1 -0
- package/dist/jobs/database/test-run-queue-subscriber-database.service.d.ts +26 -0
- package/dist/jobs/database/test-run-queue-subscriber-database.service.d.ts.map +1 -0
- package/dist/jobs/database/test-run-queue-subscriber-database.service.js +120 -0
- package/dist/jobs/database/test-run-queue-subscriber-database.service.js.map +1 -0
- package/dist/seeders/seed-data/solid-core-metadata.json +3 -1
- package/dist/services/access-token-denylist.service.d.ts +16 -0
- package/dist/services/access-token-denylist.service.d.ts.map +1 -0
- package/dist/services/access-token-denylist.service.js +74 -0
- package/dist/services/access-token-denylist.service.js.map +1 -0
- package/dist/services/authentication.service.d.ts +9 -1
- package/dist/services/authentication.service.d.ts.map +1 -1
- package/dist/services/authentication.service.js +39 -4
- package/dist/services/authentication.service.js.map +1 -1
- package/dist/services/import-transaction.service.d.ts +1 -1
- package/dist/services/import-transaction.service.d.ts.map +1 -1
- package/dist/services/import-transaction.service.js +13 -23
- package/dist/services/import-transaction.service.js.map +1 -1
- package/dist/services/settings/default-settings-provider.service.d.ts +20 -0
- package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
- package/dist/services/settings/default-settings-provider.service.js +11 -0
- package/dist/services/settings/default-settings-provider.service.js.map +1 -1
- package/dist/services/workflow-secret.service.d.ts +3 -0
- package/dist/services/workflow-secret.service.d.ts.map +1 -1
- package/dist/services/workflow-secret.service.js +25 -0
- package/dist/services/workflow-secret.service.js.map +1 -1
- package/dist/solid-core.module.d.ts.map +1 -1
- package/dist/solid-core.module.js +9 -0
- package/dist/solid-core.module.js.map +1 -1
- package/dist/testing/adapters/ui/browser-provisioner.d.ts +5 -0
- package/dist/testing/adapters/ui/browser-provisioner.d.ts.map +1 -0
- package/dist/testing/adapters/ui/browser-provisioner.js +102 -0
- package/dist/testing/adapters/ui/browser-provisioner.js.map +1 -0
- package/dist/testing/adapters/ui/playwright-adapter.d.ts +40 -1
- package/dist/testing/adapters/ui/playwright-adapter.d.ts.map +1 -1
- package/dist/testing/adapters/ui/playwright-adapter.js +185 -3
- package/dist/testing/adapters/ui/playwright-adapter.js.map +1 -1
- package/dist/testing/adapters/ui/ui.types.d.ts +6 -0
- package/dist/testing/adapters/ui/ui.types.d.ts.map +1 -1
- package/dist/testing/adapters/ui/ui.types.js.map +1 -1
- package/dist/testing/contracts/runtime-context.types.d.ts +2 -0
- package/dist/testing/contracts/runtime-context.types.d.ts.map +1 -1
- package/dist/testing/contracts/runtime-context.types.js.map +1 -1
- package/dist/testing/core/interpolation.d.ts +1 -0
- package/dist/testing/core/interpolation.d.ts.map +1 -1
- package/dist/testing/core/interpolation.js +28 -68
- package/dist/testing/core/interpolation.js.map +1 -1
- package/dist/testing/core/path-resolution.spec.d.ts +2 -0
- package/dist/testing/core/path-resolution.spec.d.ts.map +1 -0
- package/dist/testing/core/path-resolution.spec.js +106 -0
- package/dist/testing/core/path-resolution.spec.js.map +1 -0
- package/dist/testing/core/path-segments.d.ts +2 -0
- package/dist/testing/core/path-segments.d.ts.map +1 -0
- package/dist/testing/core/path-segments.js +70 -0
- package/dist/testing/core/path-segments.js.map +1 -0
- package/dist/testing/core/resource-store.d.ts.map +1 -1
- package/dist/testing/core/resource-store.js +5 -2
- package/dist/testing/core/resource-store.js.map +1 -1
- package/dist/testing/core/testing-engine.d.ts.map +1 -1
- package/dist/testing/core/testing-engine.js +16 -0
- package/dist/testing/core/testing-engine.js.map +1 -1
- package/dist/testing/reporter/artifact-sink.d.ts +14 -0
- package/dist/testing/reporter/artifact-sink.d.ts.map +1 -0
- package/dist/testing/reporter/artifact-sink.js +3 -0
- package/dist/testing/reporter/artifact-sink.js.map +1 -0
- package/dist/testing/reporter/console-reporter.d.ts.map +1 -1
- package/dist/testing/reporter/console-reporter.js +7 -0
- package/dist/testing/reporter/console-reporter.js.map +1 -1
- package/dist/testing/reporter/file-service-artifact-sink.d.ts +22 -0
- package/dist/testing/reporter/file-service-artifact-sink.d.ts.map +1 -0
- package/dist/testing/reporter/file-service-artifact-sink.js +31 -0
- package/dist/testing/reporter/file-service-artifact-sink.js.map +1 -0
- package/dist/testing/reporter/lifecycle-events.types.d.ts +67 -0
- package/dist/testing/reporter/lifecycle-events.types.d.ts.map +1 -0
- package/dist/testing/reporter/lifecycle-events.types.js +3 -0
- package/dist/testing/reporter/lifecycle-events.types.js.map +1 -0
- package/dist/testing/reporter/lifecycle-webhook-reporter.d.ts +77 -0
- package/dist/testing/reporter/lifecycle-webhook-reporter.d.ts.map +1 -0
- package/dist/testing/reporter/lifecycle-webhook-reporter.js +275 -0
- package/dist/testing/reporter/lifecycle-webhook-reporter.js.map +1 -0
- package/dist/testing/reporter/progress-reporter.d.ts +3 -3
- package/dist/testing/reporter/progress-reporter.d.ts.map +1 -1
- package/dist/testing/reporter/progress-reporter.js.map +1 -1
- package/dist/testing/reporter/reporter.types.d.ts +13 -1
- package/dist/testing/reporter/reporter.types.d.ts.map +1 -1
- package/dist/testing/reporter/reporter.types.js.map +1 -1
- package/dist/testing/runner/read-scenarios-file.d.ts +9 -0
- package/dist/testing/runner/read-scenarios-file.d.ts.map +1 -0
- package/dist/testing/runner/read-scenarios-file.js +56 -0
- package/dist/testing/runner/read-scenarios-file.js.map +1 -0
- package/dist/testing/runner/run-from-metadata.d.ts +7 -2
- package/dist/testing/runner/run-from-metadata.d.ts.map +1 -1
- package/dist/testing/runner/run-from-metadata.js +57 -6
- package/dist/testing/runner/run-from-metadata.js.map +1 -1
- package/dist/testing/steps/ui/navigation.step.d.ts.map +1 -1
- package/dist/testing/steps/ui/navigation.step.js +12 -12
- package/dist/testing/steps/ui/navigation.step.js.map +1 -1
- package/package.json +1 -1
- package/postman/instant-logout.postman_collection.json +1493 -0
- package/src/commands/run-tests.command.ts +3 -0
- package/src/controllers/test-run.controller.ts +44 -0
- package/src/dtos/test-run-request.dto.ts +130 -0
- package/src/guards/access-token.guard.ts +48 -0
- package/src/index.ts +21 -1
- package/src/interfaces/active-user-data.interface.ts +8 -0
- package/src/jobs/database/test-run-queue-options-database.ts +9 -0
- package/src/jobs/database/test-run-queue-publisher-database.service.ts +24 -0
- package/src/jobs/database/test-run-queue-subscriber-database.service.ts +122 -0
- package/src/seeders/seed-data/solid-core-metadata.json +3 -1
- package/src/services/access-token-denylist.service.ts +145 -0
- package/src/services/authentication.service.ts +118 -2
- package/src/services/import-transaction.service.ts +19 -36
- package/src/services/settings/default-settings-provider.service.ts +12 -0
- package/src/services/workflow-secret.service.ts +61 -2
- package/src/solid-core.module.ts +11 -0
- package/src/testing/README.md +44 -1
- package/src/testing/adapters/ui/browser-provisioner.ts +115 -0
- package/src/testing/adapters/ui/playwright-adapter.ts +255 -5
- package/src/testing/adapters/ui/ui.types.ts +14 -0
- package/src/testing/contracts/runtime-context.types.ts +6 -0
- package/src/testing/core/interpolation.ts +39 -69
- package/src/testing/core/path-resolution.spec.ts +133 -0
- package/src/testing/core/path-segments.ts +81 -0
- package/src/testing/core/resource-store.ts +8 -2
- package/src/testing/core/testing-engine.ts +22 -0
- package/src/testing/reporter/artifact-sink.ts +16 -0
- package/src/testing/reporter/console-reporter.ts +9 -0
- package/src/testing/reporter/file-service-artifact-sink.ts +46 -0
- package/src/testing/reporter/lifecycle-events.types.ts +103 -0
- package/src/testing/reporter/lifecycle-webhook-reporter.ts +385 -0
- package/src/testing/reporter/progress-reporter.ts +3 -1
- package/src/testing/reporter/reporter.types.ts +10 -1
- package/src/testing/runner/read-scenarios-file.ts +44 -0
- package/src/testing/runner/run-from-metadata.ts +102 -9
- package/src/testing/steps/ui/navigation.step.ts +24 -20
- package/dist/services/access-token-storage.service.d.ts +0 -1
- package/dist/services/access-token-storage.service.d.ts.map +0 -1
- package/dist/services/access-token-storage.service.js +0 -1
- package/dist/services/access-token-storage.service.js.map +0 -1
- package/src/services/access-token-storage.service.ts +0 -0
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import type { ResourceStore } from "../contracts/runtime-context.types";
|
|
2
|
+
import { parsePathSegments } from "./path-segments";
|
|
2
3
|
|
|
3
4
|
export class SimpleResourceStore implements ResourceStore {
|
|
4
5
|
private readonly data: Record<string, any> = {};
|
|
5
6
|
|
|
6
7
|
get(path: string): unknown {
|
|
7
8
|
if (!path) return undefined;
|
|
8
|
-
|
|
9
|
+
// Dot and bracket notation both resolve here, so `${res:r.bodyJson.result[0].id}` and
|
|
10
|
+
// `${res:r.bodyJson.result.0.id}` are equivalent — matching `${data:...}`, which has always
|
|
11
|
+
// accepted brackets. `has()` delegates to this, so it follows automatically.
|
|
12
|
+
const parts = parsePathSegments(path);
|
|
9
13
|
let current: any = this.data;
|
|
10
14
|
for (const part of parts) {
|
|
11
15
|
if (current == null || typeof current !== "object") return undefined;
|
|
@@ -16,7 +20,9 @@ export class SimpleResourceStore implements ResourceStore {
|
|
|
16
20
|
|
|
17
21
|
set(path: string, value: unknown): void {
|
|
18
22
|
if (!path) return;
|
|
19
|
-
|
|
23
|
+
// Parsed the same way as `get`, so any path that can be read back can also be written.
|
|
24
|
+
const parts = parsePathSegments(path);
|
|
25
|
+
if (!parts.length) return;
|
|
20
26
|
let current: any = this.data;
|
|
21
27
|
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
22
28
|
const part = parts[i];
|
|
@@ -39,6 +39,9 @@ export class TestingEngine {
|
|
|
39
39
|
let scenarioError: unknown;
|
|
40
40
|
|
|
41
41
|
reporter.onScenarioStart(scenario);
|
|
42
|
+
// Start a clean capture window for this scenario (UI adapter buffers console/network
|
|
43
|
+
// events; they're only flushed to the reporter if the scenario fails below).
|
|
44
|
+
ctx.ui?.resetCapture();
|
|
42
45
|
|
|
43
46
|
try {
|
|
44
47
|
const execute = async () => {
|
|
@@ -60,6 +63,25 @@ export class TestingEngine {
|
|
|
60
63
|
} catch (err: any) {
|
|
61
64
|
scenarioError = err;
|
|
62
65
|
} finally {
|
|
66
|
+
// On failure, flush the UI adapter's captured artifacts (console + network) BEFORE
|
|
67
|
+
// onScenarioEnd — the lifecycle reporter snapshots the attached artifacts inside
|
|
68
|
+
// onScenarioEnd. No page (e.g. API scenarios) → nothing captured.
|
|
69
|
+
if (scenarioError && ctx.ui?.page && reporter.attach) {
|
|
70
|
+
try {
|
|
71
|
+
const artifacts = await ctx.ui.collectFailureArtifacts();
|
|
72
|
+
for (const a of artifacts) {
|
|
73
|
+
reporter.attach({
|
|
74
|
+
scenarioId: ctx.scenarioId,
|
|
75
|
+
name: a.name,
|
|
76
|
+
contentType: a.contentType,
|
|
77
|
+
data: a.data,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Artifact capture is best-effort; never let it mask the scenario error.
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
63
85
|
const durationMs = Date.now() - scenarioStart;
|
|
64
86
|
reporter.onScenarioEnd(scenario, {
|
|
65
87
|
ok: !scenarioError,
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract for persisting binary test artifacts (screenshots, video, HAR) to shared
|
|
3
|
+
* storage and returning a reference. The concrete {@link FileServiceArtifactSink}
|
|
4
|
+
* (backed by the core FILE_SERVICE) is added in Phase 2 (A6). Kept as a standalone
|
|
5
|
+
* interface so {@link LifecycleWebhookReporter} can depend on it without pulling in
|
|
6
|
+
* Nest DI.
|
|
7
|
+
*/
|
|
8
|
+
export interface IArtifactSink {
|
|
9
|
+
put(args: {
|
|
10
|
+
runId: string;
|
|
11
|
+
scenarioId: string;
|
|
12
|
+
name: string;
|
|
13
|
+
contentType: string;
|
|
14
|
+
data: Buffer | NodeJS.ReadableStream;
|
|
15
|
+
}): Promise<{ storageKey: string; url: string; sizeBytes: number }>;
|
|
16
|
+
}
|
|
@@ -236,6 +236,15 @@ export class ConsoleReporter implements Reporter {
|
|
|
236
236
|
}): void {
|
|
237
237
|
const header = `ATTACH ${args.name} (${args.contentType})`;
|
|
238
238
|
console.log(indentLines(header, `${STEP_INDENT}${INDENT}`));
|
|
239
|
+
// Don't dump binary payloads (e.g. screenshot bytes) to stdout — just note the size.
|
|
240
|
+
const ct = args.contentType.toLowerCase();
|
|
241
|
+
const isBinary =
|
|
242
|
+
Buffer.isBuffer(args.data) &&
|
|
243
|
+
(ct.startsWith("image/") || ct.startsWith("video/") || ct === "application/octet-stream");
|
|
244
|
+
if (isBinary) {
|
|
245
|
+
console.log(indentLines(`<${(args.data as Buffer).byteLength} bytes>`, `${STEP_INDENT}${INDENT}${INDENT}`));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
239
248
|
const dataText = Buffer.isBuffer(args.data) ? args.data.toString("utf8") : String(args.data ?? "");
|
|
240
249
|
if (!dataText.length) return;
|
|
241
250
|
console.log(indentLines(dataText, `${STEP_INDENT}${INDENT}${INDENT}`));
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Readable } from 'stream';
|
|
2
|
+
import type { IFileService } from '../../services/file/file-service.interface';
|
|
3
|
+
import type { IArtifactSink } from './artifact-sink';
|
|
4
|
+
|
|
5
|
+
async function toBuffer(data: Buffer | NodeJS.ReadableStream): Promise<Buffer> {
|
|
6
|
+
if (Buffer.isBuffer(data)) return data;
|
|
7
|
+
const chunks: Buffer[] = [];
|
|
8
|
+
for await (const chunk of data as Readable) {
|
|
9
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
10
|
+
}
|
|
11
|
+
return Buffer.concat(chunks);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Concrete {@link IArtifactSink} backed by the core {@link IFileService}. Persists binary
|
|
16
|
+
* test artifacts (screenshots, video, HAR) to shared storage — disk by default, S3 when
|
|
17
|
+
* `DEFAULT_FILE_SERVICE=s3` — so webhook payloads stay small and the DB never holds blobs.
|
|
18
|
+
*
|
|
19
|
+
* Keys are laid out as `<prefix>/<runId>/<scenarioId>/<name>`. Callers that need a durable,
|
|
20
|
+
* provider-agnostic URL (e.g. a private S3 bucket) should serve by `storageKey` through their
|
|
21
|
+
* own proxy rather than relying on the raw `url` (which for S3 is the un-signed object URL).
|
|
22
|
+
*/
|
|
23
|
+
export class FileServiceArtifactSink implements IArtifactSink {
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly fileService: IFileService,
|
|
26
|
+
private readonly opts: { prefix?: string; region?: string } = {},
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
async put(args: {
|
|
30
|
+
runId: string;
|
|
31
|
+
scenarioId: string;
|
|
32
|
+
name: string;
|
|
33
|
+
contentType: string;
|
|
34
|
+
data: Buffer | NodeJS.ReadableStream;
|
|
35
|
+
}): Promise<{ storageKey: string; url: string; sizeBytes: number }> {
|
|
36
|
+
const prefix = this.opts.prefix ?? 'test-artifacts';
|
|
37
|
+
const safe = (s: string) => String(s || '').replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 120) || 'artifact';
|
|
38
|
+
const storageKey = `${prefix}/${safe(args.runId)}/${safe(args.scenarioId)}/${safe(args.name)}`;
|
|
39
|
+
const buffer = await toBuffer(args.data);
|
|
40
|
+
const url = await this.fileService.write(storageKey, buffer, {
|
|
41
|
+
contentType: args.contentType,
|
|
42
|
+
region: this.opts.region,
|
|
43
|
+
});
|
|
44
|
+
return { storageKey, url, sizeBytes: buffer.byteLength };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ScenarioType } from "../contracts/testing-metadata.types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Incremental lifecycle events emitted by {@link LifecycleWebhookReporter} so an
|
|
5
|
+
* external consumer (e.g. testingHub) can persist run progress live, instead of
|
|
6
|
+
* only receiving a single final payload (as the legacy WebhookReporter does).
|
|
7
|
+
*/
|
|
8
|
+
export type LifecycleEventType =
|
|
9
|
+
| "run.start"
|
|
10
|
+
| "scenario.start"
|
|
11
|
+
| "scenario.end"
|
|
12
|
+
| "run.end";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reference to a captured artifact (console/network/api log, screenshot, video, HAR).
|
|
16
|
+
* Text artifacts travel inline; binary artifacts are uploaded to shared storage and
|
|
17
|
+
* referenced by `storageKey`/`url` to keep webhook payloads small.
|
|
18
|
+
*/
|
|
19
|
+
export interface ArtifactRef {
|
|
20
|
+
kind: "console" | "network" | "screenshot" | "video" | "har" | "api-log";
|
|
21
|
+
name: string;
|
|
22
|
+
contentType: string;
|
|
23
|
+
transport: "inline" | "storage";
|
|
24
|
+
/** base64 (binary) or raw text/JSON (text); present when transport === 'inline'. */
|
|
25
|
+
inlineData?: string;
|
|
26
|
+
/** file-service key; present when transport === 'storage'. */
|
|
27
|
+
storageKey?: string;
|
|
28
|
+
/** resolved getUrl(); present when transport === 'storage'. */
|
|
29
|
+
url?: string;
|
|
30
|
+
sizeBytes?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StepResultData {
|
|
34
|
+
phase: string;
|
|
35
|
+
operation: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
ok: boolean;
|
|
38
|
+
durationMs: number;
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Minimal descriptor of a scenario in the run, so consumers can snapshot the full suite at run time. */
|
|
43
|
+
export interface ScenarioDescriptor {
|
|
44
|
+
id: string;
|
|
45
|
+
name?: string;
|
|
46
|
+
type: ScenarioType;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RunStartData {
|
|
50
|
+
total: number;
|
|
51
|
+
scenarioIds: string[];
|
|
52
|
+
scenarios: ScenarioDescriptor[];
|
|
53
|
+
startedAt: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ScenarioStartData {
|
|
57
|
+
scenarioId: string;
|
|
58
|
+
name?: string;
|
|
59
|
+
type: ScenarioType;
|
|
60
|
+
index: number;
|
|
61
|
+
total: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ScenarioEndData {
|
|
65
|
+
scenarioId: string;
|
|
66
|
+
name?: string;
|
|
67
|
+
ok: boolean;
|
|
68
|
+
durationMs: number;
|
|
69
|
+
error?: string;
|
|
70
|
+
steps: StepResultData[];
|
|
71
|
+
/** console/network/api-log/screenshot/video for THIS scenario. */
|
|
72
|
+
artifacts: ArtifactRef[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface RunEndData {
|
|
76
|
+
ok: boolean;
|
|
77
|
+
total: number;
|
|
78
|
+
passed: number;
|
|
79
|
+
failed: number;
|
|
80
|
+
durationMs: number;
|
|
81
|
+
exitCode?: number;
|
|
82
|
+
error?: string;
|
|
83
|
+
/** run-level artifacts (e.g. the whole-run video). */
|
|
84
|
+
artifacts?: ArtifactRef[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Envelope wrapping every lifecycle event. `externalRunId` correlates the event to
|
|
89
|
+
* the caller's own record (e.g. a testingHub TestRun id); `seq` lets an out-of-order
|
|
90
|
+
* receiver reorder/deduplicate.
|
|
91
|
+
*/
|
|
92
|
+
export interface LifecycleEnvelope<T = unknown> {
|
|
93
|
+
event: LifecycleEventType;
|
|
94
|
+
/** correlation id supplied by the caller (e.g. testingHub TestRun id). */
|
|
95
|
+
externalRunId?: string;
|
|
96
|
+
/** runner-generated uuid for this process run. */
|
|
97
|
+
runId: string;
|
|
98
|
+
runName: string;
|
|
99
|
+
/** 0-based, increments per emitted event. */
|
|
100
|
+
seq: number;
|
|
101
|
+
emittedAt: string;
|
|
102
|
+
data: T;
|
|
103
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
|
|
3
|
+
import { ConsoleReporter } from "./console-reporter";
|
|
4
|
+
import type { IArtifactSink } from "./artifact-sink";
|
|
5
|
+
import type {
|
|
6
|
+
ArtifactRef,
|
|
7
|
+
LifecycleEnvelope,
|
|
8
|
+
LifecycleEventType,
|
|
9
|
+
ScenarioDescriptor,
|
|
10
|
+
ScenarioEndData,
|
|
11
|
+
StepResultData,
|
|
12
|
+
} from "./lifecycle-events.types";
|
|
13
|
+
import type { OpStep, ScenarioSpec, ScenarioType } from "../contracts/testing-metadata.types";
|
|
14
|
+
import type { StepPhase } from "../contracts/runtime-context.types";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Pluggable POST function so the reporter stays DI-agnostic. The default uses the
|
|
18
|
+
* global `fetch`; the Nest worker can inject an axios-backed implementation that
|
|
19
|
+
* shares the existing HttpModule stack.
|
|
20
|
+
*/
|
|
21
|
+
export type WebhookPostFn = (
|
|
22
|
+
url: string,
|
|
23
|
+
body: unknown,
|
|
24
|
+
opts: { headers?: Record<string, string>; timeoutMs?: number },
|
|
25
|
+
) => Promise<{ ok: boolean; status: number }>;
|
|
26
|
+
|
|
27
|
+
export interface LifecycleWebhookReporterOptions {
|
|
28
|
+
webhookUrl: string;
|
|
29
|
+
runName: string;
|
|
30
|
+
/** correlation id supplied by the caller (e.g. testingHub TestRun id). */
|
|
31
|
+
externalRunId?: string;
|
|
32
|
+
/** runner-generated uuid for this process run; auto-generated when omitted. */
|
|
33
|
+
runId?: string;
|
|
34
|
+
/** Phase 2: sink for binary artifacts (screenshots/video/HAR). */
|
|
35
|
+
artifactSink?: IArtifactSink;
|
|
36
|
+
headers?: Record<string, string>;
|
|
37
|
+
post?: WebhookPostFn;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
41
|
+
/** Cap inline text artifacts so a webhook body never blows past receiver limits. */
|
|
42
|
+
const INLINE_TEXT_CAP = 1_000_000;
|
|
43
|
+
|
|
44
|
+
function formatError(err: unknown): string {
|
|
45
|
+
if (!err) return "";
|
|
46
|
+
if (err instanceof Error) return err.stack || err.message;
|
|
47
|
+
return String(err);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const defaultPost: WebhookPostFn = async (url, body, opts) => {
|
|
51
|
+
const res = await fetch(url, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: { "Content-Type": "application/json", ...(opts.headers ?? {}) },
|
|
54
|
+
body: JSON.stringify(body),
|
|
55
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
|
|
56
|
+
});
|
|
57
|
+
return { ok: res.ok, status: res.status };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Map an `attach()` payload to an ArtifactRef. Covers API/console/network + video/screenshots. */
|
|
61
|
+
function classifyAttachment(name: string, contentType: string): ArtifactRef["kind"] {
|
|
62
|
+
const hint = `${name} ${contentType}`.toLowerCase();
|
|
63
|
+
if (contentType.startsWith("video/") || hint.includes("video")) return "video";
|
|
64
|
+
if (hint.includes("screenshot") || contentType.startsWith("image/")) return "screenshot";
|
|
65
|
+
if (hint.includes("console")) return "console";
|
|
66
|
+
if (hint.includes("network") || hint.includes("har")) return "network";
|
|
67
|
+
if (hint.includes("api")) return "api-log";
|
|
68
|
+
return "api-log";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isBinaryContentType(contentType: string): boolean {
|
|
72
|
+
const ct = contentType.toLowerCase();
|
|
73
|
+
return ct.startsWith("image/") || ct.startsWith("video/") || ct === "application/octet-stream";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Emits incremental lifecycle webhooks (run.start → scenario.start → scenario.end →
|
|
78
|
+
* run.end) to a single URL, while still printing to the console (extends
|
|
79
|
+
* ConsoleReporter). Deliveries are serialized through an async chain so ordering is
|
|
80
|
+
* preserved without blocking step execution, and a failed POST never throws into the
|
|
81
|
+
* run. Distinct from the legacy {@link WebhookReporter}, which only fires once on flush.
|
|
82
|
+
*/
|
|
83
|
+
export class LifecycleWebhookReporter extends ConsoleReporter {
|
|
84
|
+
private readonly webhookUrl: string;
|
|
85
|
+
private readonly runName: string;
|
|
86
|
+
private readonly externalRunId?: string;
|
|
87
|
+
private readonly runId: string;
|
|
88
|
+
private readonly headers?: Record<string, string>;
|
|
89
|
+
private readonly post: WebhookPostFn;
|
|
90
|
+
/** Phase 2: used to upload binary artifacts in onScenarioEnd. */
|
|
91
|
+
protected readonly artifactSink?: IArtifactSink;
|
|
92
|
+
|
|
93
|
+
private seq = 0;
|
|
94
|
+
private startedAtMs = Date.now();
|
|
95
|
+
private total = 0;
|
|
96
|
+
private scenarioIndex = 0;
|
|
97
|
+
private passed = 0;
|
|
98
|
+
private failed = 0;
|
|
99
|
+
private currentSteps: StepResultData[] = [];
|
|
100
|
+
private currentArtifacts: ArtifactRef[] = [];
|
|
101
|
+
/** Run-level artifacts (e.g. the whole-run video); emitted on run.end. */
|
|
102
|
+
private runArtifacts: ArtifactRef[] = [];
|
|
103
|
+
/** Serializes webhook deliveries to preserve order. */
|
|
104
|
+
private chain: Promise<void> = Promise.resolve();
|
|
105
|
+
|
|
106
|
+
constructor(opts: LifecycleWebhookReporterOptions) {
|
|
107
|
+
super();
|
|
108
|
+
this.webhookUrl = opts.webhookUrl;
|
|
109
|
+
this.runName = opts.runName;
|
|
110
|
+
this.externalRunId = opts.externalRunId;
|
|
111
|
+
this.runId = opts.runId ?? randomUUID();
|
|
112
|
+
this.artifactSink = opts.artifactSink;
|
|
113
|
+
this.headers = opts.headers;
|
|
114
|
+
this.post = opts.post ?? defaultPost;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
getRunId(): string {
|
|
118
|
+
return this.runId;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
onRunStart(args: {
|
|
122
|
+
total: number;
|
|
123
|
+
startedAt: string;
|
|
124
|
+
scenarioIds: string[];
|
|
125
|
+
scenarios?: ScenarioDescriptor[];
|
|
126
|
+
}): void {
|
|
127
|
+
this.total = args.total;
|
|
128
|
+
this.startedAtMs = Date.now();
|
|
129
|
+
this.emit("run.start", {
|
|
130
|
+
total: args.total,
|
|
131
|
+
scenarioIds: args.scenarioIds,
|
|
132
|
+
scenarios: args.scenarios ?? args.scenarioIds.map((id) => ({ id, type: "api" as ScenarioType })),
|
|
133
|
+
startedAt: args.startedAt,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
override onScenarioStart(scenario: ScenarioSpec): void {
|
|
138
|
+
super.onScenarioStart(scenario);
|
|
139
|
+
this.currentSteps = [];
|
|
140
|
+
this.currentArtifacts = [];
|
|
141
|
+
const index = this.scenarioIndex++;
|
|
142
|
+
this.emit("scenario.start", {
|
|
143
|
+
scenarioId: scenario.id,
|
|
144
|
+
name: scenario.name,
|
|
145
|
+
type: scenario.type,
|
|
146
|
+
index,
|
|
147
|
+
total: this.total,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
override onStepEnd(args: {
|
|
152
|
+
scenarioId: string;
|
|
153
|
+
phase: StepPhase;
|
|
154
|
+
step: OpStep;
|
|
155
|
+
ok: boolean;
|
|
156
|
+
error?: unknown;
|
|
157
|
+
durationMs: number;
|
|
158
|
+
}): void {
|
|
159
|
+
super.onStepEnd(args);
|
|
160
|
+
this.currentSteps.push({
|
|
161
|
+
phase: String(args.phase),
|
|
162
|
+
operation: args.step.op,
|
|
163
|
+
name: args.step.name,
|
|
164
|
+
ok: args.ok,
|
|
165
|
+
durationMs: args.durationMs,
|
|
166
|
+
error: args.error ? formatError(args.error) : undefined,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
override attach(args: {
|
|
171
|
+
scenarioId: string;
|
|
172
|
+
name: string;
|
|
173
|
+
contentType: string;
|
|
174
|
+
data: Buffer | string;
|
|
175
|
+
}): void {
|
|
176
|
+
super.attach(args);
|
|
177
|
+
const kind = classifyAttachment(args.name, args.contentType);
|
|
178
|
+
const binary = Buffer.isBuffer(args.data) && isBinaryContentType(args.contentType);
|
|
179
|
+
|
|
180
|
+
// Binary artifacts (screenshots, video): when a storage sink is configured, upload the bytes
|
|
181
|
+
// to shared storage and reference them by storageKey/url — never ship blobs inline (the
|
|
182
|
+
// inline cap would silently drop anything large, e.g. a video). The ref is mutated in place
|
|
183
|
+
// by an upload queued onto the delivery chain, so it resolves before scenario.end is posted.
|
|
184
|
+
if (binary && this.artifactSink) {
|
|
185
|
+
const buf = args.data as Buffer;
|
|
186
|
+
const artifact: ArtifactRef = {
|
|
187
|
+
kind,
|
|
188
|
+
name: args.name,
|
|
189
|
+
contentType: args.contentType,
|
|
190
|
+
transport: "storage",
|
|
191
|
+
sizeBytes: buf.byteLength,
|
|
192
|
+
};
|
|
193
|
+
this.currentArtifacts.push(artifact);
|
|
194
|
+
const runId = this.externalRunId ?? this.runId;
|
|
195
|
+
const scenarioId = args.scenarioId;
|
|
196
|
+
this.chain = this.chain
|
|
197
|
+
.then(async () => {
|
|
198
|
+
const ref = await this.artifactSink!.put({
|
|
199
|
+
runId,
|
|
200
|
+
scenarioId,
|
|
201
|
+
name: args.name,
|
|
202
|
+
contentType: args.contentType,
|
|
203
|
+
data: buf,
|
|
204
|
+
});
|
|
205
|
+
artifact.storageKey = ref.storageKey;
|
|
206
|
+
artifact.url = ref.url;
|
|
207
|
+
})
|
|
208
|
+
.catch(() => {
|
|
209
|
+
/* upload failed — leave the ref without a key; the ingest side skips empty refs */
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (binary) {
|
|
215
|
+
// Binary (e.g. a screenshot JPEG): base64-encode — a utf8 decode would corrupt the
|
|
216
|
+
// bytes. Truncating base64 also corrupts it, so if it somehow exceeds the inline cap
|
|
217
|
+
// we drop the payload rather than ship a broken image (viewport JPEGs stay well under).
|
|
218
|
+
const bytes = args.data as Buffer;
|
|
219
|
+
const base64 = bytes.toString("base64");
|
|
220
|
+
const artifact: ArtifactRef = {
|
|
221
|
+
kind,
|
|
222
|
+
name: args.name,
|
|
223
|
+
contentType: args.contentType,
|
|
224
|
+
transport: "inline",
|
|
225
|
+
sizeBytes: bytes.byteLength,
|
|
226
|
+
};
|
|
227
|
+
if (base64.length <= INLINE_TEXT_CAP) {
|
|
228
|
+
artifact.inlineData = base64;
|
|
229
|
+
}
|
|
230
|
+
this.currentArtifacts.push(artifact);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const text = Buffer.isBuffer(args.data)
|
|
235
|
+
? args.data.toString("utf8")
|
|
236
|
+
: String(args.data ?? "");
|
|
237
|
+
this.currentArtifacts.push({
|
|
238
|
+
kind,
|
|
239
|
+
name: args.name,
|
|
240
|
+
contentType: args.contentType,
|
|
241
|
+
transport: "inline",
|
|
242
|
+
inlineData: text.length > INLINE_TEXT_CAP ? text.slice(0, INLINE_TEXT_CAP) : text,
|
|
243
|
+
sizeBytes: Buffer.byteLength(text),
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
override onScenarioEnd(
|
|
248
|
+
scenario: ScenarioSpec,
|
|
249
|
+
result: { ok: boolean; error?: unknown; durationMs: number },
|
|
250
|
+
): void {
|
|
251
|
+
super.onScenarioEnd(scenario, result);
|
|
252
|
+
if (result.ok) {
|
|
253
|
+
this.passed += 1;
|
|
254
|
+
} else {
|
|
255
|
+
this.failed += 1;
|
|
256
|
+
}
|
|
257
|
+
const data: ScenarioEndData = {
|
|
258
|
+
scenarioId: scenario.id,
|
|
259
|
+
name: scenario.name,
|
|
260
|
+
ok: result.ok,
|
|
261
|
+
durationMs: result.durationMs,
|
|
262
|
+
error: result.error ? formatError(result.error) : undefined,
|
|
263
|
+
steps: [...this.currentSteps],
|
|
264
|
+
artifacts: [...this.currentArtifacts],
|
|
265
|
+
};
|
|
266
|
+
this.emit("scenario.end", data);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Attach a run-level binary artifact (e.g. the whole-run video). Uploaded to shared storage
|
|
271
|
+
* via the sink (never inline for a video) and referenced by storageKey/url on run.end. Must
|
|
272
|
+
* be called before {@link flushPending} so the upload is queued ahead of the run.end delivery.
|
|
273
|
+
*/
|
|
274
|
+
attachRunArtifact(args: { name: string; contentType: string; data: Buffer | string }): void {
|
|
275
|
+
const kind = classifyAttachment(args.name, args.contentType);
|
|
276
|
+
const binary = Buffer.isBuffer(args.data) && isBinaryContentType(args.contentType);
|
|
277
|
+
|
|
278
|
+
if (binary && this.artifactSink) {
|
|
279
|
+
const buf = args.data as Buffer;
|
|
280
|
+
const artifact: ArtifactRef = {
|
|
281
|
+
kind,
|
|
282
|
+
name: args.name,
|
|
283
|
+
contentType: args.contentType,
|
|
284
|
+
transport: "storage",
|
|
285
|
+
sizeBytes: buf.byteLength,
|
|
286
|
+
};
|
|
287
|
+
this.runArtifacts.push(artifact);
|
|
288
|
+
const runId = this.externalRunId ?? this.runId;
|
|
289
|
+
this.chain = this.chain
|
|
290
|
+
.then(async () => {
|
|
291
|
+
const ref = await this.artifactSink!.put({
|
|
292
|
+
runId,
|
|
293
|
+
scenarioId: "run",
|
|
294
|
+
name: args.name,
|
|
295
|
+
contentType: args.contentType,
|
|
296
|
+
data: buf,
|
|
297
|
+
});
|
|
298
|
+
artifact.storageKey = ref.storageKey;
|
|
299
|
+
artifact.url = ref.url;
|
|
300
|
+
})
|
|
301
|
+
.catch(() => {
|
|
302
|
+
/* upload failed — leave the ref without a key; ingest skips empty refs */
|
|
303
|
+
});
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// No sink: fall back to inline (a large video base64 that exceeds the cap is dropped).
|
|
308
|
+
if (binary) {
|
|
309
|
+
const bytes = args.data as Buffer;
|
|
310
|
+
const base64 = bytes.toString("base64");
|
|
311
|
+
const artifact: ArtifactRef = {
|
|
312
|
+
kind,
|
|
313
|
+
name: args.name,
|
|
314
|
+
contentType: args.contentType,
|
|
315
|
+
transport: "inline",
|
|
316
|
+
sizeBytes: bytes.byteLength,
|
|
317
|
+
};
|
|
318
|
+
if (base64.length <= INLINE_TEXT_CAP) artifact.inlineData = base64;
|
|
319
|
+
this.runArtifacts.push(artifact);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const text = Buffer.isBuffer(args.data) ? args.data.toString("utf8") : String(args.data ?? "");
|
|
324
|
+
this.runArtifacts.push({
|
|
325
|
+
kind,
|
|
326
|
+
name: args.name,
|
|
327
|
+
contentType: args.contentType,
|
|
328
|
+
transport: "inline",
|
|
329
|
+
inlineData: text.length > INLINE_TEXT_CAP ? text.slice(0, INLINE_TEXT_CAP) : text,
|
|
330
|
+
sizeBytes: Buffer.byteLength(text),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Emit run.end and wait for all queued deliveries to complete. `error` names a
|
|
336
|
+
* run-level failure reason (e.g. an unreadable scenario file) so a run that failed
|
|
337
|
+
* before any scenario ran carries a clear message rather than a bare count.
|
|
338
|
+
*/
|
|
339
|
+
async flushPending(exitCode?: number, error?: string): Promise<void> {
|
|
340
|
+
this.emit("run.end", {
|
|
341
|
+
// A non-zero exitCode (e.g. the runner threw before any scenario ran) must
|
|
342
|
+
// mark the run failed even when failed === 0.
|
|
343
|
+
ok: this.failed === 0 && (exitCode === undefined || exitCode === 0),
|
|
344
|
+
total: this.total || this.passed + this.failed,
|
|
345
|
+
passed: this.passed,
|
|
346
|
+
failed: this.failed,
|
|
347
|
+
durationMs: Date.now() - this.startedAtMs,
|
|
348
|
+
exitCode,
|
|
349
|
+
error,
|
|
350
|
+
artifacts: this.runArtifacts.length ? [...this.runArtifacts] : undefined,
|
|
351
|
+
});
|
|
352
|
+
await this.chain;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private emit<T>(event: LifecycleEventType, data: T): void {
|
|
356
|
+
const envelope: LifecycleEnvelope<T> = {
|
|
357
|
+
event,
|
|
358
|
+
externalRunId: this.externalRunId,
|
|
359
|
+
runId: this.runId,
|
|
360
|
+
runName: this.runName,
|
|
361
|
+
seq: this.seq++,
|
|
362
|
+
emittedAt: new Date().toISOString(),
|
|
363
|
+
data,
|
|
364
|
+
};
|
|
365
|
+
this.chain = this.chain.then(() => this.deliver(envelope)).catch(() => {});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private async deliver(envelope: LifecycleEnvelope): Promise<void> {
|
|
369
|
+
try {
|
|
370
|
+
const res = await this.post(this.webhookUrl, envelope, {
|
|
371
|
+
headers: this.headers,
|
|
372
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
373
|
+
});
|
|
374
|
+
if (!res.ok) {
|
|
375
|
+
console.warn(
|
|
376
|
+
`[LifecycleWebhookReporter] ${envelope.event} → HTTP ${res.status}`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
} catch (err) {
|
|
380
|
+
console.warn(
|
|
381
|
+
`[LifecycleWebhookReporter] failed to deliver ${envelope.event}: ${err}`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -4,6 +4,8 @@ import type { OpStep, ScenarioSpec } from "../contracts/testing-metadata.types";
|
|
|
4
4
|
import type { SolidTestSpecResult } from "../contracts/test-spec.types";
|
|
5
5
|
import type { Reporter } from "./reporter.types";
|
|
6
6
|
|
|
7
|
+
type RunStartArgs = Parameters<NonNullable<Reporter["onRunStart"]>>[0];
|
|
8
|
+
|
|
7
9
|
const ANSI_BOLD = "\u001b[1m";
|
|
8
10
|
const ANSI_BOLD_OFF = "\u001b[22m";
|
|
9
11
|
const ANSI_DIM = "\u001b[2m";
|
|
@@ -24,7 +26,7 @@ export class ProgressReporter implements Reporter {
|
|
|
24
26
|
|
|
25
27
|
constructor(private readonly delegate: Reporter) {}
|
|
26
28
|
|
|
27
|
-
onRunStart(args:
|
|
29
|
+
onRunStart(args: RunStartArgs): void {
|
|
28
30
|
this.total = args.total;
|
|
29
31
|
this.startedAt = Date.now();
|
|
30
32
|
this.completed = 0;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { StepPhase } from "../contracts/runtime-context.types";
|
|
2
|
-
import type { OpStep, ScenarioSpec } from "../contracts/testing-metadata.types";
|
|
2
|
+
import type { OpStep, ScenarioSpec, ScenarioType } from "../contracts/testing-metadata.types";
|
|
3
3
|
import type { SolidTestSpecResult } from "../contracts/test-spec.types";
|
|
4
4
|
|
|
5
5
|
export interface Reporter {
|
|
6
6
|
onRunStart?(args: {
|
|
7
7
|
total: number;
|
|
8
|
+
startedAt: string;
|
|
9
|
+
scenarioIds: string[];
|
|
10
|
+
scenarios?: Array<{ id: string; name?: string; type: ScenarioType }>;
|
|
8
11
|
}): void;
|
|
9
12
|
onScenarioStart(scenario: ScenarioSpec): void;
|
|
10
13
|
onScenarioEnd(
|
|
@@ -36,6 +39,12 @@ export interface Reporter {
|
|
|
36
39
|
contentType: string;
|
|
37
40
|
data: Buffer | string;
|
|
38
41
|
}): void;
|
|
42
|
+
/** Attach a run-level artifact (e.g. the whole-run video), emitted on run.end. */
|
|
43
|
+
attachRunArtifact?(args: {
|
|
44
|
+
name: string;
|
|
45
|
+
contentType: string;
|
|
46
|
+
data: Buffer | string;
|
|
47
|
+
}): void;
|
|
39
48
|
onRunEnd?(args: {
|
|
40
49
|
ok: boolean;
|
|
41
50
|
total: number;
|