walkwright 0.0.1
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/LICENSE +21 -0
- package/README.md +276 -0
- package/bin/walkwright.js +3 -0
- package/build/aria.d.ts +92 -0
- package/build/aria.js +215 -0
- package/build/aria.js.map +1 -0
- package/build/browser.d.ts +3 -0
- package/build/browser.js +18 -0
- package/build/browser.js.map +1 -0
- package/build/chunk-5CHKYNP7.js +507 -0
- package/build/chunk-5CHKYNP7.js.map +1 -0
- package/build/chunk-6YRKPGUO.js +1197 -0
- package/build/chunk-6YRKPGUO.js.map +1 -0
- package/build/chunk-A452S7R3.js +35 -0
- package/build/chunk-A452S7R3.js.map +1 -0
- package/build/chunk-BODO6HIH.js +60 -0
- package/build/chunk-BODO6HIH.js.map +1 -0
- package/build/chunk-FJI2CMCE.js +439 -0
- package/build/chunk-FJI2CMCE.js.map +1 -0
- package/build/chunk-JYHZVRB2.js +80 -0
- package/build/chunk-JYHZVRB2.js.map +1 -0
- package/build/chunk-KQN5IYN6.js +97 -0
- package/build/chunk-KQN5IYN6.js.map +1 -0
- package/build/chunk-KTXNERHT.js +72 -0
- package/build/chunk-KTXNERHT.js.map +1 -0
- package/build/chunk-MFTC7XL2.js +141 -0
- package/build/chunk-MFTC7XL2.js.map +1 -0
- package/build/chunk-N4SSLHMP.js +108 -0
- package/build/chunk-N4SSLHMP.js.map +1 -0
- package/build/chunk-RNNW7OW2.js +270 -0
- package/build/chunk-RNNW7OW2.js.map +1 -0
- package/build/chunk-UAWBPTDW.js +51 -0
- package/build/chunk-UAWBPTDW.js.map +1 -0
- package/build/chunk-Z7CW5CAY.js +64 -0
- package/build/chunk-Z7CW5CAY.js.map +1 -0
- package/build/cli.d.ts +1 -0
- package/build/cli.js +253 -0
- package/build/cli.js.map +1 -0
- package/build/config.d.ts +13 -0
- package/build/config.js +15 -0
- package/build/config.js.map +1 -0
- package/build/descriptor.d.ts +96 -0
- package/build/descriptor.js +27 -0
- package/build/descriptor.js.map +1 -0
- package/build/device-auth.d.ts +6 -0
- package/build/device-auth.js +11 -0
- package/build/device-auth.js.map +1 -0
- package/build/locator.d.ts +140 -0
- package/build/locator.js +92 -0
- package/build/locator.js.map +1 -0
- package/build/paths.d.ts +11 -0
- package/build/paths.js +22 -0
- package/build/paths.js.map +1 -0
- package/build/pom-kcSMxwX2.d.ts +349 -0
- package/build/pom.d.ts +6 -0
- package/build/pom.js +37 -0
- package/build/pom.js.map +1 -0
- package/build/project.d.ts +13 -0
- package/build/project.js +19 -0
- package/build/project.js.map +1 -0
- package/build/provisioner.d.ts +34 -0
- package/build/provisioner.js +14 -0
- package/build/provisioner.js.map +1 -0
- package/build/recorder.d.ts +6 -0
- package/build/recorder.js +15 -0
- package/build/recorder.js.map +1 -0
- package/build/reporter.d.ts +16 -0
- package/build/reporter.js +426 -0
- package/build/reporter.js.map +1 -0
- package/build/schema.d.ts +49 -0
- package/build/schema.js +16 -0
- package/build/schema.js.map +1 -0
- package/build/storage-state.d.ts +22 -0
- package/build/storage-state.js +42 -0
- package/build/storage-state.js.map +1 -0
- package/build/store.d.ts +17 -0
- package/build/store.js +9 -0
- package/build/store.js.map +1 -0
- package/build/ui.d.ts +49 -0
- package/build/ui.js +56 -0
- package/build/ui.js.map +1 -0
- package/build/upload.d.ts +10 -0
- package/build/upload.js +8 -0
- package/build/upload.js.map +1 -0
- package/build/wait.d.ts +14 -0
- package/build/wait.js +50 -0
- package/build/wait.js.map +1 -0
- package/package.json +52 -0
- package/src/aria.ts +326 -0
- package/src/browser.ts +15 -0
- package/src/cli.ts +295 -0
- package/src/config.ts +16 -0
- package/src/descriptor.ts +179 -0
- package/src/device-auth.ts +188 -0
- package/src/locator.ts +683 -0
- package/src/paths.ts +70 -0
- package/src/pom.ts +2363 -0
- package/src/project.ts +77 -0
- package/src/provisioner.ts +183 -0
- package/src/recorder.ts +877 -0
- package/src/reporter.ts +561 -0
- package/src/schema.ts +37 -0
- package/src/storage-state.ts +71 -0
- package/src/store.ts +96 -0
- package/src/ui.ts +333 -0
- package/src/upload.ts +116 -0
- package/src/wait.ts +63 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { ensureProject, login } from "./device-auth.js";
|
|
7
|
+
import {
|
|
8
|
+
defaultReportsUrl,
|
|
9
|
+
describeConfigPath,
|
|
10
|
+
readWalkwrightConfig,
|
|
11
|
+
repoName,
|
|
12
|
+
writeWalkwrightConfig,
|
|
13
|
+
} from "./project.js";
|
|
14
|
+
import {
|
|
15
|
+
blank,
|
|
16
|
+
bold,
|
|
17
|
+
failure,
|
|
18
|
+
glyph,
|
|
19
|
+
line,
|
|
20
|
+
logo,
|
|
21
|
+
paint,
|
|
22
|
+
wordmark,
|
|
23
|
+
} from "./ui.js";
|
|
24
|
+
|
|
25
|
+
const [command, ...args] = process.argv.slice(2);
|
|
26
|
+
|
|
27
|
+
switch (command) {
|
|
28
|
+
case "walk":
|
|
29
|
+
await walkCommand(args);
|
|
30
|
+
break;
|
|
31
|
+
case "login":
|
|
32
|
+
await loginCommand(args);
|
|
33
|
+
break;
|
|
34
|
+
case "--version":
|
|
35
|
+
case "-v":
|
|
36
|
+
banner();
|
|
37
|
+
break;
|
|
38
|
+
case undefined:
|
|
39
|
+
case "--help":
|
|
40
|
+
case "-h":
|
|
41
|
+
case "?":
|
|
42
|
+
help();
|
|
43
|
+
break;
|
|
44
|
+
default:
|
|
45
|
+
fail(`unknown command ${command}`, "walkwright --help lists the commands");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function version(): string {
|
|
49
|
+
const { version: value } = createRequire(import.meta.url)(
|
|
50
|
+
"../package.json",
|
|
51
|
+
) as { version: string };
|
|
52
|
+
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function banner(): void {
|
|
57
|
+
blank();
|
|
58
|
+
|
|
59
|
+
for (const row of logo()) {
|
|
60
|
+
line(row);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
blank();
|
|
64
|
+
line(paint("dim", ` walkwright ${version()}`));
|
|
65
|
+
blank();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function help(): void {
|
|
69
|
+
const flag = (name: string, text: string): string =>
|
|
70
|
+
` ${paint("code", name.padEnd(20))}${paint("dim", text)}`;
|
|
71
|
+
const header = (text: string): string => paint("accent", text);
|
|
72
|
+
|
|
73
|
+
blank();
|
|
74
|
+
line(`${wordmark()} ${paint("dim", version())}`);
|
|
75
|
+
blank();
|
|
76
|
+
line(header("USAGE"));
|
|
77
|
+
line(
|
|
78
|
+
` ${paint("text", "walkwright walk")} ${paint("dim", "[flags] [playwright args...]")}`,
|
|
79
|
+
);
|
|
80
|
+
line(
|
|
81
|
+
` ${paint("text", "walkwright login")} ${paint("dim", "[--force] [--url <server>]")}`,
|
|
82
|
+
);
|
|
83
|
+
blank();
|
|
84
|
+
line(header("WALK"));
|
|
85
|
+
line(
|
|
86
|
+
flag(
|
|
87
|
+
"--upload-on-fail",
|
|
88
|
+
"upload the passing lanes even though the walk failed",
|
|
89
|
+
),
|
|
90
|
+
);
|
|
91
|
+
line(flag("--no-upload", "skip the upload"));
|
|
92
|
+
line(flag("--branch <name>", "report under this branch instead of git's"));
|
|
93
|
+
line(
|
|
94
|
+
flag("--project <name>", "the playwright project to run (default walk)"),
|
|
95
|
+
);
|
|
96
|
+
line(flag("--", "everything after goes to playwright"));
|
|
97
|
+
blank();
|
|
98
|
+
line(header("LOGIN"));
|
|
99
|
+
line(flag("--force", "log in again"));
|
|
100
|
+
line(
|
|
101
|
+
flag("--url <server>", `the review console (default ${defaultReportsUrl})`),
|
|
102
|
+
);
|
|
103
|
+
blank();
|
|
104
|
+
line(header("ALSO"));
|
|
105
|
+
line(flag("--version", "the banner"));
|
|
106
|
+
line(flag("--help, -h, ?", "this"));
|
|
107
|
+
blank();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function loginCommand(args: string[]): Promise<void> {
|
|
111
|
+
if (args.includes("--help") || args.includes("-h") || args.includes("?")) {
|
|
112
|
+
help();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const existing = readWalkwrightConfig();
|
|
117
|
+
const configFile = describeConfigPath();
|
|
118
|
+
|
|
119
|
+
if (existing !== undefined && !args.includes("--force")) {
|
|
120
|
+
line(
|
|
121
|
+
paint("ok", glyph.ok),
|
|
122
|
+
` already set up ${paint("dim", glyph.dot)} project ${bold(existing.project)}`,
|
|
123
|
+
);
|
|
124
|
+
line(paint("dim", ` ${configFile} ${glyph.dot} --force to log in again`));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const url = urlArg(args) ?? defaultReportsUrl;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const sessionToken = await login(url);
|
|
132
|
+
const project = await ensureProject(url, sessionToken, repoName());
|
|
133
|
+
|
|
134
|
+
writeWalkwrightConfig({ url, ...project });
|
|
135
|
+
line(
|
|
136
|
+
paint("ok", glyph.ok),
|
|
137
|
+
` wrote ${paint("code", configFile)} ${paint("dim", glyph.dot)} project ${bold(project.project)}`,
|
|
138
|
+
);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
failure(describe(error), "run walkwright login again");
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function walkCommand(args: string[]): Promise<void> {
|
|
146
|
+
let uploadOnFail = false;
|
|
147
|
+
let noUpload = false;
|
|
148
|
+
let branch: string | undefined;
|
|
149
|
+
let project: string | undefined;
|
|
150
|
+
const forwarded: string[] = [];
|
|
151
|
+
|
|
152
|
+
let index = 0;
|
|
153
|
+
let own = true;
|
|
154
|
+
|
|
155
|
+
while (index < args.length) {
|
|
156
|
+
const arg = args[index];
|
|
157
|
+
|
|
158
|
+
if (arg === undefined) {
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!own) {
|
|
163
|
+
forwarded.push(arg);
|
|
164
|
+
index += 1;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
switch (arg) {
|
|
169
|
+
case "--":
|
|
170
|
+
own = false;
|
|
171
|
+
index += 1;
|
|
172
|
+
break;
|
|
173
|
+
case "--help":
|
|
174
|
+
case "-h":
|
|
175
|
+
case "?":
|
|
176
|
+
help();
|
|
177
|
+
return;
|
|
178
|
+
case "--upload-on-fail":
|
|
179
|
+
uploadOnFail = true;
|
|
180
|
+
index += 1;
|
|
181
|
+
break;
|
|
182
|
+
case "--no-upload":
|
|
183
|
+
noUpload = true;
|
|
184
|
+
index += 1;
|
|
185
|
+
break;
|
|
186
|
+
case "--branch":
|
|
187
|
+
branch = requiredValue(args, index, arg);
|
|
188
|
+
index += 2;
|
|
189
|
+
break;
|
|
190
|
+
case "--project":
|
|
191
|
+
project = requiredValue(args, index, arg);
|
|
192
|
+
index += 2;
|
|
193
|
+
break;
|
|
194
|
+
default:
|
|
195
|
+
forwarded.push(arg);
|
|
196
|
+
index += 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (uploadOnFail && noUpload) {
|
|
201
|
+
fail(
|
|
202
|
+
"--upload-on-fail and --no-upload cannot be used together",
|
|
203
|
+
"pick one",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const resolver = createRequire(join(process.cwd(), "package.json"));
|
|
208
|
+
|
|
209
|
+
let playwrightCli: string;
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
playwrightCli = resolver.resolve("@playwright/test/cli");
|
|
213
|
+
} catch {
|
|
214
|
+
fail(
|
|
215
|
+
"@playwright/test is not installed in this project",
|
|
216
|
+
"pnpm add -D @playwright/test, then run walkwright walk again",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
221
|
+
env["WALK_RECORDING"] = "1";
|
|
222
|
+
|
|
223
|
+
if (uploadOnFail) {
|
|
224
|
+
env["WALKWRIGHT_UPLOAD_ON_FAIL"] = "1";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (noUpload) {
|
|
228
|
+
env["WALKWRIGHT_NO_UPLOAD"] = "1";
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (branch !== undefined) {
|
|
232
|
+
env["WALKWRIGHT_BRANCH"] = branch;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const exitCode = await runPlaywright(playwrightCli, project, forwarded, env);
|
|
236
|
+
process.exit(exitCode);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function requiredValue(args: string[], index: number, flag: string): string {
|
|
240
|
+
const value = args[index + 1];
|
|
241
|
+
|
|
242
|
+
if (value === undefined || value.startsWith("--")) {
|
|
243
|
+
fail(`${flag} needs a value`, `walkwright walk ${flag} <value>`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return value;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function runPlaywright(
|
|
250
|
+
playwrightCli: string,
|
|
251
|
+
project: string | undefined,
|
|
252
|
+
forwarded: string[],
|
|
253
|
+
env: NodeJS.ProcessEnv,
|
|
254
|
+
): Promise<number> {
|
|
255
|
+
return await new Promise((resolve, reject) => {
|
|
256
|
+
const child = spawn(
|
|
257
|
+
process.execPath,
|
|
258
|
+
[playwrightCli, "test", `--project=${project ?? "walk"}`, ...forwarded],
|
|
259
|
+
{ stdio: "inherit", env, cwd: process.cwd() },
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
child.on("error", (error) => {
|
|
263
|
+
reject(error);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
child.on("exit", (exitCode) => {
|
|
267
|
+
resolve(exitCode ?? 1);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function urlArg(args: string[]): string | undefined {
|
|
273
|
+
const index = args.indexOf("--url");
|
|
274
|
+
|
|
275
|
+
if (index === -1) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const value = args[index + 1];
|
|
280
|
+
|
|
281
|
+
if (value === undefined || value.startsWith("--")) {
|
|
282
|
+
fail("--url needs a value", "walkwright login --url https://…");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return value;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function describe(error: unknown): string {
|
|
289
|
+
return error instanceof Error ? error.message : String(error);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function fail(message: string, hint: string): never {
|
|
293
|
+
failure(message, hint);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ReporterDescription } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
import type { RecorderFixtures } from "./recorder.js";
|
|
4
|
+
import type { ReporterOptions } from "./reporter.js";
|
|
5
|
+
|
|
6
|
+
const recording = process.env["WALK_RECORDING"] === "1";
|
|
7
|
+
|
|
8
|
+
export const walk = {
|
|
9
|
+
recording,
|
|
10
|
+
|
|
11
|
+
reporter(options: ReporterOptions): ReporterDescription[] {
|
|
12
|
+
return recording ? [["walkwright/reporter", options]] : [];
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
use: { recording } satisfies Pick<RecorderFixtures, "recording">,
|
|
16
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import type { LocatorDescriptor, Text } from "./locator.js";
|
|
4
|
+
import { locatorDescriptorSchema, textSchema } from "./locator.js";
|
|
5
|
+
|
|
6
|
+
export interface ParamDecl {
|
|
7
|
+
name: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const paramDeclSchema: z.ZodType<ParamDecl> = z.object({
|
|
11
|
+
name: z.string(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export type ElementDescriptor =
|
|
15
|
+
| { locator: LocatorDescriptor; params?: ParamDecl[] }
|
|
16
|
+
| { surface: NestedSurfaceDescriptor }
|
|
17
|
+
| { overlay: OverlayDescriptor; open?: OpaqueImpl }
|
|
18
|
+
| { opaque: true; provenance: "hand" };
|
|
19
|
+
|
|
20
|
+
export interface OpaqueImpl {
|
|
21
|
+
opaque: true;
|
|
22
|
+
provenance: "hand";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const opaqueImplSchema: z.ZodType<OpaqueImpl> = z.object({
|
|
26
|
+
opaque: z.literal(true),
|
|
27
|
+
provenance: z.literal("hand"),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const elementDescriptorSchema: z.ZodType<ElementDescriptor> = z.lazy(
|
|
31
|
+
() =>
|
|
32
|
+
z.union([
|
|
33
|
+
z.object({
|
|
34
|
+
locator: locatorDescriptorSchema,
|
|
35
|
+
params: z.array(paramDeclSchema).optional(),
|
|
36
|
+
}),
|
|
37
|
+
z.object({ surface: nestedSurfaceDescriptorSchema }),
|
|
38
|
+
z.object({
|
|
39
|
+
overlay: overlayDescriptorSchema,
|
|
40
|
+
open: opaqueImplSchema.optional(),
|
|
41
|
+
}),
|
|
42
|
+
z.object({ opaque: z.literal(true), provenance: z.literal("hand") }),
|
|
43
|
+
]),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
export interface ActionDescriptor {
|
|
47
|
+
name: string;
|
|
48
|
+
impl: OpaqueImpl;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const actionDescriptorSchema: z.ZodType<ActionDescriptor> = z.object({
|
|
52
|
+
name: z.string(),
|
|
53
|
+
impl: opaqueImplSchema,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export interface RegexDescriptor {
|
|
57
|
+
pattern: string;
|
|
58
|
+
flags?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const regexDescriptorSchema: z.ZodType<RegexDescriptor> = z.object({
|
|
62
|
+
pattern: z.string(),
|
|
63
|
+
flags: z.string().optional(),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export type CaptureRuleDescriptor =
|
|
67
|
+
| { element: string[]; text: string; match?: RegexDescriptor }
|
|
68
|
+
| { match: RegexDescriptor; text: string };
|
|
69
|
+
|
|
70
|
+
export const captureRuleDescriptorSchema: z.ZodType<CaptureRuleDescriptor> =
|
|
71
|
+
z.union([
|
|
72
|
+
z.object({
|
|
73
|
+
element: z.array(z.string()),
|
|
74
|
+
text: z.string(),
|
|
75
|
+
match: regexDescriptorSchema.optional(),
|
|
76
|
+
}),
|
|
77
|
+
z.object({ match: regexDescriptorSchema, text: z.string() }),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
export interface CaptureDescriptor {
|
|
81
|
+
rules: CaptureRuleDescriptor[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const captureDescriptorSchema: z.ZodType<CaptureDescriptor> = z.object({
|
|
85
|
+
rules: z.array(captureRuleDescriptorSchema),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export interface SurfaceDescriptor {
|
|
89
|
+
elements: Record<string, ElementDescriptor>;
|
|
90
|
+
actions?: ActionDescriptor[];
|
|
91
|
+
capture?: CaptureDescriptor;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const surfaceShape = {
|
|
95
|
+
elements: z.record(
|
|
96
|
+
z.string(),
|
|
97
|
+
z.lazy((): z.ZodType<ElementDescriptor> => elementDescriptorSchema),
|
|
98
|
+
),
|
|
99
|
+
actions: z.array(actionDescriptorSchema).optional(),
|
|
100
|
+
capture: captureDescriptorSchema.optional(),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export interface NestedSurfaceDescriptor extends SurfaceDescriptor {
|
|
104
|
+
root?: LocatorDescriptor;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const nestedSurfaceDescriptorSchema: z.ZodType<NestedSurfaceDescriptor> =
|
|
108
|
+
z.lazy(() =>
|
|
109
|
+
z.object({
|
|
110
|
+
root: locatorDescriptorSchema.optional(),
|
|
111
|
+
...surfaceShape,
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
export interface OverlayDescriptor extends SurfaceDescriptor {
|
|
116
|
+
root: LocatorDescriptor;
|
|
117
|
+
name?: Text;
|
|
118
|
+
recorder: { label: string };
|
|
119
|
+
close: { esc: boolean; button?: LocatorDescriptor };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const overlayDescriptorSchema: z.ZodType<OverlayDescriptor> = z.lazy(
|
|
123
|
+
() =>
|
|
124
|
+
z.object({
|
|
125
|
+
root: locatorDescriptorSchema,
|
|
126
|
+
name: textSchema.optional(),
|
|
127
|
+
recorder: z.object({ label: z.string() }),
|
|
128
|
+
close: z.object({
|
|
129
|
+
esc: z.boolean(),
|
|
130
|
+
button: locatorDescriptorSchema.optional(),
|
|
131
|
+
}),
|
|
132
|
+
...surfaceShape,
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
export type ReadyDescriptor = { selector: string } | { opaque: true };
|
|
137
|
+
|
|
138
|
+
const readyDescriptorSchema: z.ZodType<ReadyDescriptor> = z.union([
|
|
139
|
+
z.object({ selector: z.string() }),
|
|
140
|
+
z.object({ opaque: z.literal(true) }),
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
export interface PageDescriptor extends SurfaceDescriptor {
|
|
144
|
+
path: string;
|
|
145
|
+
name: string;
|
|
146
|
+
recorder?: { group?: string[]; label?: string };
|
|
147
|
+
ready?: ReadyDescriptor;
|
|
148
|
+
header?: Text;
|
|
149
|
+
anchor?: LocatorDescriptor | { opaque: true };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const pageDescriptorSchema: z.ZodType<PageDescriptor> = z.object({
|
|
153
|
+
path: z.string(),
|
|
154
|
+
name: z.string(),
|
|
155
|
+
recorder: z
|
|
156
|
+
.object({
|
|
157
|
+
group: z.array(z.string()).optional(),
|
|
158
|
+
label: z.string().optional(),
|
|
159
|
+
})
|
|
160
|
+
.optional(),
|
|
161
|
+
ready: readyDescriptorSchema.optional(),
|
|
162
|
+
header: textSchema.optional(),
|
|
163
|
+
anchor: z
|
|
164
|
+
.union([locatorDescriptorSchema, z.object({ opaque: z.literal(true) })])
|
|
165
|
+
.optional(),
|
|
166
|
+
...surfaceShape,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
export interface AppDescriptor {
|
|
170
|
+
ready?: ReadyDescriptor;
|
|
171
|
+
pages: PageDescriptor[];
|
|
172
|
+
capture?: CaptureDescriptor;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const appDescriptorSchema: z.ZodType<AppDescriptor> = z.object({
|
|
176
|
+
ready: readyDescriptorSchema.optional(),
|
|
177
|
+
pages: z.array(pageDescriptorSchema),
|
|
178
|
+
capture: captureDescriptorSchema.optional(),
|
|
179
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import type { WalkwrightConfig } from "./project.js";
|
|
4
|
+
import { blank, bold, glyph, line, paint, prompt, pulse } from "./ui.js";
|
|
5
|
+
|
|
6
|
+
const clientId = "walk-cli";
|
|
7
|
+
|
|
8
|
+
interface DeviceCode {
|
|
9
|
+
device_code: string;
|
|
10
|
+
user_code: string;
|
|
11
|
+
verification_uri: string;
|
|
12
|
+
verification_uri_complete: string;
|
|
13
|
+
expires_in: number;
|
|
14
|
+
interval: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface DeviceTokenResponse {
|
|
18
|
+
error?: string;
|
|
19
|
+
access_token?: string;
|
|
20
|
+
session?: { token?: string };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function login(baseUrl: string): Promise<string> {
|
|
24
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
25
|
+
|
|
26
|
+
const code = await postJson<DeviceCode>(`${base}/api/auth/device/code`, {
|
|
27
|
+
client_id: clientId,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
blank();
|
|
31
|
+
line(
|
|
32
|
+
prompt(
|
|
33
|
+
`open ${paint("code", code.verification_uri)} and enter ${bold(paint("accent", code.user_code))}`,
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
openBrowser(code.verification_uri_complete);
|
|
37
|
+
|
|
38
|
+
const stop = pulse(paint("dim", " waiting for approval"));
|
|
39
|
+
|
|
40
|
+
let token: string;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
token = await poll(base, code);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
stop();
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stop();
|
|
50
|
+
line(paint("ok", glyph.ok), ` approved${await approvedAs(base, token)}`);
|
|
51
|
+
|
|
52
|
+
return token;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function approvedAs(base: string, token: string): Promise<string> {
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetch(`${base}/api/auth/get-session`, {
|
|
58
|
+
headers: { authorization: `Bearer ${token}` },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const body = (await response.json()) as { user?: { name?: string } } | null;
|
|
66
|
+
const name = body?.user?.name;
|
|
67
|
+
|
|
68
|
+
return name === undefined || name === "" ? "" : ` as ${bold(name)}`;
|
|
69
|
+
} catch {
|
|
70
|
+
return "";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function poll(base: string, code: DeviceCode): Promise<string> {
|
|
75
|
+
const deadline = Date.now() + code.expires_in * 1000;
|
|
76
|
+
let intervalMs = Math.max(1, code.interval) * 1000;
|
|
77
|
+
|
|
78
|
+
while (Date.now() < deadline) {
|
|
79
|
+
await sleep(intervalMs);
|
|
80
|
+
|
|
81
|
+
const response = await fetch(`${base}/api/auth/device/token`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: { "content-type": "application/json" },
|
|
84
|
+
body: JSON.stringify({
|
|
85
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
86
|
+
device_code: code.device_code,
|
|
87
|
+
client_id: clientId,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const body = (await response.json()) as DeviceTokenResponse;
|
|
92
|
+
|
|
93
|
+
if (response.ok) {
|
|
94
|
+
const token = body.session?.token ?? body.access_token;
|
|
95
|
+
|
|
96
|
+
if (token === undefined) {
|
|
97
|
+
throw new Error("device login succeeded but returned no session token");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return token;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (body.error === "authorization_pending") {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (body.error === "slow_down") {
|
|
108
|
+
intervalMs += 1000;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
throw new Error(
|
|
113
|
+
body.error === "expired_token" || body.error === "access_denied"
|
|
114
|
+
? "that didn't work — the code may have expired. run walkwright login again."
|
|
115
|
+
: `that didn't work — ${body.error ?? String(response.status)}. run walkwright login again.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
throw new Error(
|
|
120
|
+
"that didn't work — the code expired. run walkwright login again.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function ensureProject(
|
|
125
|
+
baseUrl: string,
|
|
126
|
+
sessionToken: string,
|
|
127
|
+
name: string,
|
|
128
|
+
): Promise<Pick<WalkwrightConfig, "project" | "token">> {
|
|
129
|
+
const base = baseUrl.replace(/\/$/, "");
|
|
130
|
+
|
|
131
|
+
const response = await fetch(`${base}/api/projects/ensure`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: {
|
|
134
|
+
"content-type": "application/json",
|
|
135
|
+
authorization: `Bearer ${sessionToken}`,
|
|
136
|
+
},
|
|
137
|
+
body: JSON.stringify({ name }),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`project setup failed — ${String(response.status)} from ${base}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return (await response.json()) as Pick<WalkwrightConfig, "project" | "token">;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function postJson<Body>(url: string, payload: unknown): Promise<Body> {
|
|
150
|
+
let response: Response;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
response = await fetch(url, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "content-type": "application/json" },
|
|
156
|
+
body: JSON.stringify(payload),
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
throw new Error(`can't reach ${new URL(url).origin}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
throw new Error(`${String(response.status)} from ${url}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return (await response.json()) as Body;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function openBrowser(url: string): void {
|
|
170
|
+
const [command, args] =
|
|
171
|
+
process.platform === "darwin"
|
|
172
|
+
? ["open", [url]]
|
|
173
|
+
: process.platform === "win32"
|
|
174
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
175
|
+
: ["xdg-open", [url]];
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
execFileSync(command, args, { stdio: "ignore" });
|
|
179
|
+
} catch {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function sleep(ms: number): Promise<void> {
|
|
185
|
+
await new Promise<void>((resolve) => {
|
|
186
|
+
setTimeout(resolve, ms);
|
|
187
|
+
});
|
|
188
|
+
}
|