e2e 0.16.0 → 0.18.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 -0
- package/dist/.build.json +1 -1
- package/dist/agent/act.js +7 -4
- package/dist/agent/action-dispatcher.js +4 -5
- package/dist/agent/actions.d.ts +32 -23
- package/dist/agent/actions.js +38 -24
- package/dist/agent/ai-sdk.js +5 -4
- package/dist/agent/error.js +3 -3
- package/dist/agent/invocation.js +3 -4
- package/dist/agent/model/sdk.js +2 -0
- package/dist/agent/model/tool-choice.d.ts +9 -0
- package/dist/agent/model/tool-choice.js +14 -0
- package/dist/agent/observation-feed.d.ts +5 -6
- package/dist/agent/observation-feed.js +8 -11
- package/dist/agent/observation.d.ts +40 -11
- package/dist/agent/observation.js +109 -59
- package/dist/agent/phases.d.ts +7 -1
- package/dist/agent/phases.js +12 -3
- package/dist/agent/point-tap.d.ts +8 -8
- package/dist/agent/point-tap.js +1 -1
- package/dist/agent/prompts.js +3 -0
- package/dist/agent/replay.d.ts +19 -8
- package/dist/agent/replay.js +29 -29
- package/dist/agent/scroll-target.d.ts +2 -3
- package/dist/agent/step-cache.d.ts +43 -15
- package/dist/agent/step-cache.js +156 -67
- package/dist/agent/tool-loop.js +94 -5
- package/dist/cache/anchors.d.ts +72 -23
- package/dist/cache/anchors.js +233 -60
- package/dist/cache/context.d.ts +25 -14
- package/dist/cache/context.js +50 -8
- package/dist/cache/decide.d.ts +2 -2
- package/dist/cache/decide.js +4 -6
- package/dist/cache/identity.d.ts +18 -4
- package/dist/cache/identity.js +9 -4
- package/dist/cache/recorder.d.ts +15 -1
- package/dist/cache/recorder.js +36 -23
- package/dist/cache/rekeyed.d.ts +46 -0
- package/dist/cache/rekeyed.js +89 -0
- package/dist/cache/relocate.d.ts +14 -14
- package/dist/cache/relocate.js +33 -25
- package/dist/cache/route.d.ts +32 -33
- package/dist/cache/route.js +94 -50
- package/dist/cache/store.d.ts +6 -0
- package/dist/cache/store.js +21 -1
- package/dist/cache/trace.d.ts +53 -14
- package/dist/cache/trace.js +76 -30
- package/dist/cli/bin.js +2 -2
- package/dist/cli/index.js +43 -32
- package/dist/cli/init/engines.d.ts +1 -3
- package/dist/cli/init/engines.js +2 -2
- package/dist/cli/init/gateways.d.ts +1 -1
- package/dist/cli/init/gateways.js +11 -1
- package/dist/cli/init/mcp-config.d.ts +2 -1
- package/dist/cli/init/mcp-config.js +8 -3
- package/dist/cli/init/sibling-versions.json +1 -1
- package/dist/cli/init/versions.d.ts +0 -8
- package/dist/cli/init/versions.js +1 -10
- package/dist/cli/mcp.d.ts +3 -2
- package/dist/cli/mcp.js +52 -16
- package/dist/cli/run-output.d.ts +17 -0
- package/dist/cli/run-output.js +45 -0
- package/dist/collect/collect.js +8 -4
- package/dist/collect/registry.js +27 -16
- package/dist/config/compiled-files.d.ts +36 -0
- package/dist/config/compiled-files.js +50 -0
- package/dist/config/diagnose.js +21 -19
- package/dist/config/esm-hooks.d.ts +39 -44
- package/dist/config/esm-hooks.js +238 -63
- package/dist/config/load.d.ts +1 -7
- package/dist/config/load.js +23 -66
- package/dist/config/register.d.ts +8 -0
- package/dist/config/register.js +9 -0
- package/dist/config/resolve.d.ts +1 -0
- package/dist/config/resolve.js +33 -1
- package/dist/config/specifiers.d.ts +19 -0
- package/dist/config/specifiers.js +33 -0
- package/dist/config/tsconfig.d.ts +56 -0
- package/dist/config/tsconfig.js +115 -0
- package/dist/config/typescript.d.ts +19 -0
- package/dist/config/typescript.js +253 -0
- package/dist/engine/contract.d.ts +1 -0
- package/dist/engine/index.d.ts +4 -2
- package/dist/engine/session.js +2 -2
- package/dist/expect/async.js +4 -2
- package/dist/expect/poll-scope.d.ts +52 -0
- package/dist/expect/poll-scope.js +113 -0
- package/dist/expect/poll.js +33 -6
- package/dist/expect/values.js +11 -4
- package/dist/internal/abandoned.d.ts +11 -0
- package/dist/internal/abandoned.js +23 -0
- package/dist/internal/brands.d.ts +2 -0
- package/dist/internal/brands.js +2 -0
- package/dist/internal/client-identity.d.ts +9 -0
- package/dist/internal/client-identity.js +14 -0
- package/dist/internal/errors.d.ts +10 -3
- package/dist/internal/errors.js +23 -8
- package/dist/internal/node-version.d.ts +16 -7
- package/dist/internal/node-version.js +50 -15
- package/dist/internal/package-manager.js +1 -1
- package/dist/internal/realm-slot.d.ts +5 -4
- package/dist/internal/redact.d.ts +9 -4
- package/dist/internal/redact.js +120 -23
- package/dist/internal/time.d.ts +26 -4
- package/dist/internal/time.js +58 -14
- package/dist/internal/urls.d.ts +9 -5
- package/dist/internal/urls.js +19 -10
- package/dist/locator/engine.d.ts +12 -2
- package/dist/locator/engine.js +55 -14
- package/dist/locator/screen.js +59 -9
- package/dist/mcp/catalog.d.ts +3 -2
- package/dist/mcp/catalog.js +6 -6
- package/dist/mcp/output.d.ts +59 -0
- package/dist/mcp/output.js +129 -0
- package/dist/mcp/server.d.ts +4 -0
- package/dist/mcp/server.js +9 -12
- package/dist/mcp/session.d.ts +8 -1
- package/dist/mcp/session.js +56 -15
- package/dist/mcp/tools.js +7 -5
- package/dist/oauth/chatgpt.js +3 -23
- package/dist/oauth/cli.js +1 -0
- package/dist/oauth/copilot.d.ts +5 -4
- package/dist/oauth/copilot.js +80 -7
- package/dist/oauth/grok.js +2 -1
- package/dist/oauth/models.js +2 -1
- package/dist/oauth/opencode-console.d.ts +10 -0
- package/dist/oauth/opencode-console.js +133 -0
- package/dist/oauth/providers/github-copilot.d.ts +12 -0
- package/dist/oauth/providers/github-copilot.js +77 -23
- package/dist/oauth/providers/opencode-console.d.ts +31 -0
- package/dist/oauth/providers/opencode-console.js +148 -0
- package/dist/oauth/providers.d.ts +2 -6
- package/dist/oauth/providers.js +3 -7
- package/dist/oauth/responses.d.ts +9 -0
- package/dist/oauth/responses.js +23 -0
- package/dist/report/build.d.ts +26 -0
- package/dist/report/build.js +16 -8
- package/dist/report/failure-text.d.ts +6 -1
- package/dist/report/failure-text.js +14 -4
- package/dist/report/format.d.ts +10 -5
- package/dist/report/format.js +12 -5
- package/dist/report/junit.js +11 -12
- package/dist/report/list.d.ts +12 -0
- package/dist/report/list.js +61 -24
- package/dist/report/markdown.d.ts +1 -1
- package/dist/report/markdown.js +37 -14
- package/dist/report/repeats.d.ts +6 -1
- package/dist/report/repeats.js +12 -3
- package/dist/run/artifacts.d.ts +22 -4
- package/dist/run/artifacts.js +71 -3
- package/dist/run/execute.d.ts +34 -5
- package/dist/run/execute.js +93 -44
- package/dist/run/extended-fixtures.js +6 -2
- package/dist/run/failure-evidence.js +10 -4
- package/dist/run/fixtures.js +6 -8
- package/dist/run/in-process.d.ts +2 -0
- package/dist/run/in-process.js +52 -10
- package/dist/run/last-run.d.ts +42 -8
- package/dist/run/last-run.js +121 -14
- package/dist/run/process-output.d.ts +73 -0
- package/dist/run/process-output.js +130 -0
- package/dist/run/provision.d.ts +2 -0
- package/dist/run/provision.js +1 -0
- package/dist/run/realm.d.ts +4 -1
- package/dist/run/realm.js +20 -6
- package/dist/run/records.d.ts +20 -4
- package/dist/run/records.js +26 -0
- package/dist/run/retry.d.ts +10 -1
- package/dist/run/retry.js +30 -12
- package/dist/run/runner.d.ts +9 -1
- package/dist/run/runner.js +101 -39
- package/dist/run/scheduler.js +255 -19
- package/dist/run/secrecy.d.ts +22 -19
- package/dist/run/secrecy.js +22 -18
- package/dist/run/serial.d.ts +75 -2
- package/dist/run/serial.js +243 -59
- package/dist/run/standalone.js +1 -1
- package/dist/run/steps.d.ts +0 -6
- package/dist/run/steps.js +3 -20
- package/dist/run/trace-redaction.d.ts +5 -6
- package/dist/run/trace-redaction.js +5 -6
- package/dist/run/worker/entry.js +40 -18
- package/dist/run/worker/handle.js +10 -5
- package/dist/run/worker/protocol.d.ts +85 -3
- package/dist/run/worker/session.d.ts +2 -0
- package/dist/run/worker/session.js +29 -0
- package/dist/telemetry/environment.d.ts +2 -1
- package/dist/telemetry/environment.js +12 -2
- package/dist/telemetry/events.js +1 -0
- package/dist/telemetry/project.d.ts +5 -6
- package/dist/telemetry/project.js +6 -11
- package/dist/telemetry/store.d.ts +5 -5
- package/dist/telemetry/store.js +5 -7
- package/dist/telemetry/telemetry.d.ts +2 -3
- package/dist/telemetry/telemetry.js +2 -4
- package/dist/types.d.ts +15 -6
- package/docs/agent-steps.mdx +1 -0
- package/docs/agents.mdx +7 -3
- package/docs/api-testing.mdx +2 -1
- package/docs/authentication.mdx +6 -3
- package/docs/browser.mdx +49 -12
- package/docs/bug-bash.mdx +2 -1
- package/docs/cache.mdx +66 -19
- package/docs/ci.mdx +14 -9
- package/docs/coding-agents.mdx +2 -1
- package/docs/core-concepts.mdx +208 -0
- package/docs/debugging.mdx +25 -12
- package/docs/decision-models.mdx +142 -0
- package/docs/example-projects.mdx +32 -0
- package/docs/executors.mdx +7 -2
- package/docs/explore.mdx +1 -0
- package/docs/github.mdx +14 -5
- package/docs/index.mdx +9 -5
- package/docs/integrations/eas.mdx +1 -0
- package/docs/integrations/index.mdx +1 -0
- package/docs/integrations/kernel.mdx +3 -2
- package/docs/migrate/cypress.mdx +26 -3
- package/docs/migrate/detox.mdx +24 -1
- package/docs/migrate/maestro.mdx +28 -4
- package/docs/migrate/playwright.mdx +44 -18
- package/docs/migrate/selenium.mdx +27 -3
- package/docs/mobile-ci.mdx +6 -5
- package/docs/mobile.mdx +96 -8
- package/docs/models.mdx +60 -9
- package/docs/quickstart.mdx +28 -16
- package/docs/reference/agent.mdx +13 -3
- package/docs/reference/app.mdx +2 -1
- package/docs/reference/cli.mdx +41 -18
- package/docs/reference/config.mdx +67 -13
- package/docs/reference/engine.mdx +25 -13
- package/docs/reference/environment.mdx +10 -2
- package/docs/reference/errors.mdx +13 -10
- package/docs/reference/expect.mdx +36 -13
- package/docs/reference/mcp.mdx +21 -8
- package/docs/reference/mobile.mdx +24 -13
- package/docs/reference/reporters.mdx +9 -2
- package/docs/reference/screen.mdx +49 -16
- package/docs/reference/test.mdx +32 -5
- package/docs/reference/web.mdx +133 -23
- package/docs/security.mdx +58 -42
- package/docs/subscriptions.mdx +92 -12
- package/docs/telemetry.mdx +9 -6
- package/docs/tools.mdx +1 -0
- package/docs/{starting-your-app.mdx → web.mdx} +157 -11
- package/docs/writing-an-engine.mdx +2 -1
- package/package.json +25 -8
- package/schema/report-v1.schema.json +14 -1
- package/skills/e2e/SKILL.md +5 -3
- package/skills/e2e/references/agent.md +33 -11
- package/skills/e2e/references/debugging.md +4 -4
- package/skills/e2e/references/mcp.md +8 -3
- package/skills/e2e/references/running.md +15 -9
- package/skills/e2e/references/setup.md +45 -16
- package/skills/e2e/references/writing-tests.md +35 -18
- package/docs/viewports.mdx +0 -146
- package/docs/writing-tests.mdx +0 -256
package/dist/run/execute.d.ts
CHANGED
|
@@ -9,17 +9,33 @@ import type { AttemptRecordings } from '../internal/recording-modes.ts';
|
|
|
9
9
|
import type { ArtifactStore } from '../types.ts';
|
|
10
10
|
import { type ArtifactSink } from './fixtures.ts';
|
|
11
11
|
import { RealmManager, type FileRef, type Realm } from './realm.ts';
|
|
12
|
-
import type { AttemptRecord, ResultRecord, RunError, SerialGroupRecord } from './records.ts';
|
|
13
|
-
import { type SerialHost, type SharedSerialSession } from './serial.ts';
|
|
12
|
+
import type { AttemptRecord, ResultRecord, RunError, SerialGroupRecord, SerialMemberRecord } from './records.ts';
|
|
13
|
+
import { type SerialAttemptRun, type SerialAttemptStart, type SerialHost, type SharedSerialSession } from './serial.ts';
|
|
14
14
|
import { SessionStaging, SessionStore } from './sessions.ts';
|
|
15
15
|
import { type StepProgress } from './steps.ts';
|
|
16
16
|
export interface ExecutionEvents {
|
|
17
17
|
onResult?(result: ResultRecord): void;
|
|
18
18
|
onSerialGroup?(group: SerialGroupRecord): void;
|
|
19
|
+
/** An attempt of an ordinary or setup pair begins, its realm and `beforeAll` hooks included. */
|
|
20
|
+
onAttemptStart?(pair: TestTargetPair, attemptIndex: number): void;
|
|
21
|
+
/** One finished attempt of an ordinary or setup pair, ahead of the pair's result. */
|
|
22
|
+
onAttempt?(pair: TestTargetPair, attempt: AttemptRecord): void;
|
|
23
|
+
/** One member that finished in a serial group attempt, ahead of the group. */
|
|
24
|
+
onSerialMember?(groupId: string, attempt: SerialAttemptStart, member: SerialMemberRecord): void;
|
|
25
|
+
/** One finished serial group attempt, ahead of the group. */
|
|
26
|
+
onSerialAttempt?(groupId: string, run: SerialAttemptRun): void;
|
|
19
27
|
/** Fires before a runnable pair starts; a serial unit announces each member as it begins. */
|
|
20
28
|
onPairStart?(pair: TestTargetPair): void;
|
|
21
29
|
/** Live step progress of one running attempt, for reporters. */
|
|
22
30
|
onProgress?(pair: TestTargetPair, progress: StepProgress): void;
|
|
31
|
+
/** An attempt's test timeout started, as its `beforeEach` hooks and body begin. */
|
|
32
|
+
onAttemptDeadline?(pair: TestTargetPair, attempt: {
|
|
33
|
+
readonly index: number;
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly startedAt: string;
|
|
36
|
+
}): void;
|
|
37
|
+
/** The attempt `onAttemptDeadline` announced has ended, or one that never got that far has. */
|
|
38
|
+
onAttemptEnd?(): void;
|
|
23
39
|
/** One line of progress the engine's `init` reported, already naming the target and worker slot. */
|
|
24
40
|
onNotice?(message: string): void;
|
|
25
41
|
/**
|
|
@@ -34,6 +50,12 @@ export interface TargetExecutorOptions {
|
|
|
34
50
|
readonly target: ResolvedTarget;
|
|
35
51
|
readonly runId: string;
|
|
36
52
|
readonly artifactsRoot: string;
|
|
53
|
+
/**
|
|
54
|
+
* The directory under `artifactsRoot` a `--last-failed` rerun's attempts
|
|
55
|
+
* write in (`claimRerunDir`), beside the evidence of the run it reruns;
|
|
56
|
+
* undefined when the attempts fill the root themselves.
|
|
57
|
+
*/
|
|
58
|
+
readonly rerunDir?: string | undefined;
|
|
37
59
|
readonly sessionStore: SessionStore;
|
|
38
60
|
readonly headed: boolean;
|
|
39
61
|
/** This worker's slot among the target's workers; see `EngineInitInfo.workerSlot`. */
|
|
@@ -103,6 +125,7 @@ export declare class TargetExecutor implements SerialHost {
|
|
|
103
125
|
get runId(): string;
|
|
104
126
|
get artifactStore(): ArtifactStore | undefined;
|
|
105
127
|
readonly artifactsRoot: string;
|
|
128
|
+
readonly rerunDir: string | undefined;
|
|
106
129
|
readonly interruptSignal: AbortSignal;
|
|
107
130
|
readonly realms: RealmManager;
|
|
108
131
|
readonly debug: DebugTrace;
|
|
@@ -113,6 +136,8 @@ export declare class TargetExecutor implements SerialHost {
|
|
|
113
136
|
private lastAttemptTestId;
|
|
114
137
|
private readonly models;
|
|
115
138
|
private readonly sessionIdentity;
|
|
139
|
+
/** The file store's listing under `cache.strict`, read once for every attempt here. */
|
|
140
|
+
private readonly storedRecordings;
|
|
116
141
|
/** Resolves once the engine's init hook completed for this worker. */
|
|
117
142
|
private engineReady;
|
|
118
143
|
constructor(options: TargetExecutorOptions);
|
|
@@ -123,6 +148,10 @@ export declare class TargetExecutor implements SerialHost {
|
|
|
123
148
|
pairStarted(pair: TestTargetPair): void;
|
|
124
149
|
/** Emits one finished serial group, ahead of its members' results (SerialHost). */
|
|
125
150
|
emitSerialGroup(group: SerialGroupRecord): void;
|
|
151
|
+
/** Streams one member finished in a serial group attempt (SerialHost). */
|
|
152
|
+
serialMemberFinished(groupId: string, attempt: SerialAttemptStart, member: SerialMemberRecord): void;
|
|
153
|
+
/** Streams one finished serial group attempt (SerialHost). */
|
|
154
|
+
serialAttemptFinished(groupId: string, run: SerialAttemptRun): void;
|
|
126
155
|
/** Builds one engine operation context. */
|
|
127
156
|
private op;
|
|
128
157
|
/** Run-level errors recorded so far, in order. */
|
|
@@ -187,9 +216,9 @@ export declare class TargetExecutor implements SerialHost {
|
|
|
187
216
|
* hands the app (basic-auth credentials). Registered for the session's and
|
|
188
217
|
* the process's redaction by `resolveSecretValue`, with every value the
|
|
189
218
|
* engine derives from it (the base64 credential an `Authorization` header
|
|
190
|
-
* carries) under the same name
|
|
191
|
-
*
|
|
192
|
-
*
|
|
219
|
+
* carries) under the same name, so its text, trace, and text downloads
|
|
220
|
+
* are redacted of them. Its pixels stay as they are, since nothing was
|
|
221
|
+
* typed.
|
|
193
222
|
*/
|
|
194
223
|
private resolveEngineSecret;
|
|
195
224
|
/**
|
package/dist/run/execute.js
CHANGED
|
@@ -10,21 +10,23 @@ import { timestamp, uuidv7 } from '../internal/ids.js';
|
|
|
10
10
|
import { engineAppInfo } from '../config/app.js';
|
|
11
11
|
import { Deadline, NEVER_ABORTS, withAbort, withScopedBudget, withTimeout } from '../internal/time.js';
|
|
12
12
|
import { createAgentCacheContext, flushStagedTraces } from '../cache/context.js';
|
|
13
|
+
import { storedRecordingsFor } from '../cache/rekeyed.js';
|
|
13
14
|
import { pairRecordings } from '../collect/select.js';
|
|
14
|
-
import { createAttemptArtifacts, sanitizePathSegment } from './artifacts.js';
|
|
15
|
+
import { attemptSegments, createAttemptArtifacts, sanitizePathSegment } from './artifacts.js';
|
|
15
16
|
import { AttemptBudget } from './budget.js';
|
|
16
17
|
import { createEngineSession } from '../engine/session.js';
|
|
17
18
|
import { createExtendedFixtures } from './extended-fixtures.js';
|
|
18
19
|
import { captureFailureEvidence } from './failure-evidence.js';
|
|
19
20
|
import { createFixtures } from './fixtures.js';
|
|
20
21
|
import { publishAttempt } from '../expect/attempt.js';
|
|
22
|
+
import { PollScope, runInPollScope } from '../expect/poll-scope.js';
|
|
21
23
|
import { SoftFailures } from '../expect/soft.js';
|
|
22
24
|
import { findRegistered, RealmManager, runHook } from './realm.js';
|
|
23
25
|
import { isFailedStatus } from './records.js';
|
|
24
26
|
import { runWithRetries } from './retry.js';
|
|
25
27
|
import { runSerialUnit } from './serial.js';
|
|
26
28
|
import { interruptedSkip, pairKey, pairResult, repeatSegment, unstartedResult } from './units.js';
|
|
27
|
-
import { adoptSecrecy, carriedSecrecy, processSecrets, registerDerivedSecrets, redactForSession, resolveSecretValue, sessionSecrecy, staticSecretLedger } from './secrecy.js';
|
|
29
|
+
import { adoptSecrecy, carriedSecrecy, processSecrets, registerDerivedSecrets, redactForSession, redactsRecordings, resolveSecretValue, sessionSecrecy, staticSecretLedger } from './secrecy.js';
|
|
28
30
|
import { isSecret } from '../secrets.js';
|
|
29
31
|
import { SessionStaging, SessionStore, targetIdentity } from './sessions.js';
|
|
30
32
|
import { redactTraceArchives } from './trace-redaction.js';
|
|
@@ -43,6 +45,7 @@ export class TargetExecutor {
|
|
|
43
45
|
return this.config.artifactStore;
|
|
44
46
|
}
|
|
45
47
|
artifactsRoot;
|
|
48
|
+
rerunDir;
|
|
46
49
|
interruptSignal;
|
|
47
50
|
realms;
|
|
48
51
|
debug;
|
|
@@ -53,12 +56,15 @@ export class TargetExecutor {
|
|
|
53
56
|
lastAttemptTestId;
|
|
54
57
|
models;
|
|
55
58
|
sessionIdentity;
|
|
59
|
+
/** The file store's listing under `cache.strict`, read once for every attempt here. */
|
|
60
|
+
storedRecordings;
|
|
56
61
|
/** Resolves once the engine's init hook completed for this worker. */
|
|
57
62
|
engineReady;
|
|
58
63
|
constructor(options) {
|
|
59
64
|
this.options = options;
|
|
60
65
|
this.target = options.target;
|
|
61
66
|
this.artifactsRoot = options.artifactsRoot;
|
|
67
|
+
this.rerunDir = options.rerunDir;
|
|
62
68
|
this.interruptSignal = options.interruptSignal;
|
|
63
69
|
this.debug = options.debug ?? new DebugTrace(false);
|
|
64
70
|
this.models = new WorkerModels((error) => {
|
|
@@ -74,6 +80,7 @@ export class TargetExecutor {
|
|
|
74
80
|
debug: this.debug,
|
|
75
81
|
});
|
|
76
82
|
this.sessionIdentity = targetIdentity(options.target);
|
|
83
|
+
this.storedRecordings = storedRecordingsFor(options.config.cache);
|
|
77
84
|
}
|
|
78
85
|
get config() {
|
|
79
86
|
return this.options.config;
|
|
@@ -90,6 +97,14 @@ export class TargetExecutor {
|
|
|
90
97
|
emitSerialGroup(group) {
|
|
91
98
|
this.options.events?.onSerialGroup?.(group);
|
|
92
99
|
}
|
|
100
|
+
/** Streams one member finished in a serial group attempt (SerialHost). */
|
|
101
|
+
serialMemberFinished(groupId, attempt, member) {
|
|
102
|
+
this.options.events?.onSerialMember?.(groupId, attempt, member);
|
|
103
|
+
}
|
|
104
|
+
/** Streams one finished serial group attempt (SerialHost). */
|
|
105
|
+
serialAttemptFinished(groupId, run) {
|
|
106
|
+
this.options.events?.onSerialAttempt?.(groupId, run);
|
|
107
|
+
}
|
|
93
108
|
/** Builds one engine operation context. */
|
|
94
109
|
op(attemptId, timeoutMs, signal) {
|
|
95
110
|
return { signal, timeoutMs, runId: this.options.runId, attemptId, origin: 'test' };
|
|
@@ -249,6 +264,7 @@ export class TargetExecutor {
|
|
|
249
264
|
let realm = incomingRealm;
|
|
250
265
|
let hookFailure;
|
|
251
266
|
const finalStatus = await runWithRetries(pair.options.retries + 1, this.interruptSignal, async (attemptIndex) => {
|
|
267
|
+
this.options.events?.onAttemptStart?.(pair, attemptIndex);
|
|
252
268
|
if (realm === null)
|
|
253
269
|
realm = await this.realms.create(file);
|
|
254
270
|
const registered = findRegistered(realm, pair.test);
|
|
@@ -263,6 +279,7 @@ export class TargetExecutor {
|
|
|
263
279
|
kind: 'ordinary',
|
|
264
280
|
});
|
|
265
281
|
attempts.push(attempt);
|
|
282
|
+
this.options.events?.onAttempt?.(pair, attempt);
|
|
266
283
|
if (isFailedStatus(attempt.status)) {
|
|
267
284
|
// A failed realm is never reused, but afterAll still runs for every
|
|
268
285
|
// scope whose beforeAll started in it. A body that skipped itself
|
|
@@ -309,6 +326,7 @@ export class TargetExecutor {
|
|
|
309
326
|
const attempts = [];
|
|
310
327
|
let hookFailure;
|
|
311
328
|
const finalStatus = await runWithRetries(pair.options.retries + 1, this.interruptSignal, async (attemptIndex) => {
|
|
329
|
+
this.options.events?.onAttemptStart?.(pair, attemptIndex);
|
|
312
330
|
const realm = attemptIndex === 0 && freshRegistration !== undefined
|
|
313
331
|
? this.realms.adopt(freshRegistration, file)
|
|
314
332
|
: await this.realms.create(file);
|
|
@@ -327,15 +345,17 @@ export class TargetExecutor {
|
|
|
327
345
|
kind: 'setup',
|
|
328
346
|
staging,
|
|
329
347
|
});
|
|
348
|
+
const missing = attempt.status === 'passed' ? staging.missing() : [];
|
|
349
|
+
if (missing.length > 0) {
|
|
350
|
+
attempt.status = 'failed';
|
|
351
|
+
attempt.error = serializeError(new E2EError('test', 'SESSION_CONTRACT', `setup must save each declared session exactly once; missing: [${missing.join(', ')}]`), { phase: 'body' });
|
|
352
|
+
}
|
|
330
353
|
attempts.push(attempt);
|
|
354
|
+
// Sent before teardown and the session saves, as in runOrdinaryPair:
|
|
355
|
+
// a crash in either then lands on this attempt instead of erasing it.
|
|
356
|
+
this.options.events?.onAttempt?.(pair, attempt);
|
|
331
357
|
await this.realms.leave(realm);
|
|
332
358
|
if (attempt.status === 'passed') {
|
|
333
|
-
const missing = staging.missing();
|
|
334
|
-
if (missing.length > 0) {
|
|
335
|
-
attempt.status = 'failed';
|
|
336
|
-
attempt.error = serializeError(new E2EError('test', 'SESSION_CONTRACT', `setup must save each declared session exactly once; missing: [${missing.join(', ')}]`), { phase: 'body' });
|
|
337
|
-
return attempt;
|
|
338
|
-
}
|
|
339
359
|
for (const [name, saved] of staging.entries()) {
|
|
340
360
|
await this.options.sessionStore.save(name, this.sessionIdentity, saved);
|
|
341
361
|
}
|
|
@@ -412,9 +432,9 @@ export class TargetExecutor {
|
|
|
412
432
|
* hands the app (basic-auth credentials). Registered for the session's and
|
|
413
433
|
* the process's redaction by `resolveSecretValue`, with every value the
|
|
414
434
|
* engine derives from it (the base64 credential an `Authorization` header
|
|
415
|
-
* carries) under the same name
|
|
416
|
-
*
|
|
417
|
-
*
|
|
435
|
+
* carries) under the same name, so its text, trace, and text downloads
|
|
436
|
+
* are redacted of them. Its pixels stay as they are, since nothing was
|
|
437
|
+
* typed.
|
|
418
438
|
*/
|
|
419
439
|
async resolveEngineSecret(session, secret, options) {
|
|
420
440
|
const engine = this.target.engine;
|
|
@@ -425,7 +445,6 @@ export class TargetExecutor {
|
|
|
425
445
|
const secrecy = sessionSecrecy(session, this.config.allSecrets);
|
|
426
446
|
const plaintext = await resolveSecretValue(secret, this.config.allSecrets, secrecy.ledger);
|
|
427
447
|
registerDerivedSecrets(secret.name, options?.derived?.(plaintext) ?? [], secrecy.ledger);
|
|
428
|
-
secrecy.exposure.raise('engine');
|
|
429
448
|
return plaintext;
|
|
430
449
|
}
|
|
431
450
|
/**
|
|
@@ -472,14 +491,14 @@ export class TargetExecutor {
|
|
|
472
491
|
}
|
|
473
492
|
// An engine records what happened, filled secrets included, so the
|
|
474
493
|
// trace is the runner's to redact before anything hashes or stores
|
|
475
|
-
// it.
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
494
|
+
// it. Any value the ledger holds may be in it, filled or not (a URL
|
|
495
|
+
// the test spelled it into, an engine option), so a trace is kept
|
|
496
|
+
// only once rewritten; only a session that knows no value skips it.
|
|
497
|
+
// Its screencast frames go only where pixels are withheld, after a
|
|
498
|
+
// fill.
|
|
480
499
|
const secrecy = sessionSecrecy(session, this.config.allSecrets);
|
|
481
500
|
let redaction = 'not-required';
|
|
482
|
-
if (secrecy
|
|
501
|
+
if (redactsRecordings(secrecy)) {
|
|
483
502
|
try {
|
|
484
503
|
await redactTraceArchives(artifactSink.dir, archives, secrecy.ledger, { keepFrames: !secrecy.exposure.withholdsPixels });
|
|
485
504
|
}
|
|
@@ -533,7 +552,14 @@ export class TargetExecutor {
|
|
|
533
552
|
target: this.target.name,
|
|
534
553
|
agent: pair.agent,
|
|
535
554
|
attempt: attemptIndex,
|
|
536
|
-
}, () =>
|
|
555
|
+
}, async () => {
|
|
556
|
+
try {
|
|
557
|
+
return await this.executeAttempt(pair, registered, realm, attemptIndex, context);
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
this.options.events?.onAttemptEnd?.();
|
|
561
|
+
}
|
|
562
|
+
});
|
|
537
563
|
}
|
|
538
564
|
async executeAttempt(pair, registered, realm, attemptIndex, context) {
|
|
539
565
|
const attemptId = uuidv7();
|
|
@@ -588,7 +614,13 @@ export class TargetExecutor {
|
|
|
588
614
|
// agent and repeat segments keep a test run as several agents, or
|
|
589
615
|
// several times, from overwriting itself.
|
|
590
616
|
segments: shared?.artifactSegments ??
|
|
591
|
-
|
|
617
|
+
attemptSegments(this.rerunDir, [
|
|
618
|
+
this.target.name,
|
|
619
|
+
sanitizePathSegment(registered.artifactName ?? pair.test.id),
|
|
620
|
+
pair.agent,
|
|
621
|
+
...repeatSegment(pair.repeat),
|
|
622
|
+
`attempt-${attemptIndex}`,
|
|
623
|
+
]),
|
|
592
624
|
attemptId,
|
|
593
625
|
currentStepId: () => steps.currentStepId,
|
|
594
626
|
...(this.config.artifactStore === undefined ? {} : { store: this.config.artifactStore }),
|
|
@@ -614,6 +646,8 @@ export class TargetExecutor {
|
|
|
614
646
|
let skipped;
|
|
615
647
|
let phase = 'launch';
|
|
616
648
|
let timedOut = false;
|
|
649
|
+
// The polls the fixtures, the beforeEach hooks, and the body start.
|
|
650
|
+
const bodyPolls = new PollScope('the test body');
|
|
617
651
|
// Captured the moment the primary failure lands: steps that pass later —
|
|
618
652
|
// afterEach cleanup, teardown — must not confirm traces the failure
|
|
619
653
|
// implicated (a cleanup assertion says nothing about the failed flow).
|
|
@@ -639,9 +673,13 @@ export class TargetExecutor {
|
|
|
639
673
|
// One more look at the app the moment the failure lands: what the screen
|
|
640
674
|
// held then is the evidence the message lacks. Taken before teardown, so
|
|
641
675
|
// an `afterEach` that navigates away or resets state cannot replace it.
|
|
676
|
+
// Once per attempt: a screen that yielded nothing within the budget is
|
|
677
|
+
// not asked again, so a hung app costs the budget once.
|
|
678
|
+
let evidenceTried = false;
|
|
642
679
|
const captureEvidence = async () => {
|
|
643
|
-
if (failure === undefined ||
|
|
680
|
+
if (failure === undefined || evidenceTried || openSession === null || this.interruptSignal.aborted)
|
|
644
681
|
return;
|
|
682
|
+
evidenceTried = true;
|
|
645
683
|
const evidence = await captureFailureEvidence({
|
|
646
684
|
session: openSession,
|
|
647
685
|
error: failure,
|
|
@@ -664,6 +702,7 @@ export class TargetExecutor {
|
|
|
664
702
|
testId: pair.test.id,
|
|
665
703
|
target: this.sessionIdentity,
|
|
666
704
|
attemptIndex,
|
|
705
|
+
recordings: this.storedRecordings,
|
|
667
706
|
})
|
|
668
707
|
: undefined;
|
|
669
708
|
// A rejection nobody caught, handed in by the process while this attempt
|
|
@@ -684,6 +723,7 @@ export class TargetExecutor {
|
|
|
684
723
|
(await this.launchSession({ session: pair.options.session, recordings }, attemptId, artifacts.dir, attemptAbort.signal));
|
|
685
724
|
openSession = session;
|
|
686
725
|
const testDeadline = new Deadline(pair.options.timeout);
|
|
726
|
+
this.options.events?.onAttemptDeadline?.(pair, { index: attemptIndex, id: attemptId, startedAt });
|
|
687
727
|
const budget = new AttemptBudget(attemptAbort.signal, testDeadline);
|
|
688
728
|
const saveSession = context.kind !== 'setup'
|
|
689
729
|
? undefined
|
|
@@ -722,22 +762,25 @@ export class TargetExecutor {
|
|
|
722
762
|
// object, whichever `test` they were registered through.
|
|
723
763
|
const extended = createExtendedFixtures(registered.fixtures, fixtures, this.target.engine?.name ?? 'none');
|
|
724
764
|
/**
|
|
725
|
-
* Steps the body left running when it settled were not
|
|
726
|
-
* is recorded as failed at its call, cancelled through
|
|
727
|
-
* signal the way a timeout cancels the body, and waited for
|
|
728
|
-
* cleanup budget, so teardown starts on a quiet session.
|
|
729
|
-
* such a step would fail once the session closed with
|
|
730
|
-
* it and take the worker down.
|
|
765
|
+
* Steps and polls the body left running when it settled were not
|
|
766
|
+
* awaited. A step is recorded as failed at its call, cancelled through
|
|
767
|
+
* the attempt signal the way a timeout cancels the body, and waited for
|
|
768
|
+
* within one cleanup budget, so teardown starts on a quiet session.
|
|
769
|
+
* Left alone, such a step would fail once the session closed with
|
|
770
|
+
* nobody to catch it and take the worker down. A poll is cancelled
|
|
771
|
+
* alone; left alone, its timeout would fail whatever ran then.
|
|
772
|
+
* Returns their errors, steps first.
|
|
731
773
|
*/
|
|
732
774
|
const abandonNotAwaited = async () => {
|
|
733
775
|
if (attemptEnd.signal.aborted)
|
|
734
|
-
return
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
776
|
+
return [];
|
|
777
|
+
const pollsNotAwaited = bodyPolls.close();
|
|
778
|
+
const stepsNotAwaited = steps.abandonRunning();
|
|
779
|
+
if (stepsNotAwaited !== undefined) {
|
|
780
|
+
attemptAbort.abort();
|
|
781
|
+
await withTimeout(steps.settleAbandoned(), this.config.cleanupTimeout, () => new Error('abandoned steps did not settle')).catch(() => undefined);
|
|
782
|
+
}
|
|
783
|
+
return [stepsNotAwaited, pollsNotAwaited].filter((error) => error !== undefined);
|
|
741
784
|
};
|
|
742
785
|
const mainWork = async () => {
|
|
743
786
|
try {
|
|
@@ -751,19 +794,22 @@ export class TargetExecutor {
|
|
|
751
794
|
}
|
|
752
795
|
catch (cause) {
|
|
753
796
|
// The body's own failure stays the verdict; the step it abandoned
|
|
754
|
-
// and the soft failures it kept are noted beside it.
|
|
797
|
+
// and the soft failures it kept are noted beside it. A skip keeps
|
|
798
|
+
// only the soft failures: a step or poll it left running is not one.
|
|
755
799
|
const notAwaited = await abandonNotAwaited();
|
|
756
800
|
const softFailure = soft.close();
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
}
|
|
801
|
+
const kept = isRuntimeSkip(cause) ? [softFailure] : [...notAwaited, softFailure];
|
|
802
|
+
for (const secondary of kept) {
|
|
803
|
+
if (secondary !== undefined) {
|
|
804
|
+
secondaryErrors.push(serializeError(secondary, { phase: 'body', projectRoot: this.config.projectRoot, redact }));
|
|
762
805
|
}
|
|
763
806
|
}
|
|
764
807
|
throw cause;
|
|
765
808
|
}
|
|
766
|
-
const notAwaited = await abandonNotAwaited();
|
|
809
|
+
const [notAwaited, ...moreNotAwaited] = await abandonNotAwaited();
|
|
810
|
+
for (const secondary of moreNotAwaited) {
|
|
811
|
+
secondaryErrors.push(serializeError(secondary, { phase: 'body', projectRoot: this.config.projectRoot, redact }));
|
|
812
|
+
}
|
|
767
813
|
if (notAwaited !== undefined)
|
|
768
814
|
throw notAwaited;
|
|
769
815
|
// Soft failures fail the attempt once the body has settled, before
|
|
@@ -779,8 +825,8 @@ export class TargetExecutor {
|
|
|
779
825
|
// down with the worker process; the attempt records the interrupt and
|
|
780
826
|
// moves to cleanup.
|
|
781
827
|
const work = this.options.isolated
|
|
782
|
-
? withAbort(mainWork
|
|
783
|
-
: mainWork
|
|
828
|
+
? withAbort(runInPollScope(bodyPolls, mainWork), this.interruptSignal, () => new E2EError('interrupted', 'INTERRUPTED', `run interrupted in phase ${phase}`))
|
|
829
|
+
: runInPollScope(bodyPolls, mainWork);
|
|
784
830
|
const body = Promise.race([
|
|
785
831
|
work,
|
|
786
832
|
new Promise((_, reject) => {
|
|
@@ -798,6 +844,8 @@ export class TargetExecutor {
|
|
|
798
844
|
// The race has settled: a rejection surfacing while the evidence is
|
|
799
845
|
// captured is recorded, not aimed at it.
|
|
800
846
|
cutBody = undefined;
|
|
847
|
+
// A body cut short may have been awaiting its polls; they end with it.
|
|
848
|
+
bodyPolls.close();
|
|
801
849
|
// `skipRunningTest` has already refused the cases that may not skip.
|
|
802
850
|
if (isRuntimeSkip(cause))
|
|
803
851
|
skipped = cause;
|
|
@@ -859,6 +907,7 @@ export class TargetExecutor {
|
|
|
859
907
|
recordFailure(cause, phase);
|
|
860
908
|
}
|
|
861
909
|
finally {
|
|
910
|
+
bodyPolls.close();
|
|
862
911
|
this.strayFailure = undefined;
|
|
863
912
|
this.lastAttemptTestId = pair.test.id;
|
|
864
913
|
attemptEnd.abort();
|
|
@@ -911,7 +960,7 @@ export class TargetExecutor {
|
|
|
911
960
|
function classifyAttemptStatus(failure, timedOut, interrupted) {
|
|
912
961
|
if (interrupted && !timedOut)
|
|
913
962
|
return 'interrupted';
|
|
914
|
-
if (timedOut || failure
|
|
963
|
+
if (timedOut || failure.code === 'TEST_TIMEOUT') {
|
|
915
964
|
return 'timed-out';
|
|
916
965
|
}
|
|
917
966
|
return 'failed';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** The fixtures a test's `test.extend()` chain adds to one attempt. */
|
|
2
|
+
import { pollLineage } from '../expect/poll-scope.js';
|
|
2
3
|
import { ConfigurationError } from '../internal/errors.js';
|
|
3
4
|
/**
|
|
4
5
|
* Binds a chain of fixture definitions to one attempt's fixture object. The
|
|
@@ -50,8 +51,10 @@ export function createExtendedFixtures(definitions, fixtures, engineName) {
|
|
|
50
51
|
ready();
|
|
51
52
|
return released;
|
|
52
53
|
};
|
|
54
|
+
// The function's polls are the body's until `use` returns, then its teardown's.
|
|
55
|
+
const polls = pollLineage();
|
|
53
56
|
const run = Promise.resolve()
|
|
54
|
-
.then(() => definition.fn(fixtures, use))
|
|
57
|
+
.then(() => polls.run(() => definition.fn(fixtures, use)))
|
|
55
58
|
.then(() => {
|
|
56
59
|
if (!used) {
|
|
57
60
|
failed(new ConfigurationError('TEST_SETUP_FAILED', `fixture "${name}" returned without calling use(); a fixture awaits use(value) to hand its value to the test`));
|
|
@@ -66,7 +69,7 @@ export function createExtendedFixtures(definitions, fixtures, engineName) {
|
|
|
66
69
|
run.catch(() => undefined);
|
|
67
70
|
await provided;
|
|
68
71
|
if (!abandoned)
|
|
69
|
-
active.push({ name, run, release, misuse: () => misuse });
|
|
72
|
+
active.push({ name, run, release, polls, misuse: () => misuse });
|
|
70
73
|
};
|
|
71
74
|
return {
|
|
72
75
|
async setUp() {
|
|
@@ -78,6 +81,7 @@ export function createExtendedFixtures(definitions, fixtures, engineName) {
|
|
|
78
81
|
return active.toReversed().map((state) => ({
|
|
79
82
|
name: state.name,
|
|
80
83
|
run: async () => {
|
|
84
|
+
state.polls.handOver();
|
|
81
85
|
state.release();
|
|
82
86
|
await state.run;
|
|
83
87
|
const misuse = state.misuse();
|
|
@@ -14,6 +14,7 @@ import path from 'node:path';
|
|
|
14
14
|
import { formatNode, prepareObservation } from '../agent/observation.js';
|
|
15
15
|
import { truncateUtf8 } from '../internal/errors.js';
|
|
16
16
|
import { isTypoOf } from '../internal/suggest.js';
|
|
17
|
+
import { withAbort } from '../internal/time.js';
|
|
17
18
|
/** The whole capture, observation and screenshot together, gets this long. */
|
|
18
19
|
const EVIDENCE_TIMEOUT_MS = 5_000;
|
|
19
20
|
/** Screen lines a locator failure lists as its nearest nodes, and the bytes each keeps; the wire schema caps both. */
|
|
@@ -37,9 +38,12 @@ export async function captureFailureEvidence(options) {
|
|
|
37
38
|
return finish(evidence);
|
|
38
39
|
const operation = options.operation(signal, EVIDENCE_TIMEOUT_MS);
|
|
39
40
|
const { redact, redactCut } = options.secrecy.ledger;
|
|
41
|
+
// The engine is handed the budget, and the wait is abandoned at its end
|
|
42
|
+
// either way: an engine stuck on a hung app may never honor it.
|
|
43
|
+
const bounded = (work) => withAbort(work, signal, () => new Error('failure evidence ran out of budget'));
|
|
40
44
|
let observation;
|
|
41
45
|
try {
|
|
42
|
-
const raw = await options.session.observe(operation);
|
|
46
|
+
const raw = await bounded(() => options.session.observe(operation));
|
|
43
47
|
observation = prepareObservation(raw, {
|
|
44
48
|
redact,
|
|
45
49
|
redactCut,
|
|
@@ -73,7 +77,7 @@ export async function captureFailureEvidence(options) {
|
|
|
73
77
|
// prove a tainted viewport redacted.
|
|
74
78
|
if (!options.secrecy.exposure.withholdsPixels && !signal.aborted) {
|
|
75
79
|
try {
|
|
76
|
-
const relative = await options.session.artifacts.screenshot('failure', operation);
|
|
80
|
+
const relative = await bounded(() => options.session.artifacts.screenshot('failure', operation));
|
|
77
81
|
evidence.screenshot = options.artifacts.register('screenshot', relative);
|
|
78
82
|
}
|
|
79
83
|
catch {
|
|
@@ -109,7 +113,9 @@ function locatorCandidates(error, observation, redact, appOrigin) {
|
|
|
109
113
|
return [];
|
|
110
114
|
if (error.code !== 'LOCATOR_NOT_FOUND' && error.code !== 'LOCATOR_AMBIGUOUS')
|
|
111
115
|
return [];
|
|
112
|
-
const
|
|
116
|
+
const details = error.details ?? {};
|
|
117
|
+
// The nodes are redacted; the query is compared in the same form, so a secret test id still finds its node.
|
|
118
|
+
const [role, testId, name] = [details.role, details.testId, details.name].map((value) => (value === undefined ? undefined : redact(value)));
|
|
113
119
|
const words = tokens(name ?? '');
|
|
114
120
|
if (role === undefined && testId === undefined && words.length === 0)
|
|
115
121
|
return [];
|
|
@@ -139,7 +145,7 @@ function locatorCandidates(error, observation, redact, appOrigin) {
|
|
|
139
145
|
return scored
|
|
140
146
|
.toSorted((a, b) => b.score - a.score)
|
|
141
147
|
.slice(0, MAX_CANDIDATES)
|
|
142
|
-
.map(({ node }) => truncateUtf8(formatNode(node, 0,
|
|
148
|
+
.map(({ node }) => truncateUtf8(formatNode(node, 0, appOrigin), MAX_CANDIDATE_BYTES));
|
|
143
149
|
}
|
|
144
150
|
/**
|
|
145
151
|
* Whether a word on screen is a near miss for one asked for: a typo or an
|
package/dist/run/fixtures.js
CHANGED
|
@@ -311,20 +311,18 @@ function createApp(environment, engine, exposure) {
|
|
|
311
311
|
* `app.open()` on a target with no URL and no `navigate`, a device: it
|
|
312
312
|
* launches the pinned app fresh, the way Maestro's `launchApp` does, and is
|
|
313
313
|
* what a test calls before it looks at the screen. A path has nowhere to
|
|
314
|
-
* go there; a link goes through `device.openLink`.
|
|
314
|
+
* go there; a link goes through `device.openLink`. An engine without a
|
|
315
|
+
* relaunch hook is refused here; one with a hook reports its own reason it
|
|
316
|
+
* cannot launch, such as a build not installed yet.
|
|
315
317
|
*/
|
|
316
318
|
const launchPinnedApp = (openPath) => steps.run('app', 'app.open', '', async () => {
|
|
317
319
|
if (openPath !== undefined) {
|
|
318
320
|
throw new ConfigurationError('APP_URL_REQUIRED', `app.open("${openPath}") needs an app URL and target "${target.name}" has none: on a device app.open() launches the pinned app, and a link goes through device.openLink`);
|
|
319
321
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
323
|
-
catch (cause) {
|
|
324
|
-
if (cause.code !== 'UNSUPPORTED_CAPABILITY')
|
|
325
|
-
throw cause;
|
|
326
|
-
throw new ConfigurationError('UNSUPPORTED_CAPABILITY', `app.open() has no app to launch on target "${target.name}": pin one with the target's app.bundleId or app.appPath, or bring one up with device.openApp`);
|
|
322
|
+
if (target.engine?.session?.restart === undefined) {
|
|
323
|
+
throw new ConfigurationError('UNSUPPORTED_CAPABILITY', `app.open() has nothing to open on target "${target.name}": it has no app URL, and engine ${target.engine?.name ?? 'none'} does not relaunch a pinned app`);
|
|
327
324
|
}
|
|
325
|
+
await engine.session.app.restart(engine.operation(config.timeout));
|
|
328
326
|
});
|
|
329
327
|
return {
|
|
330
328
|
baseUrl: target.app.base?.href,
|
package/dist/run/in-process.d.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface InProcessRunnerOptions {
|
|
|
28
28
|
}) | undefined;
|
|
29
29
|
readonly runId: string;
|
|
30
30
|
readonly artifactsRoot: string;
|
|
31
|
+
/** See `TargetExecutorOptions.rerunDir`. */
|
|
32
|
+
readonly rerunDir: string | undefined;
|
|
31
33
|
readonly sessionStore: SessionStore;
|
|
32
34
|
readonly headed: boolean;
|
|
33
35
|
readonly debug: DebugTrace;
|
package/dist/run/in-process.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* `TargetWorker` core; only the transport and pair resolution differ, and the
|
|
6
6
|
* scheduler is capped at one worker so nothing overlaps in this process.
|
|
7
7
|
*/
|
|
8
|
+
import { isAbandonedRejection } from '../internal/abandoned.js';
|
|
8
9
|
import { classifyError, ConfigurationError, serializeError } from '../internal/errors.js';
|
|
9
10
|
import { pairKey } from './units.js';
|
|
10
11
|
import { TargetWorker } from './worker/session.js';
|
|
@@ -18,6 +19,22 @@ class InProcessRunner {
|
|
|
18
19
|
finish;
|
|
19
20
|
exited = false;
|
|
20
21
|
closing = false;
|
|
22
|
+
/**
|
|
23
|
+
* Set by a fatal error: the worker is dead to the scheduler from then on,
|
|
24
|
+
* as a crashed process would be, so nothing it reports about its unit
|
|
25
|
+
* while it winds down counts, and the unit's results are synthesized when
|
|
26
|
+
* it exits. Unlike a dead process it still disposes its engine, so the
|
|
27
|
+
* failures of that disposal (`shutdown-done`) are still reported.
|
|
28
|
+
*/
|
|
29
|
+
crashed = false;
|
|
30
|
+
/** Takes what this process failed to catch while the runner lives; see `catchStrays`. */
|
|
31
|
+
onRejection = (cause) => {
|
|
32
|
+
if (isAbandonedRejection(cause))
|
|
33
|
+
return;
|
|
34
|
+
if (!this.worker.strayRejection(cause))
|
|
35
|
+
this.fail(cause);
|
|
36
|
+
};
|
|
37
|
+
onException = (cause) => this.fail(cause);
|
|
21
38
|
constructor(targetName, workerSlot, events, options) {
|
|
22
39
|
this.targetName = targetName;
|
|
23
40
|
this.workerSlot = workerSlot;
|
|
@@ -30,20 +47,14 @@ class InProcessRunner {
|
|
|
30
47
|
this.finish = finish;
|
|
31
48
|
this.worker = new TargetWorker({
|
|
32
49
|
emit: (message) => {
|
|
33
|
-
if (
|
|
34
|
-
this.events.onMessage(message);
|
|
35
|
-
},
|
|
36
|
-
fatal: (cause) => {
|
|
37
|
-
if (this.exited)
|
|
50
|
+
if (this.exited || (this.crashed && message.type !== 'shutdown-done'))
|
|
38
51
|
return;
|
|
39
|
-
this.events.onMessage(
|
|
40
|
-
type: 'fatal',
|
|
41
|
-
error: serializeError(classifyError(cause)),
|
|
42
|
-
});
|
|
43
|
-
this.close();
|
|
52
|
+
this.events.onMessage(message);
|
|
44
53
|
},
|
|
54
|
+
fatal: (cause) => this.fail(cause),
|
|
45
55
|
finished: () => this.end('shut down'),
|
|
46
56
|
}, () => this.bootstrap());
|
|
57
|
+
this.catchStrays();
|
|
47
58
|
this.worker.start();
|
|
48
59
|
}
|
|
49
60
|
get alive() {
|
|
@@ -75,6 +86,34 @@ class InProcessRunner {
|
|
|
75
86
|
kill() {
|
|
76
87
|
this.close();
|
|
77
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Catches what a test leaves uncaught the way a worker process does
|
|
91
|
+
* (`worker/entry.ts`): a rejection nobody handled is charged to the attempt
|
|
92
|
+
* in flight, or recorded against the last test that finished, and is
|
|
93
|
+
* otherwise fatal to this worker, as an uncaught exception is (see
|
|
94
|
+
* `fail`). The
|
|
95
|
+
* listeners live exactly as long as the runner, so a host process keeps its
|
|
96
|
+
* own handling before and after the run. The scheduler keeps at most one
|
|
97
|
+
* in-process runner alive at a time, so nothing is charged twice.
|
|
98
|
+
*/
|
|
99
|
+
catchStrays() {
|
|
100
|
+
process.on('unhandledRejection', this.onRejection);
|
|
101
|
+
process.on('uncaughtException', this.onException);
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Reports an unrecoverable failure and ends the worker the way a process
|
|
105
|
+
* dies: its unit is interrupted and nothing more it reports is passed on,
|
|
106
|
+
* so on exit the scheduler fails the test in flight with `WORKER_CRASH`
|
|
107
|
+
* and skips the unit's others, as it does for a worker process.
|
|
108
|
+
*/
|
|
109
|
+
fail(cause) {
|
|
110
|
+
if (this.exited || this.crashed)
|
|
111
|
+
return;
|
|
112
|
+
this.events.onMessage({ type: 'fatal', error: serializeError(classifyError(cause)) });
|
|
113
|
+
this.crashed = true;
|
|
114
|
+
this.worker.handle({ type: 'interrupt' });
|
|
115
|
+
this.close();
|
|
116
|
+
}
|
|
78
117
|
/** Stops accepting work and asks the queue to wind down. */
|
|
79
118
|
close() {
|
|
80
119
|
if (this.exited || this.closing)
|
|
@@ -86,6 +125,8 @@ class InProcessRunner {
|
|
|
86
125
|
if (this.exited)
|
|
87
126
|
return;
|
|
88
127
|
this.exited = true;
|
|
128
|
+
process.off('unhandledRejection', this.onRejection);
|
|
129
|
+
process.off('uncaughtException', this.onException);
|
|
89
130
|
this.events.onExit(detail);
|
|
90
131
|
this.finish();
|
|
91
132
|
}
|
|
@@ -101,6 +142,7 @@ class InProcessRunner {
|
|
|
101
142
|
sessionStore: this.options.sessionStore,
|
|
102
143
|
runId: this.options.runId,
|
|
103
144
|
artifactsRoot: this.options.artifactsRoot,
|
|
145
|
+
rerunDir: this.options.rerunDir,
|
|
104
146
|
headed: this.options.headed,
|
|
105
147
|
workerSlot: this.workerSlot,
|
|
106
148
|
env: this.options.envFor(this.targetName),
|