@tonys-software/mimic 0.0.0-stage → 0.1.0-stg.3.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/README.md +30 -2
- package/dist/cli.js +1431 -0
- package/dist/inject.js +209 -0
- package/package.json +37 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1431 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/commands/list.ts
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
|
|
6
|
+
// src/shared/output.ts
|
|
7
|
+
var color = (code) => (text) => process.stdout.isTTY ? `\x1B[${code}m${text}\x1B[0m` : text;
|
|
8
|
+
var green = color(32);
|
|
9
|
+
var red = color(31);
|
|
10
|
+
var gray = color(90);
|
|
11
|
+
var seconds = (ms) => `${(ms / 1e3).toFixed(1)}s`;
|
|
12
|
+
|
|
13
|
+
// src/shared/scenarioFiles.ts
|
|
14
|
+
import fs from "node:fs/promises";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
function slugify(name) {
|
|
17
|
+
return name.normalize("NFD").replace(new RegExp("\\p{Mn}", "gu"), "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-|-$/g, "");
|
|
18
|
+
}
|
|
19
|
+
async function scenarioFiles(folder) {
|
|
20
|
+
const entries = await fs.readdir(folder, { withFileTypes: true, recursive: true });
|
|
21
|
+
return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => path.join(entry.parentPath, entry.name)).filter((file) => !path.relative(folder, file).split(path.sep).some((part) => part === "node_modules" || part.startsWith("."))).sort();
|
|
22
|
+
}
|
|
23
|
+
function withDefaults(locator) {
|
|
24
|
+
switch (locator.type) {
|
|
25
|
+
case "Text":
|
|
26
|
+
return { ...locator, text: locator.text ?? "" };
|
|
27
|
+
case "Anchored":
|
|
28
|
+
return { ...locator, anchor: withDefaults(locator.anchor), target: locator.target && withDefaults(locator.target) };
|
|
29
|
+
default:
|
|
30
|
+
return locator;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function requiredLocator(locator, where) {
|
|
34
|
+
if (!locator) throw new Error(`${where}: step has no locator`);
|
|
35
|
+
return withDefaults(locator);
|
|
36
|
+
}
|
|
37
|
+
function stepWithDefaults(step, baseUrl, where) {
|
|
38
|
+
switch (step.type) {
|
|
39
|
+
case "Navigate": {
|
|
40
|
+
const url = step.url ?? "";
|
|
41
|
+
return { ...step, url: baseUrl === null ? url : resolveUrl(url, baseUrl, where) };
|
|
42
|
+
}
|
|
43
|
+
case "Click":
|
|
44
|
+
return { ...step, locator: requiredLocator(step.locator, where), modifiers: step.modifiers ?? [] };
|
|
45
|
+
case "Fill":
|
|
46
|
+
return { ...step, locator: requiredLocator(step.locator, where), value: step.value ?? "", invalid: invalidValues(step.invalid, where) };
|
|
47
|
+
case "Assertion":
|
|
48
|
+
return assertionWithDefaults(step);
|
|
49
|
+
default:
|
|
50
|
+
throw new Error(`${where}: unknown step type "${step.type}"`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function assertionWithDefaults(step) {
|
|
54
|
+
return { ...step, locator: step.locator && withDefaults(step.locator) };
|
|
55
|
+
}
|
|
56
|
+
function expectSteps(expect, where) {
|
|
57
|
+
if (!Array.isArray(expect) || expect.some((step) => typeof step !== "object" || step === null || (step.type ?? "Assertion") !== "Assertion")) {
|
|
58
|
+
throw new Error(`${where}: "expect" must be a list of Assertion steps`);
|
|
59
|
+
}
|
|
60
|
+
return expect.map((step) => assertionWithDefaults({ ...step, type: "Assertion" }));
|
|
61
|
+
}
|
|
62
|
+
function invalidValues(invalid, where) {
|
|
63
|
+
if (invalid === void 0) return [];
|
|
64
|
+
if (!Array.isArray(invalid)) throw new Error(`${where}: "invalid" must be a list of { "value", "expect" }`);
|
|
65
|
+
return invalid.map((entry) => {
|
|
66
|
+
const { value, expect } = entry ?? {};
|
|
67
|
+
if (typeof value !== "string") throw new Error(`${where}: each "invalid" entry needs a string "value"`);
|
|
68
|
+
const steps = expectSteps(expect, where);
|
|
69
|
+
if (steps.length === 0) throw new Error(`${where}: invalid value "${value}" needs a non-empty "expect"`);
|
|
70
|
+
return { value, expect: steps };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function resolveUrl(url, baseUrl, where) {
|
|
74
|
+
if (URL.canParse(url)) return url;
|
|
75
|
+
if (baseUrl === void 0) throw new Error(`${where}: Navigate URL "${url}" is relative; pass --base-url`);
|
|
76
|
+
return new URL(url, baseUrl).href;
|
|
77
|
+
}
|
|
78
|
+
async function loadFolders(folders, baseUrl) {
|
|
79
|
+
const found = (await Promise.all(folders.map(async (folder) => {
|
|
80
|
+
const stat = await fs.stat(folder).catch(() => void 0);
|
|
81
|
+
if (!stat?.isDirectory()) throw new Error(`Folder "${folder}" not found`);
|
|
82
|
+
return scenarioFiles(folder);
|
|
83
|
+
}))).flat();
|
|
84
|
+
const seen = /* @__PURE__ */ new Set();
|
|
85
|
+
const paths = found.filter((file) => !seen.has(path.resolve(file)) && seen.add(path.resolve(file)));
|
|
86
|
+
const scenarios = /* @__PURE__ */ new Map();
|
|
87
|
+
const groups = /* @__PURE__ */ new Map();
|
|
88
|
+
const files = /* @__PURE__ */ new Map();
|
|
89
|
+
for (const file of paths) {
|
|
90
|
+
let data;
|
|
91
|
+
try {
|
|
92
|
+
data = JSON.parse(await fs.readFile(file, "utf8"));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new Error(`${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
95
|
+
}
|
|
96
|
+
const entries = Array.isArray(data) ? data : [data];
|
|
97
|
+
for (const [index, entry] of entries.entries()) {
|
|
98
|
+
const where = Array.isArray(data) ? `${file}[${index}]` : file;
|
|
99
|
+
const { name, steps, prerequisiteName, groupNames = [], expect = [] } = entry ?? {};
|
|
100
|
+
if (typeof name !== "string" || !name.trim() || !Array.isArray(steps)) {
|
|
101
|
+
throw new Error(`${where}: not a scenario (needs "name" and "steps")`);
|
|
102
|
+
}
|
|
103
|
+
if (prerequisiteName !== void 0 && typeof prerequisiteName !== "string") {
|
|
104
|
+
throw new Error(`${where}: "prerequisiteName" must be a scenario name`);
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(groupNames) || groupNames.some((group) => typeof group !== "string" || !slugify(group))) {
|
|
107
|
+
throw new Error(`${where}: "groupNames" must be a list of group names`);
|
|
108
|
+
}
|
|
109
|
+
const parsedSteps = steps.map((step) => stepWithDefaults(step, baseUrl, where));
|
|
110
|
+
const parsedExpect = expectSteps(expect, where);
|
|
111
|
+
if (parsedExpect.length === 0 && parsedSteps.some((step) => step.type === "Fill" && step.invalid.length > 0)) {
|
|
112
|
+
throw new Error(`${where}: a scenario with "invalid" values needs a non-empty "expect"`);
|
|
113
|
+
}
|
|
114
|
+
if (scenarios.has(name)) throw new Error(`${where}: ${files.get(name)} already has the scenario "${name}"`);
|
|
115
|
+
for (const group of groupNames) {
|
|
116
|
+
const slug = slugify(group);
|
|
117
|
+
if (!groups.has(slug)) groups.set(slug, { id: slug, name: group, slug });
|
|
118
|
+
}
|
|
119
|
+
scenarios.set(name, {
|
|
120
|
+
id: name,
|
|
121
|
+
name,
|
|
122
|
+
prerequisite: prerequisiteName,
|
|
123
|
+
groups: [...new Set(groupNames.map(slugify))],
|
|
124
|
+
steps: parsedSteps,
|
|
125
|
+
expect: parsedExpect
|
|
126
|
+
});
|
|
127
|
+
files.set(name, file);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { scenarios: [...scenarios.values()], groups: [...groups.values()], files };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ../extension/src/scenarioVariants.ts
|
|
134
|
+
var NAME_VALUE_LENGTH = 30;
|
|
135
|
+
var cut = (value) => value.length > NAME_VALUE_LENGTH ? `${value.slice(0, NAME_VALUE_LENGTH - 1)}\u2026` : value;
|
|
136
|
+
function invalidSets(scenario) {
|
|
137
|
+
const fills = scenario.steps.filter((step) => step.type === "Fill" && step.invalid.length > 0);
|
|
138
|
+
const longest = Math.max(0, ...fills.map((fill) => fill.invalid.length));
|
|
139
|
+
return Array.from({ length: longest }, (_, k) => fills.filter((fill) => fill.invalid.length > k).map((fill) => [fill, fill.invalid[k]]));
|
|
140
|
+
}
|
|
141
|
+
function variantRun(scenario, name, values, expect) {
|
|
142
|
+
return {
|
|
143
|
+
...scenario,
|
|
144
|
+
name,
|
|
145
|
+
steps: scenario.steps.map((step) => step.type === "Fill" ? { ...step, value: values.get(step) ?? step.value, invalid: [] } : step),
|
|
146
|
+
expect
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
function expandVariants(scenario) {
|
|
150
|
+
const invalid = invalidSets(scenario).map((set, index) => {
|
|
151
|
+
const name = `${scenario.name} \u203A invalid ${index + 1}: ${set.map(([, value]) => `"${cut(value.value)}"`).join(", ")}`;
|
|
152
|
+
const values = new Map(set.map(([fill, value]) => [fill, value.value]));
|
|
153
|
+
return {
|
|
154
|
+
variant: index + 1,
|
|
155
|
+
scenario: { ...variantRun(scenario, name, values, set.flatMap(([, value]) => value.expect)), id: name }
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
return [{ variant: 0, scenario: variantRun(scenario, scenario.name, /* @__PURE__ */ new Map(), scenario.expect) }, ...invalid];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// src/commands/list.ts
|
|
162
|
+
async function execute(args) {
|
|
163
|
+
const { values } = parseArgs({
|
|
164
|
+
args,
|
|
165
|
+
options: {
|
|
166
|
+
folder: { type: "string", short: "f", multiple: true },
|
|
167
|
+
help: { type: "boolean", short: "h", default: false }
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
if (values.help) {
|
|
171
|
+
console.log(list.usage);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
const folders = values.folder ?? [];
|
|
175
|
+
if (folders.length === 0) {
|
|
176
|
+
throw new Error("Pass the scenario folder with -f");
|
|
177
|
+
}
|
|
178
|
+
const { scenarios, files } = await loadFolders(folders, null);
|
|
179
|
+
if (scenarios.length === 0) {
|
|
180
|
+
console.log("No scenarios found.");
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
const byFile = Map.groupBy(scenarios, (scenario) => files.get(scenario.id));
|
|
184
|
+
for (const [file, inFile] of byFile) {
|
|
185
|
+
console.log(file);
|
|
186
|
+
for (const scenario of inFile) {
|
|
187
|
+
const { name, prerequisite, groups } = scenario;
|
|
188
|
+
const invalidRuns = expandVariants(scenario).length - 1;
|
|
189
|
+
const details = [
|
|
190
|
+
prerequisite === void 0 ? void 0 : `after "${prerequisite}"`,
|
|
191
|
+
groups.length === 0 ? void 0 : `groups: ${groups.join(", ")}`,
|
|
192
|
+
invalidRuns === 0 ? void 0 : `+${invalidRuns} invalid ${invalidRuns === 1 ? "run" : "runs"}`
|
|
193
|
+
].filter(Boolean).join("; ");
|
|
194
|
+
console.log(` ${name}${details ? gray(` (${details})`) : ""}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
var list = {
|
|
200
|
+
name: "list",
|
|
201
|
+
summary: "list the scenarios in scenario files, by file",
|
|
202
|
+
usage: `Usage: mimic list -f <folder>
|
|
203
|
+
|
|
204
|
+
Prints each scenario file under the folder with the scenarios it holds: their names, the prerequisite they continue
|
|
205
|
+
from and their groups (by slug, as -g takes them).
|
|
206
|
+
|
|
207
|
+
Options:
|
|
208
|
+
-f, --folder <path> the scenario files in this folder (recursively); can repeat
|
|
209
|
+
-h, --help show this help`,
|
|
210
|
+
execute
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// src/commands/login.ts
|
|
214
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
215
|
+
|
|
216
|
+
// src/shared/auth.ts
|
|
217
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
218
|
+
import os from "node:os";
|
|
219
|
+
import path2 from "node:path";
|
|
220
|
+
|
|
221
|
+
// src/shared/config.ts
|
|
222
|
+
var API_URL = "https://app.stg.mimicus.dev";
|
|
223
|
+
var WORKOS_CLIENT_ID = "client_01M4VM6E40X8NEXB778AQTE9KC";
|
|
224
|
+
|
|
225
|
+
// src/shared/auth.ts
|
|
226
|
+
var WORKOS_URL = "https://api.workos.com";
|
|
227
|
+
var REFRESH_MARGIN_SECONDS = 60;
|
|
228
|
+
var DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
229
|
+
var WorkOSError = class extends Error {
|
|
230
|
+
constructor(code, message) {
|
|
231
|
+
super(message);
|
|
232
|
+
this.code = code;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
function sessionFile() {
|
|
236
|
+
const configDir = process.platform === "win32" ? process.env.APPDATA ?? path2.join(os.homedir(), "AppData", "Roaming") : process.env.XDG_CONFIG_HOME ?? path2.join(os.homedir(), ".config");
|
|
237
|
+
return path2.join(configDir, "mimic", "session.json");
|
|
238
|
+
}
|
|
239
|
+
function claims(token) {
|
|
240
|
+
return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
|
|
241
|
+
}
|
|
242
|
+
async function loadSession() {
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(await readFile(sessionFile(), "utf8"));
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
async function saveSession(session) {
|
|
250
|
+
const file = sessionFile();
|
|
251
|
+
await mkdir(path2.dirname(file), { recursive: true });
|
|
252
|
+
await writeFile(file, JSON.stringify(session), { mode: 384 });
|
|
253
|
+
}
|
|
254
|
+
function deleteSession() {
|
|
255
|
+
return rm(sessionFile(), { force: true });
|
|
256
|
+
}
|
|
257
|
+
async function workOS(endpoint, body) {
|
|
258
|
+
const response = await fetch(`${WORKOS_URL}/user_management/${endpoint}`, {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
261
|
+
body: new URLSearchParams({ client_id: WORKOS_CLIENT_ID, ...body })
|
|
262
|
+
});
|
|
263
|
+
const json = await response.json().catch(() => ({}));
|
|
264
|
+
if (!response.ok) {
|
|
265
|
+
throw new WorkOSError(json.error, json.error_description ?? json.message ?? `WorkOS returned ${response.status}`);
|
|
266
|
+
}
|
|
267
|
+
return json;
|
|
268
|
+
}
|
|
269
|
+
function toSession(json, previous) {
|
|
270
|
+
return {
|
|
271
|
+
accessToken: json.access_token,
|
|
272
|
+
refreshToken: json.refresh_token,
|
|
273
|
+
email: json.user?.email ?? previous?.email ?? null
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
async function login(onCode) {
|
|
277
|
+
const device = await workOS("authorize/device", {});
|
|
278
|
+
onCode({
|
|
279
|
+
userCode: device.user_code,
|
|
280
|
+
verificationUri: device.verification_uri,
|
|
281
|
+
verificationUriComplete: device.verification_uri_complete
|
|
282
|
+
});
|
|
283
|
+
let intervalMs = (device.interval ?? 5) * 1e3;
|
|
284
|
+
const deadline = Date.now() + (device.expires_in ?? 300) * 1e3;
|
|
285
|
+
while (Date.now() < deadline) {
|
|
286
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
287
|
+
try {
|
|
288
|
+
const session = toSession(await workOS("authenticate", { grant_type: DEVICE_CODE_GRANT, device_code: device.device_code }));
|
|
289
|
+
await saveSession(session);
|
|
290
|
+
return session;
|
|
291
|
+
} catch (e) {
|
|
292
|
+
if (e instanceof WorkOSError && e.code === "authorization_pending") continue;
|
|
293
|
+
if (e instanceof WorkOSError && e.code === "slow_down") {
|
|
294
|
+
intervalMs += 5e3;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
throw e;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
throw new Error('The sign-in code expired. Run "mimic login" again.');
|
|
301
|
+
}
|
|
302
|
+
async function getAccessToken() {
|
|
303
|
+
const session = await loadSession();
|
|
304
|
+
if (!session) {
|
|
305
|
+
return void 0;
|
|
306
|
+
}
|
|
307
|
+
if (claims(session.accessToken).exp - REFRESH_MARGIN_SECONDS > Date.now() / 1e3) {
|
|
308
|
+
return session.accessToken;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
const next = toSession(await workOS("authenticate", { grant_type: "refresh_token", refresh_token: session.refreshToken }), session);
|
|
312
|
+
await saveSession(next);
|
|
313
|
+
return next.accessToken;
|
|
314
|
+
} catch (e) {
|
|
315
|
+
if (e instanceof WorkOSError && e.code === "invalid_grant") {
|
|
316
|
+
await deleteSession();
|
|
317
|
+
return void 0;
|
|
318
|
+
}
|
|
319
|
+
throw e;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/commands/login.ts
|
|
324
|
+
async function execute2(args) {
|
|
325
|
+
const { values } = parseArgs2({
|
|
326
|
+
args,
|
|
327
|
+
options: {
|
|
328
|
+
help: { type: "boolean", short: "h", default: false }
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
if (values.help) {
|
|
332
|
+
console.log(login2.usage);
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
335
|
+
const session = await login((code) => {
|
|
336
|
+
console.log(`Open ${code.verificationUriComplete}`);
|
|
337
|
+
console.log(gray(`or go to ${code.verificationUri} and enter the code ${code.userCode}`));
|
|
338
|
+
console.log("Waiting for you to confirm the sign-in...");
|
|
339
|
+
});
|
|
340
|
+
console.log(green(`Signed in as ${session.email ?? "unknown user"}`));
|
|
341
|
+
if (!claims(session.accessToken).org_id) {
|
|
342
|
+
console.log('Your account is not in an organization yet. Create one in the dashboard, then run "mimic login" again.');
|
|
343
|
+
}
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
var login2 = {
|
|
347
|
+
name: "login",
|
|
348
|
+
summary: "sign in to the config API with your account",
|
|
349
|
+
usage: `Usage: mimic login
|
|
350
|
+
|
|
351
|
+
Signs in with a code you confirm in the browser. The session is stored for your user and refreshed automatically,
|
|
352
|
+
so commands that call the config API keep working until you run "mimic logout".`,
|
|
353
|
+
execute: execute2
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// src/commands/logout.ts
|
|
357
|
+
import { parseArgs as parseArgs3 } from "node:util";
|
|
358
|
+
async function execute3(args) {
|
|
359
|
+
const { values } = parseArgs3({
|
|
360
|
+
args,
|
|
361
|
+
options: {
|
|
362
|
+
help: { type: "boolean", short: "h", default: false }
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
if (values.help) {
|
|
366
|
+
console.log(logout.usage);
|
|
367
|
+
return 0;
|
|
368
|
+
}
|
|
369
|
+
await deleteSession();
|
|
370
|
+
console.log("Signed out.");
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
var logout = {
|
|
374
|
+
name: "logout",
|
|
375
|
+
summary: "remove the stored sign-in session",
|
|
376
|
+
usage: `Usage: mimic logout
|
|
377
|
+
|
|
378
|
+
Deletes the session stored by "mimic login".`,
|
|
379
|
+
execute: execute3
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// src/commands/setPrerequisite.ts
|
|
383
|
+
import fs2 from "node:fs/promises";
|
|
384
|
+
import { parseArgs as parseArgs4 } from "node:util";
|
|
385
|
+
|
|
386
|
+
// src/shared/groups.ts
|
|
387
|
+
function findGroups(slugs, groups) {
|
|
388
|
+
const bySlug = new Map(groups.map((g) => [g.slug, g]));
|
|
389
|
+
const unknown = slugs.filter((slug) => !bySlug.has(slug));
|
|
390
|
+
if (unknown.length > 0) {
|
|
391
|
+
const known = groups.map((g) => g.slug);
|
|
392
|
+
throw new Error(`No group ${unknown.map((slug) => `"${slug}"`).join(", ")}${known.length ? ` (groups: ${known.join(", ")})` : ""}`);
|
|
393
|
+
}
|
|
394
|
+
return slugs.map((slug) => bySlug.get(slug));
|
|
395
|
+
}
|
|
396
|
+
function parseGroups(tokens) {
|
|
397
|
+
const slugs = [];
|
|
398
|
+
let collecting = false;
|
|
399
|
+
for (const token of tokens) {
|
|
400
|
+
if (token.kind === "option") {
|
|
401
|
+
collecting = token.name === "group";
|
|
402
|
+
if (collecting && token.value !== void 0) slugs.push(token.value);
|
|
403
|
+
} else if (token.kind === "positional") {
|
|
404
|
+
if (!collecting) throw new Error(`Unexpected argument "${token.value}"`);
|
|
405
|
+
slugs.push(token.value);
|
|
406
|
+
} else {
|
|
407
|
+
collecting = false;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const normalized = slugs.map((slug) => slug.trim().toLowerCase());
|
|
411
|
+
if (normalized.some((slug) => !slug)) {
|
|
412
|
+
throw new Error("--group needs group slugs");
|
|
413
|
+
}
|
|
414
|
+
return [...new Set(normalized)];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/commands/setPrerequisite.ts
|
|
418
|
+
function selectTops(scenarios, groupIds, names) {
|
|
419
|
+
const selected = new Set(scenarios.filter((s) => names.has(s.name) || s.groups.some((id) => groupIds.has(id))).map((s) => s.id));
|
|
420
|
+
return scenarios.filter((s) => selected.has(s.id) && !(s.prerequisite !== void 0 && selected.has(s.prerequisite)));
|
|
421
|
+
}
|
|
422
|
+
function descendants(scenarios, roots) {
|
|
423
|
+
const children = Map.groupBy(scenarios.filter((s) => s.prerequisite !== void 0), (s) => s.prerequisite);
|
|
424
|
+
const found = /* @__PURE__ */ new Set();
|
|
425
|
+
const pending = roots.map((s) => s.id);
|
|
426
|
+
for (let id = pending.pop(); id !== void 0; id = pending.pop()) {
|
|
427
|
+
if (found.has(id)) continue;
|
|
428
|
+
found.add(id);
|
|
429
|
+
pending.push(...(children.get(id) ?? []).map((s) => s.id));
|
|
430
|
+
}
|
|
431
|
+
return found;
|
|
432
|
+
}
|
|
433
|
+
function withPrerequisite(entry, prerequisite) {
|
|
434
|
+
const { name, prerequisiteName: _, ...rest } = entry;
|
|
435
|
+
return { name, prerequisiteName: prerequisite, ...rest };
|
|
436
|
+
}
|
|
437
|
+
var LINE_WIDTH = 120;
|
|
438
|
+
function inline(value) {
|
|
439
|
+
if (Array.isArray(value)) return `[${value.map(inline).join(", ")}]`;
|
|
440
|
+
if (value !== null && typeof value === "object") {
|
|
441
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0);
|
|
442
|
+
return entries.length === 0 ? "{}" : `{ ${entries.map(([k, v]) => `${JSON.stringify(k)}: ${inline(v)}`).join(", ")} }`;
|
|
443
|
+
}
|
|
444
|
+
return JSON.stringify(value);
|
|
445
|
+
}
|
|
446
|
+
function format(value, indent, current, prefix = "") {
|
|
447
|
+
const flat = inline(value);
|
|
448
|
+
if (value === null || typeof value !== "object" || current.length + prefix.length + flat.length <= LINE_WIDTH) return flat;
|
|
449
|
+
const inner = current + indent;
|
|
450
|
+
const items = Array.isArray(value) ? value.map((v) => inner + format(v, indent, inner)) : Object.entries(value).filter(([, v]) => v !== void 0).map(([k, v]) => {
|
|
451
|
+
const key = `${JSON.stringify(k)}: `;
|
|
452
|
+
return inner + key + format(v, indent, inner, key);
|
|
453
|
+
});
|
|
454
|
+
const [open, close] = Array.isArray(value) ? ["[", "]"] : ["{", "}"];
|
|
455
|
+
return items.length === 0 ? open + close : `${open}
|
|
456
|
+
${items.join(",\n")}
|
|
457
|
+
${current}${close}`;
|
|
458
|
+
}
|
|
459
|
+
async function rewrite(file, names, prerequisite) {
|
|
460
|
+
const text = await fs2.readFile(file, "utf8");
|
|
461
|
+
const data = JSON.parse(text);
|
|
462
|
+
const update = (entry) => names.has(entry.name) ? withPrerequisite(entry, prerequisite) : entry;
|
|
463
|
+
const updated = Array.isArray(data) ? data.map(update) : update(data);
|
|
464
|
+
const indent = /^[ \t]+/m.exec(text)?.[0] ?? " ";
|
|
465
|
+
await fs2.writeFile(file, format(updated, indent, "") + "\n");
|
|
466
|
+
}
|
|
467
|
+
async function execute4(args) {
|
|
468
|
+
const { values, tokens } = parseArgs4({
|
|
469
|
+
args,
|
|
470
|
+
allowPositionals: true,
|
|
471
|
+
tokens: true,
|
|
472
|
+
options: {
|
|
473
|
+
folder: { type: "string", short: "f", multiple: true },
|
|
474
|
+
group: { type: "string", short: "g", multiple: true },
|
|
475
|
+
scenario: { type: "string", short: "s", multiple: true },
|
|
476
|
+
prerequisite: { type: "string", short: "p" },
|
|
477
|
+
help: { type: "boolean", short: "h", default: false }
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
if (values.help) {
|
|
481
|
+
console.log(setPrerequisite.usage);
|
|
482
|
+
return 0;
|
|
483
|
+
}
|
|
484
|
+
const slugs = parseGroups(tokens);
|
|
485
|
+
const folders = values.folder ?? [];
|
|
486
|
+
const prerequisite = values.prerequisite?.trim();
|
|
487
|
+
if (folders.length === 0) {
|
|
488
|
+
throw new Error("Pass the scenario folder with -f");
|
|
489
|
+
}
|
|
490
|
+
if (!prerequisite) {
|
|
491
|
+
throw new Error("Pass the prerequisite scenario's name with -p");
|
|
492
|
+
}
|
|
493
|
+
if (slugs.length === 0 && !values.scenario?.length) {
|
|
494
|
+
throw new Error("Pick the scenarios with -g and/or -s");
|
|
495
|
+
}
|
|
496
|
+
const { scenarios, groups, files } = await loadFolders(folders, null);
|
|
497
|
+
const target = scenarios.find((s) => s.name === prerequisite);
|
|
498
|
+
if (!target) {
|
|
499
|
+
throw new Error(`No scenario named "${prerequisite}"`);
|
|
500
|
+
}
|
|
501
|
+
const names = new Set(values.scenario ?? []);
|
|
502
|
+
const unknown = [...names].filter((name) => !files.has(name));
|
|
503
|
+
if (unknown.length > 0) {
|
|
504
|
+
throw new Error(`No scenario ${unknown.map((name) => `"${name}"`).join(", ")}`);
|
|
505
|
+
}
|
|
506
|
+
const groupIds = new Set(findGroups(slugs, groups).map((g) => g.id));
|
|
507
|
+
const byId = new Map(scenarios.map((s) => [s.id, s]));
|
|
508
|
+
const ancestors = /* @__PURE__ */ new Set();
|
|
509
|
+
for (let s = target; s && !ancestors.has(s.id); s = s.prerequisite === void 0 ? void 0 : byId.get(s.prerequisite)) {
|
|
510
|
+
ancestors.add(s.id);
|
|
511
|
+
}
|
|
512
|
+
const ownChain = [...names].filter((name) => ancestors.has(name));
|
|
513
|
+
if (ownChain.length > 0) {
|
|
514
|
+
throw new Error(`"${prerequisite}" continues from ${ownChain.map((name) => `"${name}"`).join(", ")}; it can't be their prerequisite`);
|
|
515
|
+
}
|
|
516
|
+
const tops = selectTops(scenarios, groupIds, names).filter((s) => !ancestors.has(s.id));
|
|
517
|
+
const changes = tops.filter((s) => s.prerequisite !== target.id);
|
|
518
|
+
if (changes.length === 0) {
|
|
519
|
+
console.log(`Nothing to change: they already continue from "${prerequisite}".`);
|
|
520
|
+
return 0;
|
|
521
|
+
}
|
|
522
|
+
const byFile = Map.groupBy(changes, (s) => files.get(s.id));
|
|
523
|
+
await Promise.all([...byFile].map(([file, inFile]) => rewrite(file, new Set(inFile.map((s) => s.name)), prerequisite)));
|
|
524
|
+
for (const [file, inFile] of byFile) {
|
|
525
|
+
console.log(file);
|
|
526
|
+
for (const s of inFile) {
|
|
527
|
+
console.log(` ${green("\u2713")} ${s.name}${gray(s.prerequisite === void 0 ? "" : ` (was after "${s.prerequisite}")`)}`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const inherited = descendants(scenarios, changes).size - changes.length;
|
|
531
|
+
console.log(`${changes.length} changed${inherited > 0 ? gray(`; ${inherited} more continue from them and now run after "${prerequisite}" too`) : ""}`);
|
|
532
|
+
return 0;
|
|
533
|
+
}
|
|
534
|
+
var setPrerequisite = {
|
|
535
|
+
name: "set-prerequisite",
|
|
536
|
+
summary: "make scenarios in scenario files continue from another scenario",
|
|
537
|
+
usage: `Usage: mimic set-prerequisite -f <folder> -p <scenario name> (-g <slug...> | -s <name>)...
|
|
538
|
+
|
|
539
|
+
Sets "prerequisiteName" in the scenario files, e.g. to make a feature's scenarios start signed in:
|
|
540
|
+
mimic set-prerequisite -f scenarios -g todos -p "Auth: sign in"
|
|
541
|
+
Only the selected scenarios that don't already continue from another selected one change; the others keep
|
|
542
|
+
continuing from them, so they run after the prerequisite too. The prerequisite itself and its own prerequisites are
|
|
543
|
+
never changed. Files are rewritten as JSON, short objects and arrays on one line.
|
|
544
|
+
|
|
545
|
+
Options:
|
|
546
|
+
-f, --folder <path> the scenario files in this folder (recursively); can repeat
|
|
547
|
+
-p, --prerequisite <name> the scenario they continue from
|
|
548
|
+
-g, --group <slug...> select the scenarios in these groups
|
|
549
|
+
-s, --scenario <name> select this scenario; can repeat
|
|
550
|
+
-h, --help show this help`,
|
|
551
|
+
execute: execute4
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
// src/commands/run.ts
|
|
555
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
556
|
+
import os2 from "node:os";
|
|
557
|
+
import { parseArgs as parseArgs5 } from "node:util";
|
|
558
|
+
|
|
559
|
+
// src/shared/api.ts
|
|
560
|
+
import https from "node:https";
|
|
561
|
+
import http from "node:http";
|
|
562
|
+
import tls from "node:tls";
|
|
563
|
+
|
|
564
|
+
// ../jsclient/src/http.ts
|
|
565
|
+
var ApiError = class extends Error {
|
|
566
|
+
constructor(status, message) {
|
|
567
|
+
super(message);
|
|
568
|
+
this.status = status;
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
async function send(handler, method, path4, payload) {
|
|
572
|
+
const init = { method };
|
|
573
|
+
if (payload !== void 0) {
|
|
574
|
+
init.headers = { "Content-Type": "application/json" };
|
|
575
|
+
init.body = JSON.stringify(payload);
|
|
576
|
+
}
|
|
577
|
+
const response = await handler(path4, init);
|
|
578
|
+
if (!response.ok) {
|
|
579
|
+
const problem = await response.json().catch(() => void 0);
|
|
580
|
+
throw new ApiError(response.status, problem?.detail || problem?.title || `${method} ${path4} failed: ${response.status}`);
|
|
581
|
+
}
|
|
582
|
+
return response.json();
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ../jsclient/src/gen/client/Endpoints/Auth/GetCurrentUserEndpoint.ts
|
|
586
|
+
var GetCurrentUserEndpoint_default = (handler) => () => send(handler, "GET", `api/me`);
|
|
587
|
+
|
|
588
|
+
// ../jsclient/src/gen/client/Endpoints/Groups/AddScenarioToGroupEndpoint.ts
|
|
589
|
+
var AddScenarioToGroupEndpoint_default = (handler) => (id, scenarioId) => send(handler, "POST", `api/group/${encodeURIComponent(id)}/scenario/${encodeURIComponent(scenarioId)}`);
|
|
590
|
+
|
|
591
|
+
// ../jsclient/src/gen/client/Endpoints/Groups/CreateGroupEndpoint.ts
|
|
592
|
+
var CreateGroupEndpoint_default = (handler) => (payload) => send(handler, "POST", `api/group`, payload);
|
|
593
|
+
|
|
594
|
+
// ../jsclient/src/gen/client/Endpoints/Groups/DeleteGroupEndpoint.ts
|
|
595
|
+
var DeleteGroupEndpoint_default = (handler) => (id) => send(handler, "DELETE", `api/group/${encodeURIComponent(id)}`);
|
|
596
|
+
|
|
597
|
+
// ../jsclient/src/gen/client/Endpoints/Groups/ListGroupsEndpoint.ts
|
|
598
|
+
var ListGroupsEndpoint_default = (handler) => () => send(handler, "GET", `api/group`);
|
|
599
|
+
|
|
600
|
+
// ../jsclient/src/gen/client/Endpoints/Groups/RemoveScenarioFromGroupEndpoint.ts
|
|
601
|
+
var RemoveScenarioFromGroupEndpoint_default = (handler) => (id, scenarioId) => send(handler, "DELETE", `api/group/${encodeURIComponent(id)}/scenario/${encodeURIComponent(scenarioId)}`);
|
|
602
|
+
|
|
603
|
+
// ../jsclient/src/gen/client/Endpoints/Groups/UpdateGroupEndpoint.ts
|
|
604
|
+
var UpdateGroupEndpoint_default = (handler) => (id, payload) => send(handler, "PUT", `api/group/${encodeURIComponent(id)}`, payload);
|
|
605
|
+
|
|
606
|
+
// ../jsclient/src/gen/client/Endpoints/Organizations/CreateOrganizationEndpoint.ts
|
|
607
|
+
var CreateOrganizationEndpoint_default = (handler) => (payload) => send(handler, "POST", `api/organizations`, payload);
|
|
608
|
+
|
|
609
|
+
// ../jsclient/src/gen/client/Endpoints/Runs/GetRunEndpoint.ts
|
|
610
|
+
var GetRunEndpoint_default = (handler) => (id) => send(handler, "GET", `api/run/${encodeURIComponent(id)}`);
|
|
611
|
+
|
|
612
|
+
// ../jsclient/src/gen/client/Endpoints/Runs/StartRunEndpoint.ts
|
|
613
|
+
var StartRunEndpoint_default = (handler) => (payload) => send(handler, "POST", `api/run`, payload);
|
|
614
|
+
|
|
615
|
+
// ../jsclient/src/gen/client/Endpoints/Scenarios/CreateScenarioEndpoint.ts
|
|
616
|
+
var CreateScenarioEndpoint_default = (handler) => (payload) => send(handler, "POST", `api/scenario`, payload);
|
|
617
|
+
|
|
618
|
+
// ../jsclient/src/gen/client/Endpoints/Scenarios/DeleteScenarioEndpoint.ts
|
|
619
|
+
var DeleteScenarioEndpoint_default = (handler) => (id) => send(handler, "DELETE", `api/scenario/${encodeURIComponent(id)}`);
|
|
620
|
+
|
|
621
|
+
// ../jsclient/src/gen/client/Endpoints/Scenarios/GetScenarioEndpoint.ts
|
|
622
|
+
var GetScenarioEndpoint_default = (handler) => (id) => send(handler, "GET", `api/scenario/${encodeURIComponent(id)}`);
|
|
623
|
+
|
|
624
|
+
// ../jsclient/src/gen/client/Endpoints/Scenarios/ListScenariosEndpoint.ts
|
|
625
|
+
var ListScenariosEndpoint_default = (handler) => () => send(handler, "GET", `api/scenario`);
|
|
626
|
+
|
|
627
|
+
// ../jsclient/src/gen/client/Endpoints/Scenarios/UpdateScenarioEndpoint.ts
|
|
628
|
+
var UpdateScenarioEndpoint_default = (handler) => (id, payload) => send(handler, "PUT", `api/scenario/${encodeURIComponent(id)}`, payload);
|
|
629
|
+
|
|
630
|
+
// ../jsclient/src/gen/client/Endpoints/Users/InviteUserEndpoint.ts
|
|
631
|
+
var InviteUserEndpoint_default = (handler) => (payload) => send(handler, "POST", `api/invitation`, payload);
|
|
632
|
+
|
|
633
|
+
// ../jsclient/src/gen/client/Endpoints/Users/ListInvitationsEndpoint.ts
|
|
634
|
+
var ListInvitationsEndpoint_default = (handler) => () => send(handler, "GET", `api/invitation`);
|
|
635
|
+
|
|
636
|
+
// ../jsclient/src/gen/client/Endpoints/Users/ListMembersEndpoint.ts
|
|
637
|
+
var ListMembersEndpoint_default = (handler) => () => send(handler, "GET", `api/member`);
|
|
638
|
+
|
|
639
|
+
// ../jsclient/src/gen/client/Endpoints/Users/RemoveMemberEndpoint.ts
|
|
640
|
+
var RemoveMemberEndpoint_default = (handler) => (userId) => send(handler, "DELETE", `api/member/${encodeURIComponent(userId)}`);
|
|
641
|
+
|
|
642
|
+
// ../jsclient/src/gen/client/Endpoints/Users/RevokeInvitationEndpoint.ts
|
|
643
|
+
var RevokeInvitationEndpoint_default = (handler) => (id) => send(handler, "DELETE", `api/invitation/${encodeURIComponent(id)}`);
|
|
644
|
+
|
|
645
|
+
// ../jsclient/src/gen/client/index.ts
|
|
646
|
+
function createClient(handler) {
|
|
647
|
+
return {
|
|
648
|
+
getCurrentUser: GetCurrentUserEndpoint_default(handler),
|
|
649
|
+
addScenarioToGroup: AddScenarioToGroupEndpoint_default(handler),
|
|
650
|
+
createGroup: CreateGroupEndpoint_default(handler),
|
|
651
|
+
deleteGroup: DeleteGroupEndpoint_default(handler),
|
|
652
|
+
listGroups: ListGroupsEndpoint_default(handler),
|
|
653
|
+
removeScenarioFromGroup: RemoveScenarioFromGroupEndpoint_default(handler),
|
|
654
|
+
updateGroup: UpdateGroupEndpoint_default(handler),
|
|
655
|
+
createOrganization: CreateOrganizationEndpoint_default(handler),
|
|
656
|
+
getRun: GetRunEndpoint_default(handler),
|
|
657
|
+
startRun: StartRunEndpoint_default(handler),
|
|
658
|
+
createScenario: CreateScenarioEndpoint_default(handler),
|
|
659
|
+
deleteScenario: DeleteScenarioEndpoint_default(handler),
|
|
660
|
+
getScenario: GetScenarioEndpoint_default(handler),
|
|
661
|
+
listScenarios: ListScenariosEndpoint_default(handler),
|
|
662
|
+
updateScenario: UpdateScenarioEndpoint_default(handler),
|
|
663
|
+
inviteUser: InviteUserEndpoint_default(handler),
|
|
664
|
+
listInvitations: ListInvitationsEndpoint_default(handler),
|
|
665
|
+
listMembers: ListMembersEndpoint_default(handler),
|
|
666
|
+
removeMember: RemoveMemberEndpoint_default(handler),
|
|
667
|
+
revokeInvitation: RevokeInvitationEndpoint_default(handler)
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// src/shared/api.ts
|
|
672
|
+
function trustedCertificates() {
|
|
673
|
+
const getCertificates = tls.getCACertificates;
|
|
674
|
+
if (!getCertificates) return void 0;
|
|
675
|
+
return [...getCertificates("default"), ...getCertificates("system")];
|
|
676
|
+
}
|
|
677
|
+
var SIGN_IN_HINT = 'Run "mimic login" to sign in.';
|
|
678
|
+
var nodeHandler = async (path4, init) => {
|
|
679
|
+
const url = `${API_URL.replace(/\/$/, "")}/${path4}`;
|
|
680
|
+
const method = init.method ?? "GET";
|
|
681
|
+
const token = await getAccessToken();
|
|
682
|
+
if (!token) {
|
|
683
|
+
throw new Error(`Not signed in. ${SIGN_IN_HINT}`);
|
|
684
|
+
}
|
|
685
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
686
|
+
new Headers(init.headers).forEach((value, key) => {
|
|
687
|
+
headers[key] = value;
|
|
688
|
+
});
|
|
689
|
+
const client = url.startsWith("https:") ? https : http;
|
|
690
|
+
const { status, body } = await new Promise((resolve, reject) => {
|
|
691
|
+
const request = client.request(url, { method, headers, ca: trustedCertificates() }, (response) => {
|
|
692
|
+
const chunks = [];
|
|
693
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
694
|
+
response.on("end", () => resolve({ status: response.statusCode, body: Buffer.concat(chunks).toString("utf8") }));
|
|
695
|
+
});
|
|
696
|
+
request.on("error", (error) => reject(new Error(`${method} ${url} failed: ${error.message}`)));
|
|
697
|
+
request.end(init.body);
|
|
698
|
+
});
|
|
699
|
+
if (status === 401) {
|
|
700
|
+
throw new Error(`The config API rejected your session. ${SIGN_IN_HINT}`);
|
|
701
|
+
}
|
|
702
|
+
if (status === 403) {
|
|
703
|
+
throw new Error('Your account is not in an organization yet. Create one in the dashboard, then run "mimic login" again.');
|
|
704
|
+
}
|
|
705
|
+
return new Response(body, { status });
|
|
706
|
+
};
|
|
707
|
+
var api = createClient(nodeHandler);
|
|
708
|
+
|
|
709
|
+
// src/shared/runner.ts
|
|
710
|
+
import path3 from "node:path";
|
|
711
|
+
import { fileURLToPath } from "node:url";
|
|
712
|
+
import { chromium, errors } from "playwright";
|
|
713
|
+
var LOCATE_TIMEOUT_MS = 4e3;
|
|
714
|
+
var RELOCATE_TIMEOUT_MS = 1e3;
|
|
715
|
+
var POLL_INTERVAL_MS = 150;
|
|
716
|
+
var PAGE_LOAD_TIMEOUT_MS = 15e3;
|
|
717
|
+
var NO_LOAD_MS = 1500;
|
|
718
|
+
var INJECT_PATH = path3.join(path3.dirname(fileURLToPath(import.meta.url)), "inject.js");
|
|
719
|
+
var PAGE_LOAD_ERROR = `page did not finish loading in ${PAGE_LOAD_TIMEOUT_MS / 1e3}s`;
|
|
720
|
+
var MODIFIER_KEYS = {
|
|
721
|
+
Alt: "Alt",
|
|
722
|
+
Ctrl: "Control",
|
|
723
|
+
Meta: "Meta",
|
|
724
|
+
Shift: "Shift"
|
|
725
|
+
};
|
|
726
|
+
function isNavigationError(error) {
|
|
727
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
728
|
+
return /Execution context was destroyed|Cannot find context|navigation/i.test(message);
|
|
729
|
+
}
|
|
730
|
+
async function loading(promise) {
|
|
731
|
+
try {
|
|
732
|
+
return await promise;
|
|
733
|
+
} catch (error) {
|
|
734
|
+
if (error instanceof errors.TimeoutError) throw new Error(PAGE_LOAD_ERROR);
|
|
735
|
+
throw error;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
function isMainFrameNavigation(page, request) {
|
|
739
|
+
try {
|
|
740
|
+
return request.isNavigationRequest() && request.frame() === page.mainFrame();
|
|
741
|
+
} catch {
|
|
742
|
+
return false;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
var PendingNavigation = class {
|
|
746
|
+
startedAt;
|
|
747
|
+
waiters = [];
|
|
748
|
+
constructor(page) {
|
|
749
|
+
page.on("request", (request) => {
|
|
750
|
+
if (isMainFrameNavigation(page, request)) this.startedAt ??= Date.now();
|
|
751
|
+
});
|
|
752
|
+
page.on("requestfailed", (request) => {
|
|
753
|
+
if (isMainFrameNavigation(page, request)) this.end();
|
|
754
|
+
});
|
|
755
|
+
page.on("load", () => this.end());
|
|
756
|
+
}
|
|
757
|
+
async wait() {
|
|
758
|
+
if (this.startedAt === void 0) return;
|
|
759
|
+
let timeoutId;
|
|
760
|
+
const loaded = await Promise.race([
|
|
761
|
+
new Promise((resolve) => this.waiters.push(() => resolve(true))),
|
|
762
|
+
new Promise((resolve) => {
|
|
763
|
+
timeoutId = setTimeout(() => resolve(false), this.startedAt + PAGE_LOAD_TIMEOUT_MS - Date.now());
|
|
764
|
+
})
|
|
765
|
+
]);
|
|
766
|
+
clearTimeout(timeoutId);
|
|
767
|
+
if (!loaded) throw new Error(PAGE_LOAD_ERROR);
|
|
768
|
+
}
|
|
769
|
+
end() {
|
|
770
|
+
this.startedAt = void 0;
|
|
771
|
+
for (const waiter of this.waiters.splice(0)) waiter();
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
var pendingNavigations = /* @__PURE__ */ new WeakMap();
|
|
775
|
+
function restoreSessionStorage(data) {
|
|
776
|
+
const values = data[location.origin];
|
|
777
|
+
if (!values) return;
|
|
778
|
+
for (const [key, value] of Object.entries(values)) sessionStorage.setItem(key, value);
|
|
779
|
+
}
|
|
780
|
+
async function settle(page) {
|
|
781
|
+
try {
|
|
782
|
+
await page.evaluate(() => window.__scenarioRunner.settle());
|
|
783
|
+
} catch (error) {
|
|
784
|
+
if (!isNavigationError(error)) throw error;
|
|
785
|
+
}
|
|
786
|
+
await pendingNavigations.get(page)?.wait();
|
|
787
|
+
await loading(page.waitForLoadState("load", { timeout: PAGE_LOAD_TIMEOUT_MS }));
|
|
788
|
+
}
|
|
789
|
+
async function find(page, step, kind, timeoutMs = LOCATE_TIMEOUT_MS) {
|
|
790
|
+
const deadline = Date.now() + timeoutMs;
|
|
791
|
+
for (; ; ) {
|
|
792
|
+
let error;
|
|
793
|
+
try {
|
|
794
|
+
await loading(page.waitForLoadState("domcontentloaded", { timeout: PAGE_LOAD_TIMEOUT_MS }));
|
|
795
|
+
const handle = await page.evaluateHandle(
|
|
796
|
+
({ locator, kind: kind2 }) => window.__scenarioRunner.find(locator, kind2),
|
|
797
|
+
{ locator: step.locator, kind }
|
|
798
|
+
);
|
|
799
|
+
const element = handle.asElement();
|
|
800
|
+
if (element) return element;
|
|
801
|
+
error = await handle.jsonValue();
|
|
802
|
+
await handle.dispose();
|
|
803
|
+
} catch (e) {
|
|
804
|
+
if (!isNavigationError(e)) throw e;
|
|
805
|
+
error = "page navigated while locating the element";
|
|
806
|
+
}
|
|
807
|
+
if (Date.now() >= deadline) throw new Error(error);
|
|
808
|
+
await page.waitForTimeout(POLL_INTERVAL_MS);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
async function isConnected(element) {
|
|
812
|
+
try {
|
|
813
|
+
return await element.evaluate((el) => el.isConnected);
|
|
814
|
+
} catch {
|
|
815
|
+
return false;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
async function inputField(page, step, previous) {
|
|
819
|
+
if (await isConnected(previous)) return previous;
|
|
820
|
+
try {
|
|
821
|
+
return await find(page, step, "fill", RELOCATE_TIMEOUT_MS);
|
|
822
|
+
} catch {
|
|
823
|
+
throw new Error("input element was replaced and could not be found again");
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
async function typeInto(page, step, element) {
|
|
827
|
+
const target = await inputField(page, step, element);
|
|
828
|
+
const problem = await target.evaluate((el) => window.__scenarioRunner.editProblem(el));
|
|
829
|
+
if (problem) {
|
|
830
|
+
throw new Error(problem);
|
|
831
|
+
}
|
|
832
|
+
await target.fill("");
|
|
833
|
+
await page.keyboard.type(step.value);
|
|
834
|
+
await settle(page);
|
|
835
|
+
return target;
|
|
836
|
+
}
|
|
837
|
+
async function pickOption(page, step, element) {
|
|
838
|
+
const moves = await element.evaluate((el, option) => window.__scenarioRunner.optionMoves(el, option), step.value);
|
|
839
|
+
if (typeof moves === "string") throw new Error(moves);
|
|
840
|
+
await element.click({ force: true, noWaitAfter: true });
|
|
841
|
+
for (let i = 0; i < Math.abs(moves); i++) await page.keyboard.press(moves < 0 ? "ArrowUp" : "ArrowDown");
|
|
842
|
+
await page.keyboard.press("Enter");
|
|
843
|
+
await settle(page);
|
|
844
|
+
}
|
|
845
|
+
async function playFill(page, step, element) {
|
|
846
|
+
const tag = await element.evaluate((el) => el.tagName.toLowerCase());
|
|
847
|
+
if (tag === "select") {
|
|
848
|
+
await pickOption(page, step, element);
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (tag !== "input" && tag !== "textarea") throw new Error("target is not an input-capable element");
|
|
852
|
+
if (!await element.evaluate((el) => el === document.activeElement)) {
|
|
853
|
+
await element.focus();
|
|
854
|
+
await settle(page);
|
|
855
|
+
}
|
|
856
|
+
const target = await typeInto(page, step, element);
|
|
857
|
+
if (!await isConnected(target)) await typeInto(page, step, target);
|
|
858
|
+
}
|
|
859
|
+
async function playAssertion(page, step) {
|
|
860
|
+
const deadline = Date.now() + LOCATE_TIMEOUT_MS;
|
|
861
|
+
for (; ; ) {
|
|
862
|
+
let problem;
|
|
863
|
+
try {
|
|
864
|
+
await loading(page.waitForLoadState("domcontentloaded", { timeout: PAGE_LOAD_TIMEOUT_MS }));
|
|
865
|
+
problem = await page.evaluate((s) => window.__scenarioRunner.assertionProblem(s), step);
|
|
866
|
+
} catch (e) {
|
|
867
|
+
if (!isNavigationError(e)) throw e;
|
|
868
|
+
problem = `${step.assertionType} assertion failed: page navigated while checking`;
|
|
869
|
+
}
|
|
870
|
+
if (!problem) return;
|
|
871
|
+
if (Date.now() >= deadline) throw new Error(problem);
|
|
872
|
+
await page.waitForTimeout(POLL_INTERVAL_MS);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
async function playExpect(page, expect) {
|
|
876
|
+
const pending = expect.map((step, i) => ({ index: i + 1, step, problem: void 0 }));
|
|
877
|
+
const deadline = Date.now() + LOCATE_TIMEOUT_MS;
|
|
878
|
+
for (; ; ) {
|
|
879
|
+
for (const assertion of [...pending]) {
|
|
880
|
+
try {
|
|
881
|
+
await loading(page.waitForLoadState("domcontentloaded", { timeout: PAGE_LOAD_TIMEOUT_MS }));
|
|
882
|
+
assertion.problem = await page.evaluate((s) => window.__scenarioRunner.assertionCheck(s), assertion.step);
|
|
883
|
+
} catch (e) {
|
|
884
|
+
if (!isNavigationError(e)) throw e;
|
|
885
|
+
assertion.problem = "page navigated while checking";
|
|
886
|
+
}
|
|
887
|
+
if (!assertion.problem) pending.splice(pending.indexOf(assertion), 1);
|
|
888
|
+
}
|
|
889
|
+
if (pending.length === 0) return;
|
|
890
|
+
if (Date.now() >= deadline) {
|
|
891
|
+
throw new Error(pending.map((a, i) => `expect ${a.index} (${a.step.assertionType}): ${a.problem}${i === 0 ? ` (on ${page.url()})` : ""}`).join("\n"));
|
|
892
|
+
}
|
|
893
|
+
await page.waitForTimeout(POLL_INTERVAL_MS);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
async function playStep(page, step) {
|
|
897
|
+
switch (step.type) {
|
|
898
|
+
case "Navigate":
|
|
899
|
+
await loading(page.goto(step.url, { waitUntil: "load", timeout: PAGE_LOAD_TIMEOUT_MS }));
|
|
900
|
+
return;
|
|
901
|
+
case "Click": {
|
|
902
|
+
const element = await find(page, step, "click");
|
|
903
|
+
await element.click({ force: true, noWaitAfter: true, modifiers: step.modifiers.map((m) => MODIFIER_KEYS[m]) });
|
|
904
|
+
await settle(page);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
case "Fill":
|
|
908
|
+
await playFill(page, step, await find(page, step, "fill"));
|
|
909
|
+
return;
|
|
910
|
+
case "Assertion":
|
|
911
|
+
await playAssertion(page, step);
|
|
912
|
+
return;
|
|
913
|
+
default: {
|
|
914
|
+
const unhandled = step;
|
|
915
|
+
throw new Error(`unknown step type: ${unhandled.type}`);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
async function waitForNoLoad(page) {
|
|
920
|
+
let lastLoad = Date.now();
|
|
921
|
+
const onRequest = (request) => {
|
|
922
|
+
if (request.isNavigationRequest() && request.frame() === page.mainFrame()) {
|
|
923
|
+
lastLoad = Date.now();
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
page.on("request", onRequest);
|
|
927
|
+
try {
|
|
928
|
+
const deadline = Date.now() + PAGE_LOAD_TIMEOUT_MS;
|
|
929
|
+
while (Date.now() < deadline) {
|
|
930
|
+
await loading(page.waitForLoadState("load", { timeout: PAGE_LOAD_TIMEOUT_MS }));
|
|
931
|
+
const quietMs = Date.now() - lastLoad;
|
|
932
|
+
if (quietMs >= NO_LOAD_MS) {
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
await page.waitForTimeout(NO_LOAD_MS - quietMs);
|
|
936
|
+
}
|
|
937
|
+
} finally {
|
|
938
|
+
page.off("request", onRequest);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
async function captureState(context, page, parent) {
|
|
942
|
+
await waitForNoLoad(page);
|
|
943
|
+
const url = page.url();
|
|
944
|
+
const session = await page.evaluate(() => Object.fromEntries(
|
|
945
|
+
Array.from({ length: sessionStorage.length }, (_, i) => sessionStorage.key(i)).map((key) => [key, sessionStorage.getItem(key) ?? ""])
|
|
946
|
+
));
|
|
947
|
+
return {
|
|
948
|
+
url,
|
|
949
|
+
storageState: await context.storageState(),
|
|
950
|
+
sessionStorage: { ...parent?.sessionStorage, [new URL(url).origin]: session }
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
var Runner = class _Runner {
|
|
954
|
+
constructor(browser) {
|
|
955
|
+
this.browser = browser;
|
|
956
|
+
}
|
|
957
|
+
static async launch(headless) {
|
|
958
|
+
return new _Runner(await chromium.launch({ headless }));
|
|
959
|
+
}
|
|
960
|
+
async run(scenario, parent) {
|
|
961
|
+
const context = await this.browser.newContext({ storageState: parent?.storageState });
|
|
962
|
+
try {
|
|
963
|
+
await context.addInitScript({ path: INJECT_PATH });
|
|
964
|
+
const page = await context.newPage();
|
|
965
|
+
pendingNavigations.set(page, new PendingNavigation(page));
|
|
966
|
+
const [first] = scenario.steps;
|
|
967
|
+
const startUrl = first?.type === "Navigate" ? first.url : parent?.url;
|
|
968
|
+
if (!startUrl) {
|
|
969
|
+
throw new Error("scenario has no prerequisite and does not start with a Navigate step");
|
|
970
|
+
}
|
|
971
|
+
const restore = parent && await page.addInitScript(restoreSessionStorage, parent.sessionStorage);
|
|
972
|
+
await loading(page.goto(startUrl, { waitUntil: "load", timeout: PAGE_LOAD_TIMEOUT_MS }));
|
|
973
|
+
await restore?.dispose();
|
|
974
|
+
for (const [index, step] of scenario.steps.entries()) {
|
|
975
|
+
if (index === 0 && step.type === "Navigate") {
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
try {
|
|
979
|
+
await playStep(page, step);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
const message = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
982
|
+
const where = step.type === "Navigate" ? "" : ` (on ${page.url()})`;
|
|
983
|
+
throw new Error(`step ${index + 1} (${step.type}): ${message}${where}`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (scenario.expect.length > 0) await playExpect(page, scenario.expect);
|
|
987
|
+
return await captureState(context, page, parent);
|
|
988
|
+
} finally {
|
|
989
|
+
await context.close();
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
async close() {
|
|
993
|
+
await this.browser.close();
|
|
994
|
+
}
|
|
995
|
+
};
|
|
996
|
+
|
|
997
|
+
// src/commands/run.ts
|
|
998
|
+
var MEMORY_PER_WORKER = 400 * 1024 * 1024;
|
|
999
|
+
var CLOUD_POLL_INTERVAL_MS = 1e3;
|
|
1000
|
+
var WAITING_NOTICE_MS = 15e3;
|
|
1001
|
+
var APP_WAIT_MS = 3e4;
|
|
1002
|
+
var APP_POLL_INTERVAL_MS = 250;
|
|
1003
|
+
var APP_LOG_TAIL_LINES = 30;
|
|
1004
|
+
function buildForest(scenarios) {
|
|
1005
|
+
const nodes = new Map(scenarios.map((scenario) => [scenario.id, { scenario, children: [], height: 0, descendants: 0 }]));
|
|
1006
|
+
const roots = [];
|
|
1007
|
+
const orphans = /* @__PURE__ */ new Map();
|
|
1008
|
+
for (const node of nodes.values()) {
|
|
1009
|
+
const prerequisite = node.scenario.prerequisite;
|
|
1010
|
+
if (!prerequisite) {
|
|
1011
|
+
roots.push(node);
|
|
1012
|
+
} else if (nodes.has(prerequisite)) {
|
|
1013
|
+
nodes.get(prerequisite).children.push(node);
|
|
1014
|
+
} else {
|
|
1015
|
+
orphans.set(node, `prerequisite "${prerequisite}" not found`);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
const reached = /* @__PURE__ */ new Set();
|
|
1019
|
+
const measure = (node) => {
|
|
1020
|
+
reached.add(node);
|
|
1021
|
+
for (const child of node.children) {
|
|
1022
|
+
measure(child);
|
|
1023
|
+
node.height = Math.max(node.height, child.height + 1);
|
|
1024
|
+
node.descendants += child.descendants + 1;
|
|
1025
|
+
}
|
|
1026
|
+
};
|
|
1027
|
+
for (const root of [...roots, ...orphans.keys()]) measure(root);
|
|
1028
|
+
for (const node of nodes.values()) {
|
|
1029
|
+
if (!reached.has(node)) {
|
|
1030
|
+
node.children = [];
|
|
1031
|
+
orphans.set(node, "prerequisite cycle");
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
return { roots, orphans };
|
|
1035
|
+
}
|
|
1036
|
+
function byPriority(a, b) {
|
|
1037
|
+
return b.node.height - a.node.height || b.node.descendants - a.node.descendants;
|
|
1038
|
+
}
|
|
1039
|
+
function schedule(scenarios, workers, run2, onResult) {
|
|
1040
|
+
const { roots, orphans } = buildForest(scenarios);
|
|
1041
|
+
const results = [];
|
|
1042
|
+
const report = (result) => {
|
|
1043
|
+
results.push(result);
|
|
1044
|
+
onResult(result);
|
|
1045
|
+
};
|
|
1046
|
+
const skipTree = (node, reason) => {
|
|
1047
|
+
report({ scenario: node.scenario, status: "skipped", reason });
|
|
1048
|
+
for (const child of node.children) skipTree(child, reason);
|
|
1049
|
+
};
|
|
1050
|
+
for (const [node, reason] of orphans) skipTree(node, reason);
|
|
1051
|
+
const ready = roots.map((node) => ({ node, parentState: void 0 }));
|
|
1052
|
+
let running = 0;
|
|
1053
|
+
return new Promise((resolve) => {
|
|
1054
|
+
const pump = () => {
|
|
1055
|
+
ready.sort(byPriority);
|
|
1056
|
+
while (running < workers && ready.length > 0) {
|
|
1057
|
+
const { node, parentState } = ready.shift();
|
|
1058
|
+
running++;
|
|
1059
|
+
const started = Date.now();
|
|
1060
|
+
run2(node.scenario, parentState).then(
|
|
1061
|
+
(state) => {
|
|
1062
|
+
report({ scenario: node.scenario, status: "passed", state, durationMs: Date.now() - started });
|
|
1063
|
+
ready.push(...node.children.map((child) => ({ node: child, parentState: state })));
|
|
1064
|
+
},
|
|
1065
|
+
(error) => {
|
|
1066
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1067
|
+
report({ scenario: node.scenario, status: "failed", error: message, durationMs: Date.now() - started });
|
|
1068
|
+
for (const child of node.children) skipTree(child, `prerequisite "${node.scenario.name}" failed`);
|
|
1069
|
+
}
|
|
1070
|
+
).finally(() => {
|
|
1071
|
+
running--;
|
|
1072
|
+
pump();
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
if (running === 0 && ready.length === 0) resolve(results);
|
|
1076
|
+
};
|
|
1077
|
+
pump();
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
function inGroups(scenarios, groups) {
|
|
1081
|
+
const groupIds = new Set(groups.map((g) => g.id));
|
|
1082
|
+
const byId = new Map(scenarios.map((scenario) => [scenario.id, scenario]));
|
|
1083
|
+
const members = scenarios.filter((s) => s.groups.some((id) => groupIds.has(id)));
|
|
1084
|
+
const memberIds = new Set(members.map((s) => s.id));
|
|
1085
|
+
const included = /* @__PURE__ */ new Set();
|
|
1086
|
+
const prerequisiteOnly = /* @__PURE__ */ new Set();
|
|
1087
|
+
for (const scenario of members) {
|
|
1088
|
+
let next = scenario;
|
|
1089
|
+
while (next && !included.has(next.id)) {
|
|
1090
|
+
included.add(next.id);
|
|
1091
|
+
if (!memberIds.has(next.id)) prerequisiteOnly.add(next.id);
|
|
1092
|
+
next = next.prerequisite ? byId.get(next.prerequisite) : void 0;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
return { scenarios: scenarios.filter((s) => included.has(s.id)), prerequisiteOnly };
|
|
1096
|
+
}
|
|
1097
|
+
var inGroupsLabel = (slugs) => slugs.length === 0 ? "" : ` in ${slugs.length === 1 ? "group" : "groups"} ${slugs.join(", ")}`;
|
|
1098
|
+
function defaultWorkers() {
|
|
1099
|
+
const byCpu = Math.max(1, os2.availableParallelism() - 1);
|
|
1100
|
+
const byMemory = Math.max(1, Math.floor(os2.freemem() / MEMORY_PER_WORKER));
|
|
1101
|
+
return Math.min(byCpu, byMemory);
|
|
1102
|
+
}
|
|
1103
|
+
function parseWorkers(value) {
|
|
1104
|
+
if (value === void 0) {
|
|
1105
|
+
return defaultWorkers();
|
|
1106
|
+
}
|
|
1107
|
+
const workers = Number(value);
|
|
1108
|
+
if (!Number.isInteger(workers) || workers < 1) {
|
|
1109
|
+
throw new Error(`--workers must be a positive integer, got "${value}"`);
|
|
1110
|
+
}
|
|
1111
|
+
return workers;
|
|
1112
|
+
}
|
|
1113
|
+
var CLOUD_STATUS = {
|
|
1114
|
+
Pending: void 0,
|
|
1115
|
+
Passed: "passed",
|
|
1116
|
+
Failed: "failed",
|
|
1117
|
+
PrerequisiteFailed: "skipped",
|
|
1118
|
+
PrerequisiteMissing: "skipped"
|
|
1119
|
+
};
|
|
1120
|
+
function printLine({ name, status, prerequisiteOnly, durationMs, browser, message, file }) {
|
|
1121
|
+
const details = [
|
|
1122
|
+
prerequisiteOnly ? "prerequisite" : void 0,
|
|
1123
|
+
durationMs === void 0 ? void 0 : seconds(durationMs),
|
|
1124
|
+
browser
|
|
1125
|
+
].filter(Boolean).join(", ");
|
|
1126
|
+
const suffix = details ? ` ${gray(`(${details})`)}` : "";
|
|
1127
|
+
switch (status) {
|
|
1128
|
+
case "passed":
|
|
1129
|
+
console.log(prerequisiteOnly ? gray(`\u2713 ${name}${suffix}`) : `${green("\u2713")} ${name}${suffix}`);
|
|
1130
|
+
break;
|
|
1131
|
+
case "failed":
|
|
1132
|
+
console.log(`${red("\u2717")} ${name}${suffix}
|
|
1133
|
+
${red((message ?? "failed").replaceAll("\n", "\n "))}${file ? gray(` (${file})`) : ""}`);
|
|
1134
|
+
break;
|
|
1135
|
+
case "skipped":
|
|
1136
|
+
console.log(`${gray("-")} ${name} ${gray(`skipped: ${message ?? ""}`)}`);
|
|
1137
|
+
break;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
function printSummary(lines, started) {
|
|
1141
|
+
const count = (status, prerequisiteOnly) => lines.filter((l) => l.status === status && l.prerequisiteOnly === prerequisiteOnly).length;
|
|
1142
|
+
const failed = count("failed", false);
|
|
1143
|
+
const skipped = count("skipped", false);
|
|
1144
|
+
const prerequisites = ["passed", "failed", "skipped"].map((status) => [status, count(status, true)]).filter(([, n]) => n > 0).map(([status, n]) => status === "failed" ? red(`${n} failed`) : `${n} ${status}`);
|
|
1145
|
+
const prerequisitesNote = prerequisites.length ? `; prerequisites: ${prerequisites.join(", ")}` : "";
|
|
1146
|
+
console.log(`
|
|
1147
|
+
${green(`${count("passed", false)} passed`)}, ${failed ? red(`${failed} failed`) : "0 failed"}, ${skipped} skipped${prerequisitesNote} ${gray(`(${seconds(Date.now() - started)})`)}`);
|
|
1148
|
+
return lines.some((l) => l.status !== "passed") ? 1 : 0;
|
|
1149
|
+
}
|
|
1150
|
+
function reportLine(result, prerequisiteOnly) {
|
|
1151
|
+
const { name } = result.scenario;
|
|
1152
|
+
switch (result.status) {
|
|
1153
|
+
case "passed":
|
|
1154
|
+
return { name, prerequisiteOnly, status: "passed", durationMs: result.durationMs };
|
|
1155
|
+
case "failed":
|
|
1156
|
+
return { name, prerequisiteOnly, status: "failed", durationMs: result.durationMs, message: result.error };
|
|
1157
|
+
case "skipped":
|
|
1158
|
+
return { name, prerequisiteOnly, status: "skipped", message: result.reason };
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
async function runLocally(workers, windows, slugs, source) {
|
|
1162
|
+
const { scenarios: all, groups: known, files } = await source();
|
|
1163
|
+
const groups = findGroups(slugs, known);
|
|
1164
|
+
const selected = groups.length === 0 ? { scenarios: all, prerequisiteOnly: /* @__PURE__ */ new Set() } : inGroups(all, groups);
|
|
1165
|
+
const { prerequisiteOnly } = selected;
|
|
1166
|
+
const sources = /* @__PURE__ */ new Map();
|
|
1167
|
+
const scenarios = selected.scenarios.flatMap((scenario) => expandVariants(scenario).filter(({ variant }) => variant === 0 || !prerequisiteOnly.has(scenario.id)).map(({ scenario: run2 }) => {
|
|
1168
|
+
sources.set(run2.id, scenario.id);
|
|
1169
|
+
return run2;
|
|
1170
|
+
}));
|
|
1171
|
+
if (scenarios.length === 0) {
|
|
1172
|
+
console.log("No scenarios found.");
|
|
1173
|
+
return 0;
|
|
1174
|
+
}
|
|
1175
|
+
console.log(gray(`Running ${scenarios.length} scenarios${inGroupsLabel(slugs)} locally with ${workers} workers
|
|
1176
|
+
`));
|
|
1177
|
+
const started = Date.now();
|
|
1178
|
+
const runner = await Runner.launch(!windows);
|
|
1179
|
+
const lines = [];
|
|
1180
|
+
try {
|
|
1181
|
+
await schedule(scenarios, workers, (scenario, parent) => runner.run(scenario, parent), (result) => {
|
|
1182
|
+
const line = { ...reportLine(result, prerequisiteOnly.has(result.scenario.id)), file: files?.get(sources.get(result.scenario.id)) };
|
|
1183
|
+
lines.push(line);
|
|
1184
|
+
printLine(line);
|
|
1185
|
+
});
|
|
1186
|
+
} finally {
|
|
1187
|
+
await runner.close();
|
|
1188
|
+
}
|
|
1189
|
+
return printSummary(lines, started);
|
|
1190
|
+
}
|
|
1191
|
+
async function runInCloud(slugs) {
|
|
1192
|
+
const started = Date.now();
|
|
1193
|
+
const groups = findGroups(slugs, slugs.length === 0 ? [] : await api.listGroups());
|
|
1194
|
+
let run2 = await api.startRun({ groups: groups.map((g) => g.id) });
|
|
1195
|
+
if (run2.scenarios.length === 0) {
|
|
1196
|
+
console.log("No scenarios found.");
|
|
1197
|
+
return 0;
|
|
1198
|
+
}
|
|
1199
|
+
console.log(gray(`Started run ${run2.id}: ${run2.scenarios.length} scenarios${inGroupsLabel(slugs)} on the cloud workers
|
|
1200
|
+
`));
|
|
1201
|
+
const reported = /* @__PURE__ */ new Set();
|
|
1202
|
+
let lastProgress = Date.now();
|
|
1203
|
+
let waitingShown = false;
|
|
1204
|
+
for (; ; ) {
|
|
1205
|
+
for (const scenario of run2.scenarios) {
|
|
1206
|
+
const status = CLOUD_STATUS[scenario.status];
|
|
1207
|
+
const key = `${scenario.scenarioId}:${scenario.variant}`;
|
|
1208
|
+
if (!status || reported.has(key)) continue;
|
|
1209
|
+
reported.add(key);
|
|
1210
|
+
lastProgress = Date.now();
|
|
1211
|
+
waitingShown = false;
|
|
1212
|
+
printLine({
|
|
1213
|
+
name: scenario.name,
|
|
1214
|
+
status,
|
|
1215
|
+
prerequisiteOnly: scenario.prerequisiteOnly === true,
|
|
1216
|
+
durationMs: scenario.durationMs,
|
|
1217
|
+
browser: scenario.browser,
|
|
1218
|
+
message: scenario.error
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
if (run2.status === "Completed") break;
|
|
1222
|
+
if (!waitingShown && Date.now() - lastProgress >= WAITING_NOTICE_MS) {
|
|
1223
|
+
const pending = run2.scenarios.filter((s) => s.status === "Pending").length;
|
|
1224
|
+
console.log(gray(`Waiting for workers (${pending} pending)...`));
|
|
1225
|
+
waitingShown = true;
|
|
1226
|
+
}
|
|
1227
|
+
await new Promise((resolve) => setTimeout(resolve, CLOUD_POLL_INTERVAL_MS));
|
|
1228
|
+
run2 = await api.getRun(run2.id);
|
|
1229
|
+
}
|
|
1230
|
+
return printSummary(run2.scenarios.map((s) => ({
|
|
1231
|
+
status: CLOUD_STATUS[s.status] ?? "skipped",
|
|
1232
|
+
prerequisiteOnly: s.prerequisiteOnly === true
|
|
1233
|
+
})), started);
|
|
1234
|
+
}
|
|
1235
|
+
async function answers(baseUrl, timeoutMs) {
|
|
1236
|
+
try {
|
|
1237
|
+
await fetch(baseUrl, { redirect: "manual", signal: AbortSignal.timeout(Math.max(timeoutMs, 1)) });
|
|
1238
|
+
return true;
|
|
1239
|
+
} catch {
|
|
1240
|
+
return false;
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
async function startApp(command, baseUrl) {
|
|
1244
|
+
if (await answers(baseUrl, 1e3)) {
|
|
1245
|
+
throw new Error(`${baseUrl} already answers before --start ran; stop what is running there, or leave out --start to test it`);
|
|
1246
|
+
}
|
|
1247
|
+
const url = new URL(baseUrl);
|
|
1248
|
+
const port = url.port || (url.protocol === "https:" ? "443" : "80");
|
|
1249
|
+
const child = spawn(command, {
|
|
1250
|
+
shell: true,
|
|
1251
|
+
detached: process.platform !== "win32",
|
|
1252
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1253
|
+
env: { ...process.env, PORT: port }
|
|
1254
|
+
});
|
|
1255
|
+
const lines = [];
|
|
1256
|
+
let partial = "";
|
|
1257
|
+
const collect = (chunk) => {
|
|
1258
|
+
const parts = (partial + chunk.toString()).split(/\r?\n/);
|
|
1259
|
+
partial = parts.pop() ?? "";
|
|
1260
|
+
lines.push(...parts);
|
|
1261
|
+
lines.splice(0, Math.max(lines.length - APP_LOG_TAIL_LINES, 0));
|
|
1262
|
+
};
|
|
1263
|
+
child.stdout.on("data", collect);
|
|
1264
|
+
child.stderr.on("data", collect);
|
|
1265
|
+
const exited = new Promise((resolve) => {
|
|
1266
|
+
child.once("exit", (code) => resolve(code));
|
|
1267
|
+
child.once("error", () => resolve(null));
|
|
1268
|
+
});
|
|
1269
|
+
let stopped = false;
|
|
1270
|
+
const stop = () => {
|
|
1271
|
+
if (stopped || child.pid === void 0 || child.exitCode !== null) {
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
stopped = true;
|
|
1275
|
+
if (process.platform === "win32") {
|
|
1276
|
+
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
1277
|
+
} else {
|
|
1278
|
+
try {
|
|
1279
|
+
process.kill(-child.pid, "SIGTERM");
|
|
1280
|
+
} catch {
|
|
1281
|
+
child.kill("SIGTERM");
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
process.once("exit", stop);
|
|
1286
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
1287
|
+
process.once(signal, () => process.exit(130));
|
|
1288
|
+
}
|
|
1289
|
+
return { exited, logTail: () => [...lines, partial].filter((line) => line.trim() !== "").join("\n") };
|
|
1290
|
+
}
|
|
1291
|
+
async function waitForApp(baseUrl, app) {
|
|
1292
|
+
const deadline = Date.now() + APP_WAIT_MS;
|
|
1293
|
+
let exitCode;
|
|
1294
|
+
void app?.exited.then((code) => {
|
|
1295
|
+
exitCode = code;
|
|
1296
|
+
});
|
|
1297
|
+
const withLog = (message) => {
|
|
1298
|
+
const tail = app?.logTail();
|
|
1299
|
+
return tail ? `${message}
|
|
1300
|
+
Last app output:
|
|
1301
|
+
${tail}` : message;
|
|
1302
|
+
};
|
|
1303
|
+
for (; ; ) {
|
|
1304
|
+
if (await answers(baseUrl, Math.min(deadline - Date.now(), 2e3))) {
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
if (exitCode !== void 0) {
|
|
1308
|
+
throw new Error(withLog(`the app exited (code ${exitCode}) before ${baseUrl} answered`));
|
|
1309
|
+
}
|
|
1310
|
+
if (Date.now() >= deadline) {
|
|
1311
|
+
throw new Error(withLog(`no response from ${baseUrl} in ${APP_WAIT_MS / 1e3}s; ${app ? "did the app start on PORT?" : "is the app running?"}`));
|
|
1312
|
+
}
|
|
1313
|
+
await new Promise((resolve) => setTimeout(resolve, APP_POLL_INTERVAL_MS));
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
async function execute5(args) {
|
|
1317
|
+
const { values, tokens } = parseArgs5({
|
|
1318
|
+
args,
|
|
1319
|
+
allowPositionals: true,
|
|
1320
|
+
tokens: true,
|
|
1321
|
+
options: {
|
|
1322
|
+
cloud: { type: "boolean", default: false },
|
|
1323
|
+
folder: { type: "string", short: "f", multiple: true },
|
|
1324
|
+
"base-url": { type: "string" },
|
|
1325
|
+
start: { type: "string" },
|
|
1326
|
+
group: { type: "string", short: "g", multiple: true },
|
|
1327
|
+
workers: { type: "string", short: "w" },
|
|
1328
|
+
windows: { type: "boolean", default: false },
|
|
1329
|
+
help: { type: "boolean", short: "h", default: false }
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
if (values.help) {
|
|
1333
|
+
console.log(run.usage);
|
|
1334
|
+
return 0;
|
|
1335
|
+
}
|
|
1336
|
+
const slugs = parseGroups(tokens);
|
|
1337
|
+
const folders = values.folder ?? [];
|
|
1338
|
+
const baseUrl = values["base-url"];
|
|
1339
|
+
if (baseUrl !== void 0 && !URL.canParse(baseUrl)) {
|
|
1340
|
+
throw new Error(`--base-url must be an absolute URL, got "${baseUrl}"`);
|
|
1341
|
+
}
|
|
1342
|
+
if (baseUrl !== void 0 && folders.length === 0) {
|
|
1343
|
+
throw new Error("--base-url only applies with --folder");
|
|
1344
|
+
}
|
|
1345
|
+
if (values.start !== void 0 && baseUrl === void 0) {
|
|
1346
|
+
throw new Error("--start needs --folder and --base-url");
|
|
1347
|
+
}
|
|
1348
|
+
if (values.cloud) {
|
|
1349
|
+
if (values.workers !== void 0 || values.windows || folders.length > 0 || values.start !== void 0) {
|
|
1350
|
+
throw new Error("--workers, --windows, --folder and --start only apply to local runs, not --cloud");
|
|
1351
|
+
}
|
|
1352
|
+
return runInCloud(slugs);
|
|
1353
|
+
}
|
|
1354
|
+
const source = folders.length > 0 ? async () => {
|
|
1355
|
+
const loaded = await loadFolders(folders, baseUrl);
|
|
1356
|
+
if (baseUrl !== void 0) {
|
|
1357
|
+
const app = values.start === void 0 ? void 0 : await startApp(values.start, baseUrl);
|
|
1358
|
+
await waitForApp(baseUrl, app);
|
|
1359
|
+
}
|
|
1360
|
+
return loaded;
|
|
1361
|
+
} : async () => {
|
|
1362
|
+
const [scenarios, groups] = await Promise.all([api.listScenarios(), slugs.length === 0 ? [] : api.listGroups()]);
|
|
1363
|
+
return { scenarios, groups };
|
|
1364
|
+
};
|
|
1365
|
+
return runLocally(parseWorkers(values.workers), values.windows, slugs, source);
|
|
1366
|
+
}
|
|
1367
|
+
var run = {
|
|
1368
|
+
name: "run",
|
|
1369
|
+
summary: "run all scenarios or some groups, locally or on the cloud workers",
|
|
1370
|
+
usage: `Usage: mimic run [options]
|
|
1371
|
+
|
|
1372
|
+
Runs every scenario from the config API (${API_URL}); branches run after their prerequisite passes.
|
|
1373
|
+
By default they run on this machine in parallel Chromium contexts. With --cloud the config API schedules them on its
|
|
1374
|
+
workers and this command reports their results until the run completes.
|
|
1375
|
+
With --group only the scenarios in those groups run, plus the prerequisites they continue from; a scenario in
|
|
1376
|
+
several of them runs once. Groups are named by slug, as the extension shows them (e.g. -g smoke focus).
|
|
1377
|
+
With --folder the scenarios come from the JSON files in that folder and its subfolders instead of the API (local
|
|
1378
|
+
runs only). Each file holds a scenario or an array of them: "name", "steps", optionally "prerequisiteName" (another file's scenario name)
|
|
1379
|
+
and "groupNames" (-g takes their slugs: lowercase, non-alphanumerics as "-"). "expect" holds the assertions
|
|
1380
|
+
checked after the steps; each "invalid" value on a Fill step adds a run that types it and checks its own "expect"
|
|
1381
|
+
instead (run k types every field's k-th invalid value). Relative Navigate URLs resolve against --base-url, and the run waits up to 30s for it to answer, so it can start right after the app.
|
|
1382
|
+
With --start the run starts the app itself (a shell command, with PORT set to --base-url's port), waits for it,
|
|
1383
|
+
and stops it when the run ends; if it doesn't come up, its last output is printed.
|
|
1384
|
+
|
|
1385
|
+
Options:
|
|
1386
|
+
-g, --group <slug...> run only the scenarios in these groups (and their prerequisites)
|
|
1387
|
+
-f, --folder <path> run the scenario files in this folder (recursively) instead of the API's; can repeat
|
|
1388
|
+
--base-url <url> with --folder: base for relative Navigate URLs (e.g. http://localhost:5173); waits up
|
|
1389
|
+
to 30s for it to answer
|
|
1390
|
+
--start <command> with --base-url: start the app with this command (PORT set) and stop it afterwards
|
|
1391
|
+
--cloud run on the cloud workers instead of locally
|
|
1392
|
+
-w, --workers <n> local only: parallel browser contexts (default: based on CPUs and free memory)
|
|
1393
|
+
--windows local only: show the browser windows
|
|
1394
|
+
-h, --help show this help`,
|
|
1395
|
+
execute: execute5
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
// src/cli.ts
|
|
1399
|
+
var COMMANDS = [login2, logout, run, list, setPrerequisite];
|
|
1400
|
+
var USAGE = `Usage: mimic <command> [options]
|
|
1401
|
+
|
|
1402
|
+
Commands:
|
|
1403
|
+
${COMMANDS.map((command) => ` ${command.name.padEnd(17)} ${command.summary}`).join("\n")}
|
|
1404
|
+
|
|
1405
|
+
Run "mimic <command> --help" for a command's options.`;
|
|
1406
|
+
async function main() {
|
|
1407
|
+
const [name, ...args] = process.argv.slice(2);
|
|
1408
|
+
const command = COMMANDS.find((c) => c.name === name);
|
|
1409
|
+
if (command) {
|
|
1410
|
+
return command.execute(args);
|
|
1411
|
+
}
|
|
1412
|
+
if (name === "-h" || name === "--help") {
|
|
1413
|
+
console.log(USAGE);
|
|
1414
|
+
return 0;
|
|
1415
|
+
}
|
|
1416
|
+
if (name === void 0) {
|
|
1417
|
+
console.log(USAGE);
|
|
1418
|
+
return 1;
|
|
1419
|
+
}
|
|
1420
|
+
console.error(`Unknown command "${name}"
|
|
1421
|
+
|
|
1422
|
+
${USAGE}`);
|
|
1423
|
+
return 1;
|
|
1424
|
+
}
|
|
1425
|
+
main().then(
|
|
1426
|
+
(code) => process.exit(code),
|
|
1427
|
+
(error) => {
|
|
1428
|
+
console.error(red(error instanceof Error ? error.message : String(error)));
|
|
1429
|
+
process.exit(1);
|
|
1430
|
+
}
|
|
1431
|
+
);
|