@flowriki/agent 0.1.0-beta.12 → 0.1.0-beta.13

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.
@@ -6,7 +6,7 @@ import os from "os";
6
6
  import path from "path";
7
7
  import { createRequire } from "module";
8
8
  import { spawnSync } from "child_process";
9
- var AGENT_VERSION = "0.1.0-beta.12";
9
+ var AGENT_VERSION = "0.1.0-beta.13";
10
10
  async function prepareBrowsers() {
11
11
  if (browsersReady()) {
12
12
  return { ok: true, already: true, message: "chromium is already installed" };
@@ -135,7 +135,7 @@ function forgetConnection(service, env = process.env) {
135
135
  }
136
136
  var fingerprint = (secret) => crypto2.createHash("sha256").update(secret).digest("hex").slice(0, 12);
137
137
 
138
- // ../contracts/dist/chunk-BU7BOLBB.js
138
+ // ../contracts/dist/chunk-BZ2CJR5T.js
139
139
  import { z } from "zod";
140
140
  var PROTOCOL_VERSION = 1;
141
141
  var PAIRING_CODE_TTL_MS = 5 * 60 * 1e3;
@@ -282,9 +282,16 @@ var HelloSchema = z.object({
282
282
  * BEFORE somebody presses Run and waits for a browser that never opens.
283
283
  *
284
284
  * Absent means an agent from before this existed: baseline only. Names are
285
- * command names, so there is nothing new to keep in step. */
285
+ * command names, except for the few in `AGENT_FEATURES`. */
286
286
  capabilities: z.array(z.string()).default([])
287
287
  }).strict();
288
+ var AGENT_FEATURES = /* @__PURE__ */ new Set([
289
+ /** Signing in and out through a shared procedure. */
290
+ "authentication.v1",
291
+ /** More than one participant, each with a browser session of its own —
292
+ * which also covers starting one over and sharing a captured output. */
293
+ "actors.v1"
294
+ ]);
288
295
  var AgentMessageSchema = z.discriminatedUnion("type", [
289
296
  AckSchema,
290
297
  AgentEventSchema,
@@ -449,9 +456,9 @@ import fs7 from "fs";
449
456
  import path7 from "path";
450
457
 
451
458
  // ../engine-playwright/dist/index.js
452
- import { chromium, firefox, webkit } from "playwright";
459
+ import path3 from "path";
453
460
 
454
- // ../test-model/dist/chunk-ROEX7TWK.js
461
+ // ../test-model/dist/chunk-WDGUWWYI.js
455
462
  import { z as z3 } from "zod";
456
463
  var LocatorOverrideSchema = z3.object({
457
464
  strategy: z3.enum(["css", "xpath", "id", "testId", "text", "label", "placeholder", "altText", "title"]),
@@ -632,7 +639,7 @@ var DialogAnswerSchema = z3.object({
632
639
  }).strict();
633
640
  var RecordedStepSchema = RecordedTargetSchema.extend({
634
641
  id: z3.string().min(1),
635
- type: z3.enum(["fill", "select", "click", "dblclick", "rightclick", "drag", "check", "press", "hover", "upload", "scroll", "back", "forward", "navigate", "closePage", "unsupported", "setRange", "assert"]),
642
+ type: z3.enum(["fill", "select", "click", "dblclick", "rightclick", "drag", "check", "press", "hover", "upload", "scroll", "back", "forward", "navigate", "closePage", "unsupported", "setRange", "assert", "signIn", "signOut", "useActor", "resetActor", "capture"]),
636
643
  scope: z3.string(),
637
644
  value: z3.string(),
638
645
  secret: z3.boolean(),
@@ -681,11 +688,44 @@ var RecordedStepSchema = RecordedTargetSchema.extend({
681
688
  * When this is set, `value` is empty and carries nothing: the secret is
682
689
  * looked up by this name at run time, and a name that is not on this machine
683
690
  * stays unresolved and is reported, never invented. */
684
- secretRef: z3.string().regex(SECRET_REF_PATTERN).optional()
691
+ secretRef: z3.string().regex(SECRET_REF_PATTERN).optional(),
692
+ /** WHICH PROCEDURE AND WHICH IDENTITY, for `signIn` and `signOut`.
693
+ *
694
+ * A reference, never an expansion: the sign-in's own steps are not copied
695
+ * in here. That is deliberate and is the one exception to a case holding
696
+ * editable copies — a login procedure is shared infrastructure, and
697
+ * duplicating it into forty tests recreates exactly the problem this
698
+ * feature exists to remove. */
699
+ auth: z3.object({
700
+ profileId: z3.string().min(1),
701
+ accountId: z3.string().optional()
702
+ }).strict().optional(),
703
+ /** Which participant this step belongs to, for `useActor`.
704
+ *
705
+ * Selecting an actor RESUMES its session; it does not sign in again. A step
706
+ * that named an actor and re-authenticated would make moving between two
707
+ * participants cost a login each time, which is the cost this removes. */
708
+ actorId: z3.string().optional(),
709
+ navigationBase: z3.enum(["page", "application"]).optional(),
710
+ capture: z3.object({ name: z3.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/), source: z3.enum(["text", "value", "url"]) }).strict().optional()
685
711
  }).strict();
712
+ var DraftStepSchema = RecordedStepSchema.extend({
713
+ role: z3.string().default(""),
714
+ name: z3.string().default(""),
715
+ scope: z3.string().default(""),
716
+ value: z3.string().default(""),
717
+ secret: z3.boolean().default(false),
718
+ legacySelector: z3.string().default(""),
719
+ proposals: z3.array(RecordedAssertionSchema).default([])
720
+ });
686
721
  function slugify(name) {
687
722
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
688
723
  }
724
+ var ActorDefinitionSchema = z3.object({
725
+ id: z3.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/),
726
+ name: z3.string().min(1),
727
+ startSignedIn: z3.object({ profileId: z3.string().min(1), accountId: z3.string().optional() }).strict().optional()
728
+ }).strict();
689
729
  var CURRENT_SCHEMA_VERSION = 5;
690
730
  var FlowDocumentSchema = z3.object({
691
731
  schemaVersion: z3.literal(CURRENT_SCHEMA_VERSION),
@@ -711,6 +751,8 @@ var FlowDocumentSchema = z3.object({
711
751
  steps: z3.array(RecordedStepSchema),
712
752
  environment: ExecutionEnvironmentSchema.optional(),
713
753
  path: z3.string().optional(),
754
+ actors: z3.array(ActorDefinitionSchema).optional(),
755
+ defaultActorId: z3.string().optional(),
714
756
  /** Bumped on every save. An edit carries the revision it was made against,
715
757
  * so a save built on a stale copy is refused instead of overwriting work
716
758
  * somebody else — or a debug session on the same machine — has since done.
@@ -812,6 +854,7 @@ function parseFlowDocument(input) {
812
854
  // ../test-model/dist/index.js
813
855
  import { z as z4 } from "zod";
814
856
  import { z as z22 } from "zod";
857
+ import { z as z32 } from "zod";
815
858
  var EngineSchema = z4.enum(["chromium", "firefox", "webkit"]);
816
859
  var CapabilitySchema = z4.object({
817
860
  id: z4.string(),
@@ -845,6 +888,7 @@ var FlowOccurrenceSchema = z22.object({
845
888
  * two occurrences, and editing, breakpoints, results and deletion must be
846
889
  * able to tell them apart. */
847
890
  occurrenceId: z22.string().min(1),
891
+ actorId: z22.string().optional(),
848
892
  /** Editable, and initially the source flow's name. */
849
893
  name: z22.string().min(1),
850
894
  /** Where this copy starts, when it is told to navigate before running.
@@ -894,6 +938,8 @@ var TestCaseSchema = z22.object({
894
938
  useStartPath: z22.boolean().default(false),
895
939
  baseUrl: z22.string().url().optional(),
896
940
  environment: ExecutionEnvironmentSchema.optional(),
941
+ actors: z22.array(ActorDefinitionSchema).optional(),
942
+ defaultActorId: z22.string().optional(),
897
943
  occurrences: z22.array(FlowOccurrenceSchema).default([]),
898
944
  createdAt: z22.string(),
899
945
  createdBy: z22.string().default(""),
@@ -975,6 +1021,11 @@ function caseDebugPlan(testCase, baseUrl) {
975
1021
  const steps = [];
976
1022
  const occurrences = caseOccurrences(testCase);
977
1023
  for (const [index, item] of occurrences.entries()) {
1024
+ if (testCase.actors?.length) {
1025
+ const id = `case-actor:${encodeURIComponent(item.occurrenceId)}`;
1026
+ owners[id] = { occurrenceId: item.occurrenceId, stepId: null };
1027
+ steps.push({ id, type: "useActor", actorId: item.actorId ?? testCase.defaultActorId ?? "default", role: "", name: "Select participant", scope: "", value: "", secret: false, legacySelector: "", proposals: [] });
1028
+ }
978
1029
  if (index > 0 && item.navigateFirst) {
979
1030
  const id = `case-nav:${encodeURIComponent(item.occurrenceId)}`;
980
1031
  owners[id] = { occurrenceId: item.occurrenceId, stepId: null };
@@ -999,6 +1050,8 @@ function caseDebugPlan(testCase, baseUrl) {
999
1050
  updatedAt: testCase.updatedAt,
1000
1051
  steps,
1001
1052
  startPath: new URL(first?.startPath ?? "/", first?.baseUrl ?? testCase.baseUrl ?? baseUrl).href,
1053
+ ...testCase.actors ? { actors: testCase.actors } : {},
1054
+ ...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {},
1002
1055
  ...testCase.environment ? { environment: testCase.environment } : {}
1003
1056
  });
1004
1057
  return { flow, owners };
@@ -1009,13 +1062,18 @@ function restoreDebugCase(testCase, owners, steps) {
1009
1062
  let current = occurrences[0];
1010
1063
  const present = new Set(steps.map((step) => step.id));
1011
1064
  for (const [id, owner] of Object.entries(owners)) {
1012
- if (owner.stepId === null && !present.has(id)) byId.get(owner.occurrenceId).navigateFirst = false;
1065
+ if (id.startsWith("case-nav:") && owner.stepId === null && !present.has(id)) byId.get(owner.occurrenceId).navigateFirst = false;
1013
1066
  }
1014
1067
  for (const step of steps) {
1015
1068
  const owner = owners[step.id];
1016
1069
  if (owner) current = byId.get(owner.occurrenceId);
1017
1070
  if (!current) throw new Error("debug step has no case occurrence");
1018
1071
  if (owner?.stepId === null) {
1072
+ if (step.id.startsWith("case-actor:")) {
1073
+ if (step.type !== "useActor") throw new Error("a participant boundary must remain a participant step");
1074
+ current.actorId = step.actorId;
1075
+ continue;
1076
+ }
1019
1077
  if (step.type !== "navigate") throw new Error("a case boundary must remain a navigation step");
1020
1078
  current.startPath = step.value;
1021
1079
  continue;
@@ -1024,6 +1082,334 @@ function restoreDebugCase(testCase, owners, steps) {
1024
1082
  }
1025
1083
  return { ...testCase, occurrences };
1026
1084
  }
1085
+ var AuthNavigationSchema = z32.discriminatedUnion("kind", [
1086
+ /** Act from wherever the browser already is — a "Log in" link in the header.
1087
+ * The only kind that is safe part-way through a journey. */
1088
+ z32.object({ kind: z32.literal("continue") }),
1089
+ /** Open a login page first. Right when signing in is the first thing that
1090
+ * happens; it abandons a journey in progress. */
1091
+ z32.object({ kind: z32.literal("open"), path: z32.string().min(1) })
1092
+ ]);
1093
+ var AuthInputSchema = z32.object({
1094
+ name: z32.string().min(1).max(64).regex(/^[a-z0-9][a-z0-9_-]*$/i),
1095
+ label: z32.string().default(""),
1096
+ secret: z32.boolean().default(true)
1097
+ }).strict();
1098
+ var AUTH_PROFILE_SCHEMA_VERSION = 1;
1099
+ var AuthProfileSchema = z32.object({
1100
+ schemaVersion: z32.literal(AUTH_PROFILE_SCHEMA_VERSION),
1101
+ id: z32.string().min(1),
1102
+ slug: z32.string().regex(/^[a-z0-9][a-z0-9-]*$/),
1103
+ name: z32.string().min(1),
1104
+ description: z32.string().default(""),
1105
+ /** The recorded sign-in. A flow like any other, so it is edited, debugged
1106
+ * and replayed by everything that already knows how. */
1107
+ flowId: z32.string().min(1),
1108
+ navigation: AuthNavigationSchema,
1109
+ /** What the procedure needs. An account supplies each of these by name. */
1110
+ inputs: z32.array(AuthInputSchema).default([]),
1111
+ /** HOW TO KNOW IT WORKED. The last click landing is not success: a login
1112
+ * form that re-renders with "wrong password" has also finished clicking.
1113
+ * Checked before any dependent step runs, using the assertion vocabulary
1114
+ * the rest of the product already speaks. */
1115
+ success: z32.array(RecordedAssertionSchema).default([]),
1116
+ /** Optional, and separate from success: signing out is its own procedure
1117
+ * with its own proof, and most profiles will not have one at first. */
1118
+ signOutFlowId: z32.string().optional(),
1119
+ signedOut: z32.array(RecordedAssertionSchema).default([]),
1120
+ /** Descriptive only — "admin", "staff". Never used to decide anything, so
1121
+ * two profiles may share a tag without becoming interchangeable. */
1122
+ tags: z32.array(z32.string()).default([]),
1123
+ createdAt: z32.string(),
1124
+ createdBy: z32.string().default(""),
1125
+ updatedAt: z32.string(),
1126
+ revision: z32.number().int().nonnegative().default(0)
1127
+ }).strict();
1128
+ var ACCOUNT_BINDING_SCHEMA_VERSION = 1;
1129
+ var AccountBindingSchema = z32.object({
1130
+ schemaVersion: z32.literal(ACCOUNT_BINDING_SCHEMA_VERSION),
1131
+ id: z32.string().min(1),
1132
+ slug: z32.string().regex(/^[a-z0-9][a-z0-9-]*$/),
1133
+ name: z32.string().min(1),
1134
+ profileId: z32.string().min(1),
1135
+ /** Which environment of the APPLICATION UNDER TEST these credentials belong
1136
+ * to — never Flowriki's own dev or staging. Null means every environment,
1137
+ * which is what a project with one environment wants. */
1138
+ environment: z32.string().min(1).nullable().default(null),
1139
+ /** Input name → secret reference. Values live in the vault. */
1140
+ secrets: z32.record(z32.string(), z32.string().regex(/^[a-z][a-z0-9_]*$/i)).default({}),
1141
+ /** What this identity should look like once signed in — a displayed name, a
1142
+ * tenant. Not secret, and checked so that an already-authenticated browser
1143
+ * holding the WRONG account is reported rather than accepted. */
1144
+ identity: z32.object({
1145
+ label: z32.string().optional(),
1146
+ tenant: z32.string().optional()
1147
+ }).default({}),
1148
+ createdAt: z32.string(),
1149
+ createdBy: z32.string().default(""),
1150
+ updatedAt: z32.string(),
1151
+ revision: z32.number().int().nonnegative().default(0)
1152
+ }).strict();
1153
+ var AuthReferenceSchema = z32.object({
1154
+ profileId: z32.string().min(1),
1155
+ /** Absent means "the only account bound to this profile for the selected
1156
+ * environment", which is resolved before a browser opens and refused when
1157
+ * it is ambiguous — a silent choice between two identities is the failure
1158
+ * this field exists to prevent. */
1159
+ accountId: z32.string().optional()
1160
+ }).strict();
1161
+ var ActorSchema = ActorDefinitionSchema;
1162
+ var DeliveredAuthSchema = z32.object({
1163
+ profileId: z32.string().min(1),
1164
+ accountId: z32.string().min(1),
1165
+ name: z32.string(),
1166
+ accountName: z32.string(),
1167
+ steps: z32.array(RecordedStepSchema),
1168
+ navigation: AuthNavigationSchema,
1169
+ success: z32.array(RecordedAssertionSchema),
1170
+ secretRefs: z32.record(z32.string(), z32.string()),
1171
+ signOut: z32.object({ steps: z32.array(RecordedStepSchema), success: z32.array(RecordedAssertionSchema) }).optional()
1172
+ }).strict();
1173
+ function actorPreparations(actors = []) {
1174
+ return actors.flatMap((actor) => actor.startSignedIn ? [{
1175
+ id: `prepare:${actor.id}`,
1176
+ type: "signIn",
1177
+ auth: actor.startSignedIn,
1178
+ name: `Prepare ${actor.name}`,
1179
+ role: "",
1180
+ scope: "",
1181
+ value: "",
1182
+ secret: false,
1183
+ legacySelector: "",
1184
+ proposals: []
1185
+ }] : []);
1186
+ }
1187
+ function validateActors(actors = [], defaultId = "default", used = []) {
1188
+ ActorSchema.array().parse(actors);
1189
+ const ids = /* @__PURE__ */ new Set(["default"]);
1190
+ for (const actor of actors) {
1191
+ if (ids.has(actor.id)) throw new Error(`Duplicate or reserved actor: ${actor.id}`);
1192
+ ids.add(actor.id);
1193
+ }
1194
+ for (const id of [defaultId, ...used]) {
1195
+ if (id === UNSET_ACTOR) throw new Error("A participant step does not say which participant \u2014 choose one before running");
1196
+ if (id && !ids.has(id)) throw new Error(`Unknown actor: ${id} \u2014 configure this participant before running`);
1197
+ }
1198
+ }
1199
+ var UNSET_ACTOR = "\0unset";
1200
+ function actorIdsUsed(steps) {
1201
+ return steps.flatMap((step) => step.disabled ? [] : step.type === "useActor" ? [step.actorId ?? UNSET_ACTOR] : step.type === "resetActor" && step.actorId ? [step.actorId] : []);
1202
+ }
1203
+
1204
+ // ../engine-playwright/dist/index.js
1205
+ import { createHmac, randomBytes } from "crypto";
1206
+ import { errors } from "playwright";
1207
+
1208
+ // ../core/dist/index.js
1209
+ function proposeAssertions(before, after) {
1210
+ const out = [];
1211
+ if (!before || !after) return out;
1212
+ if (before.url !== after.url)
1213
+ out.push({
1214
+ kind: "screen",
1215
+ value: after.heading || after.title,
1216
+ why: "navigated to a new screen"
1217
+ });
1218
+ for (const a of after.alerts.filter((x) => !before.alerts.includes(x)))
1219
+ out.push({ kind: "message", value: a, why: "a message appeared that was not there before" });
1220
+ if (before.url === after.url && before.heading !== after.heading && after.heading)
1221
+ out.push({ kind: "heading", value: after.heading, why: "the heading changed in place" });
1222
+ const newFields = after.fields.filter((f) => !before.fields.includes(f));
1223
+ if (newFields.length)
1224
+ out.push({ kind: "fields_visible", value: newFields, why: "new fields became visible" });
1225
+ if (before.url === after.url) {
1226
+ const seenKeys = new Set(before.texts.map((t) => t.key));
1227
+ const covered = /* @__PURE__ */ new Set([after.heading, ...after.alerts]);
1228
+ const appeared = after.texts.filter((t) => !seenKeys.has(t.key) && !covered.has(t.text));
1229
+ for (const t of appeared.slice(0, 2))
1230
+ out.push({
1231
+ kind: "message",
1232
+ value: t.text,
1233
+ why: "this text appeared after the action",
1234
+ confidence: "low"
1235
+ });
1236
+ }
1237
+ return out;
1238
+ }
1239
+ function hasObservableChange(before, after) {
1240
+ return proposeAssertions(before, after).some((p) => p.confidence !== "low");
1241
+ }
1242
+ function isLocalHost(host) {
1243
+ const h = host.toLowerCase();
1244
+ return h === "localhost" || h.endsWith(".localhost") || h === "127.0.0.1" || h === "[::1]" || h === "0.0.0.0";
1245
+ }
1246
+ function hasScheme(raw) {
1247
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) return true;
1248
+ return /^[a-zA-Z][a-zA-Z0-9+.-]*:(?!\d)/.test(raw);
1249
+ }
1250
+ function normalizeStartUrl(input) {
1251
+ const raw = (input ?? "").trim();
1252
+ if (!raw) throw new Error("enter a URL to record against");
1253
+ let candidate;
1254
+ if (hasScheme(raw)) {
1255
+ candidate = raw;
1256
+ } else {
1257
+ const bare = raw.replace(/^\/+/, "");
1258
+ const host = bare.split(/[/?#]/)[0] ?? "";
1259
+ candidate = `${isLocalHost(host.split(":")[0] ?? "") ? "http" : "https"}://${bare}`;
1260
+ }
1261
+ let parsed;
1262
+ try {
1263
+ parsed = new URL(candidate);
1264
+ } catch {
1265
+ throw new Error(`"${raw}" is not a valid URL`);
1266
+ }
1267
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1268
+ throw new Error(`only http:// and https:// URLs can be recorded (got ${parsed.protocol}//)`);
1269
+ }
1270
+ if (!parsed.hostname) throw new Error(`"${raw}" has no host`);
1271
+ return parsed.toString();
1272
+ }
1273
+ function resolveStartUrl(baseUrl, startPath) {
1274
+ const base = normalizeStartUrl(baseUrl);
1275
+ return new URL(startPath || "/", base).toString();
1276
+ }
1277
+ var INPUT_ROLES = /* @__PURE__ */ new Set(["textbox", "combobox", "checkbox", "radio", "spinbutton", "searchbox"]);
1278
+ var NAVIGATIONS = /* @__PURE__ */ new Set(["back", "forward"]);
1279
+ function locatorPlan(step) {
1280
+ const o = step.locator;
1281
+ if (o && o.value.trim()) {
1282
+ const value = o.value.trim();
1283
+ switch (o.strategy) {
1284
+ // Playwright reads a leading `xpath=` prefix, and also bare paths that
1285
+ // start with `/` or `//`. Normalising here means the QA can paste either.
1286
+ case "xpath":
1287
+ return { method: "locator", arg: value.startsWith("xpath=") ? value : `xpath=${value}`, overridden: true };
1288
+ // Typed as a bare id, so nobody has to remember the `#`.
1289
+ case "id":
1290
+ return { method: "locator", arg: /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(value.replace(/^#/, "")) ? `#${value.replace(/^#/, "")}` : `[id=${JSON.stringify(value.replace(/^#/, ""))}]`, overridden: true };
1291
+ case "css":
1292
+ return { method: "locator", arg: value, overridden: true };
1293
+ case "testId":
1294
+ return { method: "getByTestId", arg: value, overridden: true };
1295
+ case "text":
1296
+ return { method: "getByText", arg: value, exact: true, overridden: true };
1297
+ case "label":
1298
+ return { method: "getByLabel", arg: value, exact: true, overridden: true };
1299
+ case "placeholder":
1300
+ return { method: "getByPlaceholder", arg: value, exact: true, overridden: true };
1301
+ case "altText":
1302
+ return { method: "getByAltText", arg: value, exact: true, overridden: true };
1303
+ case "title":
1304
+ return { method: "getByTitle", arg: value, exact: true, overridden: true };
1305
+ }
1306
+ }
1307
+ if (NAVIGATIONS.has(step.type)) {
1308
+ return { method: step.type === "back" ? "goBack" : "goForward", arg: "", overridden: false };
1309
+ }
1310
+ if (!step.name.trim()) return { method: "none", arg: "", overridden: false };
1311
+ if (step.nameFrom === "adjacent-label") {
1312
+ return {
1313
+ method: "getByText",
1314
+ arg: step.name,
1315
+ exact: true,
1316
+ overridden: false,
1317
+ viaAdjacentLabel: true
1318
+ };
1319
+ }
1320
+ if (step.nameFrom === "text") {
1321
+ return {
1322
+ method: "getByText",
1323
+ arg: step.name,
1324
+ exact: step.type !== "hover",
1325
+ overridden: false,
1326
+ visible: true
1327
+ };
1328
+ }
1329
+ return INPUT_ROLES.has(step.role) ? { method: "getByLabel", arg: step.name, exact: true, overridden: false, inputRole: step.role } : step.role ? { method: "getByRole", arg: step.role, name: step.name, exact: true, overridden: false } : { method: "getByText", arg: step.name, exact: step.type !== "hover", overridden: false, visible: true };
1330
+ }
1331
+ function frameSelector(name) {
1332
+ const positional = /^nth:(\d+)$/.exec(name);
1333
+ if (positional) return `iframe, frame >> nth=${positional[1]}`;
1334
+ const v = JSON.stringify(name);
1335
+ return `iframe[title=${v}], iframe[name=${v}], iframe[id=${v}], frame[title=${v}], frame[name=${v}], frame[id=${v}]`;
1336
+ }
1337
+ function inputSelector(role) {
1338
+ return role === "textbox" ? 'input, textarea, [contenteditable="true"], [role="textbox"]' : `input, select, [role="${role}"]`;
1339
+ }
1340
+ var SCOPE_ROLES = ["form", "group", "region", "dialog", "alertdialog"];
1341
+ var CHECKS = [
1342
+ { id: "visible", label: "is visible", kind: "visible", group: "state" },
1343
+ { id: "not-visible", label: "is not visible", kind: "visible", not: true, group: "state" },
1344
+ { id: "attached", label: "exists on the page", kind: "attached", group: "state" },
1345
+ { id: "not-attached", label: "does not exist", kind: "attached", not: true, group: "state" },
1346
+ { id: "enabled", label: "is enabled", kind: "enabled", group: "state" },
1347
+ { id: "disabled", label: "is disabled", kind: "enabled", not: true, group: "state" },
1348
+ { id: "editable", label: "is editable", kind: "editable", group: "state" },
1349
+ { id: "readonly", label: "is read-only", kind: "editable", not: true, group: "state" },
1350
+ { id: "focused", label: "is focused", kind: "focused", group: "state" },
1351
+ { id: "checked", label: "is checked", kind: "checked", value: "true", group: "state" },
1352
+ { id: "unchecked", label: "is not checked", kind: "checked", value: "false", group: "state" },
1353
+ { id: "text", label: "text is exactly", kind: "text", input: true, group: "text" },
1354
+ { id: "text-contains", label: "text contains", kind: "text", op: "contains", input: true, group: "text" },
1355
+ { id: "text-matches", label: "text matches pattern", kind: "text", op: "matches", input: true, group: "text" },
1356
+ { id: "text-not", label: "text is not", kind: "text", not: true, input: true, group: "text" },
1357
+ { id: "text-not-contains", label: "text does not contain", kind: "text", op: "contains", not: true, input: true, group: "text" },
1358
+ { id: "value", label: "value is exactly", kind: "value", input: true, group: "value" },
1359
+ { id: "value-contains", label: "value contains", kind: "value", op: "contains", input: true, group: "value" },
1360
+ { id: "value-not", label: "value is not", kind: "value", not: true, input: true, group: "value" },
1361
+ { id: "attr", label: "attribute is", kind: "attribute", input: true, attr: true, group: "attributes" },
1362
+ { id: "attr-contains", label: "attribute contains", kind: "attribute", op: "contains", input: true, attr: true, group: "attributes" },
1363
+ { id: "attr-not", label: "attribute is not", kind: "attribute", not: true, input: true, attr: true, group: "attributes" },
1364
+ { id: "class", label: "has class", kind: "class", input: true, group: "attributes" },
1365
+ { id: "not-class", label: "does not have class", kind: "class", not: true, input: true, group: "attributes" },
1366
+ { id: "count", label: "how many \xB7 is exactly", kind: "count", input: true, roleOnly: true, group: "count" },
1367
+ { id: "count-gte", label: "how many \xB7 is at least", kind: "count", op: "gte", input: true, roleOnly: true, group: "count" },
1368
+ { id: "count-lte", label: "how many \xB7 is at most", kind: "count", op: "lte", input: true, roleOnly: true, group: "count" },
1369
+ { id: "url", label: "page URL is", kind: "url", input: true, page: true, group: "page" },
1370
+ { id: "url-contains", label: "page URL contains", kind: "url", op: "contains", input: true, page: true, group: "page" },
1371
+ { id: "title", label: "page title is", kind: "title", input: true, page: true, group: "page" },
1372
+ { id: "title-contains", label: "page title contains", kind: "title", op: "contains", input: true, page: true, group: "page" }
1373
+ ];
1374
+ function matchCheck(a) {
1375
+ const op = a.op ?? "equals";
1376
+ const value = Array.isArray(a.value) ? a.value.join(", ") : a.value;
1377
+ return CHECKS.find(
1378
+ (c) => c.kind === a.kind && (c.op ?? "equals") === op && Boolean(c.not) === Boolean(a.not) && // A fixed-value entry (is checked / is not checked) only matches its own
1379
+ // value, or the two would be indistinguishable.
1380
+ (c.value === void 0 || c.value === value)
1381
+ );
1382
+ }
1383
+ var PROPOSED_LABELS = {
1384
+ screen: (v) => `screen is \u201C${v}\u201D`,
1385
+ heading: (v) => `heading is \u201C${v}\u201D`,
1386
+ message: (v) => `message \u201C${v}\u201D is shown`,
1387
+ fields_visible: (v) => `fields visible: ${v}`
1388
+ };
1389
+ function describeAssertion(a, subject) {
1390
+ const value = Array.isArray(a.value) ? a.value.join(", ") : a.value;
1391
+ const proposed = PROPOSED_LABELS[a.kind];
1392
+ if (proposed && !a.target) return proposed(value);
1393
+ const check = matchCheck(a);
1394
+ const who = a.target?.name || subject || "";
1395
+ if (!check) {
1396
+ return `${a.kind}${a.op && a.op !== "equals" ? ` ${a.op}` : ""}${a.not ? " not" : ""}${value ? ` \u201C${value}\u201D` : ""}`;
1397
+ }
1398
+ const attr = check.attr && a.attr ? ` ${a.attr}` : "";
1399
+ const expects = check.input ? ` \u201C${value}\u201D` : "";
1400
+ if (check.page) return `${check.label}${expects}`;
1401
+ if (check.roleOnly) {
1402
+ const role = a.target?.role || "element";
1403
+ return `${role}s \xB7 ${check.label.replace("how many \xB7 ", "")}${expects}`;
1404
+ }
1405
+ const label = check.label.replace("attribute", `attribute${attr}`);
1406
+ return who ? `\u201C${who}\u201D ${label}${expects}` : `${label}${expects}`;
1407
+ }
1408
+
1409
+ // ../engine-playwright/dist/index.js
1410
+ import fs4 from "fs";
1411
+ import path22 from "path";
1412
+ import { chromium, firefox, webkit } from "playwright";
1027
1413
 
1028
1414
  // ../recorder/dist/index.js
1029
1415
  function openShadowRoots() {
@@ -2386,221 +2772,463 @@ function installDebugPanel() {
2386
2772
  }
2387
2773
 
2388
2774
  // ../engine-playwright/dist/index.js
2389
- import fs4 from "fs";
2775
+ import "playwright";
2776
+ import "playwright";
2777
+ import fs22 from "fs";
2390
2778
  import { createRequire as createRequire2 } from "module";
2391
- import path3 from "path";
2779
+ import path32 from "path";
2392
2780
  import { fileURLToPath } from "url";
2393
2781
  import "playwright";
2394
-
2395
- // ../core/dist/index.js
2396
- function proposeAssertions(before, after) {
2397
- const out = [];
2398
- if (!before || !after) return out;
2399
- if (before.url !== after.url)
2400
- out.push({
2401
- kind: "screen",
2402
- value: after.heading || after.title,
2403
- why: "navigated to a new screen"
2404
- });
2405
- for (const a of after.alerts.filter((x) => !before.alerts.includes(x)))
2406
- out.push({ kind: "message", value: a, why: "a message appeared that was not there before" });
2407
- if (before.url === after.url && before.heading !== after.heading && after.heading)
2408
- out.push({ kind: "heading", value: after.heading, why: "the heading changed in place" });
2409
- const newFields = after.fields.filter((f) => !before.fields.includes(f));
2410
- if (newFields.length)
2411
- out.push({ kind: "fields_visible", value: newFields, why: "new fields became visible" });
2412
- if (before.url === after.url) {
2413
- const seenKeys = new Set(before.texts.map((t) => t.key));
2414
- const covered = /* @__PURE__ */ new Set([after.heading, ...after.alerts]);
2415
- const appeared = after.texts.filter((t) => !seenKeys.has(t.key) && !covered.has(t.text));
2416
- for (const t of appeared.slice(0, 2))
2417
- out.push({
2418
- kind: "message",
2419
- value: t.text,
2420
- why: "this text appeared after the action",
2421
- confidence: "low"
2422
- });
2423
- }
2424
- return out;
2425
- }
2426
- function hasObservableChange(before, after) {
2427
- return proposeAssertions(before, after).some((p) => p.confidence !== "low");
2782
+ import fs32 from "fs";
2783
+ import "playwright";
2784
+ function withRuntimeValues(step, values) {
2785
+ const expand = (text) => text.replace(/\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g, (_, name) => {
2786
+ if (!values.has(name)) throw new Error(`Runtime value "${name}" has not been captured yet`);
2787
+ return values.get(name);
2788
+ });
2789
+ return {
2790
+ ...step,
2791
+ value: step.secret ? step.value : expand(step.value),
2792
+ ...step.values ? { values: step.values.map(expand) } : {},
2793
+ proposals: step.proposals.map((check) => ({ ...check, value: Array.isArray(check.value) ? check.value.map(expand) : expand(check.value) }))
2794
+ };
2428
2795
  }
2429
- function isLocalHost(host) {
2430
- const h = host.toLowerCase();
2431
- return h === "localhost" || h.endsWith(".localhost") || h === "127.0.0.1" || h === "[::1]" || h === "0.0.0.0";
2796
+ var secretsBehind = (auth) => [.../* @__PURE__ */ new Set([
2797
+ ...Object.values(auth.secretRefs ?? {}),
2798
+ ...auth.steps.flatMap((step) => step.secretRef ? [step.secretRef] : [])
2799
+ ])].sort();
2800
+ function authStartUrl(auth, page, baseUrl) {
2801
+ if (auth.navigation.kind === "continue") return null;
2802
+ return new URL(auth.navigation.path, baseUrl || page.url()).href;
2803
+ }
2804
+ function describeAuthFailure(auth, cause) {
2805
+ const who = `sign in as ${auth.accountName}`;
2806
+ if (cause.step) return `could not ${who}: "${cause.step}" failed \u2014 ${cause.detail ?? "no detail"}`;
2807
+ return `could not ${who}: ${cause.detail ?? "no detail"}`;
2808
+ }
2809
+ var hasSuccessCheck = (auth) => auth.success.some((check) => check.enabled !== false);
2810
+ var UNCHECKED_AUTH = "this sign-in has no check for whether it worked \u2014 add one so a failed login is not reported as a passed step";
2811
+ function authenticationResolver(input) {
2812
+ const entries = DeliveredAuthSchema.array().parse(input ?? []);
2813
+ return (reference) => {
2814
+ const matches = entries.filter((item) => item.profileId === reference.profileId && (!reference.accountId || item.accountId === reference.accountId));
2815
+ if (matches.length !== 1) return void 0;
2816
+ return matches[0];
2817
+ };
2432
2818
  }
2433
- function hasScheme(raw) {
2434
- if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) return true;
2435
- return /^[a-zA-Z][a-zA-Z0-9+.-]*:(?!\d)/.test(raw);
2819
+ var PEPPER = randomBytes(32);
2820
+ function preparationKey(identity) {
2821
+ const material = JSON.stringify([
2822
+ identity.scope,
2823
+ identity.profileId,
2824
+ identity.accountId,
2825
+ identity.environment,
2826
+ identity.baseUrl,
2827
+ identity.browser,
2828
+ // The procedure as it will actually run.
2829
+ identity.auth.navigation,
2830
+ identity.auth.steps,
2831
+ identity.auth.success,
2832
+ // Each secret separately, so reordering cannot collide with a rotation.
2833
+ identity.secrets.map((value) => createHmac("sha256", PEPPER).update(value ?? "\0absent").digest("hex"))
2834
+ ]);
2835
+ return createHmac("sha256", PEPPER).update(material).digest("hex");
2836
+ }
2837
+ var AuthenticationReuse = class {
2838
+ held = /* @__PURE__ */ new Map();
2839
+ ttlMs;
2840
+ max;
2841
+ scope = "";
2842
+ constructor({ ttlMs = 15 * 6e4, max = 16 } = {}) {
2843
+ this.ttlMs = ttlMs;
2844
+ this.max = max;
2845
+ }
2846
+ /** ONE EXECUTION AT A TIME. Asking under a new scope forgets every session
2847
+ * from the last one.
2848
+ *
2849
+ * The scope is already part of every key, so nothing could have been read
2850
+ * across executions anyway. This is about not HOLDING them: a suite that has
2851
+ * finished has no reason for its sessions to still exist, and an agent that
2852
+ * keeps running would otherwise sit on them until they aged out. */
2853
+ using(scope) {
2854
+ if (scope === this.scope) return;
2855
+ this.scope = scope;
2856
+ for (const [key, held] of this.held) if (held.scope !== scope) this.held.delete(key);
2857
+ }
2858
+ get(key, now = Date.now()) {
2859
+ const found = this.held.get(key);
2860
+ if (!found) return void 0;
2861
+ if (now - found.storedAt > this.ttlMs) {
2862
+ this.held.delete(key);
2863
+ return void 0;
2864
+ }
2865
+ this.held.delete(key);
2866
+ this.held.set(key, found);
2867
+ return found.state;
2868
+ }
2869
+ put(key, state, now = Date.now()) {
2870
+ this.held.delete(key);
2871
+ this.held.set(key, { state, storedAt: now, scope: this.scope });
2872
+ while (this.held.size > this.max) {
2873
+ const oldest = this.held.keys().next();
2874
+ if (oldest.done) break;
2875
+ this.held.delete(oldest.value);
2876
+ }
2877
+ }
2878
+ /** After a session turned out to be stale. The next preparation signs in. */
2879
+ evict(key) {
2880
+ this.held.delete(key);
2881
+ }
2882
+ /** At the end of an execution, at a cancel, and at shutdown.
2883
+ *
2884
+ * Called from all three rather than relying on the TTL: a process that keeps
2885
+ * running holds these until they expire otherwise, and there is no reason
2886
+ * for a finished suite's sessions to exist at all. */
2887
+ clear() {
2888
+ this.held.clear();
2889
+ }
2890
+ get size() {
2891
+ return this.held.size;
2892
+ }
2893
+ };
2894
+ function keptSessionFor(opts, reference) {
2895
+ if (!opts.reuse || !reference) return null;
2896
+ opts.reuse.cache.using(opts.reuse.scope);
2897
+ const auth = opts.resolveAuth?.(reference);
2898
+ if (!auth || !auth.success.some((check) => check.enabled !== false)) return null;
2899
+ const key = preparationKey({
2900
+ scope: opts.reuse.scope,
2901
+ profileId: reference.profileId,
2902
+ accountId: reference.accountId ?? auth.accountName,
2903
+ environment: opts.reuse.environment ?? null,
2904
+ baseUrl: opts.baseUrl,
2905
+ browser: JSON.stringify(opts.environment ?? {}),
2906
+ auth,
2907
+ secrets: secretsBehind(auth).map((ref) => opts.resolveSecret?.(ref))
2908
+ });
2909
+ const state = opts.reuse.cache.get(key);
2910
+ return { key, auth, ...state ? { state } : {} };
2436
2911
  }
2437
- function normalizeStartUrl(input) {
2438
- const raw = (input ?? "").trim();
2439
- if (!raw) throw new Error("enter a URL to record against");
2440
- let candidate;
2441
- if (hasScheme(raw)) {
2442
- candidate = raw;
2443
- } else {
2444
- const bare = raw.replace(/^\/+/, "");
2445
- const host = bare.split(/[/?#]/)[0] ?? "";
2446
- candidate = `${isLocalHost(host.split(":")[0] ?? "") ? "http" : "https"}://${bare}`;
2912
+ var RunActors = class {
2913
+ constructor(main, opts) {
2914
+ this.main = main;
2915
+ this.opts = opts;
2916
+ this.original = main;
2917
+ this.current = main;
2918
+ this.currentId = opts.defaultActorId ?? "default";
2919
+ this.sessions.set(this.currentId, main);
2447
2920
  }
2448
- let parsed;
2921
+ main;
2922
+ opts;
2923
+ values = /* @__PURE__ */ new Map();
2924
+ sessions = /* @__PURE__ */ new Map();
2925
+ initialized = /* @__PURE__ */ new Set();
2926
+ /** How many times each participant has been started over, so each round's
2927
+ * evidence has a place of its own. */
2928
+ resets = /* @__PURE__ */ new Map();
2929
+ /** Sessions already closed and already collected. */
2930
+ retired = /* @__PURE__ */ new Set();
2931
+ /** Where each session writes its evidence, relative to the run's directory.
2932
+ * Fixed when the context is created — a video cannot be redirected later —
2933
+ * so it is remembered rather than recomputed at close. */
2934
+ homes = /* @__PURE__ */ new Map();
2935
+ /** How each participant was signed in: freshly, from a kept session, or
2936
+ * freshly after a kept one turned out to be stale. */
2937
+ preparations = /* @__PURE__ */ new Map();
2938
+ original;
2939
+ current;
2940
+ currentId;
2941
+ all() {
2942
+ return [...this.sessions.values()];
2943
+ }
2944
+ evidenceIn(suffix) {
2945
+ return this.opts.evidenceDir ? { evidenceDir: path3.join(this.opts.evidenceDir, suffix) } : {};
2946
+ }
2947
+ /** What to call a participant in a report. The id is a slug nobody chose to
2948
+ * read; the name is what they typed when they created it. */
2949
+ nameOf(id) {
2950
+ if (!id || id === "default") return this.opts.actors?.find((a) => a.id === (id ?? "default"))?.name ?? "the main participant";
2951
+ return this.opts.actors?.find((a) => a.id === id)?.name ?? id;
2952
+ }
2953
+ async select(id, startUrl = this.opts.startUrl) {
2954
+ const actor = this.opts.actors?.find((a) => a.id === id);
2955
+ if (id !== "default" && !actor) throw new Error(`Unknown actor: ${id}`);
2956
+ const kept = !actor?.startSignedIn ? null : this.sessions.has(id) ? this.opts.hydrated?.id === id ? this.opts.hydrated : null : keptSessionFor(this.opts, actor.startSignedIn);
2957
+ let session = this.sessions.get(id);
2958
+ if (!session) {
2959
+ session = await openRunSession({
2960
+ ...this.opts,
2961
+ browser: this.main.browser,
2962
+ ...this.evidenceIn(`actor-${id}`),
2963
+ ...kept?.state ? { storageState: kept.state } : {}
2964
+ });
2965
+ this.homes.set(session, `actor-${id}`);
2966
+ this.sessions.set(id, session);
2967
+ await this.opts.onCreate?.(session);
2968
+ }
2969
+ this.current = session;
2970
+ this.currentId = id;
2971
+ if (this.initialized.has(id)) return;
2972
+ await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2973
+ if (actor?.startSignedIn) {
2974
+ if (kept?.state) {
2975
+ const confirmed = await confirmSignedIn(session.page, kept.auth, this.opts.reuseCheckMs ?? 3e3);
2976
+ if (confirmed.ok) {
2977
+ this.preparations.set(id, "reused");
2978
+ this.initialized.add(id);
2979
+ return;
2980
+ }
2981
+ this.opts.reuse?.cache.evict(kept.key);
2982
+ this.preparations.set(id, "stale");
2983
+ session = await this.replaceWithGuest(id, session);
2984
+ await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2985
+ }
2986
+ await executeStep(
2987
+ session.page,
2988
+ {
2989
+ id: `prepare:${id}`,
2990
+ type: "signIn",
2991
+ auth: actor.startSignedIn,
2992
+ name: `Prepare ${actor.name}`,
2993
+ role: "",
2994
+ scope: "",
2995
+ value: "",
2996
+ secret: false,
2997
+ legacySelector: "",
2998
+ proposals: []
2999
+ },
3000
+ {
3001
+ pages: session.pages,
3002
+ baseUrl: this.opts.baseUrl,
3003
+ ...this.opts.resolveAuth ? { resolveAuth: this.opts.resolveAuth } : {},
3004
+ ...this.opts.resolveSecret ? { resolveSecret: this.opts.resolveSecret } : {}
3005
+ }
3006
+ );
3007
+ if (!this.preparations.has(id)) this.preparations.set(id, "fresh");
3008
+ if (kept) {
3009
+ try {
3010
+ this.opts.reuse?.cache.put(kept.key, await session.context.storageState());
3011
+ } catch {
3012
+ }
3013
+ }
3014
+ await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
3015
+ }
3016
+ this.initialized.add(id);
3017
+ }
3018
+ /** Replace a participant's context without disturbing anyone else's. */
3019
+ async replaceWithGuest(id, previous) {
3020
+ const session = await openRunSession({ ...this.opts, browser: this.main.browser, ...this.evidenceIn(`actor-${id}`) });
3021
+ this.homes.set(session, `actor-${id}`);
3022
+ this.sessions.set(id, session);
3023
+ this.current = session;
3024
+ if (previous !== this.original) await previous.context.close().catch(() => {
3025
+ });
3026
+ await this.opts.onCreate?.(session);
3027
+ return session;
3028
+ }
3029
+ /** How each participant came to be signed in, for the report.
3030
+ *
3031
+ * Visible on purpose: "this suite signed in once and reused it" and "this
3032
+ * suite signed in forty times" are different tests, and somebody reading a
3033
+ * green result is entitled to know which one they ran. */
3034
+ preparedHow() {
3035
+ return Object.fromEntries(this.preparations);
3036
+ }
3037
+ /** Throw this participant's session away and give them a new signed-out one.
3038
+ *
3039
+ * NOT SIGNING OUT. An application can be signed out of while the browser
3040
+ * keeps every cookie and every token it was given, and a test of "log out,
3041
+ * then visit this page" exists to find exactly that difference. This is the
3042
+ * other thing: a browser that has never been anywhere.
3043
+ *
3044
+ * The session being replaced is CLOSED PROPERLY, not dropped. Closing the
3045
+ * context directly threw away the video and the trace of everything that
3046
+ * participant had done — which, since a reset usually follows something
3047
+ * worth understanding, is the evidence most likely to be wanted. */
3048
+ async freshGuest(id) {
3049
+ if (id !== "default" && !this.opts.actors?.some((a) => a.id === id)) throw new Error(`Unknown actor: ${id}`);
3050
+ const previous = this.sessions.get(id);
3051
+ const round = (this.resets.get(id) ?? 0) + 1;
3052
+ this.resets.set(id, round);
3053
+ const home = `actor-${id}-${round}`;
3054
+ const session = await openRunSession({ ...this.opts, browser: this.main.browser, ...this.evidenceIn(home) });
3055
+ this.homes.set(session, home);
3056
+ this.sessions.set(id, session);
3057
+ this.current = session;
3058
+ this.currentId = id;
3059
+ if (previous) {
3060
+ const where = this.homes.get(previous);
3061
+ const artifacts = await closeRunSession(
3062
+ { ...previous, ownsBrowser: false },
3063
+ { passed: true, ...where === void 0 ? {} : this.evidenceIn(where) }
3064
+ );
3065
+ if (previous !== this.original) {
3066
+ this.main.runArtifacts.push(...artifacts.map((a) => ({ ...a, relPath: where ? `${where}/${a.relPath}` : a.relPath })));
3067
+ }
3068
+ this.retired.add(previous);
3069
+ }
3070
+ await this.opts.onCreate?.(session);
3071
+ await session.page.goto(this.opts.startUrl, { waitUntil: "domcontentloaded" });
3072
+ this.initialized.add(id);
3073
+ }
3074
+ /** Back to one participant, between debug replays.
3075
+ *
3076
+ * THE SESSION THIS BEGAN WITH IS KEPT. It used to be closed and replaced,
3077
+ * and everything that reads the browser — the panel injected into that
3078
+ * context, the recorder listening on that page, the painter bound to its
3079
+ * loads — had been handed it when the session started. After one Replay
3080
+ * they were all driving a context that had been closed, and the debugger
3081
+ * answered every request with "target has been closed".
3082
+ *
3083
+ * The extra participants are what this tears down, which is what it is for.
3084
+ * Contexts only: whatever a replay recorded belongs to the replay that is
3085
+ * ending, and the debugger collects that on its own terms. */
3086
+ async reset() {
3087
+ for (const session of this.sessions.values()) {
3088
+ if (session === this.original) continue;
3089
+ await session.context.close().catch(() => {
3090
+ });
3091
+ this.homes.delete(session);
3092
+ }
3093
+ this.main = this.original;
3094
+ this.current = this.original;
3095
+ this.currentId = this.opts.defaultActorId ?? "default";
3096
+ this.sessions = /* @__PURE__ */ new Map([[this.currentId, this.original]]);
3097
+ this.initialized.clear();
3098
+ this.retired.clear();
3099
+ this.resets.clear();
3100
+ this.values.clear();
3101
+ }
3102
+ async closeExtras(passed) {
3103
+ for (const [id, session] of this.sessions) {
3104
+ if (session === this.original || this.retired.has(session)) continue;
3105
+ const where = this.homes.get(session) ?? `actor-${id}`;
3106
+ try {
3107
+ const artifacts = await closeRunSession(session, { passed, ...this.evidenceIn(where) });
3108
+ this.main.runArtifacts.push(...artifacts.map((a) => ({ ...a, relPath: `${where}/${a.relPath}` })));
3109
+ } catch {
3110
+ await session.context.close().catch(() => {
3111
+ });
3112
+ }
3113
+ }
3114
+ }
3115
+ };
3116
+ var PASSWORD_FIELDS = 'input[type="password"]';
3117
+ async function captureFailure(opts) {
3118
+ const { page, dir, ordinal } = opts;
3119
+ let url = "";
3120
+ let title = "";
2449
3121
  try {
2450
- parsed = new URL(candidate);
3122
+ url = page.url();
3123
+ title = await page.title();
2451
3124
  } catch {
2452
- throw new Error(`"${raw}" is not a valid URL`);
2453
3125
  }
2454
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2455
- throw new Error(`only http:// and https:// URLs can be recorded (got ${parsed.protocol}//)`);
3126
+ let screenshot = null;
3127
+ try {
3128
+ const file = `step-${String(ordinal).padStart(2, "0")}.png`;
3129
+ fs4.mkdirSync(dir, { recursive: true });
3130
+ await page.screenshot({
3131
+ path: path22.join(dir, file),
3132
+ // The viewport, not the full page: a failure is about what was on screen,
3133
+ // and a full-page capture of an infinite-scroll app is a 20MB image.
3134
+ fullPage: false,
3135
+ mask: masks(opts),
3136
+ // Playwright's default is magenta, which reads as a rendering fault
3137
+ // rather than as a deliberate redaction. A flat slate box is obviously
3138
+ // something someone chose to cover up.
3139
+ maskColor: "#475569",
3140
+ timeout: 5e3
3141
+ });
3142
+ screenshot = file;
3143
+ } catch {
3144
+ screenshot = null;
2456
3145
  }
2457
- if (!parsed.hostname) throw new Error(`"${raw}" has no host`);
2458
- return parsed.toString();
3146
+ return { screenshot, url, title };
2459
3147
  }
2460
- function resolveStartUrl(baseUrl, startPath) {
2461
- const base = normalizeStartUrl(baseUrl);
2462
- return new URL(startPath || "/", base).toString();
3148
+ function maskTargets(steps = []) {
3149
+ const frames = /* @__PURE__ */ new Set([JSON.stringify([])]);
3150
+ for (const step of steps) {
3151
+ if (step.frame?.length) frames.add(JSON.stringify(step.frame));
3152
+ }
3153
+ const targets = [...frames].map((chain) => {
3154
+ const frame = JSON.parse(chain);
3155
+ return frame.length ? { by: "selector", value: PASSWORD_FIELDS, frame } : { by: "selector", value: PASSWORD_FIELDS };
3156
+ });
3157
+ const seen = /* @__PURE__ */ new Set();
3158
+ for (const step of steps) {
3159
+ const name = step.name.trim();
3160
+ if (!step.secret || !name) continue;
3161
+ const key = `${JSON.stringify(step.frame ?? [])}|${name}`;
3162
+ if (seen.has(key)) continue;
3163
+ seen.add(key);
3164
+ targets.push(step.frame?.length ? { by: "label", value: name, frame: step.frame } : { by: "label", value: name });
3165
+ }
3166
+ return targets;
2463
3167
  }
2464
- var INPUT_ROLES = /* @__PURE__ */ new Set(["textbox", "combobox", "checkbox", "radio", "spinbutton", "searchbox"]);
2465
- var NAVIGATIONS = /* @__PURE__ */ new Set(["back", "forward"]);
2466
- function locatorPlan(step) {
2467
- const o = step.locator;
2468
- if (o && o.value.trim()) {
2469
- const value = o.value.trim();
2470
- switch (o.strategy) {
2471
- // Playwright reads a leading `xpath=` prefix, and also bare paths that
2472
- // start with `/` or `//`. Normalising here means the QA can paste either.
2473
- case "xpath":
2474
- return { method: "locator", arg: value.startsWith("xpath=") ? value : `xpath=${value}`, overridden: true };
2475
- // Typed as a bare id, so nobody has to remember the `#`.
2476
- case "id":
2477
- return { method: "locator", arg: /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(value.replace(/^#/, "")) ? `#${value.replace(/^#/, "")}` : `[id=${JSON.stringify(value.replace(/^#/, ""))}]`, overridden: true };
2478
- case "css":
2479
- return { method: "locator", arg: value, overridden: true };
2480
- case "testId":
2481
- return { method: "getByTestId", arg: value, overridden: true };
2482
- case "text":
2483
- return { method: "getByText", arg: value, exact: true, overridden: true };
2484
- case "label":
2485
- return { method: "getByLabel", arg: value, exact: true, overridden: true };
2486
- case "placeholder":
2487
- return { method: "getByPlaceholder", arg: value, exact: true, overridden: true };
2488
- case "altText":
2489
- return { method: "getByAltText", arg: value, exact: true, overridden: true };
2490
- case "title":
2491
- return { method: "getByTitle", arg: value, exact: true, overridden: true };
3168
+ function masks(opts) {
3169
+ const { page } = opts;
3170
+ return maskTargets(opts.steps).map((t) => {
3171
+ let root = page;
3172
+ for (const frame of t.frame ?? []) root = root.frameLocator(frameSelector(frame));
3173
+ return t.by === "selector" ? root.locator(t.value) : root.getByLabel(t.value, { exact: true });
3174
+ });
3175
+ }
3176
+ async function finaliseArtifacts(opts) {
3177
+ const { context, dir, passed, artifacts, consoleLines } = opts;
3178
+ const keep = (mode) => mode === "always" || mode === "on-failure" && !passed;
3179
+ const out = [];
3180
+ const size = (file) => {
3181
+ try {
3182
+ return fs4.statSync(path22.join(dir, file)).size;
3183
+ } catch {
3184
+ return null;
3185
+ }
3186
+ };
3187
+ if (artifacts.trace !== "off") {
3188
+ const file = "trace.zip";
3189
+ try {
3190
+ if (keep(artifacts.trace)) {
3191
+ fs4.mkdirSync(dir, { recursive: true });
3192
+ await context.tracing.stop({ path: path22.join(dir, file) });
3193
+ out.push({ kind: "trace", relPath: file, bytes: size(file) });
3194
+ } else {
3195
+ await context.tracing.stop();
3196
+ }
3197
+ } catch {
2492
3198
  }
2493
3199
  }
2494
- if (NAVIGATIONS.has(step.type)) {
2495
- return { method: step.type === "back" ? "goBack" : "goForward", arg: "", overridden: false };
3200
+ if (artifacts.console && consoleLines.length) {
3201
+ const file = "console.log";
3202
+ try {
3203
+ fs4.mkdirSync(dir, { recursive: true });
3204
+ fs4.writeFileSync(path22.join(dir, file), consoleLines.join("\n") + "\n");
3205
+ out.push({ kind: "console", relPath: file, bytes: size(file) });
3206
+ } catch {
3207
+ }
2496
3208
  }
2497
- if (!step.name.trim()) return { method: "none", arg: "", overridden: false };
2498
- if (step.nameFrom === "adjacent-label") {
2499
- return {
2500
- method: "getByText",
2501
- arg: step.name,
2502
- exact: true,
2503
- overridden: false,
2504
- viaAdjacentLabel: true
2505
- };
3209
+ return out;
3210
+ }
3211
+ function collectVideos(dir, keep) {
3212
+ let files;
3213
+ try {
3214
+ files = fs4.readdirSync(dir).filter((f) => f.endsWith(".webm"));
3215
+ } catch {
3216
+ return [];
2506
3217
  }
2507
- if (step.nameFrom === "text") {
2508
- return {
2509
- method: "getByText",
2510
- arg: step.name,
2511
- exact: step.type !== "hover",
2512
- overridden: false,
2513
- visible: true
2514
- };
3218
+ if (!keep) {
3219
+ for (const f of files) fs4.rmSync(path22.join(dir, f), { force: true });
3220
+ return [];
2515
3221
  }
2516
- return INPUT_ROLES.has(step.role) ? { method: "getByLabel", arg: step.name, exact: true, overridden: false, inputRole: step.role } : step.role ? { method: "getByRole", arg: step.role, name: step.name, exact: true, overridden: false } : { method: "getByText", arg: step.name, exact: step.type !== "hover", overridden: false, visible: true };
2517
- }
2518
- function frameSelector(name) {
2519
- const positional = /^nth:(\d+)$/.exec(name);
2520
- if (positional) return `iframe, frame >> nth=${positional[1]}`;
2521
- const v = JSON.stringify(name);
2522
- return `iframe[title=${v}], iframe[name=${v}], iframe[id=${v}], frame[title=${v}], frame[name=${v}], frame[id=${v}]`;
2523
- }
2524
- function inputSelector(role) {
2525
- return role === "textbox" ? 'input, textarea, [contenteditable="true"], [role="textbox"]' : `input, select, [role="${role}"]`;
3222
+ return files.map((f) => {
3223
+ let bytes = null;
3224
+ try {
3225
+ bytes = fs4.statSync(path22.join(dir, f)).size;
3226
+ } catch {
3227
+ bytes = null;
3228
+ }
3229
+ return { kind: "video", relPath: f, bytes };
3230
+ });
2526
3231
  }
2527
- var SCOPE_ROLES = ["form", "group", "region", "dialog", "alertdialog"];
2528
- var CHECKS = [
2529
- { id: "visible", label: "is visible", kind: "visible", group: "state" },
2530
- { id: "not-visible", label: "is not visible", kind: "visible", not: true, group: "state" },
2531
- { id: "attached", label: "exists on the page", kind: "attached", group: "state" },
2532
- { id: "not-attached", label: "does not exist", kind: "attached", not: true, group: "state" },
2533
- { id: "enabled", label: "is enabled", kind: "enabled", group: "state" },
2534
- { id: "disabled", label: "is disabled", kind: "enabled", not: true, group: "state" },
2535
- { id: "editable", label: "is editable", kind: "editable", group: "state" },
2536
- { id: "readonly", label: "is read-only", kind: "editable", not: true, group: "state" },
2537
- { id: "focused", label: "is focused", kind: "focused", group: "state" },
2538
- { id: "checked", label: "is checked", kind: "checked", value: "true", group: "state" },
2539
- { id: "unchecked", label: "is not checked", kind: "checked", value: "false", group: "state" },
2540
- { id: "text", label: "text is exactly", kind: "text", input: true, group: "text" },
2541
- { id: "text-contains", label: "text contains", kind: "text", op: "contains", input: true, group: "text" },
2542
- { id: "text-matches", label: "text matches pattern", kind: "text", op: "matches", input: true, group: "text" },
2543
- { id: "text-not", label: "text is not", kind: "text", not: true, input: true, group: "text" },
2544
- { id: "text-not-contains", label: "text does not contain", kind: "text", op: "contains", not: true, input: true, group: "text" },
2545
- { id: "value", label: "value is exactly", kind: "value", input: true, group: "value" },
2546
- { id: "value-contains", label: "value contains", kind: "value", op: "contains", input: true, group: "value" },
2547
- { id: "value-not", label: "value is not", kind: "value", not: true, input: true, group: "value" },
2548
- { id: "attr", label: "attribute is", kind: "attribute", input: true, attr: true, group: "attributes" },
2549
- { id: "attr-contains", label: "attribute contains", kind: "attribute", op: "contains", input: true, attr: true, group: "attributes" },
2550
- { id: "attr-not", label: "attribute is not", kind: "attribute", not: true, input: true, attr: true, group: "attributes" },
2551
- { id: "class", label: "has class", kind: "class", input: true, group: "attributes" },
2552
- { id: "not-class", label: "does not have class", kind: "class", not: true, input: true, group: "attributes" },
2553
- { id: "count", label: "how many \xB7 is exactly", kind: "count", input: true, roleOnly: true, group: "count" },
2554
- { id: "count-gte", label: "how many \xB7 is at least", kind: "count", op: "gte", input: true, roleOnly: true, group: "count" },
2555
- { id: "count-lte", label: "how many \xB7 is at most", kind: "count", op: "lte", input: true, roleOnly: true, group: "count" },
2556
- { id: "url", label: "page URL is", kind: "url", input: true, page: true, group: "page" },
2557
- { id: "url-contains", label: "page URL contains", kind: "url", op: "contains", input: true, page: true, group: "page" },
2558
- { id: "title", label: "page title is", kind: "title", input: true, page: true, group: "page" },
2559
- { id: "title-contains", label: "page title contains", kind: "title", op: "contains", input: true, page: true, group: "page" }
2560
- ];
2561
- function matchCheck(a) {
2562
- const op = a.op ?? "equals";
2563
- const value = Array.isArray(a.value) ? a.value.join(", ") : a.value;
2564
- return CHECKS.find(
2565
- (c) => c.kind === a.kind && (c.op ?? "equals") === op && Boolean(c.not) === Boolean(a.not) && // A fixed-value entry (is checked / is not checked) only matches its own
2566
- // value, or the two would be indistinguishable.
2567
- (c.value === void 0 || c.value === value)
2568
- );
2569
- }
2570
- var PROPOSED_LABELS = {
2571
- screen: (v) => `screen is \u201C${v}\u201D`,
2572
- heading: (v) => `heading is \u201C${v}\u201D`,
2573
- message: (v) => `message \u201C${v}\u201D is shown`,
2574
- fields_visible: (v) => `fields visible: ${v}`
2575
- };
2576
- function describeAssertion(a, subject) {
2577
- const value = Array.isArray(a.value) ? a.value.join(", ") : a.value;
2578
- const proposed = PROPOSED_LABELS[a.kind];
2579
- if (proposed && !a.target) return proposed(value);
2580
- const check = matchCheck(a);
2581
- const who = a.target?.name || subject || "";
2582
- if (!check) {
2583
- return `${a.kind}${a.op && a.op !== "equals" ? ` ${a.op}` : ""}${a.not ? " not" : ""}${value ? ` \u201C${value}\u201D` : ""}`;
2584
- }
2585
- const attr = check.attr && a.attr ? ` ${a.attr}` : "";
2586
- const expects = check.input ? ` \u201C${value}\u201D` : "";
2587
- if (check.page) return `${check.label}${expects}`;
2588
- if (check.roleOnly) {
2589
- const role = a.target?.role || "element";
2590
- return `${role}s \xB7 ${check.label.replace("how many \xB7 ", "")}${expects}`;
2591
- }
2592
- const label = check.label.replace("attribute", `attribute${attr}`);
2593
- return who ? `\u201C${who}\u201D ${label}${expects}` : `${label}${expects}`;
2594
- }
2595
-
2596
- // ../engine-playwright/dist/index.js
2597
- import "playwright";
2598
- import { errors } from "playwright";
2599
- import fs22 from "fs";
2600
- import path22 from "path";
2601
- import "playwright";
2602
- import fs32 from "fs";
2603
- import "playwright";
2604
3232
  var DEFAULT_ENVIRONMENT = {
2605
3233
  browser: "chromium",
2606
3234
  viewport: { width: 1280, height: 720 },
@@ -2652,12 +3280,12 @@ function resolveCapture(options = {}) {
2652
3280
  };
2653
3281
  }
2654
3282
  var forcedHeadless = () => process.env["FLOWRIKI_FORCE_HEADLESS"] === "1";
2655
- async function launchSession(environment = {}, headlessRequested = true, slowMo = 0, capture = {}) {
3283
+ async function launchSession(environment = {}, headlessRequested = true, slowMo = 0, capture = {}, existingBrowser, storageState) {
2656
3284
  const headless = headlessRequested || forcedHeadless();
2657
3285
  const config = { ...DEFAULT_ENVIRONMENT, ...ExecutionEnvironmentSchema.parse(environment) };
2658
3286
  const fullWindow = config.fullWindow === true && !headless;
2659
3287
  const engine = { chromium, firefox, webkit }[config.browser];
2660
- const browser = await engine.launch({
3288
+ const browser = existingBrowser ?? await engine.launch({
2661
3289
  headless,
2662
3290
  slowMo,
2663
3291
  // Chromium's flag. Firefox and WebKit have no equivalent, and `viewport:
@@ -2673,6 +3301,7 @@ async function launchSession(environment = {}, headlessRequested = true, slowMo
2673
3301
  locale,
2674
3302
  timezoneId,
2675
3303
  colorScheme,
3304
+ ...storageState ? { storageState } : {},
2676
3305
  // Playwright can only be told to record at context creation, so a video
2677
3306
  // wanted "on failure" is recorded for every run and thrown away when the
2678
3307
  // run passes. There is no way to start one after the fact.
@@ -2691,7 +3320,7 @@ async function launchSession(environment = {}, headlessRequested = true, slowMo
2691
3320
  if (config.openClosedShadowRoots) await context.addInitScript(openShadowRoots);
2692
3321
  return { browser, context, config, artifacts };
2693
3322
  } catch (error) {
2694
- await browser.close();
3323
+ if (!existingBrowser) await browser.close();
2695
3324
  throw error;
2696
3325
  }
2697
3326
  }
@@ -2851,710 +3480,121 @@ async function locateDrop(page, step) {
2851
3480
  to: void 0
2852
3481
  });
2853
3482
  }
2854
- var OUTCOME_WINDOW_MS = 2500;
2855
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2856
- var COMMITS = /* @__PURE__ */ new Set([
2857
- "click",
2858
- "dblclick",
2859
- "rightclick",
2860
- "drag",
2861
- "check",
2862
- "press",
2863
- "upload",
2864
- "back",
2865
- "forward"
2866
- ]);
2867
- var cachedAccNameScript = null;
2868
- var ACCNAME_ASSET = "accname.global.js";
2869
- function accNameScript() {
2870
- if (cachedAccNameScript === null) {
2871
- cachedAccNameScript = fs4.readFileSync(accNameScriptPath(), "utf8");
2872
- }
2873
- return cachedAccNameScript;
3483
+ var STATES = {
3484
+ visible: "visible",
3485
+ hidden: "visible",
3486
+ attached: "attached",
3487
+ enabled: "enabled",
3488
+ editable: "editable",
3489
+ focused: "focused",
3490
+ checked: "checked"
3491
+ };
3492
+ var TEXT_KINDS = /* @__PURE__ */ new Set(["text", "message", "screen", "heading"]);
3493
+ var PAGE_KINDS = /* @__PURE__ */ new Set(["url", "title"]);
3494
+ function absenceSatisfies(state, want) {
3495
+ return !want && (state === "attached" || state === "visible");
2874
3496
  }
2875
- function accNameScriptPath() {
3497
+ function firstLine(err) {
3498
+ return String(err instanceof Error ? err.message : err).split("\n")[0].trim();
3499
+ }
3500
+ function ambiguityDetail(count) {
3501
+ return `resolves to ${count} elements \u2014 nothing on the page tells them apart`;
3502
+ }
3503
+ async function presenceOf(target) {
3504
+ let count;
2876
3505
  try {
2877
- return createRequire2(import.meta.url).resolve("@flowriki/recorder/accname.global.js");
2878
- } catch {
2879
- const beside = path3.join(path3.dirname(fileURLToPath(import.meta.url)), ACCNAME_ASSET);
2880
- if (fs4.existsSync(beside)) return beside;
3506
+ count = await target.count();
3507
+ } catch (err) {
3508
+ return { outcome: "invalid", detail: firstLine(err) };
3509
+ }
3510
+ if (count === 0) return { outcome: "absent" };
3511
+ if (count > 1) return { outcome: "ambiguous", count };
3512
+ return { outcome: "present", locator: target };
3513
+ }
3514
+ async function readState(target, state, timeout) {
3515
+ if (state === "visible") return target.isVisible();
3516
+ if (state === "attached") return await target.count() > 0;
3517
+ if (state === "enabled") return target.isEnabled({ timeout });
3518
+ if (state === "editable") return target.isEditable({ timeout });
3519
+ if (state === "checked") return target.isChecked({ timeout });
3520
+ return target.evaluate((el) => el === el.ownerDocument.activeElement);
3521
+ }
3522
+ function compare(actual, expected, op) {
3523
+ switch (op) {
3524
+ case "contains":
3525
+ return actual.includes(expected);
3526
+ case "matches":
3527
+ return new RegExp(expected).test(actual);
3528
+ case "gt":
3529
+ return Number(actual) > Number(expected);
3530
+ case "gte":
3531
+ return Number(actual) >= Number(expected);
3532
+ case "lt":
3533
+ return Number(actual) < Number(expected);
3534
+ case "lte":
3535
+ return Number(actual) <= Number(expected);
3536
+ default:
3537
+ return actual === expected;
3538
+ }
3539
+ }
3540
+ var OP_WORDS = {
3541
+ equals: ["is", "is not"],
3542
+ contains: ["contains", "does not contain"],
3543
+ matches: ["matches", "does not match"],
3544
+ gt: ["is more than", "is not more than"],
3545
+ gte: ["is at least", "is less than"],
3546
+ lt: ["is fewer than", "is not fewer than"],
3547
+ lte: ["is at most", "is more than"]
3548
+ };
3549
+ var DEFAULT_OPS = { message: "contains" };
3550
+ function explain(r) {
3551
+ switch (r.outcome) {
3552
+ case "absent":
3553
+ return { detail: "no element matched", kind: "not_found" };
3554
+ case "ambiguous":
3555
+ return { detail: ambiguityDetail(r.count), kind: "ambiguous" };
3556
+ case "invalid":
3557
+ return { detail: `the locator could not be used \u2014 ${r.detail}`, kind: "unknown" };
3558
+ case "unreadable":
3559
+ return { detail: `the element could not be read \u2014 ${r.detail}`, kind: "unknown" };
3560
+ case "not_visible":
3561
+ return { detail: "the element is present but not visible", kind: null };
3562
+ default:
3563
+ return { detail: "", kind: null };
3564
+ }
3565
+ }
3566
+ function fail(message, r) {
3567
+ const { detail, kind } = explain(r);
3568
+ throw Object.assign(new Error(detail ? `${message} \u2014 ${detail}` : message), kind ? { kind } : {});
3569
+ }
3570
+ async function checkAssertion(page, kind, value, timeoutMs = 3e3, assertion) {
3571
+ const deadline = Date.now() + timeoutMs;
3572
+ const remaining = () => Math.max(1, deadline - Date.now());
3573
+ const op = assertion?.op ?? DEFAULT_OPS[kind] ?? "equals";
3574
+ const negated = assertion?.not === true;
3575
+ const expected = String(value);
3576
+ const asStep = (t) => ({
3577
+ id: "assertion",
3578
+ type: "click",
3579
+ value: "",
3580
+ secret: false,
3581
+ legacySelector: "",
3582
+ proposals: [],
3583
+ scope: "",
3584
+ ...t
3585
+ });
3586
+ const NEEDS_TARGET = /* @__PURE__ */ new Set(["count", "attribute", "class", "value", "text", ...Object.keys(STATES)]);
3587
+ if (NEEDS_TARGET.has(kind) && !assertion?.target) throw new Error(`${kind} assertion needs an element target`);
3588
+ if (negated && !assertion?.target && (TEXT_KINDS.has(kind) || kind === "fields_visible")) {
2881
3589
  throw new Error(
2882
- `this build is missing ${ACCNAME_ASSET}, which recording injects into the page \u2014 reinstall the agent`
3590
+ `a negated ${kind} assertion needs an element target \u2014 choose the element in the editor, or use a "hidden" check to assert that something has gone`
2883
3591
  );
2884
3592
  }
2885
- }
2886
- async function startRecordingSession(opts) {
2887
- const { headless = false, onStep, onState, onDisconnect, onStopRequested } = opts;
2888
- const url = normalizeStartUrl(opts.url);
2889
- const { browser, context, config } = await launchSession(opts.environment, headless);
2890
- const steps = [];
2891
- let pendingScrollPos = null;
2892
- const pageIds = /* @__PURE__ */ new Map();
2893
- let nextPageId = 0;
2894
- let pendingDialog = null;
2895
- let stopped = false;
2896
- let paused = false;
2897
- const frameChains = /* @__PURE__ */ new Map();
2898
- const frameLabel = async (frame) => {
2899
- const el = await frame.frameElement().catch(() => null);
2900
- if (el) {
2901
- for (const attr of ["title", "name", "id"]) {
2902
- const value = (await el.getAttribute(attr).catch(() => null))?.trim();
2903
- if (value) return value;
2904
- }
2905
- }
2906
- const named = frame.name();
2907
- if (named) return named;
2908
- const siblings = frame.parentFrame()?.childFrames() ?? [];
2909
- return `nth:${Math.max(0, siblings.indexOf(frame))}`;
2910
- };
2911
- const noteFrame = async (frame) => {
2912
- try {
2913
- const chain = [];
2914
- let f = frame;
2915
- while (f && f.parentFrame()) {
2916
- chain.unshift(await frameLabel(f));
2917
- f = f.parentFrame();
2918
- }
2919
- frameChains.set(frame, chain);
2920
- } catch {
2921
- }
2922
- };
2923
- const watchFrames = (page2) => {
2924
- for (const f of page2.frames()) void noteFrame(f);
2925
- page2.on("frameattached", (f) => void noteFrame(f));
2926
- page2.on("framenavigated", (f) => void noteFrame(f));
2927
- };
2928
- const snapshot = () => ({
2929
- status: "recording",
2930
- cursor: null,
2931
- breakpoints: [],
2932
- steps: steps.map((x) => ({ ...x })),
2933
- results: {},
2934
- failure: null,
2935
- recording: !paused,
2936
- recorded: steps.map((x) => x.id),
2937
- mode: "record",
2938
- ...pendingDialog ? { dialog: { type: pendingDialog.dialog.type(), message: pendingDialog.dialog.message(), defaultValue: pendingDialog.dialog.defaultValue() } } : {}
2939
- });
2940
- const paint = () => {
2941
- const safe2 = {
2942
- ...snapshot(),
2943
- // The characters typed into a password never leave this process.
2944
- steps: snapshot().steps.map((st) => ({
2945
- id: st.id,
2946
- type: st.type,
2947
- name: st.name,
2948
- scope: st.scope,
2949
- value: st.secret ? "" : st.value,
2950
- secret: st.secret,
2951
- // The sentence, written here rather than in the page: the catalogue's
2952
- // wording lives in core, and a panel that phrased checks its own way
2953
- // would say something different from the flow editor about the same
2954
- // step.
2955
- ...st.type === "assert" && st.proposals[0] ? { detail: describeAssertion(st.proposals[0], st.name) } : {}
2956
- }))
2957
- };
2958
- for (const p of context.pages()) {
2959
- p.evaluate(
2960
- (v) => window.__flDebugRender?.(v),
2961
- safe2
2962
- ).catch(() => {
2963
- });
2964
- }
2965
- onState?.(snapshot());
2966
- };
2967
- const toPanel = async (msg) => {
2968
- await context.pages()[0]?.evaluate(
2969
- (v) => window.__flPanelMessage?.(v),
2970
- msg
2971
- ).catch(() => {
2972
- });
2973
- };
2974
- let signature = null;
2975
- browser.on("disconnected", () => {
2976
- if (!stopped) onDisconnect?.();
2977
- });
2978
- let pending = null;
2979
- const finish = (after) => {
2980
- if (!pending) return;
2981
- const { step, before, timer } = pending;
2982
- clearTimeout(timer);
2983
- pending = null;
2984
- step.proposals = proposeAssertions(before, after).map(
2985
- (p) => (
2986
- // Inferred from a text diff, so it arrives unticked for a human to
2987
- // confirm. Everything else the page stated about itself and is safe to
2988
- // run as recorded.
2989
- p.confidence === "low" ? { ...p, enabled: false } : p
2990
- )
2991
- );
2992
- onStep?.(step);
2993
- };
2994
- const consider = (sig) => {
2995
- signature = sig;
2996
- if (!pending) return;
2997
- if (hasObservableChange(pending.before, sig)) finish(sig);
2998
- };
2999
- const begin = (step, before) => {
3000
- finish(signature);
3001
- const timer = setTimeout(() => finish(signature), OUTCOME_WINDOW_MS);
3002
- timer.unref?.();
3003
- pending = { step, before, timer };
3004
- };
3005
- const targetIds = /* @__PURE__ */ new Map();
3006
- const record = (payload, before) => {
3007
- if (["fill", "select", "setRange"].includes(payload.type)) {
3008
- const prev = steps[steps.length - 1];
3009
- if (prev && prev.type === payload.type && targetIds.get(prev.id) === payload.__targetId && payload.__targetId && prev.pageId === payload.pageId && JSON.stringify(prev.frame) === JSON.stringify(payload.frame)) {
3010
- prev.value = payload.value ?? "";
3011
- if (payload.values) prev.values = payload.values;
3012
- onStep?.(prev);
3013
- paint();
3014
- return;
3015
- }
3016
- }
3017
- if (payload.type === "scroll") {
3018
- pendingScrollPos = payload.scroll;
3019
- return;
3020
- }
3021
- if (payload.type === "dblclick") {
3022
- const prev = steps[steps.length - 1];
3023
- if (prev && prev.type === "click" && prev.name === payload.name && prev.scope === payload.scope) {
3024
- steps.pop();
3025
- }
3026
- }
3027
- const step = {
3028
- id: `st_${crypto.randomUUID().slice(0, 8)}`,
3029
- type: payload.type,
3030
- role: payload.role ?? "",
3031
- name: payload.name ?? "",
3032
- ...payload.nameFrom ? { nameFrom: payload.nameFrom } : {},
3033
- scope: payload.scope ?? "",
3034
- ...payload.scopeRole ? { scopeRole: payload.scopeRole } : {},
3035
- ...payload.frame?.length ? { frame: payload.frame } : {},
3036
- ...payload.to ? { to: payload.to } : {},
3037
- ...payload.locator ? { locator: payload.locator } : {},
3038
- ...payload.values ? { values: payload.values } : {},
3039
- ...payload.warning ? { warning: payload.warning } : {},
3040
- ...payload.pageId ? { pageId: payload.pageId } : {},
3041
- ...payload.scroll ? { scroll: payload.scroll } : {},
3042
- value: payload.value ?? "",
3043
- secret: Boolean(payload.secret),
3044
- legacySelector: payload.legacySelector ?? "",
3045
- proposals: []
3046
- };
3047
- RecordedStepSchema.parse(step);
3048
- targetIds.set(step.id, payload.__targetId);
3049
- if (pendingScrollPos) {
3050
- step.scroll = pendingScrollPos;
3051
- pendingScrollPos = null;
3052
- }
3053
- steps.push(step);
3054
- onStep?.(step);
3055
- paint();
3056
- if (COMMITS.has(step.type)) begin(step, before);
3057
- };
3058
- const PRESS_GRACE_MS = 200;
3059
- let held = null;
3060
- const releaseHeld = () => {
3061
- if (!held) return;
3062
- const { payload, before, timer } = held;
3063
- clearTimeout(timer);
3064
- held = null;
3065
- record(payload, before);
3066
- };
3067
- await context.exposeBinding("__flRecord", async (src, payload) => {
3068
- if (paused || !payload || stopped && payload.__sig === void 0) return;
3069
- if (src.frame && !frameChains.has(src.frame)) await noteFrame(src.frame);
3070
- if (src.page) payload.pageId = pageIds.get(src.page) ?? "main";
3071
- if (payload && payload.__sig !== void 0) {
3072
- if (!pending || pending.step.pageId === payload.pageId) consider(payload.__sig || null);
3073
- return;
3074
- }
3075
- const before = payload.__before ?? signature;
3076
- delete payload.__before;
3077
- const chain = src?.frame ? frameChains.get(src.frame) : void 0;
3078
- if (chain?.length) payload.frame = chain;
3079
- if (payload.type === "press" && payload.value === "Enter") {
3080
- releaseHeld();
3081
- const timer = setTimeout(releaseHeld, PRESS_GRACE_MS);
3082
- timer.unref?.();
3083
- held = { payload, before, timer };
3084
- return;
3085
- }
3086
- if (payload.type === "click" && held) {
3087
- clearTimeout(held.timer);
3088
- held = null;
3089
- } else if (held && payload.name !== held.payload.name) {
3090
- releaseHeld();
3091
- }
3092
- record(payload, before);
3093
- });
3094
- await context.exposeBinding("__flPicked", async (src, payload) => {
3095
- if (src.frame && !frameChains.has(src.frame)) await noteFrame(src.frame);
3096
- if (payload?.cancelled) {
3097
- void session.setPicking(false);
3098
- void toPanel({ picked: null });
3099
- return;
3100
- }
3101
- if (!payload?.target) return;
3102
- const chain = src?.frame ? frameChains.get(src.frame) : void 0;
3103
- if (chain?.length) payload.target.frame = chain;
3104
- if (src.page) payload.target.pageId = pageIds.get(src.page) ?? "main";
3105
- try {
3106
- const on = src.page ?? page;
3107
- const all = await locateAll(on, {
3108
- id: "pick",
3109
- type: "click",
3110
- value: "",
3111
- secret: false,
3112
- legacySelector: "",
3113
- proposals: [],
3114
- scope: "",
3115
- ...payload.target
3116
- });
3117
- const handles = await all.elementHandles();
3118
- if (handles.length > 1) {
3119
- for (const [i, handle] of handles.entries()) {
3120
- const isIt = await handle.evaluate((el) => el === window.__flPickedEl).catch(() => false);
3121
- if (isIt) {
3122
- payload.target.nth = i;
3123
- break;
3124
- }
3125
- }
3126
- if (payload.target.nth === void 0) {
3127
- payload.ambiguous = handles.length;
3128
- }
3129
- } else if (handles.length === 0) {
3130
- payload.unresolvable = true;
3131
- }
3132
- await Promise.all(handles.map((h) => h.dispose().catch(() => {
3133
- })));
3134
- } catch {
3135
- }
3136
- void session.setPicking(false);
3137
- void toPanel({ picked: payload });
3138
- });
3139
- await context.exposeBinding("__flStop", () => {
3140
- onStopRequested?.(true);
3141
- });
3142
- await context.exposeBinding("__flCmd", (_src, msg) => {
3143
- if (!msg || typeof msg.command !== "string") return;
3144
- if (msg.command === "end") onStopRequested?.(msg.commit !== false);
3145
- else if (msg.command === "deleteStep") void session.deleteStep(String(msg.stepId));
3146
- else if (msg.command === "pick") void session.setPicking(msg.on !== false);
3147
- else if (msg.command === "assert") {
3148
- void session.setPicking(false);
3149
- try {
3150
- session.addAssertion(msg.assertion);
3151
- } catch (err) {
3152
- void toPanel({ pickError: firstLine(err) });
3153
- }
3154
- } else if (msg.command === "record") void session.setPaused(!msg.on);
3155
- });
3156
- const INIT_SCRIPT = `${accNameScript()}
3157
- ;window.__flChecks=${JSON.stringify(CHECKS)};
3158
- ;(${installRecorder.toString()})();
3159
- ;(${installDebugPanel.toString()})();`;
3160
- await context.addInitScript({ content: INIT_SCRIPT });
3161
- const ensureInstalled = async (p) => {
3162
- try {
3163
- await p.evaluate((src) => {
3164
- if (document.__flInstalled) return;
3165
- (0, eval)(src);
3166
- }, INIT_SCRIPT);
3167
- } catch {
3168
- }
3169
- };
3170
- const page = await context.newPage();
3171
- pageIds.set(page, "main");
3172
- const watchPage = (p) => {
3173
- watchFrames(p);
3174
- void ensureInstalled(p);
3175
- p.on("domcontentloaded", () => void ensureInstalled(p));
3176
- p.on("download", (download) => {
3177
- const step = [...steps].reverse().find((s2) => (s2.pageId ?? "main") === pageIds.get(p));
3178
- if (step) {
3179
- (step.downloads ??= []).push(download.suggestedFilename());
3180
- onStep?.(step);
3181
- paint();
3182
- }
3183
- });
3184
- p.on("dialog", (dialog) => {
3185
- const step = [...steps].reverse().find((s2) => (s2.pageId ?? "main") === pageIds.get(p));
3186
- if (!step) {
3187
- void dialog.dismiss();
3188
- record({ type: "unsupported", name: "Dialog before first action", warning: "Record an explicit navigation before a page-load dialog", pageId: pageIds.get(p) }, null);
3189
- return;
3190
- }
3191
- pendingDialog = { dialog, step };
3192
- onState?.(snapshot());
3193
- });
3194
- p.on("close", () => {
3195
- if (!stopped && p !== page) record({ type: "closePage", pageId: pageIds.get(p) }, null);
3196
- });
3197
- };
3198
- watchPage(page);
3199
- context.on("page", (p) => {
3200
- const id = `page_${++nextPageId}`;
3201
- pageIds.set(p, id);
3202
- watchPage(p);
3203
- void p.opener().then((opener) => {
3204
- const step = [...steps].reverse().find((s2) => (s2.pageId ?? "main") === pageIds.get(opener));
3205
- if (step) {
3206
- step.opensPage = id;
3207
- onStep?.(step);
3208
- paint();
3209
- } else record({ type: "unsupported", name: "Unlinked browser tab", warning: "Open this tab through a recorded link or button", pageId: id }, null);
3210
- });
3211
- });
3212
- try {
3213
- await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
3214
- } catch (err) {
3215
- stopped = true;
3216
- await browser.close().catch(() => {
3217
- });
3218
- throw new Error(`could not open ${url} \u2014 ${firstLine(err)}`);
3219
- }
3220
- const session = {
3221
- environment: config,
3222
- page,
3223
- startUrl: url,
3224
- state: snapshot,
3225
- deleteStep(id) {
3226
- const i = steps.findIndex((x) => x.id === id);
3227
- if (i !== -1) steps.splice(i, 1);
3228
- paint();
3229
- return snapshot();
3230
- },
3231
- async setPicking(on) {
3232
- const call = (fn, arg) => Promise.all(
3233
- context.pages().flatMap(
3234
- (p) => p.frames().map(
3235
- (f) => f.evaluate(
3236
- ([name, value]) => window[name]?.(value),
3237
- [fn, arg]
3238
- ).catch(() => {
3239
- })
3240
- )
3241
- )
3242
- );
3243
- if (on) await call("__flSetMuted", true);
3244
- await call("__flSetPicking", on);
3245
- if (!on && !paused) await call("__flSetMuted", false);
3246
- },
3247
- addAssertion(assertion) {
3248
- const parsed = RecordedAssertionSchema.parse({ enabled: true, ...assertion });
3249
- const step = {
3250
- id: `st_${crypto.randomUUID().slice(0, 8)}`,
3251
- type: "assert",
3252
- // Mirrored from the target so a step list can name what is being
3253
- // checked without reaching inside the assertion to find out.
3254
- role: parsed.target?.role ?? "",
3255
- name: parsed.target?.name ?? "",
3256
- ...parsed.target?.nameFrom ? { nameFrom: parsed.target.nameFrom } : {},
3257
- scope: parsed.target?.scope ?? "",
3258
- ...parsed.target?.scopeRole ? { scopeRole: parsed.target.scopeRole } : {},
3259
- ...parsed.target?.frame?.length ? { frame: parsed.target.frame } : {},
3260
- ...parsed.target?.locator ? { locator: parsed.target.locator } : {},
3261
- ...parsed.target?.pageId ? { pageId: parsed.target.pageId } : {},
3262
- value: "",
3263
- secret: false,
3264
- legacySelector: "",
3265
- proposals: [parsed]
3266
- };
3267
- if ((parsed.kind === "text" || parsed.kind === "class" || parsed.kind === "attribute") && parsed.target?.nameFrom === "text" && !parsed.target.locator) {
3268
- step.warning = "This element is identified by its own text, so a change to that text reads as a missing element rather than a failed check. Give it a test ID or a locator to tell the two apart.";
3269
- }
3270
- RecordedStepSchema.parse(step);
3271
- if (pendingScrollPos) {
3272
- step.scroll = pendingScrollPos;
3273
- pendingScrollPos = null;
3274
- }
3275
- steps.push(step);
3276
- onStep?.(step);
3277
- paint();
3278
- return snapshot();
3279
- },
3280
- async setPaused(next) {
3281
- paused = next;
3282
- await Promise.all(
3283
- context.pages().map(
3284
- (p) => p.evaluate(
3285
- (v) => window.__flSetMuted?.(v),
3286
- next
3287
- ).catch(() => {
3288
- })
3289
- )
3290
- );
3291
- paint();
3292
- return snapshot();
3293
- },
3294
- async answerDialog(accept, promptText) {
3295
- if (!pendingDialog) throw new Error("No browser dialog is waiting");
3296
- const { dialog, step } = pendingDialog;
3297
- const answer = { type: dialog.type(), message: dialog.message(), accept, ...promptText !== void 0 ? { promptText } : {} };
3298
- if (accept) await dialog.accept(promptText);
3299
- else await dialog.dismiss();
3300
- (step.dialogs ??= []).push(answer);
3301
- pendingDialog = null;
3302
- onStep?.(step);
3303
- paint();
3304
- },
3305
- async stop() {
3306
- if (pendingDialog) throw new Error("Answer the browser dialog before saving the recording");
3307
- if (stopped) return steps;
3308
- await Promise.all(context.pages().flatMap((p) => p.frames().map((f) => f.evaluate(() => window.__flFlush?.()).catch(() => {
3309
- }))));
3310
- stopped = true;
3311
- releaseHeld();
3312
- if (pending) await sleep(OUTCOME_WINDOW_MS + 300);
3313
- finish(signature);
3314
- await browser.close().catch(() => {
3315
- });
3316
- return steps;
3317
- }
3318
- };
3319
- page.on("domcontentloaded", () => paint());
3320
- context.on("page", (p) => p.on("domcontentloaded", () => paint()));
3321
- paint();
3322
- return session;
3323
- }
3324
- function firstLine(err) {
3325
- return String(err?.message ?? err).split("\n")[0].trim();
3326
- }
3327
- var PASSWORD_FIELDS = 'input[type="password"]';
3328
- async function captureFailure(opts) {
3329
- const { page, dir, ordinal } = opts;
3330
- let url = "";
3331
- let title = "";
3332
- try {
3333
- url = page.url();
3334
- title = await page.title();
3335
- } catch {
3336
- }
3337
- let screenshot = null;
3338
- try {
3339
- const file = `step-${String(ordinal).padStart(2, "0")}.png`;
3340
- fs22.mkdirSync(dir, { recursive: true });
3341
- await page.screenshot({
3342
- path: path22.join(dir, file),
3343
- // The viewport, not the full page: a failure is about what was on screen,
3344
- // and a full-page capture of an infinite-scroll app is a 20MB image.
3345
- fullPage: false,
3346
- mask: masks(opts),
3347
- // Playwright's default is magenta, which reads as a rendering fault
3348
- // rather than as a deliberate redaction. A flat slate box is obviously
3349
- // something someone chose to cover up.
3350
- maskColor: "#475569",
3351
- timeout: 5e3
3352
- });
3353
- screenshot = file;
3354
- } catch {
3355
- screenshot = null;
3356
- }
3357
- return { screenshot, url, title };
3358
- }
3359
- function maskTargets(steps = []) {
3360
- const frames = /* @__PURE__ */ new Set([JSON.stringify([])]);
3361
- for (const step of steps) {
3362
- if (step.frame?.length) frames.add(JSON.stringify(step.frame));
3363
- }
3364
- const targets = [...frames].map((chain) => {
3365
- const frame = JSON.parse(chain);
3366
- return frame.length ? { by: "selector", value: PASSWORD_FIELDS, frame } : { by: "selector", value: PASSWORD_FIELDS };
3367
- });
3368
- const seen = /* @__PURE__ */ new Set();
3369
- for (const step of steps) {
3370
- const name = step.name.trim();
3371
- if (!step.secret || !name) continue;
3372
- const key = `${JSON.stringify(step.frame ?? [])}|${name}`;
3373
- if (seen.has(key)) continue;
3374
- seen.add(key);
3375
- targets.push(step.frame?.length ? { by: "label", value: name, frame: step.frame } : { by: "label", value: name });
3376
- }
3377
- return targets;
3378
- }
3379
- function masks(opts) {
3380
- const { page } = opts;
3381
- return maskTargets(opts.steps).map((t) => {
3382
- let root = page;
3383
- for (const frame of t.frame ?? []) root = root.frameLocator(frameSelector(frame));
3384
- return t.by === "selector" ? root.locator(t.value) : root.getByLabel(t.value, { exact: true });
3385
- });
3386
- }
3387
- async function finaliseArtifacts(opts) {
3388
- const { context, dir, passed, artifacts, consoleLines } = opts;
3389
- const keep = (mode) => mode === "always" || mode === "on-failure" && !passed;
3390
- const out = [];
3391
- const size = (file) => {
3392
- try {
3393
- return fs22.statSync(path22.join(dir, file)).size;
3394
- } catch {
3395
- return null;
3396
- }
3397
- };
3398
- if (artifacts.trace !== "off") {
3399
- const file = "trace.zip";
3400
- try {
3401
- if (keep(artifacts.trace)) {
3402
- fs22.mkdirSync(dir, { recursive: true });
3403
- await context.tracing.stop({ path: path22.join(dir, file) });
3404
- out.push({ kind: "trace", relPath: file, bytes: size(file) });
3405
- } else {
3406
- await context.tracing.stop();
3407
- }
3408
- } catch {
3409
- }
3410
- }
3411
- if (artifacts.console && consoleLines.length) {
3412
- const file = "console.log";
3413
- try {
3414
- fs22.mkdirSync(dir, { recursive: true });
3415
- fs22.writeFileSync(path22.join(dir, file), consoleLines.join("\n") + "\n");
3416
- out.push({ kind: "console", relPath: file, bytes: size(file) });
3417
- } catch {
3418
- }
3419
- }
3420
- return out;
3421
- }
3422
- function collectVideos(dir, keep) {
3423
- let files;
3424
- try {
3425
- files = fs22.readdirSync(dir).filter((f) => f.endsWith(".webm"));
3426
- } catch {
3427
- return [];
3428
- }
3429
- if (!keep) {
3430
- for (const f of files) fs22.rmSync(path22.join(dir, f), { force: true });
3431
- return [];
3432
- }
3433
- return files.map((f) => {
3434
- let bytes = null;
3435
- try {
3436
- bytes = fs22.statSync(path22.join(dir, f)).size;
3437
- } catch {
3438
- bytes = null;
3439
- }
3440
- return { kind: "video", relPath: f, bytes };
3441
- });
3442
- }
3443
- var STATES = {
3444
- visible: "visible",
3445
- hidden: "visible",
3446
- attached: "attached",
3447
- enabled: "enabled",
3448
- editable: "editable",
3449
- focused: "focused",
3450
- checked: "checked"
3451
- };
3452
- var TEXT_KINDS = /* @__PURE__ */ new Set(["text", "message", "screen", "heading"]);
3453
- var PAGE_KINDS = /* @__PURE__ */ new Set(["url", "title"]);
3454
- function absenceSatisfies(state, want) {
3455
- return !want && (state === "attached" || state === "visible");
3456
- }
3457
- function firstLine2(err) {
3458
- return String(err instanceof Error ? err.message : err).split("\n")[0].trim();
3459
- }
3460
- function ambiguityDetail(count) {
3461
- return `resolves to ${count} elements \u2014 nothing on the page tells them apart`;
3462
- }
3463
- async function presenceOf(target) {
3464
- let count;
3465
- try {
3466
- count = await target.count();
3467
- } catch (err) {
3468
- return { outcome: "invalid", detail: firstLine2(err) };
3469
- }
3470
- if (count === 0) return { outcome: "absent" };
3471
- if (count > 1) return { outcome: "ambiguous", count };
3472
- return { outcome: "present", locator: target };
3473
- }
3474
- async function readState(target, state, timeout) {
3475
- if (state === "visible") return target.isVisible();
3476
- if (state === "attached") return await target.count() > 0;
3477
- if (state === "enabled") return target.isEnabled({ timeout });
3478
- if (state === "editable") return target.isEditable({ timeout });
3479
- if (state === "checked") return target.isChecked({ timeout });
3480
- return target.evaluate((el) => el === el.ownerDocument.activeElement);
3481
- }
3482
- function compare(actual, expected, op) {
3483
- switch (op) {
3484
- case "contains":
3485
- return actual.includes(expected);
3486
- case "matches":
3487
- return new RegExp(expected).test(actual);
3488
- case "gt":
3489
- return Number(actual) > Number(expected);
3490
- case "gte":
3491
- return Number(actual) >= Number(expected);
3492
- case "lt":
3493
- return Number(actual) < Number(expected);
3494
- case "lte":
3495
- return Number(actual) <= Number(expected);
3496
- default:
3497
- return actual === expected;
3498
- }
3499
- }
3500
- var OP_WORDS = {
3501
- equals: ["is", "is not"],
3502
- contains: ["contains", "does not contain"],
3503
- matches: ["matches", "does not match"],
3504
- gt: ["is more than", "is not more than"],
3505
- gte: ["is at least", "is less than"],
3506
- lt: ["is fewer than", "is not fewer than"],
3507
- lte: ["is at most", "is more than"]
3508
- };
3509
- var DEFAULT_OPS = { message: "contains" };
3510
- function explain(r) {
3511
- switch (r.outcome) {
3512
- case "absent":
3513
- return { detail: "no element matched", kind: "not_found" };
3514
- case "ambiguous":
3515
- return { detail: ambiguityDetail(r.count), kind: "ambiguous" };
3516
- case "invalid":
3517
- return { detail: `the locator could not be used \u2014 ${r.detail}`, kind: "unknown" };
3518
- case "unreadable":
3519
- return { detail: `the element could not be read \u2014 ${r.detail}`, kind: "unknown" };
3520
- case "not_visible":
3521
- return { detail: "the element is present but not visible", kind: null };
3522
- default:
3523
- return { detail: "", kind: null };
3524
- }
3525
- }
3526
- function fail(message, r) {
3527
- const { detail, kind } = explain(r);
3528
- throw Object.assign(new Error(detail ? `${message} \u2014 ${detail}` : message), kind ? { kind } : {});
3529
- }
3530
- async function checkAssertion(page, kind, value, timeoutMs = 3e3, assertion) {
3531
- const deadline = Date.now() + timeoutMs;
3532
- const remaining = () => Math.max(1, deadline - Date.now());
3533
- const op = assertion?.op ?? DEFAULT_OPS[kind] ?? "equals";
3534
- const negated = assertion?.not === true;
3535
- const expected = String(value);
3536
- const asStep = (t) => ({
3537
- id: "assertion",
3538
- type: "click",
3539
- value: "",
3540
- secret: false,
3541
- legacySelector: "",
3542
- proposals: [],
3543
- scope: "",
3544
- ...t
3545
- });
3546
- const NEEDS_TARGET = /* @__PURE__ */ new Set(["count", "attribute", "class", "value", "text", ...Object.keys(STATES)]);
3547
- if (NEEDS_TARGET.has(kind) && !assertion?.target) throw new Error(`${kind} assertion needs an element target`);
3548
- if (negated && !assertion?.target && (TEXT_KINDS.has(kind) || kind === "fields_visible")) {
3549
- throw new Error(
3550
- `a negated ${kind} assertion needs an element target \u2014 choose the element in the editor, or use a "hidden" check to assert that something has gone`
3551
- );
3552
- }
3553
- const aboutAbsence = kind === "attached" || kind === "hidden" || kind === "visible" && negated;
3554
- const target = assertion?.target && kind !== "count" ? await locate(page, asStep(assertion.target), { includeHidden: aboutAbsence }) : void 0;
3555
- if (kind === "fields_visible") {
3556
- for (const name of Array.isArray(value) ? value : [value]) {
3557
- await page.getByLabel(name, { exact: true }).or(page.getByRole("textbox", { name, exact: true })).waitFor({ state: "visible", timeout: remaining() });
3593
+ const aboutAbsence = kind === "attached" || kind === "hidden" || kind === "visible" && negated;
3594
+ const target = assertion?.target && kind !== "count" ? await locate(page, asStep(assertion.target), { includeHidden: aboutAbsence }) : void 0;
3595
+ if (kind === "fields_visible") {
3596
+ for (const name of Array.isArray(value) ? value : [value]) {
3597
+ await page.getByLabel(name, { exact: true }).or(page.getByRole("textbox", { name, exact: true })).waitFor({ state: "visible", timeout: remaining() });
3558
3598
  }
3559
3599
  return `fields visible: ${Array.isArray(value) ? value.join(", ") : value}`;
3560
3600
  }
@@ -3574,7 +3614,7 @@ async function checkAssertion(page, kind, value, timeoutMs = 3e3, assertion) {
3574
3614
  last2 = { outcome: "state", value: actual };
3575
3615
  } catch (err) {
3576
3616
  if (err instanceof Error && err.name === "TimeoutError" && Date.now() >= deadline && last2.outcome === "state") break;
3577
- last2 = { outcome: "unreadable", detail: firstLine2(err) };
3617
+ last2 = { outcome: "unreadable", detail: firstLine(err) };
3578
3618
  }
3579
3619
  } else {
3580
3620
  last2 = p;
@@ -3612,7 +3652,7 @@ async function checkAssertion(page, kind, value, timeoutMs = 3e3, assertion) {
3612
3652
  const n2 = await (await locateAll(page, asStep(assertion.target), { allowRoleOnly: true })).count();
3613
3653
  reading = { outcome: "value", value: String(n2) };
3614
3654
  } catch (err) {
3615
- reading = { outcome: "invalid", detail: firstLine2(err) };
3655
+ reading = { outcome: "invalid", detail: firstLine(err) };
3616
3656
  }
3617
3657
  } else {
3618
3658
  const p = await presenceOf(targetFor());
@@ -3626,7 +3666,7 @@ async function checkAssertion(page, kind, value, timeoutMs = 3e3, assertion) {
3626
3666
  } catch (err) {
3627
3667
  if (/Unsupported assertion/.test(String(err))) throw err;
3628
3668
  if (err instanceof Error && err.name === "TimeoutError" && Date.now() >= deadline && last.outcome === "value") break;
3629
- reading = { outcome: "unreadable", detail: firstLine2(err) };
3669
+ reading = { outcome: "unreadable", detail: firstLine(err) };
3630
3670
  }
3631
3671
  }
3632
3672
  }
@@ -3711,12 +3751,19 @@ async function applyAction(loc, step, value, ctx = {}) {
3711
3751
  }
3712
3752
  var NAVIGATIONS2 = /* @__PURE__ */ new Set(["back", "forward"]);
3713
3753
  async function openRunSession(opts) {
3714
- const { steps, headless = true, slowMo = 0, resolveSecret, evidenceDir } = opts;
3754
+ const { headless = true, slowMo = 0, resolveSecret, evidenceDir } = opts;
3755
+ const steps = [...opts.steps, ...opts.steps.flatMap((step) => {
3756
+ if (!step.auth) return [];
3757
+ const resolved = opts.resolveAuth?.(step.auth);
3758
+ return [...resolved?.steps ?? [], ...resolved?.signOut?.steps ?? []];
3759
+ })];
3715
3760
  const { browser, context, config, artifacts } = await launchSession(
3716
3761
  opts.environment,
3717
3762
  headless,
3718
3763
  slowMo,
3719
- { policy: opts.artifacts, dir: evidenceDir, hasSecrets: steps.some((s2) => s2.secret) }
3764
+ { policy: opts.artifacts, dir: evidenceDir, hasSecrets: steps.some((s2) => s2.secret) },
3765
+ opts.browser,
3766
+ opts.storageState
3720
3767
  );
3721
3768
  const page = await context.newPage();
3722
3769
  const pages = new ExecutionPages(context, page);
@@ -3730,44 +3777,232 @@ async function openRunSession(opts) {
3730
3777
  watch(page);
3731
3778
  context.on("page", watch);
3732
3779
  }
3733
- return { browser, context, page, pages, config, artifacts, consoleLines, runArtifacts: [] };
3780
+ return { ownsBrowser: !opts.browser, browser, context, page, pages, config, artifacts, consoleLines, runArtifacts: [] };
3734
3781
  }
3735
3782
  async function closeRunSession(session, { evidenceDir, passed }) {
3736
3783
  const { context, browser, artifacts, consoleLines, runArtifacts } = session;
3737
3784
  if (evidenceDir) {
3738
- runArtifacts.push(
3739
- ...await finaliseArtifacts({
3740
- context,
3741
- dir: evidenceDir,
3742
- passed,
3743
- artifacts: { video: artifacts.video, trace: artifacts.trace, console: artifacts.console },
3744
- consoleLines
3745
- })
3746
- );
3785
+ try {
3786
+ runArtifacts.push(
3787
+ ...await finaliseArtifacts({
3788
+ context,
3789
+ dir: evidenceDir,
3790
+ passed,
3791
+ artifacts: { video: artifacts.video, trace: artifacts.trace, console: artifacts.console },
3792
+ consoleLines
3793
+ })
3794
+ );
3795
+ } catch {
3796
+ }
3797
+ }
3798
+ await context.close().catch(() => {
3799
+ });
3800
+ if (evidenceDir && artifacts.video !== "off") {
3801
+ runArtifacts.push(...collectVideos(evidenceDir, artifacts.video === "always" || !passed));
3802
+ }
3803
+ if (session.ownsBrowser !== false) await browser.close().catch(() => {
3804
+ });
3805
+ return runArtifacts.filter((artifact, at) => runArtifacts.findIndex((other) => other.relPath === artifact.relPath) === at);
3806
+ }
3807
+ async function performSignIn(session, step, opts) {
3808
+ const reference = step.auth;
3809
+ if (!reference) {
3810
+ return { ok: false, label: "Sign in", detail: "this step does not say which sign-in to use", kind: "unknown", checks: [] };
3811
+ }
3812
+ const resolved = opts.resolveAuth?.(reference);
3813
+ const auth = step.type === "signOut" && resolved?.signOut ? { ...resolved, ...resolved.signOut, navigation: { kind: "continue" } } : step.type === "signOut" ? void 0 : resolved;
3814
+ if (!auth) {
3815
+ return {
3816
+ ok: false,
3817
+ label: "Sign in",
3818
+ detail: "this run was delivered without the sign-in it needs \u2014 open it again so its procedure and account are resolved",
3819
+ kind: "unknown",
3820
+ checks: []
3821
+ };
3822
+ }
3823
+ const label = step.type === "signOut" ? `Sign out ${auth.accountName}` : `Sign in as ${auth.accountName}`;
3824
+ if (step.pageId && step.pageId !== "main") {
3825
+ return {
3826
+ ok: false,
3827
+ label,
3828
+ kind: "unknown",
3829
+ checks: [],
3830
+ detail: `a sign-in cannot be aimed at another browser tab \u2014 move this step to the main tab, or record the popup inside "${auth.name}"`
3831
+ };
3832
+ }
3833
+ if ((opts.authDepth ?? 0) > 0) {
3834
+ return { ok: false, label, detail: `"${auth.name}" signs in from inside its own sign-in`, kind: "unknown", checks: [] };
3835
+ }
3836
+ if (!hasSuccessCheck(auth)) {
3837
+ return { ok: false, label, detail: UNCHECKED_AUTH, kind: "assertion", checks: [] };
3838
+ }
3839
+ const inner = await runStepsIn(session, auth.steps, {
3840
+ ...opts,
3841
+ stepOffset: 0,
3842
+ // The procedure's own steps are not the test's steps. Reporting each one
3843
+ // would bury the test being run under a login form nobody wrote.
3844
+ ...opts.onStepResult ? { onStepResult: () => void 0 } : {},
3845
+ startUrl: authStartUrl(auth, session.pages.pageFor(step), opts.baseUrl ?? ""),
3846
+ authDepth: (opts.authDepth ?? 0) + 1
3847
+ });
3848
+ if (inner.status !== "passed") {
3849
+ const failed = inner.results.find((r) => r.status === "failed");
3850
+ return {
3851
+ ok: false,
3852
+ label,
3853
+ detail: describeAuthFailure(auth, { ...failed ? { step: failed.name } : {}, detail: failed?.detail ?? inner.error }),
3854
+ kind: failed?.failureKind ?? "unknown",
3855
+ checks: []
3856
+ };
3747
3857
  }
3748
- await context.close().catch(() => {
3858
+ const proof = await runStepsIn(session, [checkStep(step.id, auth.success)], {
3859
+ ...opts,
3860
+ ...opts.onStepResult ? { onStepResult: () => void 0 } : {},
3861
+ startUrl: null,
3862
+ authDepth: (opts.authDepth ?? 0) + 1
3749
3863
  });
3750
- if (evidenceDir && artifacts.video !== "off") {
3751
- runArtifacts.push(...collectVideos(evidenceDir, artifacts.video === "always" || !passed));
3864
+ const checked = proof.results[0];
3865
+ if (proof.status !== "passed") {
3866
+ return {
3867
+ ok: false,
3868
+ label,
3869
+ detail: describeAuthFailure(auth, { detail: checked?.detail ?? "the signed-in check did not pass" }),
3870
+ kind: "assertion",
3871
+ checks: checked?.checks ?? []
3872
+ };
3752
3873
  }
3753
- await browser.close().catch(() => {
3754
- });
3755
- return runArtifacts;
3874
+ return { ok: true, label, detail: null, kind: null, checks: checked?.checks ?? [] };
3875
+ }
3876
+ async function confirmSignedIn(page, auth, timeoutMs) {
3877
+ for (const check of auth.success) {
3878
+ if (check.enabled === false) continue;
3879
+ try {
3880
+ await checkAssertion(page, check.kind, check.value, timeoutMs, check);
3881
+ } catch (err) {
3882
+ return { ok: false, detail: firstLine2(err) };
3883
+ }
3884
+ }
3885
+ return { ok: true, detail: null };
3886
+ }
3887
+ var checkStep = (id, proposals) => ({
3888
+ id: `${id}__signed-in`,
3889
+ type: "assert",
3890
+ role: "",
3891
+ name: "Signed in",
3892
+ scope: "",
3893
+ value: "",
3894
+ secret: false,
3895
+ legacySelector: "",
3896
+ proposals
3897
+ });
3898
+ function stepFailure(step, detail) {
3899
+ return {
3900
+ stepId: step.id,
3901
+ name: step.name,
3902
+ type: step.type,
3903
+ value: null,
3904
+ secret: step.secret,
3905
+ status: "failed",
3906
+ detail,
3907
+ failureKind: "unknown",
3908
+ durationMs: 0,
3909
+ checks: []
3910
+ };
3756
3911
  }
3757
3912
  async function runStepsIn(session, steps, opts) {
3758
- const { page, pages, config, artifacts } = session;
3913
+ let { page, pages, config, artifacts } = session;
3759
3914
  const { resolveSecret, resolveFile, onStepResult, evidenceDir } = opts;
3760
3915
  const results = [];
3761
3916
  let status2 = "passed";
3762
3917
  let error = null;
3763
3918
  try {
3764
3919
  if (opts.startUrl) await page.goto(opts.startUrl, { waitUntil: "domcontentloaded" });
3765
- for (const step of steps) {
3920
+ for (const recorded of steps) {
3921
+ let step = recorded;
3922
+ if (!recorded.disabled) {
3923
+ try {
3924
+ step = withRuntimeValues(recorded, opts.actors?.values ?? /* @__PURE__ */ new Map());
3925
+ } catch (err) {
3926
+ const result2 = stepFailure(recorded, firstLine2(err));
3927
+ results.push(result2);
3928
+ onStepResult?.(result2);
3929
+ status2 = "failed";
3930
+ error = `${recorded.type} "${recorded.name}": ${result2.detail}`;
3931
+ break;
3932
+ }
3933
+ }
3934
+ if ((opts.authDepth ?? 0) > 0 && !step.disabled && (step.type === "useActor" || step.type === "resetActor" || step.type === "capture")) {
3935
+ const result2 = stepFailure(step, `a shared sign-in cannot ${step.type === "capture" ? "capture an output" : "change participants"}`);
3936
+ results.push(result2);
3937
+ onStepResult?.(result2);
3938
+ status2 = "failed";
3939
+ error = result2.detail;
3940
+ break;
3941
+ }
3942
+ if (opts.actors) {
3943
+ if (step.type === "useActor" && !step.disabled) {
3944
+ const began = Date.now();
3945
+ const result2 = {
3946
+ stepId: step.id,
3947
+ name: `Continue as ${opts.actors.nameOf(step.actorId)}`,
3948
+ type: step.type,
3949
+ value: null,
3950
+ secret: false,
3951
+ status: "passed",
3952
+ detail: null,
3953
+ failureKind: null,
3954
+ durationMs: 0,
3955
+ checks: []
3956
+ };
3957
+ try {
3958
+ await opts.actors.select(step.actorId ?? "");
3959
+ } catch (err) {
3960
+ result2.status = "failed";
3961
+ result2.failureKind = "unknown";
3962
+ result2.detail = firstLine2(err);
3963
+ }
3964
+ result2.durationMs = Date.now() - began;
3965
+ results.push(result2);
3966
+ onStepResult?.(result2);
3967
+ if (result2.status === "failed") {
3968
+ status2 = "failed";
3969
+ error = `${result2.name}: ${result2.detail}`;
3970
+ break;
3971
+ }
3972
+ continue;
3973
+ }
3974
+ session = opts.actors.current;
3975
+ ({ page, pages, config, artifacts } = session);
3976
+ }
3766
3977
  if (opts.signal?.aborted) {
3767
3978
  status2 = "errored";
3768
3979
  error = "the run was cancelled";
3769
3980
  break;
3770
3981
  }
3982
+ if ((step.type === "signIn" || step.type === "signOut") && !step.disabled) {
3983
+ const started = Date.now();
3984
+ const outcome = await performSignIn(session, step, opts);
3985
+ const result2 = {
3986
+ stepId: step.id,
3987
+ name: outcome.label,
3988
+ type: step.type,
3989
+ value: null,
3990
+ secret: false,
3991
+ status: outcome.ok ? "passed" : "failed",
3992
+ detail: outcome.detail,
3993
+ failureKind: outcome.ok ? null : outcome.kind,
3994
+ durationMs: Date.now() - started,
3995
+ checks: outcome.checks
3996
+ };
3997
+ results.push(result2);
3998
+ onStepResult?.(result2);
3999
+ if (!outcome.ok) {
4000
+ status2 = "failed";
4001
+ error = outcome.detail;
4002
+ break;
4003
+ }
4004
+ continue;
4005
+ }
3771
4006
  if (step.disabled) {
3772
4007
  const skipped = {
3773
4008
  stepId: step.id,
@@ -3807,6 +4042,9 @@ async function runStepsIn(session, steps, opts) {
3807
4042
  pages,
3808
4043
  resolveSecret,
3809
4044
  resolveFile,
4045
+ valuesResolved: true,
4046
+ baseUrl: opts.baseUrl,
4047
+ ...opts.actors ? { actors: opts.actors } : {},
3810
4048
  timeoutMs: config.timeoutMs,
3811
4049
  navigationTimeoutMs: config.navigationTimeoutMs
3812
4050
  });
@@ -3826,7 +4064,7 @@ async function runStepsIn(session, steps, opts) {
3826
4064
  } catch (err) {
3827
4065
  const kind = err.kind ?? null;
3828
4066
  if (kind && !soft) checkFailureKind = kind;
3829
- result.checks.push({ kind: a.kind, expected: label, ok: false, soft, detail: firstLine(err) });
4067
+ result.checks.push({ kind: a.kind, expected: label, ok: false, soft, detail: firstLine2(err) });
3830
4068
  }
3831
4069
  }
3832
4070
  const hardFailure = result.checks.find((c) => !c.ok && !c.soft);
@@ -3838,7 +4076,7 @@ async function runStepsIn(session, steps, opts) {
3838
4076
  } catch (err) {
3839
4077
  result.status = "failed";
3840
4078
  result.failureKind = err.kind ?? (/strict mode violation/.test(String(err)) ? "ambiguous" : err instanceof errors.TimeoutError ? "timeout" : "unknown");
3841
- result.detail = firstLine(err);
4079
+ result.detail = firstLine2(err);
3842
4080
  }
3843
4081
  const wantsShot = artifacts.screenshot === "always" || artifacts.screenshot === "on-failure" && result.status === "failed";
3844
4082
  if (wantsShot && evidenceDir) {
@@ -3864,262 +4102,853 @@ async function runStepsIn(session, steps, opts) {
3864
4102
  break;
3865
4103
  }
3866
4104
  }
3867
- } catch (err) {
4105
+ } catch (err) {
4106
+ status2 = "errored";
4107
+ error = firstLine2(err);
4108
+ }
4109
+ return { results, status: status2, error };
4110
+ }
4111
+ async function replayFlow(opts) {
4112
+ const { steps, baseUrl, startPath } = opts;
4113
+ validateActors(opts.actors, opts.defaultActorId, actorIdsUsed(steps));
4114
+ const started = Date.now();
4115
+ const hydrated = keptSessionFor(
4116
+ { ...opts, baseUrl },
4117
+ opts.actors?.find((a) => a.id === (opts.defaultActorId ?? "default"))?.startSignedIn
4118
+ );
4119
+ const session = await openRunSession({
4120
+ steps: [...steps, ...actorPreparations(opts.actors)],
4121
+ ...hydrated?.state ? { storageState: hydrated.state } : {},
4122
+ ...opts.resolveAuth ? { resolveAuth: opts.resolveAuth } : {},
4123
+ ...opts.environment === void 0 ? {} : { environment: opts.environment },
4124
+ ...opts.headless === void 0 ? {} : { headless: opts.headless },
4125
+ ...opts.slowMo === void 0 ? {} : { slowMo: opts.slowMo },
4126
+ ...opts.artifacts === void 0 ? {} : { artifacts: opts.artifacts },
4127
+ ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4128
+ ...opts.resolveSecret === void 0 ? {} : { resolveSecret: opts.resolveSecret }
4129
+ });
4130
+ const actors = new RunActors(session, {
4131
+ ...opts,
4132
+ steps: [...steps, ...actorPreparations(opts.actors)],
4133
+ startUrl: resolveStartUrl(baseUrl, startPath),
4134
+ hydrated: hydrated ? { ...hydrated, id: opts.defaultActorId ?? "default" } : null
4135
+ });
4136
+ let prepared = null;
4137
+ try {
4138
+ await actors.select(opts.defaultActorId ?? "default");
4139
+ } catch (e) {
4140
+ prepared = e.message;
4141
+ }
4142
+ const { results, status: status2, error } = prepared ? { results: [], status: "failed", error: prepared } : await runStepsIn(actors.current, steps, {
4143
+ actors,
4144
+ ...opts.resolveSecret === void 0 ? {} : { resolveSecret: opts.resolveSecret },
4145
+ ...opts.resolveFile === void 0 ? {} : { resolveFile: opts.resolveFile },
4146
+ ...opts.onStepResult === void 0 ? {} : { onStepResult: opts.onStepResult },
4147
+ ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4148
+ ...opts.signal === void 0 ? {} : { signal: opts.signal },
4149
+ ...opts.resolveAuth === void 0 ? {} : { resolveAuth: opts.resolveAuth },
4150
+ baseUrl,
4151
+ startUrl: null
4152
+ });
4153
+ await actors.closeExtras(status2 === "passed");
4154
+ const runArtifacts = await closeRunSession(session, {
4155
+ ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4156
+ passed: status2 === "passed"
4157
+ });
4158
+ return {
4159
+ status: status2,
4160
+ error,
4161
+ artifacts: runArtifacts,
4162
+ durationMs: Date.now() - started,
4163
+ // Steps that actually ran. A step turned off in the editor is in
4164
+ // `results` so the report can say so, but counting it as run would make a
4165
+ // flow look busier than it was.
4166
+ stepsRun: results.filter((r) => r.status !== "skipped").length,
4167
+ stepsTotal: steps.length,
4168
+ steps: results
4169
+ };
4170
+ }
4171
+ async function executeStep(main, input, options = {}) {
4172
+ const parsed = RecordedStepSchema.parse(input);
4173
+ const step = options.valuesResolved ? parsed : withRuntimeValues(parsed, options.actors?.values ?? /* @__PURE__ */ new Map());
4174
+ if (options.actors) {
4175
+ if (step.type === "resetActor") {
4176
+ await options.actors.freshGuest(step.actorId ?? options.actors.currentId);
4177
+ return options.actors.current.page;
4178
+ }
4179
+ if (step.type === "useActor") {
4180
+ await options.actors.select(step.actorId ?? "");
4181
+ return options.actors.current.page;
4182
+ }
4183
+ main = options.actors.current.page;
4184
+ options = { ...options, pages: options.actors.current.pages };
4185
+ }
4186
+ const page = options.pages?.pageFor(step) ?? main;
4187
+ if (step.type === "signIn" || step.type === "signOut") {
4188
+ const context = main.context();
4189
+ const outcome = await performSignIn({
4190
+ browser: context.browser(),
4191
+ context,
4192
+ page: main,
4193
+ pages: options.pages ?? new ExecutionPages(context, main),
4194
+ config: { ...DEFAULT_ENVIRONMENT, timeoutMs: options.timeoutMs ?? 8e3 },
4195
+ artifacts: resolveCapture({}),
4196
+ consoleLines: [],
4197
+ runArtifacts: []
4198
+ }, step, { ...options, startUrl: null });
4199
+ if (!outcome.ok) throw new Error(outcome.detail ?? "Authentication failed");
4200
+ return main;
4201
+ }
4202
+ if (step.type === "capture") {
4203
+ if (!step.capture || !options.actors) throw new Error("Configure the runtime output name and source");
4204
+ if (step.secret) throw new Error("Secret fields cannot be captured as shared outputs");
4205
+ let value;
4206
+ if (step.capture.source === "url") value = page.url();
4207
+ else {
4208
+ const target = await locate(page, step);
4209
+ if (await target.getAttribute("type") === "password") throw new Error("Password fields cannot be captured as shared outputs");
4210
+ value = step.capture.source === "value" ? await target.inputValue() : await target.innerText();
4211
+ }
4212
+ if (value.length > 16384) throw new Error("Captured output exceeds 16 KB");
4213
+ options.actors.values.set(step.capture.name, value);
4214
+ return page;
4215
+ }
4216
+ const deadline = Date.now() + (step.timeoutMs ?? options.timeoutMs ?? 8e3);
4217
+ const remaining = () => Math.max(1, deadline - Date.now());
4218
+ const navigationBudget = () => step.navigationTimeoutMs ?? options.navigationTimeoutMs ?? 45e3;
4219
+ page.setDefaultTimeout(remaining());
4220
+ const act = async () => {
4221
+ if (step.type === "unsupported") throw new Error(step.warning || step.value || "This interaction requires a supported action before replay");
4222
+ if (step.type === "assert") {
4223
+ if (step.scroll) await applyScroll(page, step, Math.min(2e3, remaining()));
4224
+ return;
4225
+ }
4226
+ if (step.type === "navigate") {
4227
+ await page.goto(new URL(step.value, step.navigationBase === "application" ? options.baseUrl ?? page.url() : page.url()).href, { waitUntil: "domcontentloaded", timeout: navigationBudget() });
4228
+ return;
4229
+ }
4230
+ if (step.type === "closePage") {
4231
+ await page.close();
4232
+ return;
4233
+ }
4234
+ if (step.type === "back" || step.type === "forward") {
4235
+ await navigateHistory(page, step.type, navigationBudget());
4236
+ return;
4237
+ }
4238
+ if (step.type === "scroll" && step.scroll) {
4239
+ await applyScroll(page, step, remaining());
4240
+ return;
4241
+ }
4242
+ const ref = step.secretRef || step.value;
4243
+ const value = step.secret ? ref ? options.resolveSecret?.(ref) : void 0 : step.value;
4244
+ if (step.secret && value === void 0) {
4245
+ throw Object.assign(
4246
+ new Error(
4247
+ ref ? `the secret "${ref}" is not set on this machine \u2014 add it under Settings \u203A Secrets, or in .flowriki/secrets.local.json, then run again` : `this step needs a secret that has not been stored yet \u2014 open the flow, re-enter the value for "${step.name}" and save`
4248
+ ),
4249
+ { kind: "not_found" }
4250
+ );
4251
+ }
4252
+ if (step.scroll && step.type !== "scroll") {
4253
+ await applyScroll(page, step, Math.min(2e3, remaining()));
4254
+ }
4255
+ const loc = await locate(page, step);
4256
+ await ensureTarget(page, loc, step, remaining());
4257
+ page.setDefaultTimeout(remaining());
4258
+ await applyAction(loc, step, value, { resolveFile: options.resolveFile, dropTarget: await locateDrop(page, step) });
4259
+ };
4260
+ if (options.pages) await options.pages.action(page, step, remaining(), act);
4261
+ else await act();
4262
+ return page;
4263
+ }
4264
+ async function navigateHistory(page, direction, timeout) {
4265
+ let traversed = false;
4266
+ const listener = (frame) => {
4267
+ if (frame === page.mainFrame()) traversed = true;
4268
+ };
4269
+ page.on("framenavigated", listener);
4270
+ try {
4271
+ const response = await (direction === "back" ? page.goBack({ waitUntil: "domcontentloaded", timeout }) : page.goForward({ waitUntil: "domcontentloaded", timeout }));
4272
+ if (!response && (!traversed || page.url() === "about:blank")) throw Object.assign(new Error(`no history entry to go ${direction} to`), { kind: "navigation" });
4273
+ } finally {
4274
+ page.off("framenavigated", listener);
4275
+ }
4276
+ }
4277
+ async function applyScroll(page, step, timeout) {
4278
+ const scroll = step.scroll;
4279
+ if (!scroll) return;
4280
+ try {
4281
+ const container = scroll.container;
4282
+ const subject = step.type === "scroll" ? step : container ? {
4283
+ ...step,
4284
+ type: "scroll",
4285
+ role: container.role,
4286
+ name: container.name,
4287
+ scope: container.scope ?? "",
4288
+ scopeRole: container.scopeRole,
4289
+ frame: container.frame,
4290
+ pageId: container.pageId,
4291
+ nameFrom: container.nameFrom,
4292
+ locator: container.locator,
4293
+ nth: container.nth,
4294
+ legacySelector: scroll.legacySelector ?? ""
4295
+ } : step;
4296
+ const root = rootFor(page, subject);
4297
+ const loc = subject.locator || subject.name ? await locate(page, subject) : root.locator("html");
4298
+ await loc.evaluate(
4299
+ (el, pos) => {
4300
+ el.scrollTo({ left: pos.x, top: pos.y, behavior: "instant" });
4301
+ },
4302
+ { x: scroll.x, y: scroll.y },
4303
+ { timeout }
4304
+ );
4305
+ } catch {
4306
+ }
4307
+ }
4308
+ async function ensureTarget(page, loc, step, timeout) {
4309
+ const firstWait = Math.max(500, Math.floor(timeout * 0.45));
4310
+ const recovery = Math.max(1e3, Math.floor(timeout * 0.35));
4311
+ try {
4312
+ await loc.waitFor({ state: "attached", timeout: firstWait });
4313
+ return;
4314
+ } catch (error) {
4315
+ if (/strict mode violation/.test(String(error))) {
4316
+ throw Object.assign(error, { kind: "ambiguous" });
4317
+ }
4318
+ }
4319
+ if (await scrollUntilFound(page, loc, recovery)) return;
4320
+ throw Object.assign(
4321
+ new Error(
4322
+ `no element named "${step.name}" \u2014 nothing on this page matches the target or its recorded scope`
4323
+ ),
4324
+ { kind: "not_found" }
4325
+ );
4326
+ }
4327
+ async function replayCase(opts) {
4328
+ const { occurrences, baseUrl } = opts;
4329
+ const started = Date.now();
4330
+ const warnings = [];
4331
+ validateActors(opts.actors, opts.defaultActorId, occurrences.flatMap((o) => [o.actorId, ...actorIdsUsed(o.steps)]));
4332
+ const allSteps = [...occurrences.flatMap((o) => o.steps), ...actorPreparations(opts.actors)];
4333
+ for (const occurrence of occurrences) {
4334
+ if (occurrence.environment && Object.keys(occurrence.environment).length > 0) {
4335
+ warnings.push(
4336
+ `"${occurrence.name}" was recorded with its own browser settings; the case's settings were used instead`
4337
+ );
4338
+ }
4339
+ }
4340
+ const caseHydrated = keptSessionFor(
4341
+ { ...opts, baseUrl },
4342
+ opts.actors?.find((a) => a.id === (opts.defaultActorId ?? "default"))?.startSignedIn
4343
+ );
4344
+ const session = await openRunSession({
4345
+ steps: allSteps,
4346
+ ...caseHydrated?.state ? { storageState: caseHydrated.state } : {},
4347
+ ...opts.resolveAuth ? { resolveAuth: opts.resolveAuth } : {},
4348
+ ...opts.environment === void 0 ? {} : { environment: opts.environment },
4349
+ ...opts.headless === void 0 ? {} : { headless: opts.headless },
4350
+ ...opts.slowMo === void 0 ? {} : { slowMo: opts.slowMo },
4351
+ ...opts.artifacts === void 0 ? {} : { artifacts: opts.artifacts },
4352
+ ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4353
+ ...opts.resolveSecret === void 0 ? {} : { resolveSecret: opts.resolveSecret }
4354
+ });
4355
+ const actors = new RunActors(session, {
4356
+ ...opts,
4357
+ steps: allSteps,
4358
+ startUrl: resolveStartUrl(baseUrl, occurrences[0]?.startPath ?? "/"),
4359
+ hydrated: caseHydrated ? { ...caseHydrated, id: opts.defaultActorId ?? "default" } : null
4360
+ });
4361
+ const outcomes = [];
4362
+ let status2 = "passed";
4363
+ let error = null;
4364
+ try {
4365
+ for (const [index, occurrence] of occurrences.entries()) {
4366
+ if (status2 !== "passed" || opts.signal?.aborted) {
4367
+ const skipped = {
4368
+ occurrenceId: occurrence.occurrenceId,
4369
+ name: occurrence.name,
4370
+ status: "skipped",
4371
+ error: null,
4372
+ steps: [],
4373
+ stepsRun: 0,
4374
+ stepsTotal: occurrence.steps.length,
4375
+ durationMs: 0,
4376
+ continued: false
4377
+ };
4378
+ if (status2 === "passed" && opts.signal?.aborted) {
4379
+ status2 = "errored";
4380
+ error = "the run was cancelled";
4381
+ }
4382
+ outcomes.push(skipped);
4383
+ opts.onOccurrenceResult?.(skipped);
4384
+ continue;
4385
+ }
4386
+ const navigates = occurrence.navigateFirst;
4387
+ try {
4388
+ await actors.select(occurrence.actorId ?? opts.defaultActorId ?? "default", resolveStartUrl(occurrence.baseUrl ?? baseUrl, occurrence.startPath ?? "/"));
4389
+ } catch (failure) {
4390
+ status2 = "failed";
4391
+ error = failure.message;
4392
+ outcomes.push({ occurrenceId: occurrence.occurrenceId, name: occurrence.name, status: status2, error, steps: [], stepsRun: 0, stepsTotal: occurrence.steps.length, durationMs: 0, continued: false });
4393
+ continue;
4394
+ }
4395
+ const at0 = Date.now();
4396
+ const { results, status: ran, error: failed } = await runStepsIn(actors.current, occurrence.steps, {
4397
+ actors,
4398
+ baseUrl: occurrence.baseUrl ?? baseUrl,
4399
+ ...opts.resolveAuth ? { resolveAuth: opts.resolveAuth } : {},
4400
+ stepOffset: outcomes.reduce((count, previous) => count + previous.stepsTotal, 0),
4401
+ ...opts.resolveSecret === void 0 ? {} : { resolveSecret: opts.resolveSecret },
4402
+ ...opts.resolveFile === void 0 ? {} : { resolveFile: opts.resolveFile },
4403
+ ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4404
+ ...opts.signal === void 0 ? {} : { signal: opts.signal },
4405
+ ...opts.onStepResult ? { onStepResult: (result) => opts.onStepResult(result, occurrence.occurrenceId) } : {},
4406
+ startUrl: navigates ? resolveStartUrl(occurrence.baseUrl ?? baseUrl, occurrence.startPath ?? "/") : null
4407
+ });
4408
+ const outcome = {
4409
+ occurrenceId: occurrence.occurrenceId,
4410
+ name: occurrence.name,
4411
+ status: ran,
4412
+ error: failed,
4413
+ steps: results,
4414
+ stepsRun: results.filter((r) => r.status !== "skipped").length,
4415
+ stepsTotal: occurrence.steps.length,
4416
+ durationMs: Date.now() - at0,
4417
+ continued: index > 0 && !navigates
4418
+ };
4419
+ outcomes.push(outcome);
4420
+ opts.onOccurrenceResult?.(outcome);
4421
+ if (ran !== "passed") {
4422
+ status2 = ran;
4423
+ error = `${occurrence.name}: ${failed ?? "failed"}`;
4424
+ }
4425
+ }
4426
+ } finally {
4427
+ await actors.closeExtras(status2 === "passed");
4428
+ await closeRunSession(session, {
4429
+ ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4430
+ passed: status2 === "passed"
4431
+ });
4432
+ }
4433
+ if (opts.signal?.aborted) {
3868
4434
  status2 = "errored";
3869
- error = firstLine(err);
4435
+ error = "the run was cancelled";
3870
4436
  }
3871
- return { results, status: status2, error };
3872
- }
3873
- async function replayFlow(opts) {
3874
- const { steps, baseUrl, startPath } = opts;
3875
- const started = Date.now();
3876
- const session = await openRunSession({
3877
- steps,
3878
- ...opts.environment === void 0 ? {} : { environment: opts.environment },
3879
- ...opts.headless === void 0 ? {} : { headless: opts.headless },
3880
- ...opts.slowMo === void 0 ? {} : { slowMo: opts.slowMo },
3881
- ...opts.artifacts === void 0 ? {} : { artifacts: opts.artifacts },
3882
- ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
3883
- ...opts.resolveSecret === void 0 ? {} : { resolveSecret: opts.resolveSecret }
3884
- });
3885
- const { results, status: status2, error } = await runStepsIn(session, steps, {
3886
- ...opts.resolveSecret === void 0 ? {} : { resolveSecret: opts.resolveSecret },
3887
- ...opts.resolveFile === void 0 ? {} : { resolveFile: opts.resolveFile },
3888
- ...opts.onStepResult === void 0 ? {} : { onStepResult: opts.onStepResult },
3889
- ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
3890
- ...opts.signal === void 0 ? {} : { signal: opts.signal },
3891
- startUrl: resolveStartUrl(baseUrl, startPath)
3892
- });
3893
- const runArtifacts = await closeRunSession(session, {
3894
- ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
3895
- passed: status2 === "passed"
3896
- });
4437
+ const steps = outcomes.flatMap((o) => o.steps);
3897
4438
  return {
3898
4439
  status: status2,
3899
4440
  error,
3900
- artifacts: runArtifacts,
4441
+ artifacts: session.runArtifacts,
3901
4442
  durationMs: Date.now() - started,
3902
- // Steps that actually ran. A step turned off in the editor is in
3903
- // `results` so the report can say so, but counting it as run would make a
3904
- // flow look busier than it was.
3905
- stepsRun: results.filter((r) => r.status !== "skipped").length,
3906
- stepsTotal: steps.length,
3907
- steps: results
4443
+ occurrences: outcomes,
4444
+ steps,
4445
+ stepsRun: steps.filter((r) => r.status !== "skipped").length,
4446
+ stepsTotal: occurrences.reduce((n2, o) => n2 + o.steps.length, 0),
4447
+ warnings
3908
4448
  };
3909
4449
  }
3910
- async function executeStep(main, input, options = {}) {
3911
- const step = RecordedStepSchema.parse(input);
3912
- const page = options.pages?.pageFor(step) ?? main;
3913
- const deadline = Date.now() + (step.timeoutMs ?? options.timeoutMs ?? 8e3);
3914
- const remaining = () => Math.max(1, deadline - Date.now());
3915
- const navigationBudget = () => step.navigationTimeoutMs ?? options.navigationTimeoutMs ?? 45e3;
3916
- page.setDefaultTimeout(remaining());
3917
- const act = async () => {
3918
- if (step.type === "unsupported") throw new Error(step.warning || step.value || "This interaction requires a supported action before replay");
3919
- if (step.type === "assert") {
3920
- if (step.scroll) await applyScroll(page, step, Math.min(2e3, remaining()));
4450
+ var OUTCOME_WINDOW_MS = 2500;
4451
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4452
+ var COMMITS = /* @__PURE__ */ new Set([
4453
+ "click",
4454
+ "dblclick",
4455
+ "rightclick",
4456
+ "drag",
4457
+ "check",
4458
+ "press",
4459
+ "upload",
4460
+ "back",
4461
+ "forward"
4462
+ ]);
4463
+ var cachedAccNameScript = null;
4464
+ var ACCNAME_ASSET = "accname.global.js";
4465
+ function accNameScript() {
4466
+ if (cachedAccNameScript === null) {
4467
+ cachedAccNameScript = fs22.readFileSync(accNameScriptPath(), "utf8");
4468
+ }
4469
+ return cachedAccNameScript;
4470
+ }
4471
+ function accNameScriptPath() {
4472
+ try {
4473
+ return createRequire2(import.meta.url).resolve("@flowriki/recorder/accname.global.js");
4474
+ } catch {
4475
+ const beside = path32.join(path32.dirname(fileURLToPath(import.meta.url)), ACCNAME_ASSET);
4476
+ if (fs22.existsSync(beside)) return beside;
4477
+ throw new Error(
4478
+ `this build is missing ${ACCNAME_ASSET}, which recording injects into the page \u2014 reinstall the agent`
4479
+ );
4480
+ }
4481
+ }
4482
+ async function startRecordingSession(opts) {
4483
+ const { headless = false, onStep, onState, onDisconnect, onStopRequested } = opts;
4484
+ const url = normalizeStartUrl(opts.url);
4485
+ const { browser, context, config } = await launchSession(opts.environment, headless);
4486
+ const steps = [];
4487
+ let pendingScrollPos = null;
4488
+ const pageIds = /* @__PURE__ */ new Map();
4489
+ let nextPageId = 0;
4490
+ let pendingDialog = null;
4491
+ let stopped = false;
4492
+ let paused = false;
4493
+ const frameChains = /* @__PURE__ */ new Map();
4494
+ const frameLabel = async (frame) => {
4495
+ const el = await frame.frameElement().catch(() => null);
4496
+ if (el) {
4497
+ for (const attr of ["title", "name", "id"]) {
4498
+ const value = (await el.getAttribute(attr).catch(() => null))?.trim();
4499
+ if (value) return value;
4500
+ }
4501
+ }
4502
+ const named = frame.name();
4503
+ if (named) return named;
4504
+ const siblings = frame.parentFrame()?.childFrames() ?? [];
4505
+ return `nth:${Math.max(0, siblings.indexOf(frame))}`;
4506
+ };
4507
+ const noteFrame = async (frame) => {
4508
+ try {
4509
+ const chain = [];
4510
+ let f = frame;
4511
+ while (f && f.parentFrame()) {
4512
+ chain.unshift(await frameLabel(f));
4513
+ f = f.parentFrame();
4514
+ }
4515
+ frameChains.set(frame, chain);
4516
+ } catch {
4517
+ }
4518
+ };
4519
+ const watchFrames = (page2) => {
4520
+ for (const f of page2.frames()) void noteFrame(f);
4521
+ page2.on("frameattached", (f) => void noteFrame(f));
4522
+ page2.on("framenavigated", (f) => void noteFrame(f));
4523
+ };
4524
+ const snapshot = () => ({
4525
+ status: "recording",
4526
+ cursor: null,
4527
+ breakpoints: [],
4528
+ steps: steps.map((x) => ({ ...x })),
4529
+ results: {},
4530
+ failure: null,
4531
+ recording: !paused,
4532
+ recorded: steps.map((x) => x.id),
4533
+ mode: "record",
4534
+ ...pendingDialog ? { dialog: { type: pendingDialog.dialog.type(), message: pendingDialog.dialog.message(), defaultValue: pendingDialog.dialog.defaultValue() } } : {}
4535
+ });
4536
+ const paint = () => {
4537
+ const safe2 = {
4538
+ ...snapshot(),
4539
+ // The characters typed into a password never leave this process.
4540
+ steps: snapshot().steps.map((st) => ({
4541
+ id: st.id,
4542
+ type: st.type,
4543
+ name: st.name,
4544
+ scope: st.scope,
4545
+ value: st.secret ? "" : st.value,
4546
+ secret: st.secret,
4547
+ // The sentence, written here rather than in the page: the catalogue's
4548
+ // wording lives in core, and a panel that phrased checks its own way
4549
+ // would say something different from the flow editor about the same
4550
+ // step.
4551
+ ...st.type === "assert" && st.proposals[0] ? { detail: describeAssertion(st.proposals[0], st.name) } : {}
4552
+ }))
4553
+ };
4554
+ for (const p of context.pages()) {
4555
+ p.evaluate(
4556
+ (v) => window.__flDebugRender?.(v),
4557
+ safe2
4558
+ ).catch(() => {
4559
+ });
4560
+ }
4561
+ onState?.(snapshot());
4562
+ };
4563
+ const toPanel = async (msg) => {
4564
+ await context.pages()[0]?.evaluate(
4565
+ (v) => window.__flPanelMessage?.(v),
4566
+ msg
4567
+ ).catch(() => {
4568
+ });
4569
+ };
4570
+ let signature = null;
4571
+ browser.on("disconnected", () => {
4572
+ if (!stopped) onDisconnect?.();
4573
+ });
4574
+ let pending = null;
4575
+ const finish = (after) => {
4576
+ if (!pending) return;
4577
+ const { step, before, timer } = pending;
4578
+ clearTimeout(timer);
4579
+ pending = null;
4580
+ step.proposals = proposeAssertions(before, after).map(
4581
+ (p) => (
4582
+ // Inferred from a text diff, so it arrives unticked for a human to
4583
+ // confirm. Everything else the page stated about itself and is safe to
4584
+ // run as recorded.
4585
+ p.confidence === "low" ? { ...p, enabled: false } : p
4586
+ )
4587
+ );
4588
+ onStep?.(step);
4589
+ };
4590
+ const consider = (sig) => {
4591
+ signature = sig;
4592
+ if (!pending) return;
4593
+ if (hasObservableChange(pending.before, sig)) finish(sig);
4594
+ };
4595
+ const begin = (step, before) => {
4596
+ finish(signature);
4597
+ const timer = setTimeout(() => finish(signature), OUTCOME_WINDOW_MS);
4598
+ timer.unref?.();
4599
+ pending = { step, before, timer };
4600
+ };
4601
+ const targetIds = /* @__PURE__ */ new Map();
4602
+ const record = (payload, before) => {
4603
+ if (["fill", "select", "setRange"].includes(payload.type)) {
4604
+ const prev = steps[steps.length - 1];
4605
+ if (prev && prev.type === payload.type && targetIds.get(prev.id) === payload.__targetId && payload.__targetId && prev.pageId === payload.pageId && JSON.stringify(prev.frame) === JSON.stringify(payload.frame)) {
4606
+ prev.value = payload.value ?? "";
4607
+ if (payload.values) prev.values = payload.values;
4608
+ onStep?.(prev);
4609
+ paint();
4610
+ return;
4611
+ }
4612
+ }
4613
+ if (payload.type === "scroll") {
4614
+ pendingScrollPos = payload.scroll;
3921
4615
  return;
3922
4616
  }
3923
- if (step.type === "navigate") {
3924
- await page.goto(new URL(step.value, page.url()).href, { waitUntil: "domcontentloaded", timeout: navigationBudget() });
4617
+ if (payload.type === "dblclick") {
4618
+ const prev = steps[steps.length - 1];
4619
+ if (prev && prev.type === "click" && prev.name === payload.name && prev.scope === payload.scope) {
4620
+ steps.pop();
4621
+ }
4622
+ }
4623
+ const step = {
4624
+ id: `st_${crypto.randomUUID().slice(0, 8)}`,
4625
+ type: payload.type,
4626
+ role: payload.role ?? "",
4627
+ name: payload.name ?? "",
4628
+ ...payload.nameFrom ? { nameFrom: payload.nameFrom } : {},
4629
+ scope: payload.scope ?? "",
4630
+ ...payload.scopeRole ? { scopeRole: payload.scopeRole } : {},
4631
+ ...payload.frame?.length ? { frame: payload.frame } : {},
4632
+ ...payload.to ? { to: payload.to } : {},
4633
+ ...payload.locator ? { locator: payload.locator } : {},
4634
+ ...payload.values ? { values: payload.values } : {},
4635
+ ...payload.warning ? { warning: payload.warning } : {},
4636
+ ...payload.pageId ? { pageId: payload.pageId } : {},
4637
+ ...payload.scroll ? { scroll: payload.scroll } : {},
4638
+ value: payload.value ?? "",
4639
+ secret: Boolean(payload.secret),
4640
+ legacySelector: payload.legacySelector ?? "",
4641
+ proposals: []
4642
+ };
4643
+ RecordedStepSchema.parse(step);
4644
+ targetIds.set(step.id, payload.__targetId);
4645
+ if (pendingScrollPos) {
4646
+ step.scroll = pendingScrollPos;
4647
+ pendingScrollPos = null;
4648
+ }
4649
+ steps.push(step);
4650
+ onStep?.(step);
4651
+ paint();
4652
+ if (COMMITS.has(step.type)) begin(step, before);
4653
+ };
4654
+ const PRESS_GRACE_MS = 200;
4655
+ let held = null;
4656
+ const releaseHeld = () => {
4657
+ if (!held) return;
4658
+ const { payload, before, timer } = held;
4659
+ clearTimeout(timer);
4660
+ held = null;
4661
+ record(payload, before);
4662
+ };
4663
+ await context.exposeBinding("__flRecord", async (src, payload) => {
4664
+ if (paused || !payload || stopped && payload.__sig === void 0) return;
4665
+ if (src.frame && !frameChains.has(src.frame)) await noteFrame(src.frame);
4666
+ if (src.page) payload.pageId = pageIds.get(src.page) ?? "main";
4667
+ if (payload && payload.__sig !== void 0) {
4668
+ if (!pending || pending.step.pageId === payload.pageId) consider(payload.__sig || null);
3925
4669
  return;
3926
4670
  }
3927
- if (step.type === "closePage") {
3928
- await page.close();
4671
+ const before = payload.__before ?? signature;
4672
+ delete payload.__before;
4673
+ const chain = src?.frame ? frameChains.get(src.frame) : void 0;
4674
+ if (chain?.length) payload.frame = chain;
4675
+ if (payload.type === "press" && payload.value === "Enter") {
4676
+ releaseHeld();
4677
+ const timer = setTimeout(releaseHeld, PRESS_GRACE_MS);
4678
+ timer.unref?.();
4679
+ held = { payload, before, timer };
3929
4680
  return;
3930
4681
  }
3931
- if (step.type === "back" || step.type === "forward") {
3932
- await navigateHistory(page, step.type, navigationBudget());
3933
- return;
4682
+ if (payload.type === "click" && held) {
4683
+ clearTimeout(held.timer);
4684
+ held = null;
4685
+ } else if (held && payload.name !== held.payload.name) {
4686
+ releaseHeld();
3934
4687
  }
3935
- if (step.type === "scroll" && step.scroll) {
3936
- await applyScroll(page, step, remaining());
4688
+ record(payload, before);
4689
+ });
4690
+ await context.exposeBinding("__flPicked", async (src, payload) => {
4691
+ if (src.frame && !frameChains.has(src.frame)) await noteFrame(src.frame);
4692
+ if (payload?.cancelled) {
4693
+ void session.setPicking(false);
4694
+ void toPanel({ picked: null });
3937
4695
  return;
3938
4696
  }
3939
- const ref = step.secretRef || step.value;
3940
- const value = step.secret ? ref ? options.resolveSecret?.(ref) : void 0 : step.value;
3941
- if (step.secret && value === void 0) {
3942
- throw Object.assign(
3943
- new Error(
3944
- ref ? `the secret "${ref}" is not set on this machine \u2014 add it under Settings \u203A Secrets, or in .flowriki/secrets.local.json, then run again` : `this step needs a secret that has not been stored yet \u2014 open the flow, re-enter the value for "${step.name}" and save`
3945
- ),
3946
- { kind: "not_found" }
3947
- );
3948
- }
3949
- if (step.scroll && step.type !== "scroll") {
3950
- await applyScroll(page, step, Math.min(2e3, remaining()));
4697
+ if (!payload?.target) return;
4698
+ const chain = src?.frame ? frameChains.get(src.frame) : void 0;
4699
+ if (chain?.length) payload.target.frame = chain;
4700
+ if (src.page) payload.target.pageId = pageIds.get(src.page) ?? "main";
4701
+ try {
4702
+ const on = src.page ?? page;
4703
+ const all = await locateAll(on, {
4704
+ id: "pick",
4705
+ type: "click",
4706
+ value: "",
4707
+ secret: false,
4708
+ legacySelector: "",
4709
+ proposals: [],
4710
+ scope: "",
4711
+ ...payload.target
4712
+ });
4713
+ const handles = await all.elementHandles();
4714
+ if (handles.length > 1) {
4715
+ for (const [i, handle] of handles.entries()) {
4716
+ const isIt = await handle.evaluate((el) => el === window.__flPickedEl).catch(() => false);
4717
+ if (isIt) {
4718
+ payload.target.nth = i;
4719
+ break;
4720
+ }
4721
+ }
4722
+ if (payload.target.nth === void 0) {
4723
+ payload.ambiguous = handles.length;
4724
+ }
4725
+ } else if (handles.length === 0) {
4726
+ payload.unresolvable = true;
4727
+ }
4728
+ await Promise.all(handles.map((h) => h.dispose().catch(() => {
4729
+ })));
4730
+ } catch {
3951
4731
  }
3952
- const loc = await locate(page, step);
3953
- await ensureTarget(page, loc, step, remaining());
3954
- page.setDefaultTimeout(remaining());
3955
- await applyAction(loc, step, value, { resolveFile: options.resolveFile, dropTarget: await locateDrop(page, step) });
3956
- };
3957
- if (options.pages) await options.pages.action(page, step, remaining(), act);
3958
- else await act();
3959
- return page;
3960
- }
3961
- async function navigateHistory(page, direction, timeout) {
3962
- let traversed = false;
3963
- const listener = (frame) => {
3964
- if (frame === page.mainFrame()) traversed = true;
3965
- };
3966
- page.on("framenavigated", listener);
3967
- try {
3968
- const response = await (direction === "back" ? page.goBack({ waitUntil: "domcontentloaded", timeout }) : page.goForward({ waitUntil: "domcontentloaded", timeout }));
3969
- if (!response && (!traversed || page.url() === "about:blank")) throw Object.assign(new Error(`no history entry to go ${direction} to`), { kind: "navigation" });
3970
- } finally {
3971
- page.off("framenavigated", listener);
3972
- }
3973
- }
3974
- async function applyScroll(page, step, timeout) {
3975
- const scroll = step.scroll;
3976
- if (!scroll) return;
3977
- try {
3978
- const container = scroll.container;
3979
- const subject = step.type === "scroll" ? step : container ? {
3980
- ...step,
3981
- type: "scroll",
3982
- role: container.role,
3983
- name: container.name,
3984
- scope: container.scope ?? "",
3985
- scopeRole: container.scopeRole,
3986
- frame: container.frame,
3987
- pageId: container.pageId,
3988
- nameFrom: container.nameFrom,
3989
- locator: container.locator,
3990
- nth: container.nth,
3991
- legacySelector: scroll.legacySelector ?? ""
3992
- } : step;
3993
- const root = rootFor(page, subject);
3994
- const loc = subject.locator || subject.name ? await locate(page, subject) : root.locator("html");
3995
- await loc.evaluate(
3996
- (el, pos) => {
3997
- el.scrollTo({ left: pos.x, top: pos.y, behavior: "instant" });
3998
- },
3999
- { x: scroll.x, y: scroll.y },
4000
- { timeout }
4001
- );
4002
- } catch {
4003
- }
4004
- }
4005
- async function ensureTarget(page, loc, step, timeout) {
4006
- const firstWait = Math.max(500, Math.floor(timeout * 0.45));
4007
- const recovery = Math.max(1e3, Math.floor(timeout * 0.35));
4008
- try {
4009
- await loc.waitFor({ state: "attached", timeout: firstWait });
4010
- return;
4011
- } catch (error) {
4012
- if (/strict mode violation/.test(String(error))) {
4013
- throw Object.assign(error, { kind: "ambiguous" });
4732
+ void session.setPicking(false);
4733
+ void toPanel({ picked: payload });
4734
+ });
4735
+ await context.exposeBinding("__flStop", () => {
4736
+ onStopRequested?.(true);
4737
+ });
4738
+ await context.exposeBinding("__flCmd", (_src, msg) => {
4739
+ if (!msg || typeof msg.command !== "string") return;
4740
+ if (msg.command === "end") onStopRequested?.(msg.commit !== false);
4741
+ else if (msg.command === "deleteStep") void session.deleteStep(String(msg.stepId));
4742
+ else if (msg.command === "pick") void session.setPicking(msg.on !== false);
4743
+ else if (msg.command === "assert") {
4744
+ void session.setPicking(false);
4745
+ try {
4746
+ session.addAssertion(msg.assertion);
4747
+ } catch (err) {
4748
+ void toPanel({ pickError: firstLine2(err) });
4749
+ }
4750
+ } else if (msg.command === "record") void session.setPaused(!msg.on);
4751
+ });
4752
+ const INIT_SCRIPT = `${accNameScript()}
4753
+ ;window.__flChecks=${JSON.stringify(CHECKS)};
4754
+ ;(${installRecorder.toString()})();
4755
+ ;(${installDebugPanel.toString()})();`;
4756
+ const page = await context.newPage();
4757
+ if (opts.authentication) {
4758
+ try {
4759
+ await page.goto(url, { waitUntil: "domcontentloaded" });
4760
+ await executeStep(page, opts.authentication.step, { ...opts.authentication, baseUrl: url });
4761
+ const resume = { id: "auth-return", type: "navigate", name: "Open recording start page", role: "", scope: "", value: new URL(url).pathname + new URL(url).search + new URL(url).hash, navigationBase: "application", secret: false, legacySelector: "", proposals: [] };
4762
+ steps.push(opts.authentication.step, resume);
4763
+ onStep?.(opts.authentication.step);
4764
+ onStep?.(resume);
4765
+ } catch (error) {
4766
+ await browser.close();
4767
+ throw error;
4014
4768
  }
4015
4769
  }
4016
- if (await scrollUntilFound(page, loc, recovery)) return;
4017
- throw Object.assign(
4018
- new Error(
4019
- `no element named "${step.name}" \u2014 nothing on this page matches the target or its recorded scope`
4020
- ),
4021
- { kind: "not_found" }
4022
- );
4023
- }
4024
- async function replayCase(opts) {
4025
- const { occurrences, baseUrl } = opts;
4026
- const started = Date.now();
4027
- const warnings = [];
4028
- const allSteps = occurrences.flatMap((o) => o.steps);
4029
- for (const occurrence of occurrences) {
4030
- if (occurrence.environment && Object.keys(occurrence.environment).length > 0) {
4031
- warnings.push(
4032
- `"${occurrence.name}" was recorded with its own browser settings; the case's settings were used instead`
4033
- );
4770
+ await context.addInitScript({ content: INIT_SCRIPT });
4771
+ const ensureInstalled = async (p) => {
4772
+ try {
4773
+ await p.evaluate((src) => {
4774
+ if (document.__flInstalled) return;
4775
+ (0, eval)(src);
4776
+ }, INIT_SCRIPT);
4777
+ } catch {
4034
4778
  }
4035
- }
4036
- const session = await openRunSession({
4037
- steps: allSteps,
4038
- ...opts.environment === void 0 ? {} : { environment: opts.environment },
4039
- ...opts.headless === void 0 ? {} : { headless: opts.headless },
4040
- ...opts.slowMo === void 0 ? {} : { slowMo: opts.slowMo },
4041
- ...opts.artifacts === void 0 ? {} : { artifacts: opts.artifacts },
4042
- ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4043
- ...opts.resolveSecret === void 0 ? {} : { resolveSecret: opts.resolveSecret }
4779
+ };
4780
+ pageIds.set(page, "main");
4781
+ const watchPage = (p) => {
4782
+ watchFrames(p);
4783
+ void ensureInstalled(p);
4784
+ p.on("domcontentloaded", () => void ensureInstalled(p));
4785
+ p.on("download", (download) => {
4786
+ const step = [...steps].reverse().find((s2) => (s2.pageId ?? "main") === pageIds.get(p));
4787
+ if (step) {
4788
+ (step.downloads ??= []).push(download.suggestedFilename());
4789
+ onStep?.(step);
4790
+ paint();
4791
+ }
4792
+ });
4793
+ p.on("dialog", (dialog) => {
4794
+ const step = [...steps].reverse().find((s2) => (s2.pageId ?? "main") === pageIds.get(p));
4795
+ if (!step) {
4796
+ void dialog.dismiss();
4797
+ record({ type: "unsupported", name: "Dialog before first action", warning: "Record an explicit navigation before a page-load dialog", pageId: pageIds.get(p) }, null);
4798
+ return;
4799
+ }
4800
+ pendingDialog = { dialog, step };
4801
+ onState?.(snapshot());
4802
+ });
4803
+ p.on("close", () => {
4804
+ if (!stopped && p !== page) record({ type: "closePage", pageId: pageIds.get(p) }, null);
4805
+ });
4806
+ };
4807
+ watchPage(page);
4808
+ context.on("page", (p) => {
4809
+ const id = `page_${++nextPageId}`;
4810
+ pageIds.set(p, id);
4811
+ watchPage(p);
4812
+ void p.opener().then((opener) => {
4813
+ const step = [...steps].reverse().find((s2) => (s2.pageId ?? "main") === pageIds.get(opener));
4814
+ if (step) {
4815
+ step.opensPage = id;
4816
+ onStep?.(step);
4817
+ paint();
4818
+ } else record({ type: "unsupported", name: "Unlinked browser tab", warning: "Open this tab through a recorded link or button", pageId: id }, null);
4819
+ });
4044
4820
  });
4045
- const outcomes = [];
4046
- let status2 = "passed";
4047
- let error = null;
4048
4821
  try {
4049
- for (const [index, occurrence] of occurrences.entries()) {
4050
- if (status2 !== "passed" || opts.signal?.aborted) {
4051
- const skipped = {
4052
- occurrenceId: occurrence.occurrenceId,
4053
- name: occurrence.name,
4054
- status: "skipped",
4055
- error: null,
4056
- steps: [],
4057
- stepsRun: 0,
4058
- stepsTotal: occurrence.steps.length,
4059
- durationMs: 0,
4060
- continued: false
4061
- };
4062
- if (status2 === "passed" && opts.signal?.aborted) {
4063
- status2 = "errored";
4064
- error = "the run was cancelled";
4065
- }
4066
- outcomes.push(skipped);
4067
- opts.onOccurrenceResult?.(skipped);
4068
- continue;
4069
- }
4070
- const navigates = index === 0 || occurrence.navigateFirst;
4071
- const at0 = Date.now();
4072
- const { results, status: ran, error: failed } = await runStepsIn(session, occurrence.steps, {
4073
- stepOffset: outcomes.reduce((count, previous) => count + previous.stepsTotal, 0),
4074
- ...opts.resolveSecret === void 0 ? {} : { resolveSecret: opts.resolveSecret },
4075
- ...opts.resolveFile === void 0 ? {} : { resolveFile: opts.resolveFile },
4076
- ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4077
- ...opts.signal === void 0 ? {} : { signal: opts.signal },
4078
- ...opts.onStepResult ? { onStepResult: (result) => opts.onStepResult(result, occurrence.occurrenceId) } : {},
4079
- startUrl: navigates ? resolveStartUrl(occurrence.baseUrl ?? baseUrl, occurrence.startPath ?? "/") : null
4080
- });
4081
- const outcome = {
4082
- occurrenceId: occurrence.occurrenceId,
4083
- name: occurrence.name,
4084
- status: ran,
4085
- error: failed,
4086
- steps: results,
4087
- stepsRun: results.filter((r) => r.status !== "skipped").length,
4088
- stepsTotal: occurrence.steps.length,
4089
- durationMs: Date.now() - at0,
4090
- continued: !navigates
4822
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
4823
+ } catch (err) {
4824
+ stopped = true;
4825
+ await browser.close().catch(() => {
4826
+ });
4827
+ throw new Error(`could not open ${url} \u2014 ${firstLine2(err)}`);
4828
+ }
4829
+ const session = {
4830
+ environment: config,
4831
+ page,
4832
+ startUrl: url,
4833
+ state: snapshot,
4834
+ deleteStep(id) {
4835
+ const i = steps.findIndex((x) => x.id === id);
4836
+ if (i !== -1) steps.splice(i, 1);
4837
+ paint();
4838
+ return snapshot();
4839
+ },
4840
+ async setPicking(on) {
4841
+ const call = (fn, arg) => Promise.all(
4842
+ context.pages().flatMap(
4843
+ (p) => p.frames().map(
4844
+ (f) => f.evaluate(
4845
+ ([name, value]) => window[name]?.(value),
4846
+ [fn, arg]
4847
+ ).catch(() => {
4848
+ })
4849
+ )
4850
+ )
4851
+ );
4852
+ if (on) await call("__flSetMuted", true);
4853
+ await call("__flSetPicking", on);
4854
+ if (!on && !paused) await call("__flSetMuted", false);
4855
+ },
4856
+ addAssertion(assertion) {
4857
+ const parsed = RecordedAssertionSchema.parse({ enabled: true, ...assertion });
4858
+ const step = {
4859
+ id: `st_${crypto.randomUUID().slice(0, 8)}`,
4860
+ type: "assert",
4861
+ // Mirrored from the target so a step list can name what is being
4862
+ // checked without reaching inside the assertion to find out.
4863
+ role: parsed.target?.role ?? "",
4864
+ name: parsed.target?.name ?? "",
4865
+ ...parsed.target?.nameFrom ? { nameFrom: parsed.target.nameFrom } : {},
4866
+ scope: parsed.target?.scope ?? "",
4867
+ ...parsed.target?.scopeRole ? { scopeRole: parsed.target.scopeRole } : {},
4868
+ ...parsed.target?.frame?.length ? { frame: parsed.target.frame } : {},
4869
+ ...parsed.target?.locator ? { locator: parsed.target.locator } : {},
4870
+ ...parsed.target?.pageId ? { pageId: parsed.target.pageId } : {},
4871
+ value: "",
4872
+ secret: false,
4873
+ legacySelector: "",
4874
+ proposals: [parsed]
4091
4875
  };
4092
- outcomes.push(outcome);
4093
- opts.onOccurrenceResult?.(outcome);
4094
- if (ran !== "passed") {
4095
- status2 = ran;
4096
- error = `${occurrence.name}: ${failed ?? "failed"}`;
4876
+ if ((parsed.kind === "text" || parsed.kind === "class" || parsed.kind === "attribute") && parsed.target?.nameFrom === "text" && !parsed.target.locator) {
4877
+ step.warning = "This element is identified by its own text, so a change to that text reads as a missing element rather than a failed check. Give it a test ID or a locator to tell the two apart.";
4878
+ }
4879
+ RecordedStepSchema.parse(step);
4880
+ if (pendingScrollPos) {
4881
+ step.scroll = pendingScrollPos;
4882
+ pendingScrollPos = null;
4097
4883
  }
4884
+ steps.push(step);
4885
+ onStep?.(step);
4886
+ paint();
4887
+ return snapshot();
4888
+ },
4889
+ async setPaused(next) {
4890
+ paused = next;
4891
+ await Promise.all(
4892
+ context.pages().map(
4893
+ (p) => p.evaluate(
4894
+ (v) => window.__flSetMuted?.(v),
4895
+ next
4896
+ ).catch(() => {
4897
+ })
4898
+ )
4899
+ );
4900
+ paint();
4901
+ return snapshot();
4902
+ },
4903
+ async answerDialog(accept, promptText) {
4904
+ if (!pendingDialog) throw new Error("No browser dialog is waiting");
4905
+ const { dialog, step } = pendingDialog;
4906
+ const answer = { type: dialog.type(), message: dialog.message(), accept, ...promptText !== void 0 ? { promptText } : {} };
4907
+ if (accept) await dialog.accept(promptText);
4908
+ else await dialog.dismiss();
4909
+ (step.dialogs ??= []).push(answer);
4910
+ pendingDialog = null;
4911
+ onStep?.(step);
4912
+ paint();
4913
+ },
4914
+ async stop() {
4915
+ if (pendingDialog) throw new Error("Answer the browser dialog before saving the recording");
4916
+ if (stopped) return steps;
4917
+ await Promise.all(context.pages().flatMap((p) => p.frames().map((f) => f.evaluate(() => window.__flFlush?.()).catch(() => {
4918
+ }))));
4919
+ stopped = true;
4920
+ releaseHeld();
4921
+ if (pending) await sleep(OUTCOME_WINDOW_MS + 300);
4922
+ finish(signature);
4923
+ await browser.close().catch(() => {
4924
+ });
4925
+ return steps;
4098
4926
  }
4099
- } finally {
4100
- await closeRunSession(session, {
4101
- ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4102
- passed: status2 === "passed"
4103
- });
4104
- }
4105
- if (opts.signal?.aborted) {
4106
- status2 = "errored";
4107
- error = "the run was cancelled";
4108
- }
4109
- const steps = outcomes.flatMap((o) => o.steps);
4110
- return {
4111
- status: status2,
4112
- error,
4113
- artifacts: session.runArtifacts,
4114
- durationMs: Date.now() - started,
4115
- occurrences: outcomes,
4116
- steps,
4117
- stepsRun: steps.filter((r) => r.status !== "skipped").length,
4118
- stepsTotal: occurrences.reduce((n2, o) => n2 + o.steps.length, 0),
4119
- warnings
4120
4927
  };
4928
+ page.on("domcontentloaded", () => paint());
4929
+ context.on("page", (p) => p.on("domcontentloaded", () => paint()));
4930
+ paint();
4931
+ return session;
4932
+ }
4933
+ function firstLine2(err) {
4934
+ return String(err?.message ?? err).split("\n")[0].trim();
4935
+ }
4936
+ var SESSION_STEPS = /* @__PURE__ */ new Set(["signIn", "signOut", "useActor", "resetActor", "capture"]);
4937
+ var usesAnOutput = (text) => Array.isArray(text) ? text.some(usesAnOutput) : String(text ?? "").includes("{{");
4938
+ function needsWholeRun(opts) {
4939
+ if (opts.actors?.length) return true;
4940
+ return opts.steps.some((step) => SESSION_STEPS.has(step.type) || usesAnOutput(step.value) || (step.values ?? []).some(usesAnOutput) || (step.proposals ?? []).some((check) => usesAnOutput(check.value)));
4121
4941
  }
4122
4942
  async function verifySteps(opts) {
4943
+ if (needsWholeRun(opts)) {
4944
+ const result = await replayFlow({ ...opts, onStepResult: (step) => opts.onStepResult?.({ stepId: step.stepId, status: step.status, matches: 0, detail: step.detail ?? "Action and assertions passed" }) });
4945
+ return {
4946
+ ok: result.status === "passed",
4947
+ error: result.error,
4948
+ failedAtStepId: result.steps.find((s2) => s2.status === "failed")?.stepId ?? null,
4949
+ results: result.steps.map((s2) => ({ stepId: s2.stepId, status: s2.status, matches: 0, detail: s2.detail ?? "Action and assertions passed" }))
4950
+ };
4951
+ }
4123
4952
  const {
4124
4953
  steps,
4125
4954
  baseUrl,
@@ -4153,7 +4982,7 @@ async function verifySteps(opts) {
4153
4982
  let matches = 0;
4154
4983
  try {
4155
4984
  const current = pages.pageFor(step);
4156
- if (!["back", "forward", "navigate", "closePage", "unsupported", "scroll", "assert"].includes(step.type)) {
4985
+ if (!["back", "forward", "navigate", "closePage", "unsupported", "scroll", "assert", "signIn", "signOut"].includes(step.type)) {
4157
4986
  const all = await locateAll(current, step);
4158
4987
  const budget = step.timeoutMs ?? timeoutMs;
4159
4988
  try {
@@ -4165,7 +4994,7 @@ async function verifySteps(opts) {
4165
4994
  matches = await all.count();
4166
4995
  if (matches > 1 && step.nth === void 0) throw new Error(`${matches} elements match this step \u2014 choose a unique target`);
4167
4996
  }
4168
- const active = await executeStep(page, step, { pages, resolveSecret, resolveFile, timeoutMs });
4997
+ const active = await executeStep(page, step, { pages, resolveSecret, resolveFile, timeoutMs, baseUrl, ...opts.resolveAuth ? { resolveAuth: opts.resolveAuth } : {} });
4169
4998
  for (const a of step.proposals) if (a.enabled !== false) await checkAssertion(active, a.kind, a.value, config.assertionTimeoutMs, a);
4170
4999
  report2({
4171
5000
  stepId: step.id,
@@ -4176,15 +5005,15 @@ async function verifySteps(opts) {
4176
5005
  } catch (caught) {
4177
5006
  const err = /Timeout/.test(String(caught)) ? new Error(`Target could not be found within the step timeout`) : caught;
4178
5007
  const what = step.type === "assert" ? `check ${describeAssertion(step.proposals[0] ?? { kind: "visible", value: "" }, step.name)}` : `${step.type} "${step.name}"`;
4179
- error = `step ${index + 1} (${what}): ${firstLine(err)}`;
5008
+ error = `step ${index + 1} (${what}): ${firstLine2(err)}`;
4180
5009
  failedAtStepId = step.id;
4181
- report2({ stepId: step.id, status: "failed", matches, detail: firstLine(err) });
5010
+ report2({ stepId: step.id, status: "failed", matches, detail: firstLine2(err) });
4182
5011
  for (const later of steps.slice(index + 1)) report2({ stepId: later.id, status: "skipped", matches: 0, detail: "the flow could not reach this step" });
4183
5012
  break;
4184
5013
  }
4185
5014
  }
4186
5015
  } catch (err) {
4187
- error = firstLine(err);
5016
+ error = firstLine2(err);
4188
5017
  } finally {
4189
5018
  await browser.close();
4190
5019
  }
@@ -4193,8 +5022,8 @@ async function verifySteps(opts) {
4193
5022
  var cachedAccName = null;
4194
5023
  function accNameScript2() {
4195
5024
  if (cachedAccName === null) {
4196
- const path33 = accNameScriptPath();
4197
- cachedAccName = fs32.readFileSync(path33, "utf8");
5025
+ const path42 = accNameScriptPath();
5026
+ cachedAccName = fs32.readFileSync(path42, "utf8");
4198
5027
  }
4199
5028
  return cachedAccName;
4200
5029
  }
@@ -4221,7 +5050,9 @@ async function startDebugSession(opts) {
4221
5050
  let stopRequested = false;
4222
5051
  const applied = /* @__PURE__ */ new Set();
4223
5052
  const recordedInSession = /* @__PURE__ */ new Set();
4224
- const { browser, context } = await launchSession({ ...opts.environment, timeoutMs }, headless, slowMo);
5053
+ validateActors(opts.actors, opts.defaultActorId, actorIdsUsed(steps));
5054
+ let actors;
5055
+ const { browser, context, config, artifacts } = await launchSession({ ...opts.environment, timeoutMs }, headless, slowMo);
4225
5056
  let closed = false;
4226
5057
  browser.on("disconnected", () => {
4227
5058
  if (!closed) {
@@ -4238,7 +5069,8 @@ async function startDebugSession(opts) {
4238
5069
  failure,
4239
5070
  recording,
4240
5071
  recorded: [...recordedInSession],
4241
- mode: "debug"
5072
+ mode: "debug",
5073
+ actorId: actors?.currentId ?? "default"
4242
5074
  });
4243
5075
  const publish = () => {
4244
5076
  const s2 = snapshot();
@@ -4261,7 +5093,7 @@ async function startDebugSession(opts) {
4261
5093
  }))
4262
5094
  };
4263
5095
  await Promise.all(
4264
- context.pages().map(
5096
+ (actors?.all().flatMap((s22) => s22.context.pages()) ?? context.pages()).map(
4265
5097
  (p) => p.evaluate(
4266
5098
  (v) => window.__flDebugRender?.(v),
4267
5099
  safe2
@@ -4271,68 +5103,82 @@ async function startDebugSession(opts) {
4271
5103
  );
4272
5104
  }
4273
5105
  const targetIds = /* @__PURE__ */ new Map();
4274
- await context.exposeBinding("__flRecord", (_src, payload) => {
4275
- if (!recording || !payload || payload.__sig !== void 0) return;
4276
- const step = {
4277
- id: `st_${crypto.randomUUID().slice(0, 8)}`,
4278
- type: payload.type,
4279
- role: payload.role ?? "",
4280
- ...payload.nameFrom ? { nameFrom: payload.nameFrom } : {},
4281
- name: payload.name ?? "",
4282
- scope: payload.scope ?? "",
4283
- frame: payload.frame,
4284
- scopeRole: payload.scopeRole,
4285
- to: payload.to,
4286
- locator: payload.locator,
4287
- values: payload.values,
4288
- warning: payload.warning,
4289
- scroll: payload.scroll,
4290
- value: payload.value ?? "",
4291
- secret: Boolean(payload.secret),
4292
- legacySelector: payload.legacySelector ?? "",
4293
- proposals: []
4294
- };
4295
- const at = cursor ? steps.findIndex((s2) => s2.id === cursor) : steps.length;
4296
- const insertAt = at === -1 ? steps.length : at;
4297
- if (["fill", "select", "setRange"].includes(step.type)) {
4298
- const prev = steps[insertAt - 1];
4299
- if (prev && applied.has(prev.id) && prev.type === step.type && payload.__targetId && targetIds.get(prev.id) === payload.__targetId) {
4300
- prev.value = step.value;
4301
- prev.values = step.values;
4302
- prev.secret = step.secret;
4303
- publish();
4304
- return;
5106
+ async function installContext(context2) {
5107
+ await context2.exposeBinding("__flRecord", (_src, payload) => {
5108
+ if (!recording || !payload || payload.__sig !== void 0 || actors && _src.page.context() !== actors.current.context) return;
5109
+ const step = {
5110
+ id: `st_${crypto.randomUUID().slice(0, 8)}`,
5111
+ type: payload.type,
5112
+ role: payload.role ?? "",
5113
+ ...payload.nameFrom ? { nameFrom: payload.nameFrom } : {},
5114
+ name: payload.name ?? "",
5115
+ scope: payload.scope ?? "",
5116
+ frame: payload.frame,
5117
+ scopeRole: payload.scopeRole,
5118
+ to: payload.to,
5119
+ locator: payload.locator,
5120
+ values: payload.values,
5121
+ warning: payload.warning,
5122
+ scroll: payload.scroll,
5123
+ value: payload.value ?? "",
5124
+ secret: Boolean(payload.secret),
5125
+ legacySelector: payload.legacySelector ?? "",
5126
+ proposals: []
5127
+ };
5128
+ const at = cursor ? steps.findIndex((s2) => s2.id === cursor) : steps.length;
5129
+ const insertAt = at === -1 ? steps.length : at;
5130
+ if (["fill", "select", "setRange"].includes(step.type)) {
5131
+ const prev = steps[insertAt - 1];
5132
+ if (prev && applied.has(prev.id) && prev.type === step.type && payload.__targetId && targetIds.get(prev.id) === payload.__targetId) {
5133
+ prev.value = step.value;
5134
+ prev.values = step.values;
5135
+ prev.secret = step.secret;
5136
+ publish();
5137
+ return;
5138
+ }
4305
5139
  }
4306
- }
4307
- RecordedStepSchema.parse(step);
4308
- targetIds.set(step.id, payload.__targetId);
4309
- steps.splice(insertAt, 0, step);
4310
- applied.add(step.id);
4311
- recordedInSession.add(step.id);
4312
- results[step.id] = {
4313
- stepId: step.id,
4314
- status: "passed",
4315
- detail: "recorded here",
4316
- durationMs: 0,
4317
- checks: []
4318
- };
4319
- publish();
4320
- });
4321
- await context.exposeBinding("__flCmd", (_src, msg) => {
4322
- void handleCommand(msg);
4323
- });
4324
- await context.addInitScript({ content: `${accNameScript2()}
5140
+ RecordedStepSchema.parse(step);
5141
+ targetIds.set(step.id, payload.__targetId);
5142
+ steps.splice(insertAt, 0, step);
5143
+ applied.add(step.id);
5144
+ recordedInSession.add(step.id);
5145
+ results[step.id] = {
5146
+ stepId: step.id,
5147
+ status: "passed",
5148
+ detail: "recorded here",
5149
+ durationMs: 0,
5150
+ checks: []
5151
+ };
5152
+ publish();
5153
+ });
5154
+ await context2.exposeBinding("__flCmd", (_src, msg) => {
5155
+ void handleCommand(msg);
5156
+ });
5157
+ await context2.addInitScript({ content: `${accNameScript2()}
4325
5158
  ;(${installRecorder.toString()})();
4326
5159
  ;(${installDebugPanel.toString()})();` });
4327
- context.on("page", (p) => p.on("domcontentloaded", () => void paint(snapshot())));
5160
+ context2.on("page", (p) => p.on("domcontentloaded", () => void paint(snapshot())));
5161
+ }
5162
+ await installContext(context);
4328
5163
  const page = await context.newPage();
4329
5164
  const pages = new ExecutionPages(context, page);
4330
5165
  page.on("domcontentloaded", () => void paint(snapshot()));
4331
5166
  await setMuted(true);
4332
- await page.goto(resolveStartUrl(baseUrl, startPath), { waitUntil: "domcontentloaded" });
5167
+ actors = new RunActors({ browser, context, config, artifacts, page, pages, consoleLines: [], runArtifacts: [] }, {
5168
+ ...opts,
5169
+ startUrl: resolveStartUrl(baseUrl, startPath),
5170
+ onCreate: async (session2) => installContext(session2.context)
5171
+ });
5172
+ try {
5173
+ await actors.select(opts.defaultActorId ?? "default");
5174
+ } catch (error) {
5175
+ closed = true;
5176
+ await browser.close();
5177
+ throw error;
5178
+ }
4333
5179
  async function setMuted(on) {
4334
5180
  await Promise.all(
4335
- context.pages().map(
5181
+ (actors?.all().flatMap((s2) => s2.context.pages()) ?? context.pages()).map(
4336
5182
  (p) => p.evaluate((v) => window.__flSetMuted?.(v), on).catch(() => {
4337
5183
  })
4338
5184
  )
@@ -4399,8 +5245,10 @@ async function startDebugSession(opts) {
4399
5245
  checks: []
4400
5246
  };
4401
5247
  try {
5248
+ step = withRuntimeValues(step, actors?.values ?? /* @__PURE__ */ new Map());
4402
5249
  const fresh = step.secret && recordedInSession.has(step.id);
4403
- const active = await executeStep(page, fresh ? { ...step, secret: false } : step, { pages, resolveSecret, resolveFile, timeoutMs });
5250
+ const on = actors?.current ?? { page, pages };
5251
+ const active = await executeStep(on.page, fresh ? { ...step, secret: false } : step, { pages: on.pages, resolveSecret, resolveFile, timeoutMs, baseUrl, valuesResolved: true, ...actors ? { actors } : {}, ...opts.resolveAuth ? { resolveAuth: opts.resolveAuth } : {} });
4404
5252
  for (const a of step.proposals ?? []) {
4405
5253
  if (a.enabled === false) continue;
4406
5254
  const label = describeAssertion(a, step.name);
@@ -4408,7 +5256,7 @@ async function startDebugSession(opts) {
4408
5256
  const detail = await checkAssertion(active, a.kind, a.value, step.timeoutMs ?? timeoutMs, a);
4409
5257
  result.checks.push({ kind: a.kind, expected: label, ok: true, soft: a.soft === true, detail });
4410
5258
  } catch (err) {
4411
- result.checks.push({ kind: a.kind, expected: label, ok: false, soft: a.soft === true, detail: firstLine(err) });
5259
+ result.checks.push({ kind: a.kind, expected: label, ok: false, soft: a.soft === true, detail: firstLine2(err) });
4412
5260
  }
4413
5261
  }
4414
5262
  const bad = result.checks.find((c) => !c.ok && !c.soft);
@@ -4418,7 +5266,7 @@ async function startDebugSession(opts) {
4418
5266
  }
4419
5267
  } catch (err) {
4420
5268
  result.status = "failed";
4421
- result.detail = firstLine(err);
5269
+ result.detail = firstLine2(err);
4422
5270
  }
4423
5271
  result.durationMs = Date.now() - t0;
4424
5272
  return result;
@@ -4458,7 +5306,9 @@ async function startDebugSession(opts) {
4458
5306
  return publish();
4459
5307
  }
4460
5308
  const session = {
4461
- page,
5309
+ get page() {
5310
+ return actors?.current.page ?? page;
5311
+ },
4462
5312
  state: snapshot,
4463
5313
  async play() {
4464
5314
  if (failure) {
@@ -4532,7 +5382,8 @@ async function startDebugSession(opts) {
4532
5382
  status2 = "idle";
4533
5383
  publish();
4534
5384
  await pages.reset();
4535
- await page.goto(resolveStartUrl(baseUrl, startPath), { waitUntil: "domcontentloaded" });
5385
+ await actors.reset();
5386
+ await actors.select(opts.defaultActorId ?? "default");
4536
5387
  return runUntil();
4537
5388
  },
4538
5389
  toggleBreakpoint(stepId) {
@@ -5416,7 +6267,7 @@ function toSummary(row) {
5416
6267
  };
5417
6268
  }
5418
6269
 
5419
- // ../repository-store/dist/chunk-EWIK7XW6.js
6270
+ // ../repository-store/dist/chunk-IXRMPDVC.js
5420
6271
  var FLOWRIKI_DIR = ".flowriki";
5421
6272
  var FEATURES_DIR = `${FLOWRIKI_DIR}/features`;
5422
6273
  var CONFIG_PATH = `${FLOWRIKI_DIR}/config.json`;
@@ -5425,6 +6276,8 @@ var FEATURE_FILE = "feature.json";
5425
6276
  var FLOW_SUFFIX = ".flow.json";
5426
6277
  var CASES_DIR = `${FLOWRIKI_DIR}/cases`;
5427
6278
  var SUITES_DIR = `${FLOWRIKI_DIR}/suites`;
6279
+ var AUTH_DIR = `${FLOWRIKI_DIR}/auth`;
6280
+ var ACCOUNTS_DIR = `${AUTH_DIR}/accounts`;
5428
6281
  function assertSlug(slug) {
5429
6282
  if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error(`invalid slug: ${slug}`);
5430
6283
  return slug;
@@ -5454,6 +6307,8 @@ function parseConfig(raw) {
5454
6307
  const source = raw && typeof raw === "object" ? raw : {};
5455
6308
  return {
5456
6309
  baseUrl: typeof source["baseUrl"] === "string" ? source["baseUrl"] : "",
6310
+ ...typeof source["applicationEnvironment"] === "string" ? { applicationEnvironment: source["applicationEnvironment"] } : {},
6311
+ reuseAuthentication: source["reuseAuthentication"] === true,
5457
6312
  headless: source["headless"] === true,
5458
6313
  environment: ExecutionEnvironmentSchema.parse(source["environment"] ?? {}),
5459
6314
  artifacts: ArtifactPolicySchema.parse(source["artifacts"] ?? {}),
@@ -5491,7 +6346,7 @@ import path6 from "path";
5491
6346
  import { execFileSync } from "child_process";
5492
6347
  import { execFileSync as execFileSync2 } from "child_process";
5493
6348
  import fs33 from "fs";
5494
- import path32 from "path";
6349
+ import path33 from "path";
5495
6350
  function uniqueRef(flowSlug, name, taken) {
5496
6351
  const base = `${flowSlug}_${slugify(name) || "secret"}`;
5497
6352
  if (!taken.has(base)) {
@@ -6040,7 +6895,7 @@ function tryGit(root, args) {
6040
6895
  }
6041
6896
  function readRepo(root) {
6042
6897
  const isRepo = tryGit(root, ["rev-parse", "--is-inside-work-tree"]) === "true";
6043
- const markerFile = path32.join(root, MARKER_PATH);
6898
+ const markerFile = path33.join(root, MARKER_PATH);
6044
6899
  let marker = null;
6045
6900
  let markerUnreadable = false;
6046
6901
  if (fs33.existsSync(markerFile)) {
@@ -6095,8 +6950,8 @@ function writeMarker(root, marker, opts = {}) {
6095
6950
  if (!facts.clean) {
6096
6951
  throw new ClaimRefused("this repository has uncommitted changes \u2014 commit or stash them first");
6097
6952
  }
6098
- const file = path32.join(root, MARKER_PATH);
6099
- fs33.mkdirSync(path32.dirname(file), { recursive: true });
6953
+ const file = path33.join(root, MARKER_PATH);
6954
+ fs33.mkdirSync(path33.dirname(file), { recursive: true });
6100
6955
  fs33.writeFileSync(file, JSON.stringify(marker, null, 2) + "\n");
6101
6956
  git2(root, ["add", "--", MARKER_PATH]);
6102
6957
  git2(root, ["commit", "-m", `Bind this repository to Flowriki project ${marker.projectId}`]);
@@ -6181,6 +7036,10 @@ async function executeFlow(opts) {
6181
7036
  // One resolution, shared with debug and verification. See Store.environmentFor.
6182
7037
  environment: store.environmentFor(flow),
6183
7038
  steps: flow.steps,
7039
+ ...flow.actors ? { actors: flow.actors } : {},
7040
+ ...flow.defaultActorId ? { defaultActorId: flow.defaultActorId } : {},
7041
+ ...store.resolveAuth ? { resolveAuth: store.resolveAuth } : {},
7042
+ ...opts.reuse ? { reuse: opts.reuse } : {},
6184
7043
  // The flow's own origin when it has one, the project's otherwise. A flow
6185
7044
  // that carries no origin is the normal case now: the environment belongs
6186
7045
  // to the project, so moving a suite from staging to production is a
@@ -6461,6 +7320,10 @@ async function executeCase(opts) {
6461
7320
  try {
6462
7321
  outcome = await replayCase({
6463
7322
  occurrences: caseOccurrences(testCase),
7323
+ ...testCase.actors ? { actors: testCase.actors } : {},
7324
+ ...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {},
7325
+ resolveAuth: authenticationResolver(opts.authentications),
7326
+ ...opts.reuse ? { reuse: opts.reuse } : {},
6464
7327
  baseUrl: testCase.baseUrl ?? baseUrl,
6465
7328
  ...testCase.environment ? { environment: testCase.environment } : {},
6466
7329
  headless,
@@ -6658,6 +7521,11 @@ var RecordingCoordinator = class {
6658
7521
  try {
6659
7522
  this.session = await startRecordingSession({
6660
7523
  url: target,
7524
+ ...opts.authentication ? { authentication: {
7525
+ step: { id: "auth-preparation", type: "signIn", auth: opts.authentication, name: "Sign in before recording", role: "", scope: "", value: "", secret: false, legacySelector: "", proposals: [] },
7526
+ resolveAuth: authenticationResolver(opts.authentications),
7527
+ resolveSecret: (ref) => opts.secrets?.[ref]
7528
+ } } : {},
6661
7529
  // The project's saved defaults, unless this recording asks for
6662
7530
  // something else. Without the fallback the settings screen would write
6663
7531
  // a file nothing ever read.
@@ -6847,6 +7715,9 @@ var DebugCoordinator = class {
6847
7715
  const secrets = store.readSecrets();
6848
7716
  const session = await startDebugSession({
6849
7717
  steps: flow.steps,
7718
+ ...flow.actors ? { actors: flow.actors } : {},
7719
+ ...flow.defaultActorId ? { defaultActorId: flow.defaultActorId } : {},
7720
+ ...store.resolveAuth ? { resolveAuth: store.resolveAuth } : {},
6850
7721
  environment: store.environmentFor(flow),
6851
7722
  resolveFile: (name) => store.resolveFile(name),
6852
7723
  baseUrl,
@@ -6968,7 +7839,7 @@ async function verifyFlowSteps(deps, opts) {
6968
7839
  const { store } = deps;
6969
7840
  const flow = store.getFlow(opts.flowId);
6970
7841
  if (!flow) throw new FlowNotFound("flow not found");
6971
- const steps = (opts.steps ?? flow.steps).map((s2) => RecordedStepSchema.parse(s2));
7842
+ const steps = (opts.steps ?? flow.steps).map((s2) => DraftStepSchema.parse(s2));
6972
7843
  if (!opts.verifyIds.length) return { results: [], error: null, failedAtStepId: null, ok: true };
6973
7844
  const baseUrl = store.baseUrlFor(flow);
6974
7845
  const claim = deps.lease?.acquire("run");
@@ -6979,12 +7850,15 @@ async function verifyFlowSteps(deps, opts) {
6979
7850
  try {
6980
7851
  const secrets = store.readSecrets();
6981
7852
  return await verifySteps({
7853
+ ...store.resolveAuth ? { resolveAuth: store.resolveAuth } : {},
6982
7854
  environment: store.environmentFor(flow),
6983
7855
  resolveFile: (name) => store.resolveFile(name),
6984
7856
  // Streamed, so the editor fills in as the pass proceeds instead of
6985
7857
  // sitting blank for the twenty-odd seconds a replay takes.
6986
7858
  ...deps.onStepResult ? { onStepResult: deps.onStepResult } : {},
6987
7859
  steps,
7860
+ ...flow.actors ? { actors: flow.actors } : {},
7861
+ ...flow.defaultActorId ? { defaultActorId: flow.defaultActorId } : {},
6988
7862
  baseUrl,
6989
7863
  startPath: flow.startPath,
6990
7864
  verifyIds: opts.verifyIds,
@@ -7002,6 +7876,7 @@ async function verifyFlowSteps(deps, opts) {
7002
7876
  function deliveredRun(delivered) {
7003
7877
  const { flow, secrets, config } = delivered;
7004
7878
  return {
7879
+ resolveAuth: authenticationResolver(delivered.authentications),
7005
7880
  readConfig: () => config,
7006
7881
  // By id, and only the one that was sent. A machine serving no checkout has
7007
7882
  // no other flow to confuse it with.
@@ -7095,7 +7970,7 @@ function resolveCheckouts(dirs) {
7095
7970
  return { projects, skipped };
7096
7971
  }
7097
7972
 
7098
- // ../agent-core/dist/chunk-63IFXBPX.js
7973
+ // ../agent-core/dist/chunk-EJDGHASQ.js
7099
7974
  import { WebSocket, WebSocketServer } from "ws";
7100
7975
  var HEARTBEAT_MS = 3e4;
7101
7976
  function adapt(socket) {
@@ -7768,12 +8643,12 @@ function repositoryHandlers({ storeFor }) {
7768
8643
  return { ok: false, reason: "failed", detail: "no flow document was sent" };
7769
8644
  }
7770
8645
  try {
7771
- const path33 = store.saveFlow(flow, {
8646
+ const path34 = store.saveFlow(flow, {
7772
8647
  create: command.args["create"] === true,
7773
8648
  ...command.flowRevision !== void 0 ? { expectedRevision: command.flowRevision } : {}
7774
8649
  });
7775
8650
  const saved = store.getFlow(flow.id);
7776
- return { ok: true, result: { path: path33, revision: saved?.revision ?? null } };
8651
+ return { ok: true, result: { path: path34, revision: saved?.revision ?? null } };
7777
8652
  } catch (err) {
7778
8653
  if (err instanceof FlowConflict) {
7779
8654
  return { ok: false, reason: "stale_revision", detail: err.message };
@@ -7964,7 +8839,14 @@ function historyHandlers({ storage, artifactLimit }) {
7964
8839
  };
7965
8840
  }
7966
8841
  function executionHandlers(ctx) {
7967
- const { storeFor, storage, onActivity, lease, cancels } = ctx;
8842
+ const { storeFor, storage, onActivity, lease, cancels, authenticationReuse } = ctx;
8843
+ const reuseFor = (command, projectId, config) => {
8844
+ if (!authenticationReuse || config?.reuseAuthentication !== true) return void 0;
8845
+ const cancelKey = command.args["cancelKey"];
8846
+ const span = typeof cancelKey === "string" && cancelKey.startsWith("suite:") ? cancelKey : command.id;
8847
+ const environment = config?.applicationEnvironment ?? null;
8848
+ return { cache: authenticationReuse, scope: `${projectId}/${span}`, environment };
8849
+ };
7968
8850
  return {
7969
8851
  "flow.run": async (command, project, emit) => {
7970
8852
  if (command.flowRevision === void 0) {
@@ -7974,6 +8856,7 @@ function executionHandlers(ctx) {
7974
8856
  const carried = command.args["flow"];
7975
8857
  const source = carried ? deliveredRun({
7976
8858
  flow: carried,
8859
+ authentications: command.args["authentications"],
7977
8860
  secrets: command.args["secrets"] ?? {},
7978
8861
  config: command.args["config"] ?? {}
7979
8862
  }) : storeFor(project.root);
@@ -8005,8 +8888,10 @@ function executionHandlers(ctx) {
8005
8888
  if (typeof cancelKey === "string" && cancelKey.startsWith("suite:")) cancels.set(cancelKey, cancels.get(command.id));
8006
8889
  let runId = null;
8007
8890
  try {
8891
+ const keeping = reuseFor(command, project.id, source.readConfig());
8008
8892
  const outcome = await executeFlow({
8009
8893
  signal: aborter.signal,
8894
+ ...keeping ? { reuse: keeping } : {},
8010
8895
  ...typeof command.args["headless"] === "boolean" ? { headless: command.args["headless"] } : {},
8011
8896
  ...command.args["repeat"] ? { repeat: Number(command.args["repeat"]) } : {},
8012
8897
  store: source,
@@ -8074,8 +8959,10 @@ function executionHandlers(ctx) {
8074
8959
  if (typeof cancelKey === "string" && cancelKey.startsWith("suite:")) cancels.set(cancelKey, cancels.get(command.id));
8075
8960
  let runId = null;
8076
8961
  try {
8962
+ const keepingForCase = reuseFor(command, project.id, command.args["config"]);
8077
8963
  const { outcome, run } = await executeCase({
8078
8964
  testCase,
8965
+ authentications: command.args["authentications"],
8079
8966
  projectId: project.id,
8080
8967
  config: command.args["config"] ?? {},
8081
8968
  secrets: command.args["secrets"] ?? {},
@@ -8083,6 +8970,7 @@ function executionHandlers(ctx) {
8083
8970
  ...storage ? { storage } : {},
8084
8971
  trigger: "ui",
8085
8972
  ...typeof command.args["headless"] === "boolean" ? { headless: command.args["headless"] } : {},
8973
+ ...keepingForCase ? { reuse: keepingForCase } : {},
8086
8974
  signal: aborter.signal,
8087
8975
  onEvent: (event) => {
8088
8976
  const seen = event.runId;
@@ -8120,6 +9008,7 @@ function executionHandlers(ctx) {
8120
9008
  const cancelled = runId ? cancels.get(runId) : candidates.length === 1 ? candidates[0] : void 0;
8121
9009
  if (!cancelled || cancelled.projectId !== project.id) return { ok: false, reason: "failed", detail: `no run ${runId} is in progress on this machine` };
8122
9010
  cancelled.cancel();
9011
+ authenticationReuse?.clear();
8123
9012
  return { ok: true, result: { runId, projectId: project.id, stopping: true } };
8124
9013
  }
8125
9014
  };
@@ -8151,6 +9040,11 @@ function recordingHandlers({ recorder, onActivity }) {
8151
9040
  // returned on stop for the service to commit to the repository. A
8152
9041
  // machine that has the project checked out still records into it.
8153
9042
  root,
9043
+ ...command.args["authentication"] ? {
9044
+ authentication: AuthReferenceSchema.parse(command.args["authentication"]),
9045
+ authentications: command.args["authentications"],
9046
+ secrets: command.args["secrets"] ?? {}
9047
+ } : {},
8154
9048
  projectId: project.id,
8155
9049
  featureId,
8156
9050
  name,
@@ -8234,6 +9128,7 @@ function debuggingHandlers(ctx) {
8234
9128
  root: project.root,
8235
9129
  ...carried ? { delivered: {
8236
9130
  flow: carried,
9131
+ authentications: command.args["authentications"],
8237
9132
  secrets: command.args["secrets"] ?? {},
8238
9133
  config: command.args["config"] ?? {}
8239
9134
  } } : {},
@@ -8274,6 +9169,7 @@ function debuggingHandlers(ctx) {
8274
9169
  ...carried ? {
8275
9170
  delivered: {
8276
9171
  flow: carried,
9172
+ authentications: command.args["authentications"],
8277
9173
  secrets: command.args["secrets"] ?? {},
8278
9174
  config: command.args["config"] ?? {},
8279
9175
  scratchRoot: scratchRoot(project.id)
@@ -8342,7 +9238,11 @@ function buildHandlers(deps) {
8342
9238
  // A video is easily hundreds of megabytes, and nobody wants one pushed down
8343
9239
  // a control channel by accident.
8344
9240
  artifactLimit: deps.artifactLimitBytes ?? 25 * 1024 * 1024,
8345
- cancels: /* @__PURE__ */ new Map()
9241
+ cancels: /* @__PURE__ */ new Map(),
9242
+ // ONE PER RUNTIME, holding one suite's participants at a time. It never
9243
+ // reaches disk and never outlives the process; a project that has not
9244
+ // asked for reuse never puts anything in it.
9245
+ authenticationReuse: deps.authenticationReuse ?? new AuthenticationReuse()
8346
9246
  };
8347
9247
  const debug = debuggingHandlers(context);
8348
9248
  return {
@@ -8399,6 +9299,7 @@ function createAgentRuntime(options) {
8399
9299
  lease,
8400
9300
  store: deliveredRun({
8401
9301
  flow: delivered.flow,
9302
+ authentications: delivered.authentications,
8402
9303
  secrets: delivered.secrets,
8403
9304
  config: delivered.config
8404
9305
  }),
@@ -8476,9 +9377,11 @@ function createAgentRuntime(options) {
8476
9377
  },
8477
9378
  active: () => recordingIn?.active() ?? null
8478
9379
  };
9380
+ const keptSignIns = new AuthenticationReuse();
8479
9381
  function buildAgent() {
8480
9382
  const handlers = buildHandlers({
8481
9383
  lease,
9384
+ authenticationReuse: keptSignIns,
8482
9385
  storeFor: (r) => new Store(r),
8483
9386
  ...options.storage ? { storage: options.storage } : {},
8484
9387
  recorder,
@@ -8510,7 +9413,11 @@ function createAgentRuntime(options) {
8510
9413
  // drifts from what the agent can actually do is worse than none: it
8511
9414
  // would have the service dispatch work this build cannot perform, or
8512
9415
  // refuse work it can.
8513
- capabilities: Object.keys(handlers),
9416
+ // Commands, and then the FEATURES this build supports inside commands
9417
+ // it has always accepted. Named from the shared set rather than written
9418
+ // out here, so an agent cannot claim a feature the service has no gate
9419
+ // for, or miss one it has.
9420
+ capabilities: [...Object.keys(handlers), ...AGENT_FEATURES],
8514
9421
  // EVERY checkout, each named by its own marker. One agent run serves
8515
9422
  // all of them; the service routes each command to the folder that is
8516
9423
  // the project it names.
@@ -8520,6 +9427,11 @@ function createAgentRuntime(options) {
8520
9427
  }
8521
9428
  const agent = buildAgent();
8522
9429
  journal = agent.journal;
9430
+ const closeAgent = agent.close.bind(agent);
9431
+ agent.close = () => {
9432
+ closeAgent();
9433
+ keptSignIns.clear();
9434
+ };
8523
9435
  return agent;
8524
9436
  }
8525
9437