@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 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 assertionWithDefaults(step) {
54
- return { ...step, locator: step.locator && withDefaults(step.locator) };
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 parseGroups(tokens) {
397
- const slugs = [];
398
- let collecting = false;
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 === "group";
402
- if (collecting && token.value !== void 0) slugs.push(token.value);
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
- slugs.push(token.value);
414
+ collecting.push(token.value);
406
415
  } else {
407
- collecting = false;
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.assertionType} assertion failed: page navigated while checking`;
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.assertionType}): ${a.problem}${i === 0 ? ` (on ${page.url()})` : ""}`).join("\n"));
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
- async function runInCloud(slugs) {
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
- let run2 = await api.startRun({ groups: groups.map((g) => g.id) });
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
- console.log(gray(`Started run ${run2.id}: ${run2.scenarios.length} scenarios${inGroupsLabel(slugs)} on the cloud workers
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
- console.log(gray(`Waiting for workers (${pending} pending)...`));
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 answers(baseUrl, 1e3)) {
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 answers(baseUrl, Math.min(deadline - Date.now(), 2e3))) {
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 slugs = parseGroups(tokens);
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 (values.start !== void 0 && baseUrl === void 0) {
1346
- throw new Error("--start needs --folder and --base-url");
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 || values.start !== void 0) {
1350
- throw new Error("--workers, --windows, --folder and --start only apply to local runs, not --cloud");
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 afterwards
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(USAGE);
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(USAGE);
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 assertionCheck(step) {
158
- const expected = step.value ?? "";
159
- if (step.assertionType === "UrlEquals") {
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
- const { element, error } = locate(step.locator);
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 (step.assertionType) {
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 = step.assertionType;
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 problem = assertionCheck(step);
191
- return problem && `${step.assertionType} assertion failed: ${problem}`;
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.0-stg.3.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
  }