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/locator/screen.js
CHANGED
|
@@ -12,7 +12,7 @@ import { obj } from '../internal/objects.js';
|
|
|
12
12
|
import { isTextMatch, normalizeText } from '../internal/text.js';
|
|
13
13
|
import { attributeOf, denySecureRead, isNodeVisible, locatorDetails } from './engine.js';
|
|
14
14
|
import { describeExpression, filterExpression, indexExpression, roleQuery, testIdQuery, textQuery, } from './expression.js';
|
|
15
|
-
import { POLL_INTERVAL_MS, pollCondition, sleep } from '../internal/time.js';
|
|
15
|
+
import { cutOffAtDeadline, POLL_INTERVAL_MS, pollCondition, sleep } from '../internal/time.js';
|
|
16
16
|
/**
|
|
17
17
|
* Internals hang off the locator object itself under a global symbol so that
|
|
18
18
|
* an expect() imported in an isolated test-module realm can still reach them.
|
|
@@ -55,6 +55,27 @@ const TEXT_OPTION_KEYS = ['exact', 'visible'];
|
|
|
55
55
|
const ROLE_FILTER_KEYS = [...TEXT_OPTION_KEYS, 'checked', 'disabled', 'selected', 'expanded', 'pressed', 'level'];
|
|
56
56
|
/** The keys of `RoleOptions`: the accessible name and the filters. */
|
|
57
57
|
const ROLE_OPTION_KEYS = ['name', ...ROLE_FILTER_KEYS];
|
|
58
|
+
/** The states `locator.waitFor` waits for, Playwright's four. */
|
|
59
|
+
const WAIT_FOR_STATES = ['attached', 'detached', 'visible', 'hidden'];
|
|
60
|
+
/** The states absence satisfies: a frame missing from the document reads as zero matches for them. */
|
|
61
|
+
const ABSENCE_STATES = new Set(['detached', 'hidden']);
|
|
62
|
+
/**
|
|
63
|
+
* Whether the one match, or its absence, is in `state`: `attached` is any
|
|
64
|
+
* match, `detached` none, `visible` a match that is not hidden, and `hidden`
|
|
65
|
+
* a hidden match or none.
|
|
66
|
+
*/
|
|
67
|
+
function inWaitForState(node, state) {
|
|
68
|
+
switch (state) {
|
|
69
|
+
case 'attached':
|
|
70
|
+
return node !== null;
|
|
71
|
+
case 'detached':
|
|
72
|
+
return node === null;
|
|
73
|
+
case 'visible':
|
|
74
|
+
return isNodeVisible(node);
|
|
75
|
+
case 'hidden':
|
|
76
|
+
return !isNodeVisible(node);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
58
79
|
class ScreenImpl {
|
|
59
80
|
context;
|
|
60
81
|
scope;
|
|
@@ -106,10 +127,15 @@ class ScreenImpl {
|
|
|
106
127
|
}
|
|
107
128
|
async swipe(options) {
|
|
108
129
|
if (isSwipePath(options)) {
|
|
109
|
-
rejectUnknownOptions('swipe', options, ['from', 'to']);
|
|
130
|
+
rejectUnknownOptions('swipe', options, ['from', 'to', 'duration']);
|
|
110
131
|
const from = requirePoint(options.from, 'swipe from');
|
|
111
132
|
const to = requirePoint(options.to, 'swipe to');
|
|
112
|
-
|
|
133
|
+
const durationMs = validateSwipeDuration(options.duration);
|
|
134
|
+
await this.context.steps.run('screen', 'screen.swipe', `${describePoint(from)} → ${describePoint(to)}`, () => this.context.engine.performAt(from, obj({
|
|
135
|
+
kind: 'swipeTo',
|
|
136
|
+
target: to,
|
|
137
|
+
durationMs,
|
|
138
|
+
})));
|
|
113
139
|
return;
|
|
114
140
|
}
|
|
115
141
|
rejectUnknownOptions('swipe', options, ['direction', 'momentum']);
|
|
@@ -139,8 +165,19 @@ class ScreenImpl {
|
|
|
139
165
|
details: locatorDetails(internals.expression, Date.now() - startedMs),
|
|
140
166
|
...(cause === undefined ? {} : { cause }),
|
|
141
167
|
});
|
|
168
|
+
let sampled = false;
|
|
142
169
|
for (;;) {
|
|
143
|
-
const
|
|
170
|
+
const startedWithMs = deadline.remaining();
|
|
171
|
+
let node;
|
|
172
|
+
try {
|
|
173
|
+
({ node } = await engine.tryRead(internals.expression, deadline));
|
|
174
|
+
}
|
|
175
|
+
catch (cause) {
|
|
176
|
+
if (sampled && cutOffAtDeadline(cause, startedWithMs))
|
|
177
|
+
throw notVisible(cause);
|
|
178
|
+
throw cause;
|
|
179
|
+
}
|
|
180
|
+
sampled = true;
|
|
144
181
|
if (isNodeVisible(node))
|
|
145
182
|
return;
|
|
146
183
|
if (deadline.expired())
|
|
@@ -361,7 +398,11 @@ class LocatorImpl extends ScreenImpl {
|
|
|
361
398
|
return nodes.map((node) => normalizeText(node.text ?? ''));
|
|
362
399
|
}
|
|
363
400
|
async waitFor(options) {
|
|
364
|
-
|
|
401
|
+
rejectUnknownOptions('waitFor', options, ['state', 'timeout']);
|
|
402
|
+
const state = options?.state === undefined ? 'visible' : options.state;
|
|
403
|
+
if (!WAIT_FOR_STATES.includes(state)) {
|
|
404
|
+
throw new TestError('INVALID_ARGUMENT', `waitFor state must be one of ${WAIT_FOR_STATES.join(', ')}, got ${JSON.stringify(state)}`);
|
|
405
|
+
}
|
|
365
406
|
await this.context.steps.run('locator', 'locator.waitFor', `${this.label} → ${state}`, async () => {
|
|
366
407
|
const { engine } = this.context;
|
|
367
408
|
const deadline = engine.deadline(options?.timeout);
|
|
@@ -371,12 +412,12 @@ class LocatorImpl extends ScreenImpl {
|
|
|
371
412
|
signal: engine.signal,
|
|
372
413
|
negated: false,
|
|
373
414
|
evaluate: async () => {
|
|
374
|
-
const { node } = await engine.tryRead(this.expression, deadline);
|
|
375
|
-
|
|
376
|
-
return state === 'visible' ? visible : !visible;
|
|
415
|
+
const { node } = await engine.tryRead(this.expression, deadline, ABSENCE_STATES.has(state) ? 'empty' : 'wait');
|
|
416
|
+
return inWaitForState(node, state);
|
|
377
417
|
},
|
|
378
|
-
onTimeout: () => new TestError('LOCATOR_NOT_FOUND', `locator did not become ${state}: ${this.label}`, {
|
|
418
|
+
onTimeout: (cause) => new TestError('LOCATOR_NOT_FOUND', `locator did not become ${state}: ${this.label}`, {
|
|
379
419
|
details: locatorDetails(this.expression, Date.now() - startedMs),
|
|
420
|
+
...(cause === undefined ? {} : { cause }),
|
|
380
421
|
}),
|
|
381
422
|
});
|
|
382
423
|
}, { verifies: true });
|
|
@@ -461,6 +502,15 @@ function validateLongPress(durationMs) {
|
|
|
461
502
|
}
|
|
462
503
|
return durationMs;
|
|
463
504
|
}
|
|
505
|
+
/** Validates the shared swipe duration bound; none named leaves the gesture to the engine's default. */
|
|
506
|
+
function validateSwipeDuration(duration) {
|
|
507
|
+
if (duration === undefined)
|
|
508
|
+
return undefined;
|
|
509
|
+
if (!Number.isInteger(duration) || duration < 16 || duration > 10_000) {
|
|
510
|
+
throw new TestError('INVALID_ARGUMENT', `swipe duration must be an integer from 16 through 10000, got ${String(duration)}`);
|
|
511
|
+
}
|
|
512
|
+
return duration;
|
|
513
|
+
}
|
|
464
514
|
/**
|
|
465
515
|
* The `modifiers` of a click, validated: a list of distinct key modifiers,
|
|
466
516
|
* spread into the action only when it names one.
|
package/dist/mcp/catalog.d.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { ToolSet } from 'ai';
|
|
14
14
|
import type { StepExecutorContext } from '../agent/executor.ts';
|
|
15
|
+
import { type NodeRedaction } from '../agent/observation.ts';
|
|
15
16
|
import type { ScreenPresenter } from '../agent/screen-update.ts';
|
|
16
17
|
import type { LocatorExpression, SemanticNode, TargetSession } from '../engine/surface.ts';
|
|
17
18
|
import type { LocatorEngine } from '../locator/engine.ts';
|
|
@@ -35,7 +36,7 @@ export interface CatalogOptions {
|
|
|
35
36
|
/** The agent's project tools (`agents.<name>.tools`), served beside the built-in ones. */
|
|
36
37
|
readonly tools: Readonly<Record<string, AgentTool>>;
|
|
37
38
|
/** The attempt's secret ledger: what `locate` shows of a node passes through it, as `observe` does. */
|
|
38
|
-
readonly
|
|
39
|
+
readonly redaction: NodeRedaction;
|
|
39
40
|
/** The session's recorder, when the engine records video. */
|
|
40
41
|
readonly recorder: SessionRecorder | undefined;
|
|
41
42
|
readonly warn: (message: string) => void;
|
|
@@ -60,4 +61,4 @@ export interface LocateQuery {
|
|
|
60
61
|
/** Builds the expression and the matching `screen.*` call from the tool arguments. */
|
|
61
62
|
export declare function locateQuery(args: LocateArgs): LocateQuery;
|
|
62
63
|
/** Renders a locate result: the count, the verdict a test would get, and the nodes, each through the attempt's redactor. */
|
|
63
|
-
export declare function describeLocate(query: LocateQuery, count: number, nodes: readonly SemanticNode[],
|
|
64
|
+
export declare function describeLocate(query: LocateQuery, count: number, nodes: readonly SemanticNode[], redaction: NodeRedaction): string;
|
package/dist/mcp/catalog.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { z } from 'zod';
|
|
14
14
|
import { projectTools } from '../agent/default-agent.js';
|
|
15
|
-
import { projectTree } from '../agent/observation.js';
|
|
15
|
+
import { projectTree, redactNode } from '../agent/observation.js';
|
|
16
16
|
import { GRAMMAR_TOOL_NAMES } from '../agent/action-names.js';
|
|
17
17
|
import { createGrammarTools } from '../agent/primitives.js';
|
|
18
18
|
import { ConfigurationError } from '../internal/errors.js';
|
|
@@ -34,7 +34,7 @@ export function createSessionCatalog(options) {
|
|
|
34
34
|
const builtIn = {
|
|
35
35
|
observe: fullObserveTool(context, screen),
|
|
36
36
|
...verbs,
|
|
37
|
-
locate: locateTool(options.locator, options.session, options.
|
|
37
|
+
locate: locateTool(options.locator, options.session, options.redaction),
|
|
38
38
|
...recording,
|
|
39
39
|
};
|
|
40
40
|
const defined = options.tools;
|
|
@@ -65,7 +65,7 @@ function fullObserveTool(context, screen) {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
/** The session's `locate`: a semantic locator tried against the live screen, with the verdict a test would get. */
|
|
68
|
-
function locateTool(locator, session,
|
|
68
|
+
function locateTool(locator, session, redaction) {
|
|
69
69
|
return {
|
|
70
70
|
description: 'Try a semantic locator against the live screen before writing it into a test: screen.getByRole(role, name), getByText, getByLabel, getByPlaceholder, or getByTestId. Returns how many nodes match and which, plus the test code to use. Exactly one of role, text, label, placeholder, or testId; name narrows a role query. Matching is exact unless exact is false.',
|
|
71
71
|
inputSchema: z.object({
|
|
@@ -84,7 +84,7 @@ function locateTool(locator, session, redact) {
|
|
|
84
84
|
for (const ref of refs.slice(0, MAX_LOCATE_NODES)) {
|
|
85
85
|
read.push(await session.read(ref, locator.operation()));
|
|
86
86
|
}
|
|
87
|
-
return describeLocate(query, refs.length, read,
|
|
87
|
+
return describeLocate(query, refs.length, read, redaction);
|
|
88
88
|
},
|
|
89
89
|
};
|
|
90
90
|
}
|
|
@@ -115,7 +115,7 @@ export function locateQuery(args) {
|
|
|
115
115
|
return { expression: textQuery('placeholder', placeholder, { exact }, undefined), code: `screen.getByPlaceholder(${JSON.stringify(placeholder)}${options})` };
|
|
116
116
|
}
|
|
117
117
|
/** Renders a locate result: the count, the verdict a test would get, and the nodes, each through the attempt's redactor. */
|
|
118
|
-
export function describeLocate(query, count, nodes,
|
|
118
|
+
export function describeLocate(query, count, nodes, redaction) {
|
|
119
119
|
const lines = [`${count === 1 ? '1 node matches' : `${count} nodes match`} ${describeExpression(query.expression)}.`];
|
|
120
120
|
if (count === 1)
|
|
121
121
|
lines.push(`Use: ${query.code}`);
|
|
@@ -124,7 +124,7 @@ export function describeLocate(query, count, nodes, redact) {
|
|
|
124
124
|
else
|
|
125
125
|
lines.push(`A test action on ${query.code} would fail with LOCATOR_AMBIGUOUS. Narrow it with a name, getByRole(role, name), .filter({ hasText }), .first(), or .nth(i), or scope it under a container.`);
|
|
126
126
|
for (const node of nodes)
|
|
127
|
-
lines.push(`- ${describeNode(projectTree(node,
|
|
127
|
+
lines.push(`- ${describeNode(projectTree(redactNode(node, redaction)))}`);
|
|
128
128
|
if (count > nodes.length)
|
|
129
129
|
lines.push(`- and ${count - nodes.length} more`);
|
|
130
130
|
return lines.join('\n');
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the `e2e mcp` process prints besides the protocol. User code runs in
|
|
3
|
+
* this process (the config's top-level code, project tools, engines), and
|
|
4
|
+
* what it writes to stdout or stderr reaches the operator's stderr redacted
|
|
5
|
+
* with every secret value the process knows, as a test's output leaves an
|
|
6
|
+
* `e2e run` worker. The server's own diagnostics are redacted where they are
|
|
7
|
+
* written and pass as they are.
|
|
8
|
+
*/
|
|
9
|
+
import { type SecretLedger } from '../internal/redact.ts';
|
|
10
|
+
export type OutputStream = 'stdout' | 'stderr';
|
|
11
|
+
/** Writes text to the operator's stderr; the callback fires once the stream took it. Answers false while the stream is backed up, like `Writable.write`. */
|
|
12
|
+
export type OutputSink = (text: string, callback?: () => void) => boolean;
|
|
13
|
+
export declare class McpOutput {
|
|
14
|
+
private readonly sink;
|
|
15
|
+
/** Each stream's bytes decoded on their own, so a character split across writes stays whole. */
|
|
16
|
+
private readonly decoders;
|
|
17
|
+
/** One redactor for both streams: they land on one stderr, where a value split across them reads whole. */
|
|
18
|
+
private readonly redactor;
|
|
19
|
+
/** Config loads in flight: until each ends, what anyone prints is held. */
|
|
20
|
+
private loading;
|
|
21
|
+
/** Whether a load in flight failed, so its secrets are unknown and what is held cannot be redacted. */
|
|
22
|
+
private loadFailed;
|
|
23
|
+
/** Tool calls in flight: a held tail is released only once none is, since any of them may be partway through a value. */
|
|
24
|
+
private calls;
|
|
25
|
+
private held;
|
|
26
|
+
/** Whether the last text passed on ended its line. */
|
|
27
|
+
private atLineStart;
|
|
28
|
+
constructor(sink: OutputSink, ledger: SecretLedger);
|
|
29
|
+
/**
|
|
30
|
+
* One write of user code to `stream`, redacted across writes: the tail a
|
|
31
|
+
* later write could complete into a value waits for it, or for the end of
|
|
32
|
+
* the tool calls in flight. Held while a config loads. Answers what the
|
|
33
|
+
* sink did.
|
|
34
|
+
*/
|
|
35
|
+
write(stream: OutputStream, chunk: string | Uint8Array, callback?: () => void): boolean;
|
|
36
|
+
/** One line of the server's own, already redacted where it was written; on a line of its own even when user code left one unfinished. */
|
|
37
|
+
log(line: string): void;
|
|
38
|
+
/** A line of the server's that carries user text (an error nobody caught): redacted and held like what user code writes. */
|
|
39
|
+
report(line: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Runs `load`, a config load that registers the config's secrets, holding
|
|
42
|
+
* what anyone prints until it ends: the config's top-level code may print
|
|
43
|
+
* a value before the process knows it is one. Released redacted once every
|
|
44
|
+
* load in flight succeeded; withheld when one failed, since the secrets of
|
|
45
|
+
* a config that did not load are unknown.
|
|
46
|
+
*/
|
|
47
|
+
withholdDuring<T>(load: () => Promise<T>): Promise<T>;
|
|
48
|
+
/**
|
|
49
|
+
* Runs one tool call. Once no call is in flight, the tail held for a later
|
|
50
|
+
* write is released, redacted: user code that printed without a newline is
|
|
51
|
+
* seen when its call ends, not when the server exits.
|
|
52
|
+
*/
|
|
53
|
+
duringCall<T>(call: () => Promise<T>): Promise<T>;
|
|
54
|
+
/** Releases what is still held, redacted; nothing more will follow. Output held for a load still in flight is withheld. */
|
|
55
|
+
end(): void;
|
|
56
|
+
private releaseHeld;
|
|
57
|
+
private withhold;
|
|
58
|
+
private emit;
|
|
59
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the `e2e mcp` process prints besides the protocol. User code runs in
|
|
3
|
+
* this process (the config's top-level code, project tools, engines), and
|
|
4
|
+
* what it writes to stdout or stderr reaches the operator's stderr redacted
|
|
5
|
+
* with every secret value the process knows, as a test's output leaves an
|
|
6
|
+
* `e2e run` worker. The server's own diagnostics are redacted where they are
|
|
7
|
+
* written and pass as they are.
|
|
8
|
+
*/
|
|
9
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
10
|
+
import { StreamRedactor } from '../internal/redact.js';
|
|
11
|
+
export class McpOutput {
|
|
12
|
+
sink;
|
|
13
|
+
/** Each stream's bytes decoded on their own, so a character split across writes stays whole. */
|
|
14
|
+
decoders = {
|
|
15
|
+
stdout: new StringDecoder('utf8'),
|
|
16
|
+
stderr: new StringDecoder('utf8'),
|
|
17
|
+
};
|
|
18
|
+
/** One redactor for both streams: they land on one stderr, where a value split across them reads whole. */
|
|
19
|
+
redactor;
|
|
20
|
+
/** Config loads in flight: until each ends, what anyone prints is held. */
|
|
21
|
+
loading = 0;
|
|
22
|
+
/** Whether a load in flight failed, so its secrets are unknown and what is held cannot be redacted. */
|
|
23
|
+
loadFailed = false;
|
|
24
|
+
/** Tool calls in flight: a held tail is released only once none is, since any of them may be partway through a value. */
|
|
25
|
+
calls = 0;
|
|
26
|
+
held = [];
|
|
27
|
+
/** Whether the last text passed on ended its line. */
|
|
28
|
+
atLineStart = true;
|
|
29
|
+
constructor(sink, ledger) {
|
|
30
|
+
this.sink = sink;
|
|
31
|
+
this.redactor = new StreamRedactor(ledger);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* One write of user code to `stream`, redacted across writes: the tail a
|
|
35
|
+
* later write could complete into a value waits for it, or for the end of
|
|
36
|
+
* the tool calls in flight. Held while a config loads. Answers what the
|
|
37
|
+
* sink did.
|
|
38
|
+
*/
|
|
39
|
+
write(stream, chunk, callback) {
|
|
40
|
+
const text = typeof chunk === 'string' ? chunk : this.decoders[stream].write(chunk);
|
|
41
|
+
if (this.loading > 0) {
|
|
42
|
+
this.held.push(text);
|
|
43
|
+
if (callback !== undefined)
|
|
44
|
+
process.nextTick(callback);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return this.emit(this.redactor.push(text), callback);
|
|
48
|
+
}
|
|
49
|
+
/** One line of the server's own, already redacted where it was written; on a line of its own even when user code left one unfinished. */
|
|
50
|
+
log(line) {
|
|
51
|
+
this.emit(`${this.atLineStart ? '' : '\n'}${line}\n`);
|
|
52
|
+
}
|
|
53
|
+
/** A line of the server's that carries user text (an error nobody caught): redacted and held like what user code writes. */
|
|
54
|
+
report(line) {
|
|
55
|
+
this.write('stderr', `${this.atLineStart ? '' : '\n'}${line}\n`);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Runs `load`, a config load that registers the config's secrets, holding
|
|
59
|
+
* what anyone prints until it ends: the config's top-level code may print
|
|
60
|
+
* a value before the process knows it is one. Released redacted once every
|
|
61
|
+
* load in flight succeeded; withheld when one failed, since the secrets of
|
|
62
|
+
* a config that did not load are unknown.
|
|
63
|
+
*/
|
|
64
|
+
async withholdDuring(load) {
|
|
65
|
+
this.loading += 1;
|
|
66
|
+
try {
|
|
67
|
+
return await load();
|
|
68
|
+
}
|
|
69
|
+
catch (cause) {
|
|
70
|
+
this.loadFailed = true;
|
|
71
|
+
throw cause;
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
this.loading -= 1;
|
|
75
|
+
if (this.loading === 0)
|
|
76
|
+
this.releaseHeld();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Runs one tool call. Once no call is in flight, the tail held for a later
|
|
81
|
+
* write is released, redacted: user code that printed without a newline is
|
|
82
|
+
* seen when its call ends, not when the server exits.
|
|
83
|
+
*/
|
|
84
|
+
async duringCall(call) {
|
|
85
|
+
this.calls += 1;
|
|
86
|
+
try {
|
|
87
|
+
return await call();
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
this.calls -= 1;
|
|
91
|
+
if (this.calls === 0 && this.loading === 0)
|
|
92
|
+
this.emit(this.redactor.flush());
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Releases what is still held, redacted; nothing more will follow. Output held for a load still in flight is withheld. */
|
|
96
|
+
end() {
|
|
97
|
+
this.write('stdout', this.decoders.stdout.end());
|
|
98
|
+
this.write('stderr', this.decoders.stderr.end());
|
|
99
|
+
if (this.loading > 0)
|
|
100
|
+
this.withhold();
|
|
101
|
+
this.emit(this.redactor.flush());
|
|
102
|
+
}
|
|
103
|
+
releaseHeld() {
|
|
104
|
+
if (this.loadFailed) {
|
|
105
|
+
this.withhold();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const held = this.held;
|
|
109
|
+
this.held = [];
|
|
110
|
+
for (const text of held)
|
|
111
|
+
this.emit(this.redactor.push(text));
|
|
112
|
+
}
|
|
113
|
+
withhold() {
|
|
114
|
+
const bytes = this.held.reduce((total, text) => total + Buffer.byteLength(text), 0);
|
|
115
|
+
this.held = [];
|
|
116
|
+
this.loadFailed = false;
|
|
117
|
+
if (bytes > 0)
|
|
118
|
+
this.log(`e2e mcp: [warning] withheld ${bytes} bytes of output printed while a config failed to load: its secrets are unknown, so the output cannot be redacted`);
|
|
119
|
+
}
|
|
120
|
+
emit(text, callback) {
|
|
121
|
+
if (text === '') {
|
|
122
|
+
if (callback !== undefined)
|
|
123
|
+
process.nextTick(callback);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
this.atLineStart = text.endsWith('\n');
|
|
127
|
+
return this.sink(text, callback);
|
|
128
|
+
}
|
|
129
|
+
}
|
package/dist/mcp/server.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* it the process's and a test hands it pipes.
|
|
8
8
|
*/
|
|
9
9
|
import type { Readable, Writable } from 'node:stream';
|
|
10
|
+
import type { McpOutput } from './output.ts';
|
|
10
11
|
import type { McpSessionSummary } from './usage.ts';
|
|
11
12
|
/** The client as it named itself in `initialize`. */
|
|
12
13
|
export interface McpClient {
|
|
@@ -19,6 +20,7 @@ export interface ServeOptions {
|
|
|
19
20
|
readonly configPath?: string | undefined;
|
|
20
21
|
/** The target every session opens on; otherwise a call names one, or the only one is used. */
|
|
21
22
|
readonly target?: string | undefined;
|
|
23
|
+
/** Whether a session shows its UI when `open_session` does not set `headed`. */
|
|
22
24
|
readonly headed: boolean;
|
|
23
25
|
/** How many sessions may be open at once, from `--max-sessions`. */
|
|
24
26
|
readonly maxSessions?: number | undefined;
|
|
@@ -29,6 +31,8 @@ export interface ServeOptions {
|
|
|
29
31
|
readonly stdout: Writable;
|
|
30
32
|
/** Diagnostics for the operator, normally stderr. */
|
|
31
33
|
readonly log: (line: string) => void;
|
|
34
|
+
/** What user code in this process prints: held while a config loads, and its unfinished lines released once no tool call is in flight. */
|
|
35
|
+
readonly output?: Pick<McpOutput, 'withholdDuring' | 'duringCall'> | undefined;
|
|
32
36
|
/** Ends the server from outside: a process signal. */
|
|
33
37
|
readonly signal?: AbortSignal | undefined;
|
|
34
38
|
/** Told once per `open_session`, when its session closes or its open fails, with the client that asked; undefined before `initialize`. */
|
package/dist/mcp/server.js
CHANGED
|
@@ -10,11 +10,11 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
|
|
|
10
10
|
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
11
11
|
import { readGuide, skillTopics } from '../cli/skill.js';
|
|
12
12
|
import { ConfigurationError, errorMessage } from '../internal/errors.js';
|
|
13
|
+
import { processSecrets } from '../run/secrecy.js';
|
|
13
14
|
import { loadProjectConfig, locateProjectConfig } from './config.js';
|
|
14
15
|
import { SessionHost } from './session.js';
|
|
15
|
-
import { errorResult } from './tools.js';
|
|
16
16
|
const INSTRUCTIONS = `e2e is a local-first end-to-end test runner; this server drives an e2e project's app (its e2e.config.ts) live.
|
|
17
|
-
Call open_session (optionally with a target
|
|
17
|
+
Call open_session (optionally with a target, a config path, and headed: true when the user wants to watch) to get a session, its tool catalog, and the first observation. Then call {tool, args} runs any catalog tool: observe, the grammar its engine honors, type_secret, locate, screenshot, start_recording and stop_recording when the engine records video (a video of the app for a pull request), and the project's own tools; tools lists them, tools {tool} shows one tool's arguments. close_session when done.
|
|
18
18
|
Several sessions can be open at once, each with its own browser or device, so parallel agents (subagents) each open their own: pass the session id from open_session to every tools, call, and close_session.
|
|
19
19
|
Write deterministic tests (tests/*.e2e.ts) from what you saw and run them with the CLI: npx e2e run <file>. Resources e2e://guide and e2e://guide/{topic} hold the writing guide.`;
|
|
20
20
|
/** Serves until the client disconnects or `signal` aborts; resolves with the exit code. */
|
|
@@ -26,9 +26,11 @@ export async function serveMcp(options) {
|
|
|
26
26
|
if (!disconnected && server.isConnected())
|
|
27
27
|
void server.sendLoggingMessage({ level, logger: 'e2e', data: message }).catch(() => undefined);
|
|
28
28
|
};
|
|
29
|
+
const output = options.output;
|
|
29
30
|
const host = new SessionHost({
|
|
30
31
|
locateConfig: (configPath) => locateProjectConfig({ cwd: options.cwd, configPath: configPath ?? options.configPath }),
|
|
31
32
|
loadConfig: (configPath) => loadProjectConfig(configPath, options.env),
|
|
33
|
+
withholdOutput: output === undefined ? undefined : (load) => output.withholdDuring(load),
|
|
32
34
|
env: options.env,
|
|
33
35
|
headed: options.headed,
|
|
34
36
|
maxSessions: options.maxSessions,
|
|
@@ -44,14 +46,9 @@ export async function serveMcp(options) {
|
|
|
44
46
|
description: spec.description,
|
|
45
47
|
inputSchema: spec.inputSchema,
|
|
46
48
|
annotations: { readOnlyHint: spec.readOnly, openWorldHint: false },
|
|
47
|
-
},
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return await spec.call(args, { signal: ctx.mcpReq.signal });
|
|
51
|
-
}
|
|
52
|
-
catch (cause) {
|
|
53
|
-
return errorResult(cause);
|
|
54
|
-
}
|
|
49
|
+
}, (args, ctx) => {
|
|
50
|
+
const call = () => spec.call(args, { signal: ctx.mcpReq.signal });
|
|
51
|
+
return output === undefined ? call() : output.duringCall(call);
|
|
55
52
|
});
|
|
56
53
|
}
|
|
57
54
|
registerGuide(server);
|
|
@@ -84,9 +81,9 @@ export async function serveMcp(options) {
|
|
|
84
81
|
options.log(summary);
|
|
85
82
|
}
|
|
86
83
|
catch (cause) {
|
|
87
|
-
options.log(`session teardown failed: ${errorMessage(cause)}`);
|
|
84
|
+
options.log(`session teardown failed: ${processSecrets.redact(errorMessage(cause))}`);
|
|
88
85
|
}
|
|
89
|
-
await server.close().catch((cause) => options.log(`server close failed: ${errorMessage(cause)}`));
|
|
86
|
+
await server.close().catch((cause) => options.log(`server close failed: ${processSecrets.redact(errorMessage(cause))}`));
|
|
90
87
|
return 0;
|
|
91
88
|
}
|
|
92
89
|
/** The skill as resources, for clients that read context without a tool call. */
|
package/dist/mcp/session.d.ts
CHANGED
|
@@ -25,13 +25,18 @@ export interface OpenSessionOptions {
|
|
|
25
25
|
readonly target?: string | undefined;
|
|
26
26
|
/** A config file to load instead of the server's default, relative to the server's directory. */
|
|
27
27
|
readonly config?: string | undefined;
|
|
28
|
+
/** Whether this session shows its UI; otherwise the server's `headed`. */
|
|
29
|
+
readonly headed?: boolean | undefined;
|
|
28
30
|
}
|
|
29
31
|
export interface SessionHostOptions {
|
|
30
32
|
/** The absolute path of the config a session loads, without evaluating it; `configPath` overrides the server's default. */
|
|
31
33
|
readonly locateConfig: (configPath: string | undefined) => string;
|
|
32
34
|
/** Loads the config at an absolute path fresh for each session, so an edited config applies without a restart. */
|
|
33
35
|
readonly loadConfig: (configPath: string) => Promise<LoadedConfig>;
|
|
36
|
+
/** Holds what the process prints while `load` evaluates a config and registers its secrets; by default nothing is held. */
|
|
37
|
+
readonly withholdOutput?: (<T>(load: () => Promise<T>) => Promise<T>) | undefined;
|
|
34
38
|
readonly env: NodeJS.ProcessEnv;
|
|
39
|
+
/** Whether a session shows its UI when `open_session` does not say, from `--headed`. */
|
|
35
40
|
readonly headed: boolean;
|
|
36
41
|
/** The target every session opens on, from `--target`; a call may still name one. */
|
|
37
42
|
readonly defaultTarget?: string | undefined;
|
|
@@ -54,8 +59,10 @@ export declare class SessionHost {
|
|
|
54
59
|
constructor(options: SessionHostOptions);
|
|
55
60
|
get isOpen(): boolean;
|
|
56
61
|
private get maxSessions();
|
|
57
|
-
/** The server's tools: the same four whatever the project, the config, or the target. */
|
|
62
|
+
/** The server's tools: the same four whatever the project, the config, or the target, each redacting what it returns. */
|
|
58
63
|
toolSpecs(): readonly McpToolSpec[];
|
|
64
|
+
/** Logs a line for the operator and the client, redacted with every secret value the process knows. */
|
|
65
|
+
private log;
|
|
59
66
|
/**
|
|
60
67
|
* Opens a session and returns its opening text: the summary, the catalog,
|
|
61
68
|
* and the first screen. `signal` is the request's: a client that cancels
|