e2e 0.21.1 → 0.21.2
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 +2 -0
- package/dist/.build.json +1 -1
- package/dist/agent/act-validation.js +1 -0
- package/dist/agent/act.js +7 -3
- package/dist/agent/action-dispatcher.js +30 -8
- package/dist/agent/actions.d.ts +2 -0
- package/dist/agent/actions.js +5 -2
- package/dist/agent/error.js +1 -0
- package/dist/agent/executor.d.ts +14 -6
- package/dist/agent/index.js +5 -1
- package/dist/agent/invocation.d.ts +3 -0
- package/dist/agent/model/sdk.d.ts +5 -0
- package/dist/agent/model/sdk.js +18 -1
- package/dist/agent/observation.js +1 -0
- package/dist/agent/primitives.js +14 -3
- package/dist/agent/replay.js +9 -3
- package/dist/agent/step-cache.d.ts +3 -2
- package/dist/agent/step-cache.js +7 -4
- package/dist/agent/tool-loop.js +4 -1
- package/dist/cache/context.js +1 -1
- package/dist/cache/recorder.d.ts +2 -1
- package/dist/cache/recorder.js +11 -2
- package/dist/cache/trace-fields.d.ts +10 -0
- package/dist/cache/trace-fields.js +17 -0
- package/dist/cache/trace.d.ts +9 -1
- package/dist/cache/trace.js +26 -7
- package/dist/cli/feedback.js +2 -1
- package/dist/cli/init/sibling-versions.json +1 -1
- package/dist/collect/select.js +12 -2
- package/dist/config/agent.d.ts +1 -1
- package/dist/config/diagnose.js +9 -3
- package/dist/config/esm-hooks.js +3 -2
- package/dist/config/resolve.js +3 -2
- package/dist/config/typescript.js +52 -2
- package/dist/engine/contract.d.ts +3 -2
- package/dist/engine/manifest.js +2 -2
- package/dist/internal/errors.js +5 -2
- package/dist/internal/source.js +2 -0
- package/dist/internal/unserved-model.d.ts +5 -0
- package/dist/internal/unserved-model.js +22 -0
- package/dist/locator/screen.js +1 -2
- package/dist/oauth/copilot.js +23 -5
- package/dist/oauth/device-code.js +35 -19
- package/dist/oauth/errors.d.ts +3 -0
- package/dist/oauth/errors.js +26 -11
- package/dist/oauth/providers/github-copilot.d.ts +4 -0
- package/dist/oauth/providers/github-copilot.js +64 -16
- package/dist/oauth/providers/openai.d.ts +2 -2
- package/dist/oauth/providers/openai.js +47 -19
- package/dist/oauth/providers/opencode-console.js +36 -15
- package/dist/oauth/providers/xai.js +18 -9
- package/dist/oauth/token-endpoint.d.ts +15 -6
- package/dist/oauth/token-endpoint.js +18 -6
- package/dist/report/code-frame.js +9 -3
- package/dist/report/failure-text.d.ts +10 -2
- package/dist/report/failure-text.js +24 -8
- package/dist/report/list-model.d.ts +13 -0
- package/dist/report/list-steps.d.ts +6 -1
- package/dist/report/list-steps.js +29 -0
- package/dist/report/list.d.ts +1 -0
- package/dist/report/list.js +16 -7
- package/dist/report/live-window.js +2 -1
- package/dist/report/markdown.js +1 -1
- package/dist/run/fixtures.js +2 -0
- package/dist/run/host-trace.d.ts +3 -2
- package/dist/run/host-trace.js +2 -1
- package/dist/run/last-run.d.ts +9 -0
- package/dist/run/last-run.js +28 -0
- package/dist/run/open-session.d.ts +3 -1
- package/dist/run/open-session.js +1 -1
- package/dist/run/runner.js +6 -2
- package/dist/run/sessions.js +3 -3
- package/dist/run/standalone-attempt.d.ts +2 -1
- package/dist/run/standalone-attempt.js +3 -1
- package/dist/run/steps.d.ts +3 -1
- package/dist/run/units.d.ts +8 -0
- package/dist/run/units.js +14 -0
- package/dist/run/worker-models.d.ts +2 -0
- package/dist/run/worker-models.js +7 -0
- package/dist/types.d.ts +2 -2
- package/docs/authentication.mdx +26 -23
- package/docs/browser.mdx +7 -4
- package/docs/bug-bash.mdx +2 -2
- package/docs/cache.mdx +6 -1
- package/docs/ci/bitrise.mdx +1 -1
- package/docs/ci/codemagic.mdx +2 -2
- package/docs/ci/eas-workflows.mdx +1 -1
- package/docs/coding-agents.mdx +7 -35
- package/docs/debugging.mdx +24 -36
- package/docs/executors.mdx +1 -1
- package/docs/github.mdx +2 -1
- package/docs/integrations/eas.mdx +1 -1
- package/docs/integrations/index.mdx +6 -0
- package/docs/integrations/lightpanda.mdx +192 -0
- package/docs/integrations/testmu.mdx +152 -0
- package/docs/mcp.mdx +123 -0
- package/docs/migrate/detox.mdx +1 -1
- package/docs/migrate/maestro.mdx +101 -105
- package/docs/migrate/playwright.mdx +65 -70
- package/docs/mobile.mdx +18 -3
- package/docs/models.mdx +4 -2
- package/docs/quickstart.mdx +7 -0
- package/docs/reference/agent.mdx +369 -277
- package/docs/reference/app.mdx +141 -32
- package/docs/reference/cli.mdx +674 -558
- package/docs/reference/config.mdx +1406 -752
- package/docs/reference/engine.mdx +1062 -256
- package/docs/reference/environment.mdx +156 -84
- package/docs/reference/errors.mdx +106 -72
- package/docs/reference/expect.mdx +752 -372
- package/docs/reference/mcp.mdx +366 -141
- package/docs/reference/mobile.mdx +844 -403
- package/docs/reference/reporters.mdx +312 -106
- package/docs/reference/runner.mdx +373 -174
- package/docs/reference/screen.mdx +931 -398
- package/docs/reference/security.mdx +106 -40
- package/docs/reference/test.mdx +626 -273
- package/docs/reference/web.mdx +956 -533
- package/docs/security.mdx +6 -5
- package/docs/snippets/model-provider.jsx +763 -0
- package/docs/snippets/setup-prompt-card.jsx +22 -0
- package/docs/snippets/show-more.jsx +22 -0
- package/docs/snippets/video-card.jsx +34 -0
- package/docs/snippets/video.jsx +25 -0
- package/docs/subscriptions.mdx +10 -3
- package/docs/telemetry.mdx +39 -39
- package/package.json +3 -2
- package/schema/report-v1.schema.json +6 -0
- package/skills/e2e/references/agent.md +8 -3
- package/skills/e2e/references/debugging.md +1 -1
- package/skills/e2e/references/mcp.md +1 -1
- package/skills/e2e/references/setup.md +12 -3
- package/skills/e2e/references/writing-tests.md +1 -1
package/dist/oauth/errors.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
1
2
|
export class OAuthError extends Error {
|
|
2
3
|
code;
|
|
3
4
|
name = 'OAuthError';
|
|
@@ -6,26 +7,40 @@ export class OAuthError extends Error {
|
|
|
6
7
|
this.code = code;
|
|
7
8
|
}
|
|
8
9
|
}
|
|
10
|
+
/** The fields an error body names its failure with; a field of another type, or a body that is not an object, names nothing. */
|
|
11
|
+
const ErrorBodySchema = z
|
|
12
|
+
.object({
|
|
13
|
+
error: z.union([z.string(), z.object({ message: z.string() })]).optional().catch(undefined),
|
|
14
|
+
error_description: z.string().optional().catch(undefined),
|
|
15
|
+
message: z.string().optional().catch(undefined),
|
|
16
|
+
})
|
|
17
|
+
.catch({});
|
|
9
18
|
/** Reads an error body for a message without assuming JSON. */
|
|
10
19
|
export async function describeResponse(response) {
|
|
11
20
|
const text = await response.text().catch(() => '');
|
|
12
21
|
if (text === '')
|
|
13
22
|
return `${response.status} ${response.statusText}`.trim();
|
|
14
23
|
try {
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const detail = typeof json.error_description === 'string'
|
|
18
|
-
? json.error_description
|
|
19
|
-
: typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string'
|
|
20
|
-
? error.message
|
|
21
|
-
: typeof json.message === 'string'
|
|
22
|
-
? json.message
|
|
23
|
-
: typeof error === 'string'
|
|
24
|
-
? error
|
|
25
|
-
: undefined;
|
|
24
|
+
const { error, error_description, message } = ErrorBodySchema.parse(JSON.parse(text));
|
|
25
|
+
const detail = error_description ?? (typeof error === 'object' ? error.message : undefined) ?? message ?? (typeof error === 'string' ? error : undefined);
|
|
26
26
|
return detail === undefined ? `${response.status}: ${text.slice(0, 200)}` : `${response.status}: ${detail}`;
|
|
27
27
|
}
|
|
28
28
|
catch {
|
|
29
29
|
return `${response.status}: ${text.slice(0, 200)}`;
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/** A response's or request's JSON body checked against `schema`; FLOW_FAILED as `<subject> is not JSON` or `<subject> <invalid>` otherwise. */
|
|
33
|
+
export async function readJsonBody(source, schema, subject, invalid = 'is malformed') {
|
|
34
|
+
const text = await source.text();
|
|
35
|
+
let value;
|
|
36
|
+
try {
|
|
37
|
+
value = JSON.parse(text);
|
|
38
|
+
}
|
|
39
|
+
catch (cause) {
|
|
40
|
+
throw new OAuthError('FLOW_FAILED', `${subject} is not JSON`, { cause });
|
|
41
|
+
}
|
|
42
|
+
const result = schema.safeParse(value);
|
|
43
|
+
if (!result.success)
|
|
44
|
+
throw new OAuthError('FLOW_FAILED', `${subject} ${invalid}`, { cause: result.error });
|
|
45
|
+
return result.data;
|
|
46
|
+
}
|
|
@@ -48,3 +48,7 @@ export type CopilotProtocol = 'chat' | 'responses';
|
|
|
48
48
|
* missing listing and rejects.
|
|
49
49
|
*/
|
|
50
50
|
export declare function copilotProtocolFor(modelId: string, fetch: FetchFunction, signal: AbortSignal): Promise<CopilotProtocol | undefined>;
|
|
51
|
+
/** Why `modelId` cannot be used, as the run error reads it, once Copilot answered `model_not_supported`. */
|
|
52
|
+
export declare function copilotRefusal(modelId: string): string;
|
|
53
|
+
/** True for the AI SDK's error over a 4xx whose body code is `model_not_supported`; a wrong endpoint (`unsupported_api_for_model`) is not this. */
|
|
54
|
+
export declare function isCopilotRefusal(cause: unknown): cause is Error;
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { execFile } from 'node:child_process';
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
|
+
import { z } from 'zod';
|
|
9
10
|
import { rfc8628Flow } from '../device-code.js';
|
|
10
|
-
import { OAuthError, describeResponse } from '../errors.js';
|
|
11
|
+
import { OAuthError, describeResponse, readJsonBody } from '../errors.js';
|
|
11
12
|
const execFileAsync = promisify(execFile);
|
|
12
13
|
export const COPILOT_API_URL = 'https://api.githubcopilot.com';
|
|
13
14
|
/**
|
|
@@ -99,6 +100,13 @@ export async function sendCopilotRequest(request, credentials, upstream) {
|
|
|
99
100
|
headers.set('copilot-vision-request', 'true');
|
|
100
101
|
return upstream(new Request(url, { method: request.method, headers, signal: request.signal, ...(text === undefined ? {} : { body: text }) }));
|
|
101
102
|
}
|
|
103
|
+
/** One turn of either protocol, a chat `messages[]` entry or a Responses `input[]` item; one that is not an object reads as empty. */
|
|
104
|
+
const CopilotTurnSchema = z.object({ role: z.unknown().optional(), content: z.unknown().optional() }).catch({});
|
|
105
|
+
/** The turns of a chat or Responses body; a list of another type reads as absent. */
|
|
106
|
+
const CopilotBodySchema = z.object({
|
|
107
|
+
messages: z.array(CopilotTurnSchema).optional().catch(undefined),
|
|
108
|
+
input: z.array(CopilotTurnSchema).optional().catch(undefined),
|
|
109
|
+
});
|
|
102
110
|
/**
|
|
103
111
|
* Reads who initiated the turn and whether images are present, whichever
|
|
104
112
|
* protocol carried the body. Chat completions put turns in `messages[]` and
|
|
@@ -109,17 +117,16 @@ function copilotTurn(text) {
|
|
|
109
117
|
if (text === undefined)
|
|
110
118
|
return { initiator: 'user', vision: false };
|
|
111
119
|
try {
|
|
112
|
-
const
|
|
120
|
+
const { messages, input } = CopilotBodySchema.parse(JSON.parse(text));
|
|
113
121
|
// A Responses body has `input` and no `messages`; anything else stays on the chat path.
|
|
114
|
-
if (
|
|
115
|
-
const input = body.input;
|
|
122
|
+
if (messages === undefined && input !== undefined) {
|
|
116
123
|
return { initiator: input.at(-1)?.role === 'user' ? 'user' : 'agent', vision: input.some((item) => hasPart(item.content, 'input_image')) };
|
|
117
124
|
}
|
|
118
|
-
const
|
|
119
|
-
return { initiator:
|
|
125
|
+
const turns = messages ?? [];
|
|
126
|
+
return { initiator: turns.at(-1)?.role === 'user' ? 'user' : 'agent', vision: turns.some((message) => hasPart(message.content, 'image_url')) };
|
|
120
127
|
}
|
|
121
128
|
catch {
|
|
122
|
-
// Not JSON; the headers stay at their defaults.
|
|
129
|
+
// Not a JSON object; the headers stay at their defaults.
|
|
123
130
|
return { initiator: 'user', vision: false };
|
|
124
131
|
}
|
|
125
132
|
}
|
|
@@ -127,6 +134,26 @@ function copilotTurn(text) {
|
|
|
127
134
|
function hasPart(content, type) {
|
|
128
135
|
return Array.isArray(content) && content.some((part) => part?.type === type);
|
|
129
136
|
}
|
|
137
|
+
/** One entry of the Copilot API's `/models`, as far as the listing reads it; a field of another type reads as absent. */
|
|
138
|
+
const CopilotModelSchema = z.object({
|
|
139
|
+
id: z.string().min(1),
|
|
140
|
+
name: z.string().optional().catch(undefined),
|
|
141
|
+
vendor: z.string().optional().catch(undefined),
|
|
142
|
+
preview: z.unknown().optional(),
|
|
143
|
+
policy: z.object({ state: z.string().optional().catch(undefined) }).optional().catch(undefined),
|
|
144
|
+
supported_endpoints: z.array(z.unknown()).optional().catch(undefined),
|
|
145
|
+
capabilities: z
|
|
146
|
+
.object({
|
|
147
|
+
type: z.unknown().optional(),
|
|
148
|
+
supports: z.object({ tool_calls: z.unknown().optional(), vision: z.unknown().optional() }).optional().catch(undefined),
|
|
149
|
+
})
|
|
150
|
+
.optional()
|
|
151
|
+
.catch(undefined),
|
|
152
|
+
});
|
|
153
|
+
/** The models in a Copilot API `/models` body, `undefined` when it names none; an entry without an id is left out. */
|
|
154
|
+
const CopilotModelListSchema = z
|
|
155
|
+
.object({ data: z.array(CopilotModelSchema.optional().catch(undefined)).nullish() })
|
|
156
|
+
.transform(({ data }) => data?.filter((model) => model !== undefined));
|
|
130
157
|
/**
|
|
131
158
|
* The chat models the Copilot plan serves; embeddings and other kinds are
|
|
132
159
|
* left out because only chat models fit `copilot()`. A model it cannot call
|
|
@@ -138,23 +165,22 @@ async function listCopilotModels(fetch) {
|
|
|
138
165
|
const response = await fetch(`${COPILOT_API_URL}/models`);
|
|
139
166
|
if (!response.ok)
|
|
140
167
|
throw new OAuthError('FLOW_FAILED', `GitHub Copilot did not list its models: ${await describeResponse(response)}`);
|
|
141
|
-
const
|
|
142
|
-
const models = Array.isArray(payload.data) ? payload.data : [];
|
|
168
|
+
const models = (await readJsonBody(response, CopilotModelListSchema, 'the GitHub Copilot model list')) ?? [];
|
|
143
169
|
return models
|
|
144
|
-
.filter((model) =>
|
|
170
|
+
.filter((model) => model.capabilities?.type === undefined || model.capabilities.type === 'chat')
|
|
145
171
|
.map((model) => {
|
|
146
172
|
const supports = model.capabilities?.supports;
|
|
147
173
|
const detail = [
|
|
148
|
-
|
|
174
|
+
model.vendor,
|
|
149
175
|
supports?.tool_calls === true ? 'tools' : undefined,
|
|
150
176
|
supports?.vision === true ? 'vision' : undefined,
|
|
151
177
|
model.preview === true ? 'preview' : undefined,
|
|
152
|
-
|
|
178
|
+
model.policy?.state !== undefined && model.policy.state !== 'enabled' ? 'not enabled' : undefined,
|
|
153
179
|
copilotProtocol(model) === undefined ? 'no chat or responses' : undefined,
|
|
154
180
|
].filter((part) => part !== undefined);
|
|
155
181
|
return {
|
|
156
182
|
id: model.id,
|
|
157
|
-
...(
|
|
183
|
+
...(model.name === undefined || model.name === '' ? {} : { name: model.name }),
|
|
158
184
|
...(detail.length === 0 ? {} : { detail: detail.join(', ') }),
|
|
159
185
|
};
|
|
160
186
|
});
|
|
@@ -173,10 +199,10 @@ export async function copilotProtocolFor(modelId, fetch, signal) {
|
|
|
173
199
|
const response = await fetch(new Request(`${COPILOT_API_URL}/models`, { signal }));
|
|
174
200
|
if (!response.ok)
|
|
175
201
|
return undefined;
|
|
176
|
-
const
|
|
177
|
-
if (!
|
|
202
|
+
const listing = CopilotModelListSchema.safeParse(await response.json());
|
|
203
|
+
if (!listing.success || listing.data === undefined)
|
|
178
204
|
return undefined;
|
|
179
|
-
return copilotProtocol(
|
|
205
|
+
return copilotProtocol(listing.data.find((model) => model.id === modelId)) ?? 'chat';
|
|
180
206
|
}
|
|
181
207
|
catch (error) {
|
|
182
208
|
if (error instanceof OAuthError)
|
|
@@ -202,3 +228,25 @@ function copilotProtocol(model) {
|
|
|
202
228
|
return 'responses';
|
|
203
229
|
return undefined;
|
|
204
230
|
}
|
|
231
|
+
/** Why `modelId` cannot be used, as the run error reads it, once Copilot answered `model_not_supported`. */
|
|
232
|
+
export function copilotRefusal(modelId) {
|
|
233
|
+
return `GitHub Copilot does not serve ${modelId} to this account: the plan does not include it, it is turned off in Copilot settings on GitHub, or Copilot lists no such id; npx e2e models github-copilot lists the ids, and the listing cannot see plan limits`;
|
|
234
|
+
}
|
|
235
|
+
/** True for the AI SDK's error over a 4xx whose body code is `model_not_supported`; a wrong endpoint (`unsupported_api_for_model`) is not this. */
|
|
236
|
+
export function isCopilotRefusal(cause) {
|
|
237
|
+
if (!(cause instanceof Error) || cause.name !== 'AI_APICallError')
|
|
238
|
+
return false;
|
|
239
|
+
const { statusCode, data, responseBody } = cause;
|
|
240
|
+
if (typeof statusCode !== 'number' || statusCode < 400 || statusCode >= 500)
|
|
241
|
+
return false;
|
|
242
|
+
const parsed = data ?? (typeof responseBody === 'string' ? parseJson(responseBody) : undefined);
|
|
243
|
+
return parsed?.error?.code === 'model_not_supported';
|
|
244
|
+
}
|
|
245
|
+
function parseJson(text) {
|
|
246
|
+
try {
|
|
247
|
+
return JSON.parse(text);
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -28,8 +28,8 @@ export interface CodexProviderOptions {
|
|
|
28
28
|
}
|
|
29
29
|
export declare function createCodexProvider(options?: CodexProviderOptions): OAuthProvider<CodexCredentials, CodexLoginOptions>;
|
|
30
30
|
export declare function extractAccountId(tokens: {
|
|
31
|
-
id_token?: string;
|
|
32
|
-
access_token?: string;
|
|
31
|
+
id_token?: string | undefined;
|
|
32
|
+
access_token?: string | undefined;
|
|
33
33
|
}): string | undefined;
|
|
34
34
|
/** The code from what the user pasted: a full redirect URL, `code#state`, a query string, or the bare code. */
|
|
35
35
|
export declare function parseAuthorizationInput(input: string): {
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* Responses API, requires `store: false`, and answers only as a stream; the
|
|
7
7
|
* stream is folded back into JSON when the SDK asked for a single response.
|
|
8
8
|
*/
|
|
9
|
+
import { z } from 'zod';
|
|
9
10
|
import { startCallbackServer } from '../callback-server.js';
|
|
10
11
|
import { runDeviceFlow } from '../device-code.js';
|
|
11
|
-
import { OAuthError, describeResponse } from '../errors.js';
|
|
12
|
+
import { OAuthError, describeResponse, readJsonBody } from '../errors.js';
|
|
12
13
|
import { decodeJwtPayload } from '../jwt.js';
|
|
13
14
|
import { generatePkce, randomState } from '../pkce.js';
|
|
14
15
|
import { foldResponsesStream } from '../sse.js';
|
|
@@ -192,6 +193,17 @@ async function pasteCode(callbacks, state) {
|
|
|
192
193
|
}
|
|
193
194
|
return pasted.code;
|
|
194
195
|
}
|
|
196
|
+
/** OpenAI's user-code answer: the code to show and the id the poll names it by. */
|
|
197
|
+
const UserCodeSchema = z.object({
|
|
198
|
+
device_auth_id: z.string(),
|
|
199
|
+
user_code: z.string(),
|
|
200
|
+
interval: z.unknown().optional(),
|
|
201
|
+
});
|
|
202
|
+
/** OpenAI's answer once the user entered the code: an authorization code and the PKCE verifier to exchange it with. */
|
|
203
|
+
const DeviceGrantSchema = z.object({
|
|
204
|
+
authorization_code: z.string(),
|
|
205
|
+
code_verifier: z.string(),
|
|
206
|
+
});
|
|
195
207
|
/** The Codex CLI's device login: OpenAI's own user-code endpoints, pending until the user enters the code. */
|
|
196
208
|
function deviceLogin(callbacks, issuer, timeoutMs) {
|
|
197
209
|
const json = (url, body, signal) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), ...(signal === undefined ? {} : { signal }) });
|
|
@@ -201,7 +213,7 @@ function deviceLogin(callbacks, issuer, timeoutMs) {
|
|
|
201
213
|
const response = await json(`${issuer}/api/accounts/deviceauth/usercode`, { client_id: CLIENT_ID }, signal);
|
|
202
214
|
if (!response.ok)
|
|
203
215
|
throw new OAuthError('FLOW_FAILED', `ChatGPT device login could not start: ${await describeResponse(response)}`);
|
|
204
|
-
const device =
|
|
216
|
+
const device = await readJsonBody(response, UserCodeSchema, 'the ChatGPT device code response', 'is missing fields');
|
|
205
217
|
return {
|
|
206
218
|
deviceCode: device.device_auth_id,
|
|
207
219
|
userCode: device.user_code,
|
|
@@ -213,7 +225,7 @@ function deviceLogin(callbacks, issuer, timeoutMs) {
|
|
|
213
225
|
async poll(authorization, signal) {
|
|
214
226
|
const response = await json(`${issuer}/api/accounts/deviceauth/token`, { device_auth_id: authorization.deviceCode, user_code: authorization.userCode }, signal);
|
|
215
227
|
if (response.ok) {
|
|
216
|
-
const grant =
|
|
228
|
+
const grant = await readJsonBody(response, DeviceGrantSchema, 'the ChatGPT device grant', 'is missing fields');
|
|
217
229
|
return { status: 'granted', value: await exchangeCode(issuer, grant.authorization_code, `${issuer}/deviceauth/callback`, grant.code_verifier, signal) };
|
|
218
230
|
}
|
|
219
231
|
// Pending shows as 403 or 404 until the user enters the code.
|
|
@@ -225,6 +237,14 @@ function deviceLogin(callbacks, issuer, timeoutMs) {
|
|
|
225
237
|
},
|
|
226
238
|
});
|
|
227
239
|
}
|
|
240
|
+
/** A Responses API body, as far as the Codex backend needs it changed; every other field passes through. */
|
|
241
|
+
const CodexBodySchema = z.looseObject({
|
|
242
|
+
stream: z.unknown().optional(),
|
|
243
|
+
store: z.unknown().optional(),
|
|
244
|
+
instructions: z.unknown().optional(),
|
|
245
|
+
include: z.array(z.unknown()).nullish(),
|
|
246
|
+
max_output_tokens: z.unknown().optional(),
|
|
247
|
+
});
|
|
228
248
|
/**
|
|
229
249
|
* Routes a Responses API request at the Codex backend with the body the
|
|
230
250
|
* Codex CLI sends: no server-side storage, encrypted reasoning carried
|
|
@@ -240,13 +260,7 @@ export async function sendCodexRequest(request, credentials, upstream, options)
|
|
|
240
260
|
return upstream(new Request(request, { headers }));
|
|
241
261
|
if (credentials.residency !== undefined)
|
|
242
262
|
headers.set('x-openai-internal-codex-residency', credentials.residency);
|
|
243
|
-
|
|
244
|
-
try {
|
|
245
|
-
body = (await request.json());
|
|
246
|
-
}
|
|
247
|
-
catch (cause) {
|
|
248
|
-
throw new OAuthError('FLOW_FAILED', 'the Responses request body is not JSON', { cause });
|
|
249
|
-
}
|
|
263
|
+
const body = await readJsonBody(request, CodexBodySchema, 'the Responses request body');
|
|
250
264
|
const wantedStream = body.stream === true;
|
|
251
265
|
body.stream = true;
|
|
252
266
|
body.store = false;
|
|
@@ -260,6 +274,24 @@ export async function sendCodexRequest(request, credentials, upstream, options)
|
|
|
260
274
|
const response = await upstream(new Request(options.apiUrl, { method: request.method, headers, body: JSON.stringify(body), signal: request.signal }));
|
|
261
275
|
return wantedStream ? response : foldResponsesStream(response);
|
|
262
276
|
}
|
|
277
|
+
/** A reasoning level the Codex backend names bare or as `{ effort }`; one of another shape reads as absent. */
|
|
278
|
+
const ReasoningLevelSchema = z
|
|
279
|
+
.union([z.string(), z.object({ effort: z.string() }).transform(({ effort }) => effort)])
|
|
280
|
+
.optional()
|
|
281
|
+
.catch(undefined);
|
|
282
|
+
/** One entry of the Codex backend's model list, as far as the listing reads it; a field of another type reads as absent. */
|
|
283
|
+
const CodexModelSchema = z.object({
|
|
284
|
+
slug: z.string().min(1),
|
|
285
|
+
display_name: z.string().optional().catch(undefined),
|
|
286
|
+
visibility: z.unknown().optional(),
|
|
287
|
+
default_reasoning_level: z.string().optional().catch(undefined),
|
|
288
|
+
supported_reasoning_levels: z.array(ReasoningLevelSchema).optional().catch(undefined),
|
|
289
|
+
priority: z.number().optional().catch(undefined),
|
|
290
|
+
});
|
|
291
|
+
/** The models in the Codex backend's `/models` body; a body that names none has none, and an entry without a slug is left out. */
|
|
292
|
+
const CodexModelListSchema = z
|
|
293
|
+
.object({ models: z.array(CodexModelSchema.optional().catch(undefined)).nullish() })
|
|
294
|
+
.transform(({ models }) => (models ?? []).filter((model) => model !== undefined));
|
|
263
295
|
/**
|
|
264
296
|
* The models the Codex backend serves this subscription, `/models` next to
|
|
265
297
|
* `/responses`. Hidden ones are listed too, marked, because the backend
|
|
@@ -273,22 +305,18 @@ async function listCodexModels(fetch, apiUrl) {
|
|
|
273
305
|
const response = await fetch(url);
|
|
274
306
|
if (!response.ok)
|
|
275
307
|
throw new OAuthError('FLOW_FAILED', `ChatGPT did not list its models: ${await describeResponse(response)}`);
|
|
276
|
-
const
|
|
277
|
-
const models = Array.isArray(payload.models) ? payload.models : [];
|
|
308
|
+
const models = await readJsonBody(response, CodexModelListSchema, 'the ChatGPT model list');
|
|
278
309
|
return models
|
|
279
|
-
.
|
|
280
|
-
.toSorted((a, b) => (typeof a.priority === 'number' ? a.priority : 0) - (typeof b.priority === 'number' ? b.priority : 0))
|
|
310
|
+
.toSorted((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
|
|
281
311
|
.map((model) => {
|
|
282
|
-
const levels =
|
|
283
|
-
? model.supported_reasoning_levels.map((level) => (typeof level === 'string' ? level : level?.effort)).filter((level) => typeof level === 'string')
|
|
284
|
-
: [];
|
|
312
|
+
const levels = (model.supported_reasoning_levels ?? []).filter((level) => level !== undefined);
|
|
285
313
|
const detail = [
|
|
286
|
-
levels.length === 0 ? undefined : `reasoning ${levels.join('/')}${
|
|
314
|
+
levels.length === 0 ? undefined : `reasoning ${levels.join('/')}${model.default_reasoning_level === undefined ? '' : ` (default ${model.default_reasoning_level})`}`,
|
|
287
315
|
model.visibility === 'hide' ? 'hidden in Codex' : undefined,
|
|
288
316
|
].filter((part) => part !== undefined);
|
|
289
317
|
return {
|
|
290
318
|
id: model.slug,
|
|
291
|
-
...(
|
|
319
|
+
...(model.display_name === undefined || model.display_name === '' ? {} : { name: model.display_name }),
|
|
292
320
|
...(detail.length === 0 ? {} : { detail: detail.join('; ') }),
|
|
293
321
|
};
|
|
294
322
|
});
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* over which protocol: Zen is its `opencode` provider, Go `opencode-go`, present
|
|
7
7
|
* only for the Go subscriber. Go ids take a `go/` prefix here.
|
|
8
8
|
*/
|
|
9
|
+
import { z } from 'zod';
|
|
9
10
|
import { rfc8628Flow } from '../device-code.js';
|
|
10
|
-
import { OAuthError, describeResponse } from '../errors.js';
|
|
11
|
+
import { OAuthError, describeResponse, readJsonBody } from '../errors.js';
|
|
11
12
|
import { expiryFrom, requestTokens } from '../token-endpoint.js';
|
|
12
13
|
/** Console accepts any client id. */
|
|
13
14
|
const CLIENT_ID = 'e2e';
|
|
@@ -69,26 +70,46 @@ async function sendOpencodeConsoleRequest(request, credentials, upstream, consol
|
|
|
69
70
|
const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
|
|
70
71
|
return upstream(new Request(request.url, { method: request.method, headers, signal: request.signal, ...(body === undefined ? {} : { body }) }));
|
|
71
72
|
}
|
|
73
|
+
/** The npm package and base URL a provider or one of its models is served through; a field of another type reads as absent. */
|
|
74
|
+
const ConfigRouteSchema = z.object({
|
|
75
|
+
npm: z.string().optional().catch(undefined),
|
|
76
|
+
api: z.string().optional().catch(undefined),
|
|
77
|
+
});
|
|
78
|
+
/** One model of a workspace provider, as far as routing and the listing read it; a field of another type reads as absent. */
|
|
79
|
+
const ConfigModelSchema = z.object({
|
|
80
|
+
id: z.string().optional().catch(undefined),
|
|
81
|
+
name: z.string().optional().catch(undefined),
|
|
82
|
+
disabled: z.unknown().optional(),
|
|
83
|
+
cost: z.object({ input: z.number().optional().catch(undefined), output: z.number().optional().catch(undefined) }).optional().catch(undefined),
|
|
84
|
+
modalities: z.object({ input: z.array(z.unknown()).optional().catch(undefined) }).optional().catch(undefined),
|
|
85
|
+
provider: ConfigRouteSchema.optional().catch(undefined),
|
|
86
|
+
});
|
|
87
|
+
/** One provider of the workspace config; a model that is not an object is left out. */
|
|
88
|
+
const ConfigProviderSchema = ConfigRouteSchema.extend({
|
|
89
|
+
models: z.record(z.string(), ConfigModelSchema.optional().catch(undefined)).optional().catch(undefined),
|
|
90
|
+
});
|
|
91
|
+
/** The providers of the Console's `/api/config` body by id; a body that names none has none, and a provider that is not an object is left out. */
|
|
92
|
+
const ConfigSchema = z
|
|
93
|
+
.object({ config: z.object({ provider: z.record(z.string(), ConfigProviderSchema.optional().catch(undefined)).nullish() }).nullish() })
|
|
94
|
+
.transform(({ config }) => config?.provider ?? {});
|
|
72
95
|
async function readConfig(fetch, consoleUrl, signal) {
|
|
73
96
|
const response = await fetch(new Request(`${consoleUrl}/api/config`, signal === undefined ? {} : { signal }));
|
|
74
97
|
if (!response.ok)
|
|
75
98
|
throw new OAuthError('FLOW_FAILED', `OpenCode Console did not return the workspace config: ${await describeResponse(response)}`);
|
|
76
|
-
|
|
77
|
-
const providers = payload.config?.provider;
|
|
78
|
-
return typeof providers === 'object' && providers !== null ? providers : {};
|
|
99
|
+
return readJsonBody(response, ConfigSchema, 'the OpenCode Console workspace config');
|
|
79
100
|
}
|
|
80
101
|
/** Leaves out disabled models and Zen's free ones, which only OpenCode's own clients may use. */
|
|
81
102
|
async function listOpencodeConsoleModels(fetch, consoleUrl) {
|
|
82
103
|
const providers = await readConfig(fetch, consoleUrl);
|
|
83
|
-
const listed = (key, prefix, plan) => Object.entries(providers[key]?.models ?? {})
|
|
84
|
-
|
|
85
|
-
|
|
104
|
+
const listed = (key, prefix, plan) => Object.entries(providers[key]?.models ?? {}).flatMap(([id, model]) => {
|
|
105
|
+
if (model === undefined || model.disabled === true || (key !== GO_PROVIDER && isFree(model)))
|
|
106
|
+
return [];
|
|
86
107
|
const detail = [
|
|
87
108
|
plan,
|
|
88
|
-
|
|
89
|
-
|
|
109
|
+
model.cost?.input !== undefined && model.cost.output !== undefined && key === ZEN_PROVIDER ? `$${model.cost.input} in, $${model.cost.output} out per 1M` : undefined,
|
|
110
|
+
model.modalities?.input?.includes('image') === true ? 'vision' : undefined,
|
|
90
111
|
].filter((part) => part !== undefined);
|
|
91
|
-
return { id: `${prefix}${id}`, ...(
|
|
112
|
+
return [{ id: `${prefix}${id}`, ...(model.name === undefined || model.name === '' ? {} : { name: model.name }), detail: detail.join(', ') }];
|
|
92
113
|
});
|
|
93
114
|
return [...listed(ZEN_PROVIDER, '', 'Zen'), ...listed(GO_PROVIDER, GO_PREFIX, 'Go')];
|
|
94
115
|
}
|
|
@@ -130,15 +151,15 @@ export async function opencodeConsoleRouteFor(modelId, fetch, signal) {
|
|
|
130
151
|
if (model === undefined || model.disabled === true) {
|
|
131
152
|
throw new OAuthError('MISCONFIGURED', `the OpenCode Console workspace does not serve ${modelId}; \`npx e2e models opencode-console\` lists the ids`);
|
|
132
153
|
}
|
|
133
|
-
const npm =
|
|
134
|
-
const baseURL =
|
|
135
|
-
if (
|
|
154
|
+
const npm = model.provider?.npm ?? provider.npm;
|
|
155
|
+
const baseURL = model.provider?.api ?? provider.api;
|
|
156
|
+
if (npm === undefined || baseURL === undefined)
|
|
136
157
|
return undefined;
|
|
137
|
-
return { go, npm, baseURL, modelId:
|
|
158
|
+
return { go, npm, baseURL, modelId: model.id === undefined || model.id === '' ? id : model.id };
|
|
138
159
|
}
|
|
139
160
|
/** A refresh keeps the workspace and the refresh token when the grant omits them. */
|
|
140
161
|
function toCredentials(tokens, previous) {
|
|
141
|
-
const orgId = tokens
|
|
162
|
+
const orgId = tokens['org_id'];
|
|
142
163
|
const workspace = typeof orgId === 'string' && orgId !== '' ? orgId : previous?.orgId;
|
|
143
164
|
return {
|
|
144
165
|
access: tokens.access_token,
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* bearer against the ordinary SpaceXAI API. Refresh tokens rotate, so a renewed
|
|
5
5
|
* refresh token replaces the stored one at once.
|
|
6
6
|
*/
|
|
7
|
+
import { z } from 'zod';
|
|
7
8
|
import { rfc8628Flow } from '../device-code.js';
|
|
8
|
-
import { OAuthError, describeResponse } from '../errors.js';
|
|
9
|
+
import { OAuthError, describeResponse, readJsonBody } from '../errors.js';
|
|
9
10
|
import { decodeJwtPayload } from '../jwt.js';
|
|
10
11
|
import { expiryFrom, requestTokens } from '../token-endpoint.js';
|
|
11
12
|
/** The public OAuth client of the Grok CLI, which third-party harnesses sign in through. */
|
|
@@ -40,20 +41,28 @@ export function createXaiProvider(options = {}) {
|
|
|
40
41
|
},
|
|
41
42
|
};
|
|
42
43
|
}
|
|
44
|
+
/** One entry of SpaceXAI's language-model list, as far as the listing reads it; a field of another type reads as absent. */
|
|
45
|
+
const XaiModelSchema = z.object({
|
|
46
|
+
id: z.string().min(1),
|
|
47
|
+
aliases: z.array(z.unknown()).optional().catch(undefined),
|
|
48
|
+
input_modalities: z.array(z.unknown()).optional().catch(undefined),
|
|
49
|
+
});
|
|
50
|
+
/** One of the two keys SpaceXAI lists models under; an entry that does not fit reads as `undefined`. */
|
|
51
|
+
const XaiModelsSchema = z.array(XaiModelSchema.optional().catch(undefined)).nullish();
|
|
52
|
+
/** The models in SpaceXAI's list body, under `models` or `data`; a body that names none has none, and an entry without an id is left out. */
|
|
53
|
+
const XaiModelListSchema = z
|
|
54
|
+
.object({ models: XaiModelsSchema, data: XaiModelsSchema })
|
|
55
|
+
.transform(({ models, data }) => (models ?? data ?? []).filter((model) => model !== undefined));
|
|
43
56
|
/** The language models the subscription's token reaches; aliases are listed with their target. */
|
|
44
57
|
async function listXaiModels(fetch, url) {
|
|
45
58
|
const response = await fetch(url);
|
|
46
59
|
if (!response.ok)
|
|
47
60
|
throw new OAuthError('FLOW_FAILED', `SpaceXAI did not list its models: ${await describeResponse(response)}`);
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
.filter((model) => typeof model.id === 'string' && model.id !== '')
|
|
52
|
-
.map((model) => {
|
|
53
|
-
const aliases = Array.isArray(model.aliases) ? model.aliases.filter((alias) => typeof alias === 'string') : [];
|
|
54
|
-
const modalities = Array.isArray(model.input_modalities) ? model.input_modalities.filter((kind) => typeof kind === 'string') : [];
|
|
61
|
+
const models = await readJsonBody(response, XaiModelListSchema, 'the SpaceXAI model list');
|
|
62
|
+
return models.map((model) => {
|
|
63
|
+
const aliases = (model.aliases ?? []).filter((alias) => typeof alias === 'string');
|
|
55
64
|
const detail = [
|
|
56
|
-
|
|
65
|
+
model.input_modalities?.includes('image') === true ? 'vision' : undefined,
|
|
57
66
|
aliases.length === 0 ? undefined : `also ${aliases.join(', ')}`,
|
|
58
67
|
].filter((part) => part !== undefined);
|
|
59
68
|
return { id: model.id, ...(detail.length === 0 ? {} : { detail: detail.join('; ') }) };
|
|
@@ -4,13 +4,19 @@
|
|
|
4
4
|
* wire are coerced defensively: a `NaN` expiry written to disk would poison
|
|
5
5
|
* the credentials file.
|
|
6
6
|
*/
|
|
7
|
+
import { z } from 'zod';
|
|
7
8
|
import { type OAuthErrorCode } from './errors.ts';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
/**
|
|
10
|
+
* A token response. Vendor-specific fields ride along; an optional token of the
|
|
11
|
+
* wrong type is dropped, and `expires_in` stays raw for `expiryFrom`.
|
|
12
|
+
*/
|
|
13
|
+
declare const TokenResponseSchema: z.ZodObject<{
|
|
14
|
+
access_token: z.ZodString;
|
|
15
|
+
refresh_token: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
16
|
+
id_token: z.ZodCatch<z.ZodOptional<z.ZodString>>;
|
|
17
|
+
expires_in: z.ZodOptional<z.ZodUnknown>;
|
|
18
|
+
}, z.core.$loose>;
|
|
19
|
+
export type TokenResponse = z.infer<typeof TokenResponseSchema>;
|
|
14
20
|
export declare function postForm(url: string, params: Record<string, string>, signal?: AbortSignal): Promise<Response>;
|
|
15
21
|
/**
|
|
16
22
|
* Posts a grant and returns the tokens. A 400 or 401 means the grant itself
|
|
@@ -19,7 +25,10 @@ export declare function postForm(url: string, params: Record<string, string>, si
|
|
|
19
25
|
* the failure only; the fetch that called for the refresh appends the remedy.
|
|
20
26
|
*/
|
|
21
27
|
export declare function requestTokens(vendor: string, url: string, params: Record<string, string>, rejected?: OAuthErrorCode, signal?: AbortSignal): Promise<TokenResponse>;
|
|
28
|
+
/** The tokens in a token endpoint's JSON body, or undefined when it carries no access token. */
|
|
29
|
+
export declare function parseTokens(body: unknown): TokenResponse | undefined;
|
|
22
30
|
/** A seconds value from the server, or the default when it is missing or not a positive finite number. */
|
|
23
31
|
export declare function positiveSeconds(value: unknown, fallback: number): number;
|
|
24
32
|
/** When a token expires, from `expires_in`; one hour when the server names nothing usable. */
|
|
25
33
|
export declare function expiryFrom(expiresIn: unknown, now?: number): number;
|
|
34
|
+
export {};
|
|
@@ -4,7 +4,18 @@
|
|
|
4
4
|
* wire are coerced defensively: a `NaN` expiry written to disk would poison
|
|
5
5
|
* the credentials file.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { OAuthError, describeResponse, readJsonBody } from './errors.js';
|
|
9
|
+
/**
|
|
10
|
+
* A token response. Vendor-specific fields ride along; an optional token of the
|
|
11
|
+
* wrong type is dropped, and `expires_in` stays raw for `expiryFrom`.
|
|
12
|
+
*/
|
|
13
|
+
const TokenResponseSchema = z.looseObject({
|
|
14
|
+
access_token: z.string().min(1),
|
|
15
|
+
refresh_token: z.string().optional().catch(undefined),
|
|
16
|
+
id_token: z.string().optional().catch(undefined),
|
|
17
|
+
expires_in: z.unknown().optional(),
|
|
18
|
+
});
|
|
8
19
|
export async function postForm(url, params, signal) {
|
|
9
20
|
return fetch(url, {
|
|
10
21
|
method: 'POST',
|
|
@@ -25,11 +36,12 @@ export async function requestTokens(vendor, url, params, rejected = 'FLOW_FAILED
|
|
|
25
36
|
const code = response.status === 400 || response.status === 401 ? rejected : 'FLOW_FAILED';
|
|
26
37
|
throw new OAuthError(code, `${vendor} token request failed (${await describeResponse(response)})`);
|
|
27
38
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
return readJsonBody(response, TokenResponseSchema, `the ${vendor} token response`, 'is missing access_token');
|
|
40
|
+
}
|
|
41
|
+
/** The tokens in a token endpoint's JSON body, or undefined when it carries no access token. */
|
|
42
|
+
export function parseTokens(body) {
|
|
43
|
+
const result = TokenResponseSchema.safeParse(body);
|
|
44
|
+
return result.success ? result.data : undefined;
|
|
33
45
|
}
|
|
34
46
|
/** A seconds value from the server, or the default when it is missing or not a positive finite number. */
|
|
35
47
|
export function positiveSeconds(value, fallback) {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { readFileSync } from 'node:fs';
|
|
7
7
|
import { userFrame } from '../internal/source.js';
|
|
8
|
-
import { bounded, terminalColumns } from './format.js';
|
|
8
|
+
import { bounded, fitColumns, terminalColumns, visibleWidth } from './format.js';
|
|
9
9
|
export { userFrame };
|
|
10
10
|
/** Lines of context on each side of the failing line. */
|
|
11
11
|
const FRAME_RANGE = 2;
|
|
@@ -36,10 +36,16 @@ export function codeFrame(frame, pc) {
|
|
|
36
36
|
const raw = bounded(lines[i] ?? '').replaceAll('\t', ' ');
|
|
37
37
|
if (raw.length > MAX_FRAME_LINE_CHARS)
|
|
38
38
|
return [];
|
|
39
|
-
const text = raw.
|
|
39
|
+
const text = fitColumns(raw.trimEnd(), maxChars);
|
|
40
40
|
rows.push(`${gutter(i + 1)}${text === '' ? '' : ` ${text}`}`);
|
|
41
41
|
if (i === index) {
|
|
42
|
-
|
|
42
|
+
const prefix = bounded((lines[i] ?? '').slice(0, Math.max(0, frame.column - 1)));
|
|
43
|
+
const column = visibleWidth(prefix);
|
|
44
|
+
// A clipped-away location has no visible token to annotate.
|
|
45
|
+
const limit = text === raw.trimEnd() ? maxChars : visibleWidth(text) - 1;
|
|
46
|
+
if (column < limit) {
|
|
47
|
+
rows.push(`${gutter()}${' '.repeat(column + 1)}${pc.red('^')}`);
|
|
48
|
+
}
|
|
43
49
|
}
|
|
44
50
|
}
|
|
45
51
|
return rows;
|
|
@@ -55,8 +55,16 @@ export declare function toldAttempt(result: ReportResult, final: Outcome): Attem
|
|
|
55
55
|
export type FailedStep = ReportStep & {
|
|
56
56
|
readonly status: Exclude<ReportStep['status'], 'passed'>;
|
|
57
57
|
};
|
|
58
|
-
/**
|
|
59
|
-
|
|
58
|
+
/**
|
|
59
|
+
* The step whose error ended the attempt, with its position: the step in the
|
|
60
|
+
* error's phase whose own error carries its code and line, else the last with
|
|
61
|
+
* its code, since a caught failure comes earlier, else the step a timeout or
|
|
62
|
+
* interrupt cut.
|
|
63
|
+
*/
|
|
64
|
+
export declare function failedStepOf(attempt: {
|
|
65
|
+
readonly steps: readonly ReportStep[];
|
|
66
|
+
readonly error?: ReportError | undefined;
|
|
67
|
+
}): {
|
|
60
68
|
index: number;
|
|
61
69
|
step: FailedStep;
|
|
62
70
|
} | undefined;
|