@tonys-software/mimic 0.1.0-stg.3.1 → 0.1.1-stg.5.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 +4 -0
- package/dist/cli.js +336 -31
- package/dist/inject.js +20 -8
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -20,8 +20,12 @@ mimic list -f scenarios
|
|
|
20
20
|
mimic run
|
|
21
21
|
mimic run --group smoke
|
|
22
22
|
mimic run --cloud
|
|
23
|
+
mimic run --cloud --tunnel 5173
|
|
23
24
|
```
|
|
24
25
|
|
|
26
|
+
With `--tunnel` the cloud workers open `localhost:5173` on your machine through a tunnel, so they can test an app
|
|
27
|
+
that only runs locally. Scenarios must navigate to `localhost`, not `127.0.0.1`; Safari workers can't run them.
|
|
28
|
+
|
|
25
29
|
`mimic --help` lists every command, and `mimic <command> --help` shows its options.
|
|
26
30
|
|
|
27
31
|
## Update
|
package/dist/cli.js
CHANGED
|
@@ -45,19 +45,23 @@ function stepWithDefaults(step, baseUrl, where) {
|
|
|
45
45
|
case "Fill":
|
|
46
46
|
return { ...step, locator: requiredLocator(step.locator, where), value: step.value ?? "", invalid: invalidValues(step.invalid, where) };
|
|
47
47
|
case "Assertion":
|
|
48
|
-
return assertionWithDefaults(step);
|
|
48
|
+
return assertionWithDefaults(step, where);
|
|
49
49
|
default:
|
|
50
50
|
throw new Error(`${where}: unknown step type "${step.type}"`);
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
-
function
|
|
54
|
-
return { ...
|
|
53
|
+
function checkWithDefaults(check) {
|
|
54
|
+
return { ...check, locator: check.locator && withDefaults(check.locator) };
|
|
55
|
+
}
|
|
56
|
+
function assertionWithDefaults(step, where) {
|
|
57
|
+
if (!Array.isArray(step.checks) || step.checks.length === 0) throw new Error(`${where}: an Assertion step needs a non-empty "checks"`);
|
|
58
|
+
return { ...step, checks: step.checks.map(checkWithDefaults) };
|
|
55
59
|
}
|
|
56
60
|
function expectSteps(expect, where) {
|
|
57
61
|
if (!Array.isArray(expect) || expect.some((step) => typeof step !== "object" || step === null || (step.type ?? "Assertion") !== "Assertion")) {
|
|
58
62
|
throw new Error(`${where}: "expect" must be a list of Assertion steps`);
|
|
59
63
|
}
|
|
60
|
-
return expect.map((step) => assertionWithDefaults({ ...step, type: "Assertion" }));
|
|
64
|
+
return expect.map((step) => assertionWithDefaults({ ...step, type: "Assertion" }, where));
|
|
61
65
|
}
|
|
62
66
|
function invalidValues(invalid, where) {
|
|
63
67
|
if (invalid === void 0) return [];
|
|
@@ -221,6 +225,7 @@ import path2 from "node:path";
|
|
|
221
225
|
// src/shared/config.ts
|
|
222
226
|
var API_URL = "https://app.stg.mimicus.dev";
|
|
223
227
|
var WORKOS_CLIENT_ID = "client_01M4VM6E40X8NEXB778AQTE9KC";
|
|
228
|
+
var VERSION = "0.1.1-stg.5.1";
|
|
224
229
|
|
|
225
230
|
// src/shared/auth.ts
|
|
226
231
|
var WORKOS_URL = "https://api.workos.com";
|
|
@@ -251,6 +256,10 @@ async function saveSession(session) {
|
|
|
251
256
|
await mkdir(path2.dirname(file), { recursive: true });
|
|
252
257
|
await writeFile(file, JSON.stringify(session), { mode: 384 });
|
|
253
258
|
}
|
|
259
|
+
async function signedInEmail() {
|
|
260
|
+
const session = await loadSession();
|
|
261
|
+
return session ? session.email : void 0;
|
|
262
|
+
}
|
|
254
263
|
function deleteSession() {
|
|
255
264
|
return rm(sessionFile(), { force: true });
|
|
256
265
|
}
|
|
@@ -393,20 +402,23 @@ function findGroups(slugs, groups) {
|
|
|
393
402
|
}
|
|
394
403
|
return slugs.map((slug) => bySlug.get(slug));
|
|
395
404
|
}
|
|
396
|
-
function
|
|
397
|
-
const
|
|
398
|
-
let collecting
|
|
405
|
+
function parseLists(tokens, names) {
|
|
406
|
+
const lists = new Map(names.map((name) => [name, []]));
|
|
407
|
+
let collecting;
|
|
399
408
|
for (const token of tokens) {
|
|
400
409
|
if (token.kind === "option") {
|
|
401
|
-
collecting = token.name
|
|
402
|
-
if (collecting && token.value !== void 0)
|
|
410
|
+
collecting = lists.get(token.name);
|
|
411
|
+
if (collecting && token.value !== void 0) collecting.push(token.value);
|
|
403
412
|
} else if (token.kind === "positional") {
|
|
404
413
|
if (!collecting) throw new Error(`Unexpected argument "${token.value}"`);
|
|
405
|
-
|
|
414
|
+
collecting.push(token.value);
|
|
406
415
|
} else {
|
|
407
|
-
collecting =
|
|
416
|
+
collecting = void 0;
|
|
408
417
|
}
|
|
409
418
|
}
|
|
419
|
+
return lists;
|
|
420
|
+
}
|
|
421
|
+
function parseGroups(tokens, slugs = parseLists(tokens, ["group"]).get("group")) {
|
|
410
422
|
const normalized = slugs.map((slug) => slug.trim().toLowerCase());
|
|
411
423
|
if (normalized.some((slug) => !slug)) {
|
|
412
424
|
throw new Error("--group needs group slugs");
|
|
@@ -553,6 +565,7 @@ Options:
|
|
|
553
565
|
|
|
554
566
|
// src/commands/run.ts
|
|
555
567
|
import { spawn, spawnSync } from "node:child_process";
|
|
568
|
+
import net2 from "node:net";
|
|
556
569
|
import os2 from "node:os";
|
|
557
570
|
import { parseArgs as parseArgs5 } from "node:util";
|
|
558
571
|
|
|
@@ -627,6 +640,15 @@ var ListScenariosEndpoint_default = (handler) => () => send(handler, "GET", `api
|
|
|
627
640
|
// ../jsclient/src/gen/client/Endpoints/Scenarios/UpdateScenarioEndpoint.ts
|
|
628
641
|
var UpdateScenarioEndpoint_default = (handler) => (id, payload) => send(handler, "PUT", `api/scenario/${encodeURIComponent(id)}`, payload);
|
|
629
642
|
|
|
643
|
+
// ../jsclient/src/gen/client/Endpoints/Screenshots/AcceptScreenshotEndpoint.ts
|
|
644
|
+
var AcceptScreenshotEndpoint_default = (handler) => (runId, stepId, checkIndex) => send(handler, "POST", `api/run/${encodeURIComponent(runId)}/screenshot/${encodeURIComponent(stepId)}/${encodeURIComponent(checkIndex)}/accept`);
|
|
645
|
+
|
|
646
|
+
// ../jsclient/src/gen/client/Endpoints/Screenshots/GetScreenshotEndpoint.ts
|
|
647
|
+
var GetScreenshotEndpoint_default = (handler) => (id) => send(handler, "GET", `api/screenshot/${encodeURIComponent(id)}`);
|
|
648
|
+
|
|
649
|
+
// ../jsclient/src/gen/client/Endpoints/Screenshots/UploadScreenshotEndpoint.ts
|
|
650
|
+
var UploadScreenshotEndpoint_default = (handler) => (payload) => send(handler, "POST", `api/screenshot`, payload);
|
|
651
|
+
|
|
630
652
|
// ../jsclient/src/gen/client/Endpoints/Users/InviteUserEndpoint.ts
|
|
631
653
|
var InviteUserEndpoint_default = (handler) => (payload) => send(handler, "POST", `api/invitation`, payload);
|
|
632
654
|
|
|
@@ -660,6 +682,9 @@ function createClient(handler) {
|
|
|
660
682
|
getScenario: GetScenarioEndpoint_default(handler),
|
|
661
683
|
listScenarios: ListScenariosEndpoint_default(handler),
|
|
662
684
|
updateScenario: UpdateScenarioEndpoint_default(handler),
|
|
685
|
+
acceptScreenshot: AcceptScreenshotEndpoint_default(handler),
|
|
686
|
+
getScreenshot: GetScreenshotEndpoint_default(handler),
|
|
687
|
+
uploadScreenshot: UploadScreenshotEndpoint_default(handler),
|
|
663
688
|
inviteUser: InviteUserEndpoint_default(handler),
|
|
664
689
|
listInvitations: ListInvitationsEndpoint_default(handler),
|
|
665
690
|
listMembers: ListMembersEndpoint_default(handler),
|
|
@@ -856,7 +881,11 @@ async function playFill(page, step, element) {
|
|
|
856
881
|
const target = await typeInto(page, step, element);
|
|
857
882
|
if (!await isConnected(target)) await typeInto(page, step, target);
|
|
858
883
|
}
|
|
884
|
+
function rejectScreenshot(step) {
|
|
885
|
+
if (step.checks.some((check) => check.type === "Screenshot")) throw new Error("Screenshot assertions are only supported by the worker, not by the CLI");
|
|
886
|
+
}
|
|
859
887
|
async function playAssertion(page, step) {
|
|
888
|
+
rejectScreenshot(step);
|
|
860
889
|
const deadline = Date.now() + LOCATE_TIMEOUT_MS;
|
|
861
890
|
for (; ; ) {
|
|
862
891
|
let problem;
|
|
@@ -865,7 +894,7 @@ async function playAssertion(page, step) {
|
|
|
865
894
|
problem = await page.evaluate((s) => window.__scenarioRunner.assertionProblem(s), step);
|
|
866
895
|
} catch (e) {
|
|
867
896
|
if (!isNavigationError(e)) throw e;
|
|
868
|
-
problem = `${step.
|
|
897
|
+
problem = `${step.checks.map((check) => check.type).join(", ")} assertion failed: page navigated while checking`;
|
|
869
898
|
}
|
|
870
899
|
if (!problem) return;
|
|
871
900
|
if (Date.now() >= deadline) throw new Error(problem);
|
|
@@ -873,6 +902,7 @@ async function playAssertion(page, step) {
|
|
|
873
902
|
}
|
|
874
903
|
}
|
|
875
904
|
async function playExpect(page, expect) {
|
|
905
|
+
expect.forEach(rejectScreenshot);
|
|
876
906
|
const pending = expect.map((step, i) => ({ index: i + 1, step, problem: void 0 }));
|
|
877
907
|
const deadline = Date.now() + LOCATE_TIMEOUT_MS;
|
|
878
908
|
for (; ; ) {
|
|
@@ -888,7 +918,7 @@ async function playExpect(page, expect) {
|
|
|
888
918
|
}
|
|
889
919
|
if (pending.length === 0) return;
|
|
890
920
|
if (Date.now() >= deadline) {
|
|
891
|
-
throw new Error(pending.map((a, i) => `expect ${a.index} (${a.step.
|
|
921
|
+
throw new Error(pending.map((a, i) => `expect ${a.index} (${a.step.checks.map((check) => check.type).join(", ")}): ${a.problem}${i === 0 ? ` (on ${page.url()})` : ""}`).join("\n"));
|
|
892
922
|
}
|
|
893
923
|
await page.waitForTimeout(POLL_INTERVAL_MS);
|
|
894
924
|
}
|
|
@@ -994,6 +1024,199 @@ var Runner = class _Runner {
|
|
|
994
1024
|
}
|
|
995
1025
|
};
|
|
996
1026
|
|
|
1027
|
+
// src/shared/tunnel.ts
|
|
1028
|
+
import http2 from "node:http";
|
|
1029
|
+
import net from "node:net";
|
|
1030
|
+
import WebSocket, { createWebSocketStream } from "ws";
|
|
1031
|
+
var STREAM_ID = /^[0-9a-f]{1,64}$/;
|
|
1032
|
+
var PROXY_HEADERS = /* @__PURE__ */ new Set(["proxy-connection", "proxy-authorization"]);
|
|
1033
|
+
function allowedTarget(ports, host, port) {
|
|
1034
|
+
return host.toLowerCase() === "localhost" && ports.has(port) ? port : void 0;
|
|
1035
|
+
}
|
|
1036
|
+
function absoluteTarget(ports, rawUrl) {
|
|
1037
|
+
if (!rawUrl || !URL.canParse(rawUrl)) return void 0;
|
|
1038
|
+
const url = new URL(rawUrl);
|
|
1039
|
+
if (url.protocol !== "http:" && url.protocol !== "ws:") return void 0;
|
|
1040
|
+
const port = allowedTarget(ports, url.hostname, Number(url.port || 80));
|
|
1041
|
+
return port === void 0 ? void 0 : { port, path: url.pathname + url.search };
|
|
1042
|
+
}
|
|
1043
|
+
function connectTarget(ports, authority) {
|
|
1044
|
+
const match = authority?.match(/^([^:]+):(\d+)$/);
|
|
1045
|
+
return match ? allowedTarget(ports, match[1], Number(match[2])) : void 0;
|
|
1046
|
+
}
|
|
1047
|
+
function forwardedHeaders(rawHeaders, port) {
|
|
1048
|
+
const headers = [];
|
|
1049
|
+
for (let i = 0; i < rawHeaders.length; i += 2) {
|
|
1050
|
+
const name = rawHeaders[i].toLowerCase();
|
|
1051
|
+
if (name === "host") headers.push(rawHeaders[i], `localhost:${port}`);
|
|
1052
|
+
else if (!PROXY_HEADERS.has(name)) headers.push(rawHeaders[i], rawHeaders[i + 1]);
|
|
1053
|
+
}
|
|
1054
|
+
return headers;
|
|
1055
|
+
}
|
|
1056
|
+
function refuse(socket, ports) {
|
|
1057
|
+
const body = `mimic tunnel only forwards ${[...ports].map((p) => `localhost:${p}`).join(", ")}
|
|
1058
|
+
`;
|
|
1059
|
+
socket.end(`HTTP/1.1 403 Forbidden\r
|
|
1060
|
+
Content-Type: text/plain\r
|
|
1061
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1062
|
+
Connection: close\r
|
|
1063
|
+
\r
|
|
1064
|
+
${body}`);
|
|
1065
|
+
}
|
|
1066
|
+
function splice(socket, port, onConnect) {
|
|
1067
|
+
let connected = false;
|
|
1068
|
+
const upstream = net.connect({ host: "localhost", port, autoSelectFamily: true }, () => {
|
|
1069
|
+
connected = true;
|
|
1070
|
+
onConnect(upstream);
|
|
1071
|
+
upstream.pipe(socket);
|
|
1072
|
+
socket.pipe(upstream);
|
|
1073
|
+
});
|
|
1074
|
+
upstream.on("error", (error) => {
|
|
1075
|
+
if (connected) return socket.destroy();
|
|
1076
|
+
const body = `localhost:${port} did not answer: ${error.message}
|
|
1077
|
+
`;
|
|
1078
|
+
socket.end(`HTTP/1.1 502 Bad Gateway\r
|
|
1079
|
+
Content-Type: text/plain\r
|
|
1080
|
+
Content-Length: ${Buffer.byteLength(body)}\r
|
|
1081
|
+
Connection: close\r
|
|
1082
|
+
\r
|
|
1083
|
+
${body}`);
|
|
1084
|
+
});
|
|
1085
|
+
socket.on("error", () => upstream.destroy());
|
|
1086
|
+
socket.on("close", () => upstream.destroy());
|
|
1087
|
+
}
|
|
1088
|
+
function createProxy(ports) {
|
|
1089
|
+
const server = http2.createServer((req, res) => {
|
|
1090
|
+
const target = absoluteTarget(ports, req.url);
|
|
1091
|
+
if (!target) {
|
|
1092
|
+
res.writeHead(403, { "content-type": "text/plain", connection: "close" });
|
|
1093
|
+
res.end(`mimic tunnel only forwards ${[...ports].map((p) => `localhost:${p}`).join(", ")}
|
|
1094
|
+
`, () => req.socket.destroy());
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const upstream = http2.request({
|
|
1098
|
+
host: "localhost",
|
|
1099
|
+
port: target.port,
|
|
1100
|
+
method: req.method,
|
|
1101
|
+
path: target.path,
|
|
1102
|
+
headers: forwardedHeaders(req.rawHeaders, target.port),
|
|
1103
|
+
autoSelectFamily: true
|
|
1104
|
+
}, (response) => {
|
|
1105
|
+
res.writeHead(response.statusCode, response.statusMessage, response.rawHeaders);
|
|
1106
|
+
response.pipe(res);
|
|
1107
|
+
});
|
|
1108
|
+
upstream.on("error", (error) => {
|
|
1109
|
+
if (res.headersSent) {
|
|
1110
|
+
res.destroy();
|
|
1111
|
+
} else {
|
|
1112
|
+
res.writeHead(502, { "content-type": "text/plain" });
|
|
1113
|
+
res.end(`localhost:${target.port} did not answer: ${error.message}
|
|
1114
|
+
`);
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
res.on("close", () => upstream.destroy());
|
|
1118
|
+
req.pipe(upstream);
|
|
1119
|
+
});
|
|
1120
|
+
server.on("connect", (req, socket, head) => {
|
|
1121
|
+
const port = connectTarget(ports, req.url);
|
|
1122
|
+
if (port === void 0) return refuse(socket, ports);
|
|
1123
|
+
splice(socket, port, (upstream) => {
|
|
1124
|
+
socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
1125
|
+
upstream.write(head);
|
|
1126
|
+
});
|
|
1127
|
+
});
|
|
1128
|
+
server.on("upgrade", (req, socket, head) => {
|
|
1129
|
+
const target = absoluteTarget(ports, req.url);
|
|
1130
|
+
if (!target) return refuse(socket, ports);
|
|
1131
|
+
splice(socket, target.port, (upstream) => {
|
|
1132
|
+
const headers = forwardedHeaders(req.rawHeaders, target.port);
|
|
1133
|
+
const lines = [`${req.method} ${target.path} HTTP/1.1`];
|
|
1134
|
+
for (let i = 0; i < headers.length; i += 2) lines.push(`${headers[i]}: ${headers[i + 1]}`);
|
|
1135
|
+
upstream.write(`${lines.join("\r\n")}\r
|
|
1136
|
+
\r
|
|
1137
|
+
`);
|
|
1138
|
+
upstream.write(head);
|
|
1139
|
+
});
|
|
1140
|
+
});
|
|
1141
|
+
server.on("clientError", (_error, socket) => socket.destroy());
|
|
1142
|
+
return server;
|
|
1143
|
+
}
|
|
1144
|
+
function asSocket(stream) {
|
|
1145
|
+
let idleMs = 0;
|
|
1146
|
+
let timer;
|
|
1147
|
+
const arm = () => {
|
|
1148
|
+
clearTimeout(timer);
|
|
1149
|
+
timer = idleMs > 0 ? setTimeout(() => stream.emit("timeout"), idleMs) : void 0;
|
|
1150
|
+
};
|
|
1151
|
+
const push = stream.push.bind(stream);
|
|
1152
|
+
const write = stream.write.bind(stream);
|
|
1153
|
+
stream.on("close", () => clearTimeout(timer));
|
|
1154
|
+
return Object.assign(stream, {
|
|
1155
|
+
push: (chunk, encoding) => {
|
|
1156
|
+
arm();
|
|
1157
|
+
return push(chunk, encoding);
|
|
1158
|
+
},
|
|
1159
|
+
write: (...args) => {
|
|
1160
|
+
arm();
|
|
1161
|
+
return write(...args);
|
|
1162
|
+
},
|
|
1163
|
+
setTimeout: (ms, callback) => {
|
|
1164
|
+
idleMs = ms;
|
|
1165
|
+
if (callback) stream.once("timeout", callback);
|
|
1166
|
+
arm();
|
|
1167
|
+
return stream;
|
|
1168
|
+
},
|
|
1169
|
+
setNoDelay: () => stream,
|
|
1170
|
+
setKeepAlive: () => stream
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
function openTunnel({ connectUrl, ticket, ports, ca }) {
|
|
1174
|
+
const allowed = new Set(ports);
|
|
1175
|
+
const proxy = createProxy(allowed);
|
|
1176
|
+
const headers = { authorization: `Bearer ${ticket}` };
|
|
1177
|
+
const open = /* @__PURE__ */ new Set();
|
|
1178
|
+
const control = new WebSocket(connectUrl, { headers, ca });
|
|
1179
|
+
const openStream = (streamId) => {
|
|
1180
|
+
const socket = new WebSocket(connectUrl.replace(/\/control$/, `/stream/${streamId}`), { headers, ca });
|
|
1181
|
+
open.add(socket);
|
|
1182
|
+
socket.on("close", () => open.delete(socket));
|
|
1183
|
+
const stream = createWebSocketStream(socket);
|
|
1184
|
+
stream.on("error", () => stream.destroy());
|
|
1185
|
+
proxy.emit("connection", asSocket(stream));
|
|
1186
|
+
};
|
|
1187
|
+
control.on("message", (data, isBinary) => {
|
|
1188
|
+
if (isBinary) return;
|
|
1189
|
+
try {
|
|
1190
|
+
const { open: streamId } = JSON.parse(data.toString());
|
|
1191
|
+
if (typeof streamId === "string" && STREAM_ID.test(streamId)) openStream(streamId);
|
|
1192
|
+
} catch {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
});
|
|
1196
|
+
const closed = new Promise((resolve) => control.once("close", () => {
|
|
1197
|
+
for (const socket of open) socket.terminate();
|
|
1198
|
+
proxy.close();
|
|
1199
|
+
resolve();
|
|
1200
|
+
}));
|
|
1201
|
+
return new Promise((resolve, reject) => {
|
|
1202
|
+
control.once("unexpected-response", (_request, response) => {
|
|
1203
|
+
reject(new Error(`the relay refused the tunnel (HTTP ${response.statusCode})`));
|
|
1204
|
+
control.terminate();
|
|
1205
|
+
});
|
|
1206
|
+
control.once("error", (error) => reject(new Error(`could not reach the relay at ${connectUrl}: ${error.message}`)));
|
|
1207
|
+
control.once("open", () => {
|
|
1208
|
+
control.on("error", () => control.terminate());
|
|
1209
|
+
resolve({
|
|
1210
|
+
get streams() {
|
|
1211
|
+
return open.size;
|
|
1212
|
+
},
|
|
1213
|
+
closed,
|
|
1214
|
+
close: () => control.close()
|
|
1215
|
+
});
|
|
1216
|
+
});
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
|
|
997
1220
|
// src/commands/run.ts
|
|
998
1221
|
var MEMORY_PER_WORKER = 400 * 1024 * 1024;
|
|
999
1222
|
var CLOUD_POLL_INTERVAL_MS = 1e3;
|
|
@@ -1188,16 +1411,65 @@ async function runLocally(workers, windows, slugs, source) {
|
|
|
1188
1411
|
}
|
|
1189
1412
|
return printSummary(lines, started);
|
|
1190
1413
|
}
|
|
1191
|
-
|
|
1414
|
+
function parsePorts(values) {
|
|
1415
|
+
const ports = values.flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
|
|
1416
|
+
const invalid = ports.find((port) => !/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535);
|
|
1417
|
+
if (invalid !== void 0) {
|
|
1418
|
+
throw new Error(`--tunnel takes ports between 1 and 65535, got "${invalid}"`);
|
|
1419
|
+
}
|
|
1420
|
+
return [...new Set(ports.map(Number))];
|
|
1421
|
+
}
|
|
1422
|
+
function listens(port, timeoutMs) {
|
|
1423
|
+
return new Promise((resolve) => {
|
|
1424
|
+
const socket = net2.connect({ host: "localhost", port, autoSelectFamily: true, timeout: Math.max(timeoutMs, 1) });
|
|
1425
|
+
const done = (result) => {
|
|
1426
|
+
socket.destroy();
|
|
1427
|
+
resolve(result);
|
|
1428
|
+
};
|
|
1429
|
+
socket.once("connect", () => done(true));
|
|
1430
|
+
socket.once("timeout", () => done(false));
|
|
1431
|
+
socket.once("error", () => done(false));
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
async function waitForPorts(ports, start) {
|
|
1435
|
+
const probe = (port) => (timeoutMs) => listens(port, timeoutMs);
|
|
1436
|
+
const app = start === void 0 ? void 0 : await startApp(start, `http://localhost:${ports[0]}`, probe(ports[0]));
|
|
1437
|
+
for (const port of ports) {
|
|
1438
|
+
await waitForApp(`localhost:${port}`, port === ports[0] ? app : void 0, probe(port));
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
async function runInCloud(slugs, ports, start) {
|
|
1192
1442
|
const started = Date.now();
|
|
1193
1443
|
const groups = findGroups(slugs, slugs.length === 0 ? [] : await api.listGroups());
|
|
1194
|
-
|
|
1444
|
+
if (ports.length > 0) {
|
|
1445
|
+
await waitForPorts(ports, start);
|
|
1446
|
+
}
|
|
1447
|
+
const run2 = await api.startRun({ groups: groups.map((g) => g.id), tunnelPorts: ports });
|
|
1195
1448
|
if (run2.scenarios.length === 0) {
|
|
1196
1449
|
console.log("No scenarios found.");
|
|
1197
1450
|
return 0;
|
|
1198
1451
|
}
|
|
1199
|
-
|
|
1200
|
-
|
|
1452
|
+
const { connection } = run2;
|
|
1453
|
+
const tunnel = connection ? await openTunnel({ connectUrl: connection.connectUrl, ticket: connection.ticket, ports, ca: trustedCertificates() }) : void 0;
|
|
1454
|
+
try {
|
|
1455
|
+
const reach = connection && `${ports.map((p) => `localhost:${p}`).join(", ")} through ${new URL(connection.connectUrl).host}`;
|
|
1456
|
+
return await pollCloudRun(slugs, run2, tunnel, reach, started);
|
|
1457
|
+
} finally {
|
|
1458
|
+
tunnel?.close();
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
async function pollCloudRun(slugs, firstReport, tunnel, reach, started) {
|
|
1462
|
+
let run2 = firstReport;
|
|
1463
|
+
let tunnelLost = false;
|
|
1464
|
+
void tunnel?.closed.then(() => {
|
|
1465
|
+
tunnelLost = true;
|
|
1466
|
+
});
|
|
1467
|
+
console.log(gray(`Started run ${run2.id}: ${run2.scenarios.length} scenarios${inGroupsLabel(slugs)} on the cloud workers`));
|
|
1468
|
+
if (reach) {
|
|
1469
|
+
console.log(gray(`Tunnel open: workers reach ${reach}`));
|
|
1470
|
+
}
|
|
1471
|
+
console.log();
|
|
1472
|
+
let lostShown = false;
|
|
1201
1473
|
const reported = /* @__PURE__ */ new Set();
|
|
1202
1474
|
let lastProgress = Date.now();
|
|
1203
1475
|
let waitingShown = false;
|
|
@@ -1219,9 +1491,14 @@ async function runInCloud(slugs) {
|
|
|
1219
1491
|
});
|
|
1220
1492
|
}
|
|
1221
1493
|
if (run2.status === "Completed") break;
|
|
1494
|
+
if (tunnelLost && !lostShown) {
|
|
1495
|
+
console.log(red("The tunnel closed; the remaining scenarios cannot reach your app"));
|
|
1496
|
+
lostShown = true;
|
|
1497
|
+
}
|
|
1222
1498
|
if (!waitingShown && Date.now() - lastProgress >= WAITING_NOTICE_MS) {
|
|
1223
1499
|
const pending = run2.scenarios.filter((s) => s.status === "Pending").length;
|
|
1224
|
-
|
|
1500
|
+
const streams = tunnel && !tunnelLost ? `, tunnel: ${tunnel.streams} open connections` : "";
|
|
1501
|
+
console.log(gray(`Waiting for workers (${pending} pending${streams})...`));
|
|
1225
1502
|
waitingShown = true;
|
|
1226
1503
|
}
|
|
1227
1504
|
await new Promise((resolve) => setTimeout(resolve, CLOUD_POLL_INTERVAL_MS));
|
|
@@ -1240,8 +1517,8 @@ async function answers(baseUrl, timeoutMs) {
|
|
|
1240
1517
|
return false;
|
|
1241
1518
|
}
|
|
1242
1519
|
}
|
|
1243
|
-
async function startApp(command, baseUrl) {
|
|
1244
|
-
if (await
|
|
1520
|
+
async function startApp(command, baseUrl, probe = (ms) => answers(baseUrl, ms)) {
|
|
1521
|
+
if (await probe(1e3)) {
|
|
1245
1522
|
throw new Error(`${baseUrl} already answers before --start ran; stop what is running there, or leave out --start to test it`);
|
|
1246
1523
|
}
|
|
1247
1524
|
const url = new URL(baseUrl);
|
|
@@ -1288,7 +1565,7 @@ async function startApp(command, baseUrl) {
|
|
|
1288
1565
|
}
|
|
1289
1566
|
return { exited, logTail: () => [...lines, partial].filter((line) => line.trim() !== "").join("\n") };
|
|
1290
1567
|
}
|
|
1291
|
-
async function waitForApp(baseUrl, app) {
|
|
1568
|
+
async function waitForApp(baseUrl, app, probe = (ms) => answers(baseUrl, ms)) {
|
|
1292
1569
|
const deadline = Date.now() + APP_WAIT_MS;
|
|
1293
1570
|
let exitCode;
|
|
1294
1571
|
void app?.exited.then((code) => {
|
|
@@ -1301,7 +1578,7 @@ Last app output:
|
|
|
1301
1578
|
${tail}` : message;
|
|
1302
1579
|
};
|
|
1303
1580
|
for (; ; ) {
|
|
1304
|
-
if (await
|
|
1581
|
+
if (await probe(Math.min(deadline - Date.now(), 2e3))) {
|
|
1305
1582
|
return;
|
|
1306
1583
|
}
|
|
1307
1584
|
if (exitCode !== void 0) {
|
|
@@ -1325,6 +1602,7 @@ async function execute5(args) {
|
|
|
1325
1602
|
start: { type: "string" },
|
|
1326
1603
|
group: { type: "string", short: "g", multiple: true },
|
|
1327
1604
|
workers: { type: "string", short: "w" },
|
|
1605
|
+
tunnel: { type: "string", short: "t", multiple: true },
|
|
1328
1606
|
windows: { type: "boolean", default: false },
|
|
1329
1607
|
help: { type: "boolean", short: "h", default: false }
|
|
1330
1608
|
}
|
|
@@ -1333,7 +1611,9 @@ async function execute5(args) {
|
|
|
1333
1611
|
console.log(run.usage);
|
|
1334
1612
|
return 0;
|
|
1335
1613
|
}
|
|
1336
|
-
const
|
|
1614
|
+
const lists = parseLists(tokens, ["group", "tunnel"]);
|
|
1615
|
+
const slugs = parseGroups(tokens, lists.get("group"));
|
|
1616
|
+
const ports = parsePorts(lists.get("tunnel"));
|
|
1337
1617
|
const folders = values.folder ?? [];
|
|
1338
1618
|
const baseUrl = values["base-url"];
|
|
1339
1619
|
if (baseUrl !== void 0 && !URL.canParse(baseUrl)) {
|
|
@@ -1342,14 +1622,17 @@ async function execute5(args) {
|
|
|
1342
1622
|
if (baseUrl !== void 0 && folders.length === 0) {
|
|
1343
1623
|
throw new Error("--base-url only applies with --folder");
|
|
1344
1624
|
}
|
|
1345
|
-
if (
|
|
1346
|
-
throw new Error("--
|
|
1625
|
+
if (ports.length > 0 && !values.cloud) {
|
|
1626
|
+
throw new Error("--tunnel only applies with --cloud; local runs reach localhost directly");
|
|
1627
|
+
}
|
|
1628
|
+
if (values.start !== void 0 && baseUrl === void 0 && ports.length === 0) {
|
|
1629
|
+
throw new Error("--start needs --folder and --base-url, or --cloud and --tunnel");
|
|
1347
1630
|
}
|
|
1348
1631
|
if (values.cloud) {
|
|
1349
|
-
if (values.workers !== void 0 || values.windows || folders.length > 0
|
|
1350
|
-
throw new Error("--workers, --windows
|
|
1632
|
+
if (values.workers !== void 0 || values.windows || folders.length > 0) {
|
|
1633
|
+
throw new Error("--workers, --windows and --folder only apply to local runs, not --cloud");
|
|
1351
1634
|
}
|
|
1352
|
-
return runInCloud(slugs);
|
|
1635
|
+
return runInCloud(slugs, ports, values.start);
|
|
1353
1636
|
}
|
|
1354
1637
|
const source = folders.length > 0 ? async () => {
|
|
1355
1638
|
const loaded = await loadFolders(folders, baseUrl);
|
|
@@ -1381,14 +1664,19 @@ checked after the steps; each "invalid" value on a Fill step adds a run that typ
|
|
|
1381
1664
|
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
1665
|
With --start the run starts the app itself (a shell command, with PORT set to --base-url's port), waits for it,
|
|
1383
1666
|
and stops it when the run ends; if it doesn't come up, its last output is printed.
|
|
1667
|
+
With --cloud --tunnel the cloud workers reach these ports on this machine as localhost:<port>, through a tunnel that
|
|
1668
|
+
stays open until the run completes (scenarios must navigate to localhost, not 127.0.0.1); --start then starts the
|
|
1669
|
+
app with PORT set to the first tunnel port.
|
|
1384
1670
|
|
|
1385
1671
|
Options:
|
|
1386
1672
|
-g, --group <slug...> run only the scenarios in these groups (and their prerequisites)
|
|
1387
1673
|
-f, --folder <path> run the scenario files in this folder (recursively) instead of the API's; can repeat
|
|
1388
1674
|
--base-url <url> with --folder: base for relative Navigate URLs (e.g. http://localhost:5173); waits up
|
|
1389
1675
|
to 30s for it to answer
|
|
1390
|
-
--start <command> with --base-url: start the app with this command (PORT set) and stop it
|
|
1676
|
+
--start <command> with --base-url or --tunnel: start the app with this command (PORT set) and stop it
|
|
1677
|
+
afterwards
|
|
1391
1678
|
--cloud run on the cloud workers instead of locally
|
|
1679
|
+
-t, --tunnel <port...> with --cloud: let the workers reach localhost:<port> on this machine (e.g. -t 5173)
|
|
1392
1680
|
-w, --workers <n> local only: parallel browser contexts (default: based on CPUs and free memory)
|
|
1393
1681
|
--windows local only: show the browser windows
|
|
1394
1682
|
-h, --help show this help`,
|
|
@@ -1399,10 +1687,19 @@ Options:
|
|
|
1399
1687
|
var COMMANDS = [login2, logout, run, list, setPrerequisite];
|
|
1400
1688
|
var USAGE = `Usage: mimic <command> [options]
|
|
1401
1689
|
|
|
1690
|
+
Options:
|
|
1691
|
+
-h, --help show this help
|
|
1692
|
+
-v, --version print the version
|
|
1693
|
+
|
|
1402
1694
|
Commands:
|
|
1403
1695
|
${COMMANDS.map((command) => ` ${command.name.padEnd(17)} ${command.summary}`).join("\n")}
|
|
1404
1696
|
|
|
1405
1697
|
Run "mimic <command> --help" for a command's options.`;
|
|
1698
|
+
async function header() {
|
|
1699
|
+
const email = await signedInEmail();
|
|
1700
|
+
const user = email === void 0 ? 'not signed in (run "mimic login")' : `signed in as ${email ?? "unknown user"}`;
|
|
1701
|
+
return gray(`mimic ${VERSION}, ${API_URL}, ${user}`);
|
|
1702
|
+
}
|
|
1406
1703
|
async function main() {
|
|
1407
1704
|
const [name, ...args] = process.argv.slice(2);
|
|
1408
1705
|
const command = COMMANDS.find((c) => c.name === name);
|
|
@@ -1410,11 +1707,19 @@ async function main() {
|
|
|
1410
1707
|
return command.execute(args);
|
|
1411
1708
|
}
|
|
1412
1709
|
if (name === "-h" || name === "--help") {
|
|
1413
|
-
console.log(
|
|
1710
|
+
console.log(`${await header()}
|
|
1711
|
+
|
|
1712
|
+
${USAGE}`);
|
|
1713
|
+
return 0;
|
|
1714
|
+
}
|
|
1715
|
+
if (name === "-v" || name === "--version") {
|
|
1716
|
+
console.log(VERSION);
|
|
1414
1717
|
return 0;
|
|
1415
1718
|
}
|
|
1416
1719
|
if (name === void 0) {
|
|
1417
|
-
console.log(
|
|
1720
|
+
console.log(`${await header()}
|
|
1721
|
+
|
|
1722
|
+
${USAGE}`);
|
|
1418
1723
|
return 1;
|
|
1419
1724
|
}
|
|
1420
1725
|
console.error(`Unknown command "${name}"
|
package/dist/inject.js
CHANGED
|
@@ -154,15 +154,16 @@
|
|
|
154
154
|
return value;
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
|
-
function
|
|
158
|
-
const expected =
|
|
159
|
-
if (
|
|
157
|
+
function checkProblem(check) {
|
|
158
|
+
const expected = check.value ?? "";
|
|
159
|
+
if (check.type === "UrlEquals") {
|
|
160
160
|
const url = expectedUrl(expected);
|
|
161
161
|
return location.href === url ? void 0 : `expected URL "${url}", but got "${location.href}"`;
|
|
162
162
|
}
|
|
163
|
-
|
|
163
|
+
if (check.type === "Screenshot") return void 0;
|
|
164
|
+
const { element, error } = locate(check.locator);
|
|
164
165
|
if (!element) return error ?? "element not found";
|
|
165
|
-
switch (
|
|
166
|
+
switch (check.type) {
|
|
166
167
|
case "ElementExists":
|
|
167
168
|
return void 0;
|
|
168
169
|
case "ElementVisible":
|
|
@@ -181,14 +182,24 @@
|
|
|
181
182
|
}
|
|
182
183
|
return element.value === expected ? void 0 : `expected value "${expected}", but got "${element.value}"`;
|
|
183
184
|
default: {
|
|
184
|
-
const unhandled =
|
|
185
|
+
const unhandled = check.type;
|
|
185
186
|
return `unknown assertion type: ${unhandled}`;
|
|
186
187
|
}
|
|
187
188
|
}
|
|
188
189
|
}
|
|
190
|
+
function firstFailure(step) {
|
|
191
|
+
for (const [i, check] of step.checks.entries()) {
|
|
192
|
+
const problem = checkProblem(check);
|
|
193
|
+
if (problem) return { check, problem, prefix: step.checks.length > 1 ? `check ${i + 1}: ` : "" };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function assertionCheck(step) {
|
|
197
|
+
const failure = firstFailure(step);
|
|
198
|
+
return failure && `${failure.prefix}${failure.problem}`;
|
|
199
|
+
}
|
|
189
200
|
function assertionProblem(step) {
|
|
190
|
-
const
|
|
191
|
-
return
|
|
201
|
+
const failure = firstFailure(step);
|
|
202
|
+
return failure && `${failure.prefix}${failure.check.type} assertion failed: ${failure.problem}`;
|
|
192
203
|
}
|
|
193
204
|
|
|
194
205
|
// src/shared/inject.ts
|
|
@@ -196,6 +207,7 @@
|
|
|
196
207
|
find(locator, kind) {
|
|
197
208
|
const { element, error } = locate(locator);
|
|
198
209
|
if (!element) return error ?? "element not found";
|
|
210
|
+
if (kind === "assert") return element;
|
|
199
211
|
return (kind === "click" ? clickProblem : fillProblem)(element) ?? element;
|
|
200
212
|
},
|
|
201
213
|
isConnected: (el) => el.isConnected,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tonys-software/mimic",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.1-stg.5.1",
|
|
5
5
|
"description": "Run Mimic scenarios from the command line",
|
|
6
6
|
"license": "UNLICENSED",
|
|
7
7
|
"repository": {
|
|
@@ -25,14 +25,17 @@
|
|
|
25
25
|
"scripts": {
|
|
26
26
|
"postinstall": "playwright install chromium",
|
|
27
27
|
"build": "tsc --noEmit && node build.mjs",
|
|
28
|
-
"run-all": "node dist/cli.js run"
|
|
28
|
+
"run-all": "node dist/cli.js run",
|
|
29
|
+
"tunnel-dev": "node scripts/tunnel-dev.ts"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
|
-
"playwright": "^1.64.0"
|
|
32
|
+
"playwright": "^1.64.0",
|
|
33
|
+
"ws": "^8.22.0"
|
|
32
34
|
},
|
|
33
35
|
"devDependencies": {
|
|
34
36
|
"@service/jsclient": "file:../jsclient",
|
|
35
37
|
"@types/node": "^22.18.0",
|
|
38
|
+
"@types/ws": "^8.18.2",
|
|
36
39
|
"esbuild": "^0.25.10",
|
|
37
40
|
"typescript": "~5.9.3"
|
|
38
41
|
}
|