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

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.14";
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-T76NYOOM.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,52 @@ 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
+ /** WHERE THIS SIGN-IN BEGINS — and it belongs here, not on the procedure.
703
+ *
704
+ * The same login serves both: taken to the login page when signing in is
705
+ * the first thing that happens, and performed from wherever the browser
706
+ * already is when a guest fills a basket and signs in at checkout. Asking
707
+ * the procedure meant choosing once, for every test that would ever use
708
+ * it. Absent means the procedure's own starting page. */
709
+ from: z3.enum(["loginPage", "here"]).optional()
710
+ }).strict().optional(),
711
+ /** Which participant this step belongs to, for `useActor`.
712
+ *
713
+ * Selecting an actor RESUMES its session; it does not sign in again. A step
714
+ * that named an actor and re-authenticated would make moving between two
715
+ * participants cost a login each time, which is the cost this removes. */
716
+ actorId: z3.string().optional(),
717
+ navigationBase: z3.enum(["page", "application"]).optional(),
718
+ capture: z3.object({ name: z3.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/), source: z3.enum(["text", "value", "url"]) }).strict().optional()
685
719
  }).strict();
720
+ var DraftStepSchema = RecordedStepSchema.extend({
721
+ role: z3.string().default(""),
722
+ name: z3.string().default(""),
723
+ scope: z3.string().default(""),
724
+ value: z3.string().default(""),
725
+ secret: z3.boolean().default(false),
726
+ legacySelector: z3.string().default(""),
727
+ proposals: z3.array(RecordedAssertionSchema).default([])
728
+ });
686
729
  function slugify(name) {
687
730
  return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
688
731
  }
732
+ var ActorDefinitionSchema = z3.object({
733
+ id: z3.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/),
734
+ name: z3.string().min(1),
735
+ startSignedIn: z3.object({ profileId: z3.string().min(1), accountId: z3.string().optional() }).strict().optional()
736
+ }).strict();
689
737
  var CURRENT_SCHEMA_VERSION = 5;
690
738
  var FlowDocumentSchema = z3.object({
691
739
  schemaVersion: z3.literal(CURRENT_SCHEMA_VERSION),
@@ -711,6 +759,8 @@ var FlowDocumentSchema = z3.object({
711
759
  steps: z3.array(RecordedStepSchema),
712
760
  environment: ExecutionEnvironmentSchema.optional(),
713
761
  path: z3.string().optional(),
762
+ actors: z3.array(ActorDefinitionSchema).optional(),
763
+ defaultActorId: z3.string().optional(),
714
764
  /** Bumped on every save. An edit carries the revision it was made against,
715
765
  * so a save built on a stale copy is refused instead of overwriting work
716
766
  * somebody else — or a debug session on the same machine — has since done.
@@ -812,6 +862,7 @@ function parseFlowDocument(input) {
812
862
  // ../test-model/dist/index.js
813
863
  import { z as z4 } from "zod";
814
864
  import { z as z22 } from "zod";
865
+ import { z as z32 } from "zod";
815
866
  var EngineSchema = z4.enum(["chromium", "firefox", "webkit"]);
816
867
  var CapabilitySchema = z4.object({
817
868
  id: z4.string(),
@@ -845,6 +896,7 @@ var FlowOccurrenceSchema = z22.object({
845
896
  * two occurrences, and editing, breakpoints, results and deletion must be
846
897
  * able to tell them apart. */
847
898
  occurrenceId: z22.string().min(1),
899
+ actorId: z22.string().optional(),
848
900
  /** Editable, and initially the source flow's name. */
849
901
  name: z22.string().min(1),
850
902
  /** Where this copy starts, when it is told to navigate before running.
@@ -894,6 +946,8 @@ var TestCaseSchema = z22.object({
894
946
  useStartPath: z22.boolean().default(false),
895
947
  baseUrl: z22.string().url().optional(),
896
948
  environment: ExecutionEnvironmentSchema.optional(),
949
+ actors: z22.array(ActorDefinitionSchema).optional(),
950
+ defaultActorId: z22.string().optional(),
897
951
  occurrences: z22.array(FlowOccurrenceSchema).default([]),
898
952
  createdAt: z22.string(),
899
953
  createdBy: z22.string().default(""),
@@ -975,6 +1029,11 @@ function caseDebugPlan(testCase, baseUrl) {
975
1029
  const steps = [];
976
1030
  const occurrences = caseOccurrences(testCase);
977
1031
  for (const [index, item] of occurrences.entries()) {
1032
+ if (testCase.actors?.length) {
1033
+ const id = `case-actor:${encodeURIComponent(item.occurrenceId)}`;
1034
+ owners[id] = { occurrenceId: item.occurrenceId, stepId: null };
1035
+ steps.push({ id, type: "useActor", actorId: item.actorId ?? testCase.defaultActorId ?? "default", role: "", name: "Select participant", scope: "", value: "", secret: false, legacySelector: "", proposals: [] });
1036
+ }
978
1037
  if (index > 0 && item.navigateFirst) {
979
1038
  const id = `case-nav:${encodeURIComponent(item.occurrenceId)}`;
980
1039
  owners[id] = { occurrenceId: item.occurrenceId, stepId: null };
@@ -999,6 +1058,8 @@ function caseDebugPlan(testCase, baseUrl) {
999
1058
  updatedAt: testCase.updatedAt,
1000
1059
  steps,
1001
1060
  startPath: new URL(first?.startPath ?? "/", first?.baseUrl ?? testCase.baseUrl ?? baseUrl).href,
1061
+ ...testCase.actors ? { actors: testCase.actors } : {},
1062
+ ...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {},
1002
1063
  ...testCase.environment ? { environment: testCase.environment } : {}
1003
1064
  });
1004
1065
  return { flow, owners };
@@ -1009,13 +1070,18 @@ function restoreDebugCase(testCase, owners, steps) {
1009
1070
  let current = occurrences[0];
1010
1071
  const present = new Set(steps.map((step) => step.id));
1011
1072
  for (const [id, owner] of Object.entries(owners)) {
1012
- if (owner.stepId === null && !present.has(id)) byId.get(owner.occurrenceId).navigateFirst = false;
1073
+ if (id.startsWith("case-nav:") && owner.stepId === null && !present.has(id)) byId.get(owner.occurrenceId).navigateFirst = false;
1013
1074
  }
1014
1075
  for (const step of steps) {
1015
1076
  const owner = owners[step.id];
1016
1077
  if (owner) current = byId.get(owner.occurrenceId);
1017
1078
  if (!current) throw new Error("debug step has no case occurrence");
1018
1079
  if (owner?.stepId === null) {
1080
+ if (step.id.startsWith("case-actor:")) {
1081
+ if (step.type !== "useActor") throw new Error("a participant boundary must remain a participant step");
1082
+ current.actorId = step.actorId;
1083
+ continue;
1084
+ }
1019
1085
  if (step.type !== "navigate") throw new Error("a case boundary must remain a navigation step");
1020
1086
  current.startPath = step.value;
1021
1087
  continue;
@@ -1024,6 +1090,342 @@ function restoreDebugCase(testCase, owners, steps) {
1024
1090
  }
1025
1091
  return { ...testCase, occurrences };
1026
1092
  }
1093
+ var AuthNavigationSchema = z32.discriminatedUnion("kind", [
1094
+ /** Act from wherever the browser already is — a "Log in" link in the header.
1095
+ * The only kind that is safe part-way through a journey. */
1096
+ z32.object({ kind: z32.literal("continue") }),
1097
+ /** Open the procedure's own starting page first. Right when signing in is
1098
+ * the first thing that happens; it abandons a journey in progress. */
1099
+ z32.object({ kind: z32.literal("open"), path: z32.string().min(1) })
1100
+ ]);
1101
+ var AuthFieldSchema = z32.object({
1102
+ /** The step in the recorded procedure this value is typed into. */
1103
+ stepId: z32.string().min(1),
1104
+ /** What it is called where somebody fills it in — the field's own name on
1105
+ * the page, so an account form reads "Username" rather than "st_9f2a". */
1106
+ label: z32.string().min(1),
1107
+ /** A secret arrives as a reference and is never written into a document. */
1108
+ secret: z32.boolean().default(false),
1109
+ /** What the recording typed. Empty for a secret, whose value never travels.
1110
+ * Kept so the first account can be made from what was recorded, with
1111
+ * nothing to fill in. */
1112
+ recorded: z32.string().default("")
1113
+ }).strict();
1114
+ var AUTH_PROFILE_SCHEMA_VERSION = 2;
1115
+ var AuthProfileSchema = z32.object({
1116
+ schemaVersion: z32.literal(AUTH_PROFILE_SCHEMA_VERSION),
1117
+ id: z32.string().min(1),
1118
+ slug: z32.string().regex(/^[a-z0-9][a-z0-9-]*$/),
1119
+ name: z32.string().min(1),
1120
+ description: z32.string().default(""),
1121
+ /** The recorded sign-in. A flow like any other, so it is edited, debugged
1122
+ * and replayed by everything that already knows how. */
1123
+ flowId: z32.string().min(1),
1124
+ /** Which of the values it types differ per identity. */
1125
+ fields: z32.array(AuthFieldSchema).default([]),
1126
+ /** Optional, and its own procedure with its own proof — most profiles will
1127
+ * not have one at first. The proof is read off that flow, the same way. */
1128
+ signOutFlowId: z32.string().optional(),
1129
+ /** Descriptive only — "admin", "staff". Never used to decide anything, so
1130
+ * two profiles may share a tag without becoming interchangeable. */
1131
+ tags: z32.array(z32.string()).default([]),
1132
+ createdAt: z32.string(),
1133
+ createdBy: z32.string().default(""),
1134
+ updatedAt: z32.string(),
1135
+ revision: z32.number().int().nonnegative().default(0)
1136
+ }).strict();
1137
+ var ACCOUNT_BINDING_SCHEMA_VERSION = 2;
1138
+ var AccountBindingSchema = z32.object({
1139
+ schemaVersion: z32.literal(ACCOUNT_BINDING_SCHEMA_VERSION),
1140
+ id: z32.string().min(1),
1141
+ slug: z32.string().regex(/^[a-z0-9][a-z0-9-]*$/),
1142
+ name: z32.string().min(1),
1143
+ profileId: z32.string().min(1),
1144
+ /** Which environment of the APPLICATION UNDER TEST these credentials belong
1145
+ * to — never Flowriki's own dev or staging. Null means every environment,
1146
+ * which is what a project with one environment wants. */
1147
+ environment: z32.string().min(1).nullable().default(null),
1148
+ /** Step id → what this identity types there. Ordinary values: a username is
1149
+ * not a credential, and keeping it here is what lets somebody read an
1150
+ * account and know who it is. */
1151
+ values: z32.record(z32.string(), z32.string()).default({}),
1152
+ /** Step id → secret REFERENCE. The value is sealed in the vault and never
1153
+ * enters the repository, whatever form it was typed into. */
1154
+ secrets: z32.record(z32.string(), z32.string().regex(/^[a-z][a-z0-9_]*$/i)).default({}),
1155
+ /** What this identity should look like once signed in — a displayed name, a
1156
+ * tenant. Not secret, and checked so that an already-authenticated browser
1157
+ * holding the WRONG account is reported rather than accepted. */
1158
+ identity: z32.object({
1159
+ label: z32.string().optional(),
1160
+ tenant: z32.string().optional()
1161
+ }).default({}),
1162
+ createdAt: z32.string(),
1163
+ createdBy: z32.string().default(""),
1164
+ updatedAt: z32.string(),
1165
+ revision: z32.number().int().nonnegative().default(0)
1166
+ }).strict();
1167
+ var AuthReferenceSchema = z32.object({
1168
+ profileId: z32.string().min(1),
1169
+ /** Where this use of it begins. See the step's own field. */
1170
+ from: z32.enum(["loginPage", "here"]).optional(),
1171
+ /** Absent means "the only account bound to this profile for the selected
1172
+ * environment", which is resolved before a browser opens and refused when
1173
+ * it is ambiguous — a silent choice between two identities is the failure
1174
+ * this field exists to prevent. */
1175
+ accountId: z32.string().optional()
1176
+ }).strict();
1177
+ var ActorSchema = ActorDefinitionSchema;
1178
+ var DeliveredAuthSchema = z32.object({
1179
+ profileId: z32.string().min(1),
1180
+ accountId: z32.string().min(1),
1181
+ name: z32.string(),
1182
+ accountName: z32.string(),
1183
+ steps: z32.array(RecordedStepSchema),
1184
+ navigation: AuthNavigationSchema,
1185
+ success: z32.array(RecordedAssertionSchema),
1186
+ secretRefs: z32.record(z32.string(), z32.string()),
1187
+ signOut: z32.object({ steps: z32.array(RecordedStepSchema), success: z32.array(RecordedAssertionSchema) }).optional()
1188
+ }).strict();
1189
+ function actorPreparations(actors = []) {
1190
+ return actors.flatMap((actor) => actor.startSignedIn ? [{
1191
+ id: `prepare:${actor.id}`,
1192
+ type: "signIn",
1193
+ auth: actor.startSignedIn,
1194
+ name: `Prepare ${actor.name}`,
1195
+ role: "",
1196
+ scope: "",
1197
+ value: "",
1198
+ secret: false,
1199
+ legacySelector: "",
1200
+ proposals: []
1201
+ }] : []);
1202
+ }
1203
+ function validateActors(actors = [], defaultId = "default", used = []) {
1204
+ ActorSchema.array().parse(actors);
1205
+ const ids = /* @__PURE__ */ new Set(["default"]);
1206
+ for (const actor of actors) {
1207
+ if (ids.has(actor.id)) throw new Error(`Duplicate or reserved actor: ${actor.id}`);
1208
+ ids.add(actor.id);
1209
+ }
1210
+ for (const id of [defaultId, ...used]) {
1211
+ if (id === UNSET_ACTOR) throw new Error("A participant step does not say which participant \u2014 choose one before running");
1212
+ if (id && !ids.has(id)) throw new Error(`Unknown actor: ${id} \u2014 configure this participant before running`);
1213
+ }
1214
+ }
1215
+ var UNSET_ACTOR = "\0unset";
1216
+ function actorIdsUsed(steps) {
1217
+ return steps.flatMap((step) => step.disabled ? [] : step.type === "useActor" ? [step.actorId ?? UNSET_ACTOR] : step.type === "resetActor" && step.actorId ? [step.actorId] : []);
1218
+ }
1219
+
1220
+ // ../engine-playwright/dist/index.js
1221
+ import { createHmac, randomBytes } from "crypto";
1222
+ import { errors } from "playwright";
1223
+
1224
+ // ../core/dist/index.js
1225
+ function proposeAssertions(before, after) {
1226
+ const out = [];
1227
+ if (!before || !after) return out;
1228
+ if (before.url !== after.url)
1229
+ out.push({
1230
+ kind: "screen",
1231
+ value: after.heading || after.title,
1232
+ why: "navigated to a new screen"
1233
+ });
1234
+ for (const a of after.alerts.filter((x) => !before.alerts.includes(x)))
1235
+ out.push({ kind: "message", value: a, why: "a message appeared that was not there before" });
1236
+ if (before.url === after.url && before.heading !== after.heading && after.heading)
1237
+ out.push({ kind: "heading", value: after.heading, why: "the heading changed in place" });
1238
+ const newFields = after.fields.filter((f) => !before.fields.includes(f));
1239
+ if (newFields.length)
1240
+ out.push({ kind: "fields_visible", value: newFields, why: "new fields became visible" });
1241
+ if (before.url === after.url) {
1242
+ const seenKeys = new Set(before.texts.map((t) => t.key));
1243
+ const covered = /* @__PURE__ */ new Set([after.heading, ...after.alerts]);
1244
+ const appeared = after.texts.filter((t) => !seenKeys.has(t.key) && !covered.has(t.text));
1245
+ for (const t of appeared.slice(0, 2))
1246
+ out.push({
1247
+ kind: "message",
1248
+ value: t.text,
1249
+ why: "this text appeared after the action",
1250
+ confidence: "low"
1251
+ });
1252
+ }
1253
+ return out;
1254
+ }
1255
+ function hasObservableChange(before, after) {
1256
+ return proposeAssertions(before, after).some((p) => p.confidence !== "low");
1257
+ }
1258
+ function isLocalHost(host) {
1259
+ const h = host.toLowerCase();
1260
+ return h === "localhost" || h.endsWith(".localhost") || h === "127.0.0.1" || h === "[::1]" || h === "0.0.0.0";
1261
+ }
1262
+ function hasScheme(raw) {
1263
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) return true;
1264
+ return /^[a-zA-Z][a-zA-Z0-9+.-]*:(?!\d)/.test(raw);
1265
+ }
1266
+ function normalizeStartUrl(input) {
1267
+ const raw = (input ?? "").trim();
1268
+ if (!raw) throw new Error("enter a URL to record against");
1269
+ let candidate;
1270
+ if (hasScheme(raw)) {
1271
+ candidate = raw;
1272
+ } else {
1273
+ const bare = raw.replace(/^\/+/, "");
1274
+ const host = bare.split(/[/?#]/)[0] ?? "";
1275
+ candidate = `${isLocalHost(host.split(":")[0] ?? "") ? "http" : "https"}://${bare}`;
1276
+ }
1277
+ let parsed;
1278
+ try {
1279
+ parsed = new URL(candidate);
1280
+ } catch {
1281
+ throw new Error(`"${raw}" is not a valid URL`);
1282
+ }
1283
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1284
+ throw new Error(`only http:// and https:// URLs can be recorded (got ${parsed.protocol}//)`);
1285
+ }
1286
+ if (!parsed.hostname) throw new Error(`"${raw}" has no host`);
1287
+ return parsed.toString();
1288
+ }
1289
+ function resolveStartUrl(baseUrl, startPath) {
1290
+ const base = normalizeStartUrl(baseUrl);
1291
+ return new URL(startPath || "/", base).toString();
1292
+ }
1293
+ var INPUT_ROLES = /* @__PURE__ */ new Set(["textbox", "combobox", "checkbox", "radio", "spinbutton", "searchbox"]);
1294
+ var NAVIGATIONS = /* @__PURE__ */ new Set(["back", "forward"]);
1295
+ function locatorPlan(step) {
1296
+ const o = step.locator;
1297
+ if (o && o.value.trim()) {
1298
+ const value = o.value.trim();
1299
+ switch (o.strategy) {
1300
+ // Playwright reads a leading `xpath=` prefix, and also bare paths that
1301
+ // start with `/` or `//`. Normalising here means the QA can paste either.
1302
+ case "xpath":
1303
+ return { method: "locator", arg: value.startsWith("xpath=") ? value : `xpath=${value}`, overridden: true };
1304
+ // Typed as a bare id, so nobody has to remember the `#`.
1305
+ case "id":
1306
+ return { method: "locator", arg: /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(value.replace(/^#/, "")) ? `#${value.replace(/^#/, "")}` : `[id=${JSON.stringify(value.replace(/^#/, ""))}]`, overridden: true };
1307
+ case "css":
1308
+ return { method: "locator", arg: value, overridden: true };
1309
+ case "testId":
1310
+ return { method: "getByTestId", arg: value, overridden: true };
1311
+ case "text":
1312
+ return { method: "getByText", arg: value, exact: true, overridden: true };
1313
+ case "label":
1314
+ return { method: "getByLabel", arg: value, exact: true, overridden: true };
1315
+ case "placeholder":
1316
+ return { method: "getByPlaceholder", arg: value, exact: true, overridden: true };
1317
+ case "altText":
1318
+ return { method: "getByAltText", arg: value, exact: true, overridden: true };
1319
+ case "title":
1320
+ return { method: "getByTitle", arg: value, exact: true, overridden: true };
1321
+ }
1322
+ }
1323
+ if (NAVIGATIONS.has(step.type)) {
1324
+ return { method: step.type === "back" ? "goBack" : "goForward", arg: "", overridden: false };
1325
+ }
1326
+ if (!step.name.trim()) return { method: "none", arg: "", overridden: false };
1327
+ if (step.nameFrom === "adjacent-label") {
1328
+ return {
1329
+ method: "getByText",
1330
+ arg: step.name,
1331
+ exact: true,
1332
+ overridden: false,
1333
+ viaAdjacentLabel: true
1334
+ };
1335
+ }
1336
+ if (step.nameFrom === "text") {
1337
+ return {
1338
+ method: "getByText",
1339
+ arg: step.name,
1340
+ exact: step.type !== "hover",
1341
+ overridden: false,
1342
+ visible: true
1343
+ };
1344
+ }
1345
+ 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 };
1346
+ }
1347
+ function frameSelector(name) {
1348
+ const positional = /^nth:(\d+)$/.exec(name);
1349
+ if (positional) return `iframe, frame >> nth=${positional[1]}`;
1350
+ const v = JSON.stringify(name);
1351
+ return `iframe[title=${v}], iframe[name=${v}], iframe[id=${v}], frame[title=${v}], frame[name=${v}], frame[id=${v}]`;
1352
+ }
1353
+ function inputSelector(role) {
1354
+ return role === "textbox" ? 'input, textarea, [contenteditable="true"], [role="textbox"]' : `input, select, [role="${role}"]`;
1355
+ }
1356
+ var SCOPE_ROLES = ["form", "group", "region", "dialog", "alertdialog"];
1357
+ var CHECKS = [
1358
+ { id: "visible", label: "is visible", kind: "visible", group: "state" },
1359
+ { id: "not-visible", label: "is not visible", kind: "visible", not: true, group: "state" },
1360
+ { id: "attached", label: "exists on the page", kind: "attached", group: "state" },
1361
+ { id: "not-attached", label: "does not exist", kind: "attached", not: true, group: "state" },
1362
+ { id: "enabled", label: "is enabled", kind: "enabled", group: "state" },
1363
+ { id: "disabled", label: "is disabled", kind: "enabled", not: true, group: "state" },
1364
+ { id: "editable", label: "is editable", kind: "editable", group: "state" },
1365
+ { id: "readonly", label: "is read-only", kind: "editable", not: true, group: "state" },
1366
+ { id: "focused", label: "is focused", kind: "focused", group: "state" },
1367
+ { id: "checked", label: "is checked", kind: "checked", value: "true", group: "state" },
1368
+ { id: "unchecked", label: "is not checked", kind: "checked", value: "false", group: "state" },
1369
+ { id: "text", label: "text is exactly", kind: "text", input: true, group: "text" },
1370
+ { id: "text-contains", label: "text contains", kind: "text", op: "contains", input: true, group: "text" },
1371
+ { id: "text-matches", label: "text matches pattern", kind: "text", op: "matches", input: true, group: "text" },
1372
+ { id: "text-not", label: "text is not", kind: "text", not: true, input: true, group: "text" },
1373
+ { id: "text-not-contains", label: "text does not contain", kind: "text", op: "contains", not: true, input: true, group: "text" },
1374
+ { id: "value", label: "value is exactly", kind: "value", input: true, group: "value" },
1375
+ { id: "value-contains", label: "value contains", kind: "value", op: "contains", input: true, group: "value" },
1376
+ { id: "value-not", label: "value is not", kind: "value", not: true, input: true, group: "value" },
1377
+ { id: "attr", label: "attribute is", kind: "attribute", input: true, attr: true, group: "attributes" },
1378
+ { id: "attr-contains", label: "attribute contains", kind: "attribute", op: "contains", input: true, attr: true, group: "attributes" },
1379
+ { id: "attr-not", label: "attribute is not", kind: "attribute", not: true, input: true, attr: true, group: "attributes" },
1380
+ { id: "class", label: "has class", kind: "class", input: true, group: "attributes" },
1381
+ { id: "not-class", label: "does not have class", kind: "class", not: true, input: true, group: "attributes" },
1382
+ { id: "count", label: "how many \xB7 is exactly", kind: "count", input: true, roleOnly: true, group: "count" },
1383
+ { id: "count-gte", label: "how many \xB7 is at least", kind: "count", op: "gte", input: true, roleOnly: true, group: "count" },
1384
+ { id: "count-lte", label: "how many \xB7 is at most", kind: "count", op: "lte", input: true, roleOnly: true, group: "count" },
1385
+ { id: "url", label: "page URL is", kind: "url", input: true, page: true, group: "page" },
1386
+ { id: "url-contains", label: "page URL contains", kind: "url", op: "contains", input: true, page: true, group: "page" },
1387
+ { id: "title", label: "page title is", kind: "title", input: true, page: true, group: "page" },
1388
+ { id: "title-contains", label: "page title contains", kind: "title", op: "contains", input: true, page: true, group: "page" }
1389
+ ];
1390
+ function matchCheck(a) {
1391
+ const op = a.op ?? "equals";
1392
+ const value = Array.isArray(a.value) ? a.value.join(", ") : a.value;
1393
+ return CHECKS.find(
1394
+ (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
1395
+ // value, or the two would be indistinguishable.
1396
+ (c.value === void 0 || c.value === value)
1397
+ );
1398
+ }
1399
+ var PROPOSED_LABELS = {
1400
+ screen: (v) => `screen is \u201C${v}\u201D`,
1401
+ heading: (v) => `heading is \u201C${v}\u201D`,
1402
+ message: (v) => `message \u201C${v}\u201D is shown`,
1403
+ fields_visible: (v) => `fields visible: ${v}`
1404
+ };
1405
+ function describeAssertion(a, subject) {
1406
+ const value = Array.isArray(a.value) ? a.value.join(", ") : a.value;
1407
+ const proposed = PROPOSED_LABELS[a.kind];
1408
+ if (proposed && !a.target) return proposed(value);
1409
+ const check = matchCheck(a);
1410
+ const who = a.target?.name || subject || "";
1411
+ if (!check) {
1412
+ return `${a.kind}${a.op && a.op !== "equals" ? ` ${a.op}` : ""}${a.not ? " not" : ""}${value ? ` \u201C${value}\u201D` : ""}`;
1413
+ }
1414
+ const attr = check.attr && a.attr ? ` ${a.attr}` : "";
1415
+ const expects = check.input ? ` \u201C${value}\u201D` : "";
1416
+ if (check.page) return `${check.label}${expects}`;
1417
+ if (check.roleOnly) {
1418
+ const role = a.target?.role || "element";
1419
+ return `${role}s \xB7 ${check.label.replace("how many \xB7 ", "")}${expects}`;
1420
+ }
1421
+ const label = check.label.replace("attribute", `attribute${attr}`);
1422
+ return who ? `\u201C${who}\u201D ${label}${expects}` : `${label}${expects}`;
1423
+ }
1424
+
1425
+ // ../engine-playwright/dist/index.js
1426
+ import fs4 from "fs";
1427
+ import path22 from "path";
1428
+ import { chromium, firefox, webkit } from "playwright";
1027
1429
 
1028
1430
  // ../recorder/dist/index.js
1029
1431
  function openShadowRoots() {
@@ -2386,221 +2788,463 @@ function installDebugPanel() {
2386
2788
  }
2387
2789
 
2388
2790
  // ../engine-playwright/dist/index.js
2389
- import fs4 from "fs";
2791
+ import "playwright";
2792
+ import "playwright";
2793
+ import fs22 from "fs";
2390
2794
  import { createRequire as createRequire2 } from "module";
2391
- import path3 from "path";
2795
+ import path32 from "path";
2392
2796
  import { fileURLToPath } from "url";
2393
2797
  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");
2798
+ import fs32 from "fs";
2799
+ import "playwright";
2800
+ function withRuntimeValues(step, values) {
2801
+ const expand = (text) => text.replace(/\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g, (_, name) => {
2802
+ if (!values.has(name)) throw new Error(`Runtime value "${name}" has not been captured yet`);
2803
+ return values.get(name);
2804
+ });
2805
+ return {
2806
+ ...step,
2807
+ value: step.secret ? step.value : expand(step.value),
2808
+ ...step.values ? { values: step.values.map(expand) } : {},
2809
+ proposals: step.proposals.map((check) => ({ ...check, value: Array.isArray(check.value) ? check.value.map(expand) : expand(check.value) }))
2810
+ };
2428
2811
  }
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";
2812
+ var secretsBehind = (auth) => [.../* @__PURE__ */ new Set([
2813
+ ...Object.values(auth.secretRefs ?? {}),
2814
+ ...auth.steps.flatMap((step) => step.secretRef ? [step.secretRef] : [])
2815
+ ])].sort();
2816
+ function authStartUrl(auth, page, baseUrl) {
2817
+ if (auth.navigation.kind === "continue") return null;
2818
+ return new URL(auth.navigation.path, baseUrl || page.url()).href;
2819
+ }
2820
+ function describeAuthFailure(auth, cause) {
2821
+ const who = `sign in as ${auth.accountName}`;
2822
+ if (cause.step) return `could not ${who}: "${cause.step}" failed \u2014 ${cause.detail ?? "no detail"}`;
2823
+ return `could not ${who}: ${cause.detail ?? "no detail"}`;
2824
+ }
2825
+ var hasSuccessCheck = (auth) => auth.success.some((check) => check.enabled !== false);
2826
+ 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";
2827
+ function authenticationResolver(input) {
2828
+ const entries = DeliveredAuthSchema.array().parse(input ?? []);
2829
+ return (reference) => {
2830
+ const matches = entries.filter((item) => item.profileId === reference.profileId && (!reference.accountId || item.accountId === reference.accountId));
2831
+ if (matches.length !== 1) return void 0;
2832
+ return matches[0];
2833
+ };
2432
2834
  }
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);
2835
+ var PEPPER = randomBytes(32);
2836
+ function preparationKey(identity) {
2837
+ const material = JSON.stringify([
2838
+ identity.scope,
2839
+ identity.profileId,
2840
+ identity.accountId,
2841
+ identity.environment,
2842
+ identity.baseUrl,
2843
+ identity.browser,
2844
+ // The procedure as it will actually run.
2845
+ identity.auth.navigation,
2846
+ identity.auth.steps,
2847
+ identity.auth.success,
2848
+ // Each secret separately, so reordering cannot collide with a rotation.
2849
+ identity.secrets.map((value) => createHmac("sha256", PEPPER).update(value ?? "\0absent").digest("hex"))
2850
+ ]);
2851
+ return createHmac("sha256", PEPPER).update(material).digest("hex");
2852
+ }
2853
+ var AuthenticationReuse = class {
2854
+ held = /* @__PURE__ */ new Map();
2855
+ ttlMs;
2856
+ max;
2857
+ scope = "";
2858
+ constructor({ ttlMs = 15 * 6e4, max = 16 } = {}) {
2859
+ this.ttlMs = ttlMs;
2860
+ this.max = max;
2861
+ }
2862
+ /** ONE EXECUTION AT A TIME. Asking under a new scope forgets every session
2863
+ * from the last one.
2864
+ *
2865
+ * The scope is already part of every key, so nothing could have been read
2866
+ * across executions anyway. This is about not HOLDING them: a suite that has
2867
+ * finished has no reason for its sessions to still exist, and an agent that
2868
+ * keeps running would otherwise sit on them until they aged out. */
2869
+ using(scope) {
2870
+ if (scope === this.scope) return;
2871
+ this.scope = scope;
2872
+ for (const [key, held] of this.held) if (held.scope !== scope) this.held.delete(key);
2873
+ }
2874
+ get(key, now = Date.now()) {
2875
+ const found = this.held.get(key);
2876
+ if (!found) return void 0;
2877
+ if (now - found.storedAt > this.ttlMs) {
2878
+ this.held.delete(key);
2879
+ return void 0;
2880
+ }
2881
+ this.held.delete(key);
2882
+ this.held.set(key, found);
2883
+ return found.state;
2884
+ }
2885
+ put(key, state, now = Date.now()) {
2886
+ this.held.delete(key);
2887
+ this.held.set(key, { state, storedAt: now, scope: this.scope });
2888
+ while (this.held.size > this.max) {
2889
+ const oldest = this.held.keys().next();
2890
+ if (oldest.done) break;
2891
+ this.held.delete(oldest.value);
2892
+ }
2893
+ }
2894
+ /** After a session turned out to be stale. The next preparation signs in. */
2895
+ evict(key) {
2896
+ this.held.delete(key);
2897
+ }
2898
+ /** At the end of an execution, at a cancel, and at shutdown.
2899
+ *
2900
+ * Called from all three rather than relying on the TTL: a process that keeps
2901
+ * running holds these until they expire otherwise, and there is no reason
2902
+ * for a finished suite's sessions to exist at all. */
2903
+ clear() {
2904
+ this.held.clear();
2905
+ }
2906
+ get size() {
2907
+ return this.held.size;
2908
+ }
2909
+ };
2910
+ function keptSessionFor(opts, reference) {
2911
+ if (!opts.reuse || !reference) return null;
2912
+ opts.reuse.cache.using(opts.reuse.scope);
2913
+ const auth = opts.resolveAuth?.(reference);
2914
+ if (!auth || !auth.success.some((check) => check.enabled !== false)) return null;
2915
+ const key = preparationKey({
2916
+ scope: opts.reuse.scope,
2917
+ profileId: reference.profileId,
2918
+ accountId: reference.accountId ?? auth.accountName,
2919
+ environment: opts.reuse.environment ?? null,
2920
+ baseUrl: opts.baseUrl,
2921
+ browser: JSON.stringify(opts.environment ?? {}),
2922
+ auth,
2923
+ secrets: secretsBehind(auth).map((ref) => opts.resolveSecret?.(ref))
2924
+ });
2925
+ const state = opts.reuse.cache.get(key);
2926
+ return { key, auth, ...state ? { state } : {} };
2436
2927
  }
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}`;
2928
+ var RunActors = class {
2929
+ constructor(main, opts) {
2930
+ this.main = main;
2931
+ this.opts = opts;
2932
+ this.original = main;
2933
+ this.current = main;
2934
+ this.currentId = opts.defaultActorId ?? "default";
2935
+ this.sessions.set(this.currentId, main);
2447
2936
  }
2448
- let parsed;
2937
+ main;
2938
+ opts;
2939
+ values = /* @__PURE__ */ new Map();
2940
+ sessions = /* @__PURE__ */ new Map();
2941
+ initialized = /* @__PURE__ */ new Set();
2942
+ /** How many times each participant has been started over, so each round's
2943
+ * evidence has a place of its own. */
2944
+ resets = /* @__PURE__ */ new Map();
2945
+ /** Sessions already closed and already collected. */
2946
+ retired = /* @__PURE__ */ new Set();
2947
+ /** Where each session writes its evidence, relative to the run's directory.
2948
+ * Fixed when the context is created — a video cannot be redirected later —
2949
+ * so it is remembered rather than recomputed at close. */
2950
+ homes = /* @__PURE__ */ new Map();
2951
+ /** How each participant was signed in: freshly, from a kept session, or
2952
+ * freshly after a kept one turned out to be stale. */
2953
+ preparations = /* @__PURE__ */ new Map();
2954
+ original;
2955
+ current;
2956
+ currentId;
2957
+ all() {
2958
+ return [...this.sessions.values()];
2959
+ }
2960
+ evidenceIn(suffix) {
2961
+ return this.opts.evidenceDir ? { evidenceDir: path3.join(this.opts.evidenceDir, suffix) } : {};
2962
+ }
2963
+ /** What to call a participant in a report. The id is a slug nobody chose to
2964
+ * read; the name is what they typed when they created it. */
2965
+ nameOf(id) {
2966
+ if (!id || id === "default") return this.opts.actors?.find((a) => a.id === (id ?? "default"))?.name ?? "the main participant";
2967
+ return this.opts.actors?.find((a) => a.id === id)?.name ?? id;
2968
+ }
2969
+ async select(id, startUrl = this.opts.startUrl) {
2970
+ const actor = this.opts.actors?.find((a) => a.id === id);
2971
+ if (id !== "default" && !actor) throw new Error(`Unknown actor: ${id}`);
2972
+ const kept = !actor?.startSignedIn ? null : this.sessions.has(id) ? this.opts.hydrated?.id === id ? this.opts.hydrated : null : keptSessionFor(this.opts, actor.startSignedIn);
2973
+ let session = this.sessions.get(id);
2974
+ if (!session) {
2975
+ session = await openRunSession({
2976
+ ...this.opts,
2977
+ browser: this.main.browser,
2978
+ ...this.evidenceIn(`actor-${id}`),
2979
+ ...kept?.state ? { storageState: kept.state } : {}
2980
+ });
2981
+ this.homes.set(session, `actor-${id}`);
2982
+ this.sessions.set(id, session);
2983
+ await this.opts.onCreate?.(session);
2984
+ }
2985
+ this.current = session;
2986
+ this.currentId = id;
2987
+ if (this.initialized.has(id)) return;
2988
+ await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
2989
+ if (actor?.startSignedIn) {
2990
+ if (kept?.state) {
2991
+ const confirmed = await confirmSignedIn(session.page, kept.auth, this.opts.reuseCheckMs ?? 3e3);
2992
+ if (confirmed.ok) {
2993
+ this.preparations.set(id, "reused");
2994
+ this.initialized.add(id);
2995
+ return;
2996
+ }
2997
+ this.opts.reuse?.cache.evict(kept.key);
2998
+ this.preparations.set(id, "stale");
2999
+ session = await this.replaceWithGuest(id, session);
3000
+ await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
3001
+ }
3002
+ await executeStep(
3003
+ session.page,
3004
+ {
3005
+ id: `prepare:${id}`,
3006
+ type: "signIn",
3007
+ auth: actor.startSignedIn,
3008
+ name: `Prepare ${actor.name}`,
3009
+ role: "",
3010
+ scope: "",
3011
+ value: "",
3012
+ secret: false,
3013
+ legacySelector: "",
3014
+ proposals: []
3015
+ },
3016
+ {
3017
+ pages: session.pages,
3018
+ baseUrl: this.opts.baseUrl,
3019
+ ...this.opts.resolveAuth ? { resolveAuth: this.opts.resolveAuth } : {},
3020
+ ...this.opts.resolveSecret ? { resolveSecret: this.opts.resolveSecret } : {}
3021
+ }
3022
+ );
3023
+ if (!this.preparations.has(id)) this.preparations.set(id, "fresh");
3024
+ if (kept) {
3025
+ try {
3026
+ this.opts.reuse?.cache.put(kept.key, await session.context.storageState());
3027
+ } catch {
3028
+ }
3029
+ }
3030
+ await session.page.goto(startUrl, { waitUntil: "domcontentloaded" });
3031
+ }
3032
+ this.initialized.add(id);
3033
+ }
3034
+ /** Replace a participant's context without disturbing anyone else's. */
3035
+ async replaceWithGuest(id, previous) {
3036
+ const session = await openRunSession({ ...this.opts, browser: this.main.browser, ...this.evidenceIn(`actor-${id}`) });
3037
+ this.homes.set(session, `actor-${id}`);
3038
+ this.sessions.set(id, session);
3039
+ this.current = session;
3040
+ if (previous !== this.original) await previous.context.close().catch(() => {
3041
+ });
3042
+ await this.opts.onCreate?.(session);
3043
+ return session;
3044
+ }
3045
+ /** How each participant came to be signed in, for the report.
3046
+ *
3047
+ * Visible on purpose: "this suite signed in once and reused it" and "this
3048
+ * suite signed in forty times" are different tests, and somebody reading a
3049
+ * green result is entitled to know which one they ran. */
3050
+ preparedHow() {
3051
+ return Object.fromEntries(this.preparations);
3052
+ }
3053
+ /** Throw this participant's session away and give them a new signed-out one.
3054
+ *
3055
+ * NOT SIGNING OUT. An application can be signed out of while the browser
3056
+ * keeps every cookie and every token it was given, and a test of "log out,
3057
+ * then visit this page" exists to find exactly that difference. This is the
3058
+ * other thing: a browser that has never been anywhere.
3059
+ *
3060
+ * The session being replaced is CLOSED PROPERLY, not dropped. Closing the
3061
+ * context directly threw away the video and the trace of everything that
3062
+ * participant had done — which, since a reset usually follows something
3063
+ * worth understanding, is the evidence most likely to be wanted. */
3064
+ async freshGuest(id) {
3065
+ if (id !== "default" && !this.opts.actors?.some((a) => a.id === id)) throw new Error(`Unknown actor: ${id}`);
3066
+ const previous = this.sessions.get(id);
3067
+ const round = (this.resets.get(id) ?? 0) + 1;
3068
+ this.resets.set(id, round);
3069
+ const home = `actor-${id}-${round}`;
3070
+ const session = await openRunSession({ ...this.opts, browser: this.main.browser, ...this.evidenceIn(home) });
3071
+ this.homes.set(session, home);
3072
+ this.sessions.set(id, session);
3073
+ this.current = session;
3074
+ this.currentId = id;
3075
+ if (previous) {
3076
+ const where = this.homes.get(previous);
3077
+ const artifacts = await closeRunSession(
3078
+ { ...previous, ownsBrowser: false },
3079
+ { passed: true, ...where === void 0 ? {} : this.evidenceIn(where) }
3080
+ );
3081
+ if (previous !== this.original) {
3082
+ this.main.runArtifacts.push(...artifacts.map((a) => ({ ...a, relPath: where ? `${where}/${a.relPath}` : a.relPath })));
3083
+ }
3084
+ this.retired.add(previous);
3085
+ }
3086
+ await this.opts.onCreate?.(session);
3087
+ await session.page.goto(this.opts.startUrl, { waitUntil: "domcontentloaded" });
3088
+ this.initialized.add(id);
3089
+ }
3090
+ /** Back to one participant, between debug replays.
3091
+ *
3092
+ * THE SESSION THIS BEGAN WITH IS KEPT. It used to be closed and replaced,
3093
+ * and everything that reads the browser — the panel injected into that
3094
+ * context, the recorder listening on that page, the painter bound to its
3095
+ * loads — had been handed it when the session started. After one Replay
3096
+ * they were all driving a context that had been closed, and the debugger
3097
+ * answered every request with "target has been closed".
3098
+ *
3099
+ * The extra participants are what this tears down, which is what it is for.
3100
+ * Contexts only: whatever a replay recorded belongs to the replay that is
3101
+ * ending, and the debugger collects that on its own terms. */
3102
+ async reset() {
3103
+ for (const session of this.sessions.values()) {
3104
+ if (session === this.original) continue;
3105
+ await session.context.close().catch(() => {
3106
+ });
3107
+ this.homes.delete(session);
3108
+ }
3109
+ this.main = this.original;
3110
+ this.current = this.original;
3111
+ this.currentId = this.opts.defaultActorId ?? "default";
3112
+ this.sessions = /* @__PURE__ */ new Map([[this.currentId, this.original]]);
3113
+ this.initialized.clear();
3114
+ this.retired.clear();
3115
+ this.resets.clear();
3116
+ this.values.clear();
3117
+ }
3118
+ async closeExtras(passed) {
3119
+ for (const [id, session] of this.sessions) {
3120
+ if (session === this.original || this.retired.has(session)) continue;
3121
+ const where = this.homes.get(session) ?? `actor-${id}`;
3122
+ try {
3123
+ const artifacts = await closeRunSession(session, { passed, ...this.evidenceIn(where) });
3124
+ this.main.runArtifacts.push(...artifacts.map((a) => ({ ...a, relPath: `${where}/${a.relPath}` })));
3125
+ } catch {
3126
+ await session.context.close().catch(() => {
3127
+ });
3128
+ }
3129
+ }
3130
+ }
3131
+ };
3132
+ var PASSWORD_FIELDS = 'input[type="password"]';
3133
+ async function captureFailure(opts) {
3134
+ const { page, dir, ordinal } = opts;
3135
+ let url = "";
3136
+ let title = "";
2449
3137
  try {
2450
- parsed = new URL(candidate);
3138
+ url = page.url();
3139
+ title = await page.title();
2451
3140
  } catch {
2452
- throw new Error(`"${raw}" is not a valid URL`);
2453
3141
  }
2454
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
2455
- throw new Error(`only http:// and https:// URLs can be recorded (got ${parsed.protocol}//)`);
3142
+ let screenshot = null;
3143
+ try {
3144
+ const file = `step-${String(ordinal).padStart(2, "0")}.png`;
3145
+ fs4.mkdirSync(dir, { recursive: true });
3146
+ await page.screenshot({
3147
+ path: path22.join(dir, file),
3148
+ // The viewport, not the full page: a failure is about what was on screen,
3149
+ // and a full-page capture of an infinite-scroll app is a 20MB image.
3150
+ fullPage: false,
3151
+ mask: masks(opts),
3152
+ // Playwright's default is magenta, which reads as a rendering fault
3153
+ // rather than as a deliberate redaction. A flat slate box is obviously
3154
+ // something someone chose to cover up.
3155
+ maskColor: "#475569",
3156
+ timeout: 5e3
3157
+ });
3158
+ screenshot = file;
3159
+ } catch {
3160
+ screenshot = null;
2456
3161
  }
2457
- if (!parsed.hostname) throw new Error(`"${raw}" has no host`);
2458
- return parsed.toString();
3162
+ return { screenshot, url, title };
2459
3163
  }
2460
- function resolveStartUrl(baseUrl, startPath) {
2461
- const base = normalizeStartUrl(baseUrl);
2462
- return new URL(startPath || "/", base).toString();
3164
+ function maskTargets(steps = []) {
3165
+ const frames = /* @__PURE__ */ new Set([JSON.stringify([])]);
3166
+ for (const step of steps) {
3167
+ if (step.frame?.length) frames.add(JSON.stringify(step.frame));
3168
+ }
3169
+ const targets = [...frames].map((chain) => {
3170
+ const frame = JSON.parse(chain);
3171
+ return frame.length ? { by: "selector", value: PASSWORD_FIELDS, frame } : { by: "selector", value: PASSWORD_FIELDS };
3172
+ });
3173
+ const seen = /* @__PURE__ */ new Set();
3174
+ for (const step of steps) {
3175
+ const name = step.name.trim();
3176
+ if (!step.secret || !name) continue;
3177
+ const key = `${JSON.stringify(step.frame ?? [])}|${name}`;
3178
+ if (seen.has(key)) continue;
3179
+ seen.add(key);
3180
+ targets.push(step.frame?.length ? { by: "label", value: name, frame: step.frame } : { by: "label", value: name });
3181
+ }
3182
+ return targets;
2463
3183
  }
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 };
3184
+ function masks(opts) {
3185
+ const { page } = opts;
3186
+ return maskTargets(opts.steps).map((t) => {
3187
+ let root = page;
3188
+ for (const frame of t.frame ?? []) root = root.frameLocator(frameSelector(frame));
3189
+ return t.by === "selector" ? root.locator(t.value) : root.getByLabel(t.value, { exact: true });
3190
+ });
3191
+ }
3192
+ async function finaliseArtifacts(opts) {
3193
+ const { context, dir, passed, artifacts, consoleLines } = opts;
3194
+ const keep = (mode) => mode === "always" || mode === "on-failure" && !passed;
3195
+ const out = [];
3196
+ const size = (file) => {
3197
+ try {
3198
+ return fs4.statSync(path22.join(dir, file)).size;
3199
+ } catch {
3200
+ return null;
3201
+ }
3202
+ };
3203
+ if (artifacts.trace !== "off") {
3204
+ const file = "trace.zip";
3205
+ try {
3206
+ if (keep(artifacts.trace)) {
3207
+ fs4.mkdirSync(dir, { recursive: true });
3208
+ await context.tracing.stop({ path: path22.join(dir, file) });
3209
+ out.push({ kind: "trace", relPath: file, bytes: size(file) });
3210
+ } else {
3211
+ await context.tracing.stop();
3212
+ }
3213
+ } catch {
2492
3214
  }
2493
3215
  }
2494
- if (NAVIGATIONS.has(step.type)) {
2495
- return { method: step.type === "back" ? "goBack" : "goForward", arg: "", overridden: false };
3216
+ if (artifacts.console && consoleLines.length) {
3217
+ const file = "console.log";
3218
+ try {
3219
+ fs4.mkdirSync(dir, { recursive: true });
3220
+ fs4.writeFileSync(path22.join(dir, file), consoleLines.join("\n") + "\n");
3221
+ out.push({ kind: "console", relPath: file, bytes: size(file) });
3222
+ } catch {
3223
+ }
2496
3224
  }
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
- };
3225
+ return out;
3226
+ }
3227
+ function collectVideos(dir, keep) {
3228
+ let files;
3229
+ try {
3230
+ files = fs4.readdirSync(dir).filter((f) => f.endsWith(".webm"));
3231
+ } catch {
3232
+ return [];
2506
3233
  }
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
- };
3234
+ if (!keep) {
3235
+ for (const f of files) fs4.rmSync(path22.join(dir, f), { force: true });
3236
+ return [];
2515
3237
  }
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}"]`;
3238
+ return files.map((f) => {
3239
+ let bytes = null;
3240
+ try {
3241
+ bytes = fs4.statSync(path22.join(dir, f)).size;
3242
+ } catch {
3243
+ bytes = null;
3244
+ }
3245
+ return { kind: "video", relPath: f, bytes };
3246
+ });
2526
3247
  }
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
3248
  var DEFAULT_ENVIRONMENT = {
2605
3249
  browser: "chromium",
2606
3250
  viewport: { width: 1280, height: 720 },
@@ -2652,12 +3296,12 @@ function resolveCapture(options = {}) {
2652
3296
  };
2653
3297
  }
2654
3298
  var forcedHeadless = () => process.env["FLOWRIKI_FORCE_HEADLESS"] === "1";
2655
- async function launchSession(environment = {}, headlessRequested = true, slowMo = 0, capture = {}) {
3299
+ async function launchSession(environment = {}, headlessRequested = true, slowMo = 0, capture = {}, existingBrowser, storageState) {
2656
3300
  const headless = headlessRequested || forcedHeadless();
2657
3301
  const config = { ...DEFAULT_ENVIRONMENT, ...ExecutionEnvironmentSchema.parse(environment) };
2658
3302
  const fullWindow = config.fullWindow === true && !headless;
2659
3303
  const engine = { chromium, firefox, webkit }[config.browser];
2660
- const browser = await engine.launch({
3304
+ const browser = existingBrowser ?? await engine.launch({
2661
3305
  headless,
2662
3306
  slowMo,
2663
3307
  // Chromium's flag. Firefox and WebKit have no equivalent, and `viewport:
@@ -2673,6 +3317,7 @@ async function launchSession(environment = {}, headlessRequested = true, slowMo
2673
3317
  locale,
2674
3318
  timezoneId,
2675
3319
  colorScheme,
3320
+ ...storageState ? { storageState } : {},
2676
3321
  // Playwright can only be told to record at context creation, so a video
2677
3322
  // wanted "on failure" is recorded for every run and thrown away when the
2678
3323
  // run passes. There is no way to start one after the fact.
@@ -2691,7 +3336,7 @@ async function launchSession(environment = {}, headlessRequested = true, slowMo
2691
3336
  if (config.openClosedShadowRoots) await context.addInitScript(openShadowRoots);
2692
3337
  return { browser, context, config, artifacts };
2693
3338
  } catch (error) {
2694
- await browser.close();
3339
+ if (!existingBrowser) await browser.close();
2695
3340
  throw error;
2696
3341
  }
2697
3342
  }
@@ -2851,710 +3496,121 @@ async function locateDrop(page, step) {
2851
3496
  to: void 0
2852
3497
  });
2853
3498
  }
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;
3499
+ var STATES = {
3500
+ visible: "visible",
3501
+ hidden: "visible",
3502
+ attached: "attached",
3503
+ enabled: "enabled",
3504
+ editable: "editable",
3505
+ focused: "focused",
3506
+ checked: "checked"
3507
+ };
3508
+ var TEXT_KINDS = /* @__PURE__ */ new Set(["text", "message", "screen", "heading"]);
3509
+ var PAGE_KINDS = /* @__PURE__ */ new Set(["url", "title"]);
3510
+ function absenceSatisfies(state, want) {
3511
+ return !want && (state === "attached" || state === "visible");
2874
3512
  }
2875
- function accNameScriptPath() {
3513
+ function firstLine(err) {
3514
+ return String(err instanceof Error ? err.message : err).split("\n")[0].trim();
3515
+ }
3516
+ function ambiguityDetail(count) {
3517
+ return `resolves to ${count} elements \u2014 nothing on the page tells them apart`;
3518
+ }
3519
+ async function presenceOf(target) {
3520
+ let count;
2876
3521
  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;
3522
+ count = await target.count();
3523
+ } catch (err) {
3524
+ return { outcome: "invalid", detail: firstLine(err) };
3525
+ }
3526
+ if (count === 0) return { outcome: "absent" };
3527
+ if (count > 1) return { outcome: "ambiguous", count };
3528
+ return { outcome: "present", locator: target };
3529
+ }
3530
+ async function readState(target, state, timeout) {
3531
+ if (state === "visible") return target.isVisible();
3532
+ if (state === "attached") return await target.count() > 0;
3533
+ if (state === "enabled") return target.isEnabled({ timeout });
3534
+ if (state === "editable") return target.isEditable({ timeout });
3535
+ if (state === "checked") return target.isChecked({ timeout });
3536
+ return target.evaluate((el) => el === el.ownerDocument.activeElement);
3537
+ }
3538
+ function compare(actual, expected, op) {
3539
+ switch (op) {
3540
+ case "contains":
3541
+ return actual.includes(expected);
3542
+ case "matches":
3543
+ return new RegExp(expected).test(actual);
3544
+ case "gt":
3545
+ return Number(actual) > Number(expected);
3546
+ case "gte":
3547
+ return Number(actual) >= Number(expected);
3548
+ case "lt":
3549
+ return Number(actual) < Number(expected);
3550
+ case "lte":
3551
+ return Number(actual) <= Number(expected);
3552
+ default:
3553
+ return actual === expected;
3554
+ }
3555
+ }
3556
+ var OP_WORDS = {
3557
+ equals: ["is", "is not"],
3558
+ contains: ["contains", "does not contain"],
3559
+ matches: ["matches", "does not match"],
3560
+ gt: ["is more than", "is not more than"],
3561
+ gte: ["is at least", "is less than"],
3562
+ lt: ["is fewer than", "is not fewer than"],
3563
+ lte: ["is at most", "is more than"]
3564
+ };
3565
+ var DEFAULT_OPS = { message: "contains" };
3566
+ function explain(r) {
3567
+ switch (r.outcome) {
3568
+ case "absent":
3569
+ return { detail: "no element matched", kind: "not_found" };
3570
+ case "ambiguous":
3571
+ return { detail: ambiguityDetail(r.count), kind: "ambiguous" };
3572
+ case "invalid":
3573
+ return { detail: `the locator could not be used \u2014 ${r.detail}`, kind: "unknown" };
3574
+ case "unreadable":
3575
+ return { detail: `the element could not be read \u2014 ${r.detail}`, kind: "unknown" };
3576
+ case "not_visible":
3577
+ return { detail: "the element is present but not visible", kind: null };
3578
+ default:
3579
+ return { detail: "", kind: null };
3580
+ }
3581
+ }
3582
+ function fail(message, r) {
3583
+ const { detail, kind } = explain(r);
3584
+ throw Object.assign(new Error(detail ? `${message} \u2014 ${detail}` : message), kind ? { kind } : {});
3585
+ }
3586
+ async function checkAssertion(page, kind, value, timeoutMs = 3e3, assertion) {
3587
+ const deadline = Date.now() + timeoutMs;
3588
+ const remaining = () => Math.max(1, deadline - Date.now());
3589
+ const op = assertion?.op ?? DEFAULT_OPS[kind] ?? "equals";
3590
+ const negated = assertion?.not === true;
3591
+ const expected = String(value);
3592
+ const asStep = (t) => ({
3593
+ id: "assertion",
3594
+ type: "click",
3595
+ value: "",
3596
+ secret: false,
3597
+ legacySelector: "",
3598
+ proposals: [],
3599
+ scope: "",
3600
+ ...t
3601
+ });
3602
+ const NEEDS_TARGET = /* @__PURE__ */ new Set(["count", "attribute", "class", "value", "text", ...Object.keys(STATES)]);
3603
+ if (NEEDS_TARGET.has(kind) && !assertion?.target) throw new Error(`${kind} assertion needs an element target`);
3604
+ if (negated && !assertion?.target && (TEXT_KINDS.has(kind) || kind === "fields_visible")) {
2881
3605
  throw new Error(
2882
- `this build is missing ${ACCNAME_ASSET}, which recording injects into the page \u2014 reinstall the agent`
3606
+ `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
3607
  );
2884
3608
  }
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() });
3609
+ const aboutAbsence = kind === "attached" || kind === "hidden" || kind === "visible" && negated;
3610
+ const target = assertion?.target && kind !== "count" ? await locate(page, asStep(assertion.target), { includeHidden: aboutAbsence }) : void 0;
3611
+ if (kind === "fields_visible") {
3612
+ for (const name of Array.isArray(value) ? value : [value]) {
3613
+ await page.getByLabel(name, { exact: true }).or(page.getByRole("textbox", { name, exact: true })).waitFor({ state: "visible", timeout: remaining() });
3558
3614
  }
3559
3615
  return `fields visible: ${Array.isArray(value) ? value.join(", ") : value}`;
3560
3616
  }
@@ -3574,7 +3630,7 @@ async function checkAssertion(page, kind, value, timeoutMs = 3e3, assertion) {
3574
3630
  last2 = { outcome: "state", value: actual };
3575
3631
  } catch (err) {
3576
3632
  if (err instanceof Error && err.name === "TimeoutError" && Date.now() >= deadline && last2.outcome === "state") break;
3577
- last2 = { outcome: "unreadable", detail: firstLine2(err) };
3633
+ last2 = { outcome: "unreadable", detail: firstLine(err) };
3578
3634
  }
3579
3635
  } else {
3580
3636
  last2 = p;
@@ -3612,7 +3668,7 @@ async function checkAssertion(page, kind, value, timeoutMs = 3e3, assertion) {
3612
3668
  const n2 = await (await locateAll(page, asStep(assertion.target), { allowRoleOnly: true })).count();
3613
3669
  reading = { outcome: "value", value: String(n2) };
3614
3670
  } catch (err) {
3615
- reading = { outcome: "invalid", detail: firstLine2(err) };
3671
+ reading = { outcome: "invalid", detail: firstLine(err) };
3616
3672
  }
3617
3673
  } else {
3618
3674
  const p = await presenceOf(targetFor());
@@ -3626,7 +3682,7 @@ async function checkAssertion(page, kind, value, timeoutMs = 3e3, assertion) {
3626
3682
  } catch (err) {
3627
3683
  if (/Unsupported assertion/.test(String(err))) throw err;
3628
3684
  if (err instanceof Error && err.name === "TimeoutError" && Date.now() >= deadline && last.outcome === "value") break;
3629
- reading = { outcome: "unreadable", detail: firstLine2(err) };
3685
+ reading = { outcome: "unreadable", detail: firstLine(err) };
3630
3686
  }
3631
3687
  }
3632
3688
  }
@@ -3711,12 +3767,19 @@ async function applyAction(loc, step, value, ctx = {}) {
3711
3767
  }
3712
3768
  var NAVIGATIONS2 = /* @__PURE__ */ new Set(["back", "forward"]);
3713
3769
  async function openRunSession(opts) {
3714
- const { steps, headless = true, slowMo = 0, resolveSecret, evidenceDir } = opts;
3770
+ const { headless = true, slowMo = 0, resolveSecret, evidenceDir } = opts;
3771
+ const steps = [...opts.steps, ...opts.steps.flatMap((step) => {
3772
+ if (!step.auth) return [];
3773
+ const resolved = opts.resolveAuth?.(step.auth);
3774
+ return [...resolved?.steps ?? [], ...resolved?.signOut?.steps ?? []];
3775
+ })];
3715
3776
  const { browser, context, config, artifacts } = await launchSession(
3716
3777
  opts.environment,
3717
3778
  headless,
3718
3779
  slowMo,
3719
- { policy: opts.artifacts, dir: evidenceDir, hasSecrets: steps.some((s2) => s2.secret) }
3780
+ { policy: opts.artifacts, dir: evidenceDir, hasSecrets: steps.some((s2) => s2.secret) },
3781
+ opts.browser,
3782
+ opts.storageState
3720
3783
  );
3721
3784
  const page = await context.newPage();
3722
3785
  const pages = new ExecutionPages(context, page);
@@ -3730,44 +3793,240 @@ async function openRunSession(opts) {
3730
3793
  watch(page);
3731
3794
  context.on("page", watch);
3732
3795
  }
3733
- return { browser, context, page, pages, config, artifacts, consoleLines, runArtifacts: [] };
3796
+ return { ownsBrowser: !opts.browser, browser, context, page, pages, config, artifacts, consoleLines, runArtifacts: [] };
3734
3797
  }
3735
3798
  async function closeRunSession(session, { evidenceDir, passed }) {
3736
3799
  const { context, browser, artifacts, consoleLines, runArtifacts } = session;
3737
3800
  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
- );
3801
+ try {
3802
+ runArtifacts.push(
3803
+ ...await finaliseArtifacts({
3804
+ context,
3805
+ dir: evidenceDir,
3806
+ passed,
3807
+ artifacts: { video: artifacts.video, trace: artifacts.trace, console: artifacts.console },
3808
+ consoleLines
3809
+ })
3810
+ );
3811
+ } catch {
3812
+ }
3813
+ }
3814
+ await context.close().catch(() => {
3815
+ });
3816
+ if (evidenceDir && artifacts.video !== "off") {
3817
+ runArtifacts.push(...collectVideos(evidenceDir, artifacts.video === "always" || !passed));
3818
+ }
3819
+ if (session.ownsBrowser !== false) await browser.close().catch(() => {
3820
+ });
3821
+ return runArtifacts.filter((artifact, at) => runArtifacts.findIndex((other) => other.relPath === artifact.relPath) === at);
3822
+ }
3823
+ async function performSignIn(session, step, opts) {
3824
+ const reference = step.auth;
3825
+ if (!reference) {
3826
+ return { ok: false, label: "Sign in", detail: "this step does not say which sign-in to use", kind: "unknown", checks: [] };
3827
+ }
3828
+ const resolved = opts.resolveAuth?.(reference);
3829
+ const auth = step.type === "signOut" && resolved?.signOut ? { ...resolved, ...resolved.signOut, navigation: { kind: "continue" } } : step.type === "signOut" ? void 0 : resolved;
3830
+ if (!auth) {
3831
+ return {
3832
+ ok: false,
3833
+ label: "Sign in",
3834
+ detail: "this run was delivered without the sign-in it needs \u2014 open it again so its procedure and account are resolved",
3835
+ kind: "unknown",
3836
+ checks: []
3837
+ };
3838
+ }
3839
+ const label = step.type === "signOut" ? `Sign out ${auth.accountName}` : `Sign in as ${auth.accountName}`;
3840
+ if (step.pageId && step.pageId !== "main") {
3841
+ return {
3842
+ ok: false,
3843
+ label,
3844
+ kind: "unknown",
3845
+ checks: [],
3846
+ 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}"`
3847
+ };
3848
+ }
3849
+ if ((opts.authDepth ?? 0) > 0) {
3850
+ return { ok: false, label, detail: `"${auth.name}" signs in from inside its own sign-in`, kind: "unknown", checks: [] };
3851
+ }
3852
+ if (!hasSuccessCheck(auth)) {
3853
+ return { ok: false, label, detail: UNCHECKED_AUTH, kind: "assertion", checks: [] };
3854
+ }
3855
+ const inner = await runStepsIn(session, auth.steps, {
3856
+ ...opts,
3857
+ stepOffset: 0,
3858
+ // The procedure's own steps are not the test's steps. Reporting each one
3859
+ // would bury the test being run under a login form nobody wrote.
3860
+ ...opts.onStepResult ? { onStepResult: () => void 0 } : {},
3861
+ // WHERE THIS STEP WANTS TO BEGIN, over where the procedure normally does.
3862
+ // Same login, two honest uses: taken to the login page when signing in is
3863
+ // the first thing that happens, and performed from wherever the browser
3864
+ // already got to when a guest fills a basket and signs in at checkout.
3865
+ startUrl: authStartUrl(
3866
+ step.auth?.from === "here" ? { ...auth, navigation: { kind: "continue" } } : auth,
3867
+ session.pages.pageFor(step),
3868
+ opts.baseUrl ?? ""
3869
+ ),
3870
+ authDepth: (opts.authDepth ?? 0) + 1
3871
+ });
3872
+ if (inner.status !== "passed") {
3873
+ const failed = inner.results.find((r) => r.status === "failed");
3874
+ return {
3875
+ ok: false,
3876
+ label,
3877
+ detail: describeAuthFailure(auth, { ...failed ? { step: failed.name } : {}, detail: failed?.detail ?? inner.error }),
3878
+ kind: failed?.failureKind ?? "unknown",
3879
+ checks: []
3880
+ };
3747
3881
  }
3748
- await context.close().catch(() => {
3882
+ const proof = await runStepsIn(session, [checkStep(step.id, auth.success)], {
3883
+ ...opts,
3884
+ ...opts.onStepResult ? { onStepResult: () => void 0 } : {},
3885
+ startUrl: null,
3886
+ authDepth: (opts.authDepth ?? 0) + 1
3749
3887
  });
3750
- if (evidenceDir && artifacts.video !== "off") {
3751
- runArtifacts.push(...collectVideos(evidenceDir, artifacts.video === "always" || !passed));
3888
+ const checked = proof.results[0];
3889
+ if (proof.status !== "passed") {
3890
+ return {
3891
+ ok: false,
3892
+ label,
3893
+ detail: describeAuthFailure(auth, { detail: checked?.detail ?? "the signed-in check did not pass" }),
3894
+ kind: "assertion",
3895
+ checks: checked?.checks ?? []
3896
+ };
3752
3897
  }
3753
- await browser.close().catch(() => {
3754
- });
3755
- return runArtifacts;
3898
+ return { ok: true, label, detail: null, kind: null, checks: checked?.checks ?? [] };
3899
+ }
3900
+ async function confirmSignedIn(page, auth, timeoutMs) {
3901
+ for (const check of auth.success) {
3902
+ if (check.enabled === false) continue;
3903
+ try {
3904
+ await checkAssertion(page, check.kind, check.value, timeoutMs, check);
3905
+ } catch (err) {
3906
+ return { ok: false, detail: firstLine2(err) };
3907
+ }
3908
+ }
3909
+ return { ok: true, detail: null };
3910
+ }
3911
+ var checkStep = (id, proposals) => ({
3912
+ id: `${id}__signed-in`,
3913
+ type: "assert",
3914
+ role: "",
3915
+ name: "Signed in",
3916
+ scope: "",
3917
+ value: "",
3918
+ secret: false,
3919
+ legacySelector: "",
3920
+ proposals
3921
+ });
3922
+ function stepFailure(step, detail) {
3923
+ return {
3924
+ stepId: step.id,
3925
+ name: step.name,
3926
+ type: step.type,
3927
+ value: null,
3928
+ secret: step.secret,
3929
+ status: "failed",
3930
+ detail,
3931
+ failureKind: "unknown",
3932
+ durationMs: 0,
3933
+ checks: []
3934
+ };
3756
3935
  }
3757
3936
  async function runStepsIn(session, steps, opts) {
3758
- const { page, pages, config, artifacts } = session;
3937
+ let { page, pages, config, artifacts } = session;
3759
3938
  const { resolveSecret, resolveFile, onStepResult, evidenceDir } = opts;
3760
3939
  const results = [];
3761
3940
  let status2 = "passed";
3762
3941
  let error = null;
3763
3942
  try {
3764
3943
  if (opts.startUrl) await page.goto(opts.startUrl, { waitUntil: "domcontentloaded" });
3765
- for (const step of steps) {
3944
+ for (const recorded of steps) {
3945
+ let step = recorded;
3946
+ if (!recorded.disabled) {
3947
+ try {
3948
+ step = withRuntimeValues(recorded, opts.actors?.values ?? /* @__PURE__ */ new Map());
3949
+ } catch (err) {
3950
+ const result2 = stepFailure(recorded, firstLine2(err));
3951
+ results.push(result2);
3952
+ onStepResult?.(result2);
3953
+ status2 = "failed";
3954
+ error = `${recorded.type} "${recorded.name}": ${result2.detail}`;
3955
+ break;
3956
+ }
3957
+ }
3958
+ if ((opts.authDepth ?? 0) > 0 && !step.disabled && (step.type === "useActor" || step.type === "resetActor" || step.type === "capture")) {
3959
+ const result2 = stepFailure(step, `a shared sign-in cannot ${step.type === "capture" ? "capture an output" : "change participants"}`);
3960
+ results.push(result2);
3961
+ onStepResult?.(result2);
3962
+ status2 = "failed";
3963
+ error = result2.detail;
3964
+ break;
3965
+ }
3966
+ if (opts.actors) {
3967
+ if (step.type === "useActor" && !step.disabled) {
3968
+ const began = Date.now();
3969
+ const result2 = {
3970
+ stepId: step.id,
3971
+ name: `Continue as ${opts.actors.nameOf(step.actorId)}`,
3972
+ type: step.type,
3973
+ value: null,
3974
+ secret: false,
3975
+ status: "passed",
3976
+ detail: null,
3977
+ failureKind: null,
3978
+ durationMs: 0,
3979
+ checks: []
3980
+ };
3981
+ try {
3982
+ await opts.actors.select(step.actorId ?? "");
3983
+ } catch (err) {
3984
+ result2.status = "failed";
3985
+ result2.failureKind = "unknown";
3986
+ result2.detail = firstLine2(err);
3987
+ }
3988
+ result2.durationMs = Date.now() - began;
3989
+ results.push(result2);
3990
+ onStepResult?.(result2);
3991
+ if (result2.status === "failed") {
3992
+ status2 = "failed";
3993
+ error = `${result2.name}: ${result2.detail}`;
3994
+ break;
3995
+ }
3996
+ continue;
3997
+ }
3998
+ session = opts.actors.current;
3999
+ ({ page, pages, config, artifacts } = session);
4000
+ }
3766
4001
  if (opts.signal?.aborted) {
3767
4002
  status2 = "errored";
3768
4003
  error = "the run was cancelled";
3769
4004
  break;
3770
4005
  }
4006
+ if ((step.type === "signIn" || step.type === "signOut") && !step.disabled) {
4007
+ const started = Date.now();
4008
+ const outcome = await performSignIn(session, step, opts);
4009
+ const result2 = {
4010
+ stepId: step.id,
4011
+ name: outcome.label,
4012
+ type: step.type,
4013
+ value: null,
4014
+ secret: false,
4015
+ status: outcome.ok ? "passed" : "failed",
4016
+ detail: outcome.detail,
4017
+ failureKind: outcome.ok ? null : outcome.kind,
4018
+ durationMs: Date.now() - started,
4019
+ checks: outcome.checks
4020
+ };
4021
+ results.push(result2);
4022
+ onStepResult?.(result2);
4023
+ if (!outcome.ok) {
4024
+ status2 = "failed";
4025
+ error = outcome.detail;
4026
+ break;
4027
+ }
4028
+ continue;
4029
+ }
3771
4030
  if (step.disabled) {
3772
4031
  const skipped = {
3773
4032
  stepId: step.id,
@@ -3807,6 +4066,9 @@ async function runStepsIn(session, steps, opts) {
3807
4066
  pages,
3808
4067
  resolveSecret,
3809
4068
  resolveFile,
4069
+ valuesResolved: true,
4070
+ baseUrl: opts.baseUrl,
4071
+ ...opts.actors ? { actors: opts.actors } : {},
3810
4072
  timeoutMs: config.timeoutMs,
3811
4073
  navigationTimeoutMs: config.navigationTimeoutMs
3812
4074
  });
@@ -3826,7 +4088,7 @@ async function runStepsIn(session, steps, opts) {
3826
4088
  } catch (err) {
3827
4089
  const kind = err.kind ?? null;
3828
4090
  if (kind && !soft) checkFailureKind = kind;
3829
- result.checks.push({ kind: a.kind, expected: label, ok: false, soft, detail: firstLine(err) });
4091
+ result.checks.push({ kind: a.kind, expected: label, ok: false, soft, detail: firstLine2(err) });
3830
4092
  }
3831
4093
  }
3832
4094
  const hardFailure = result.checks.find((c) => !c.ok && !c.soft);
@@ -3838,7 +4100,7 @@ async function runStepsIn(session, steps, opts) {
3838
4100
  } catch (err) {
3839
4101
  result.status = "failed";
3840
4102
  result.failureKind = err.kind ?? (/strict mode violation/.test(String(err)) ? "ambiguous" : err instanceof errors.TimeoutError ? "timeout" : "unknown");
3841
- result.detail = firstLine(err);
4103
+ result.detail = firstLine2(err);
3842
4104
  }
3843
4105
  const wantsShot = artifacts.screenshot === "always" || artifacts.screenshot === "on-failure" && result.status === "failed";
3844
4106
  if (wantsShot && evidenceDir) {
@@ -3864,262 +4126,853 @@ async function runStepsIn(session, steps, opts) {
3864
4126
  break;
3865
4127
  }
3866
4128
  }
3867
- } catch (err) {
4129
+ } catch (err) {
4130
+ status2 = "errored";
4131
+ error = firstLine2(err);
4132
+ }
4133
+ return { results, status: status2, error };
4134
+ }
4135
+ async function replayFlow(opts) {
4136
+ const { steps, baseUrl, startPath } = opts;
4137
+ validateActors(opts.actors, opts.defaultActorId, actorIdsUsed(steps));
4138
+ const started = Date.now();
4139
+ const hydrated = keptSessionFor(
4140
+ { ...opts, baseUrl },
4141
+ opts.actors?.find((a) => a.id === (opts.defaultActorId ?? "default"))?.startSignedIn
4142
+ );
4143
+ const session = await openRunSession({
4144
+ steps: [...steps, ...actorPreparations(opts.actors)],
4145
+ ...hydrated?.state ? { storageState: hydrated.state } : {},
4146
+ ...opts.resolveAuth ? { resolveAuth: opts.resolveAuth } : {},
4147
+ ...opts.environment === void 0 ? {} : { environment: opts.environment },
4148
+ ...opts.headless === void 0 ? {} : { headless: opts.headless },
4149
+ ...opts.slowMo === void 0 ? {} : { slowMo: opts.slowMo },
4150
+ ...opts.artifacts === void 0 ? {} : { artifacts: opts.artifacts },
4151
+ ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4152
+ ...opts.resolveSecret === void 0 ? {} : { resolveSecret: opts.resolveSecret }
4153
+ });
4154
+ const actors = new RunActors(session, {
4155
+ ...opts,
4156
+ steps: [...steps, ...actorPreparations(opts.actors)],
4157
+ startUrl: resolveStartUrl(baseUrl, startPath),
4158
+ hydrated: hydrated ? { ...hydrated, id: opts.defaultActorId ?? "default" } : null
4159
+ });
4160
+ let prepared = null;
4161
+ try {
4162
+ await actors.select(opts.defaultActorId ?? "default");
4163
+ } catch (e) {
4164
+ prepared = e.message;
4165
+ }
4166
+ const { results, status: status2, error } = prepared ? { results: [], status: "failed", error: prepared } : await runStepsIn(actors.current, steps, {
4167
+ actors,
4168
+ ...opts.resolveSecret === void 0 ? {} : { resolveSecret: opts.resolveSecret },
4169
+ ...opts.resolveFile === void 0 ? {} : { resolveFile: opts.resolveFile },
4170
+ ...opts.onStepResult === void 0 ? {} : { onStepResult: opts.onStepResult },
4171
+ ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4172
+ ...opts.signal === void 0 ? {} : { signal: opts.signal },
4173
+ ...opts.resolveAuth === void 0 ? {} : { resolveAuth: opts.resolveAuth },
4174
+ baseUrl,
4175
+ startUrl: null
4176
+ });
4177
+ await actors.closeExtras(status2 === "passed");
4178
+ const runArtifacts = await closeRunSession(session, {
4179
+ ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4180
+ passed: status2 === "passed"
4181
+ });
4182
+ return {
4183
+ status: status2,
4184
+ error,
4185
+ artifacts: runArtifacts,
4186
+ durationMs: Date.now() - started,
4187
+ // Steps that actually ran. A step turned off in the editor is in
4188
+ // `results` so the report can say so, but counting it as run would make a
4189
+ // flow look busier than it was.
4190
+ stepsRun: results.filter((r) => r.status !== "skipped").length,
4191
+ stepsTotal: steps.length,
4192
+ steps: results
4193
+ };
4194
+ }
4195
+ async function executeStep(main, input, options = {}) {
4196
+ const parsed = RecordedStepSchema.parse(input);
4197
+ const step = options.valuesResolved ? parsed : withRuntimeValues(parsed, options.actors?.values ?? /* @__PURE__ */ new Map());
4198
+ if (options.actors) {
4199
+ if (step.type === "resetActor") {
4200
+ await options.actors.freshGuest(step.actorId ?? options.actors.currentId);
4201
+ return options.actors.current.page;
4202
+ }
4203
+ if (step.type === "useActor") {
4204
+ await options.actors.select(step.actorId ?? "");
4205
+ return options.actors.current.page;
4206
+ }
4207
+ main = options.actors.current.page;
4208
+ options = { ...options, pages: options.actors.current.pages };
4209
+ }
4210
+ const page = options.pages?.pageFor(step) ?? main;
4211
+ if (step.type === "signIn" || step.type === "signOut") {
4212
+ const context = main.context();
4213
+ const outcome = await performSignIn({
4214
+ browser: context.browser(),
4215
+ context,
4216
+ page: main,
4217
+ pages: options.pages ?? new ExecutionPages(context, main),
4218
+ config: { ...DEFAULT_ENVIRONMENT, timeoutMs: options.timeoutMs ?? 8e3 },
4219
+ artifacts: resolveCapture({}),
4220
+ consoleLines: [],
4221
+ runArtifacts: []
4222
+ }, step, { ...options, startUrl: null });
4223
+ if (!outcome.ok) throw new Error(outcome.detail ?? "Authentication failed");
4224
+ return main;
4225
+ }
4226
+ if (step.type === "capture") {
4227
+ if (!step.capture || !options.actors) throw new Error("Configure the runtime output name and source");
4228
+ if (step.secret) throw new Error("Secret fields cannot be captured as shared outputs");
4229
+ let value;
4230
+ if (step.capture.source === "url") value = page.url();
4231
+ else {
4232
+ const target = await locate(page, step);
4233
+ if (await target.getAttribute("type") === "password") throw new Error("Password fields cannot be captured as shared outputs");
4234
+ value = step.capture.source === "value" ? await target.inputValue() : await target.innerText();
4235
+ }
4236
+ if (value.length > 16384) throw new Error("Captured output exceeds 16 KB");
4237
+ options.actors.values.set(step.capture.name, value);
4238
+ return page;
4239
+ }
4240
+ const deadline = Date.now() + (step.timeoutMs ?? options.timeoutMs ?? 8e3);
4241
+ const remaining = () => Math.max(1, deadline - Date.now());
4242
+ const navigationBudget = () => step.navigationTimeoutMs ?? options.navigationTimeoutMs ?? 45e3;
4243
+ page.setDefaultTimeout(remaining());
4244
+ const act = async () => {
4245
+ if (step.type === "unsupported") throw new Error(step.warning || step.value || "This interaction requires a supported action before replay");
4246
+ if (step.type === "assert") {
4247
+ if (step.scroll) await applyScroll(page, step, Math.min(2e3, remaining()));
4248
+ return;
4249
+ }
4250
+ if (step.type === "navigate") {
4251
+ await page.goto(new URL(step.value, step.navigationBase === "application" ? options.baseUrl ?? page.url() : page.url()).href, { waitUntil: "domcontentloaded", timeout: navigationBudget() });
4252
+ return;
4253
+ }
4254
+ if (step.type === "closePage") {
4255
+ await page.close();
4256
+ return;
4257
+ }
4258
+ if (step.type === "back" || step.type === "forward") {
4259
+ await navigateHistory(page, step.type, navigationBudget());
4260
+ return;
4261
+ }
4262
+ if (step.type === "scroll" && step.scroll) {
4263
+ await applyScroll(page, step, remaining());
4264
+ return;
4265
+ }
4266
+ const ref = step.secretRef || step.value;
4267
+ const value = step.secret ? ref ? options.resolveSecret?.(ref) : void 0 : step.value;
4268
+ if (step.secret && value === void 0) {
4269
+ throw Object.assign(
4270
+ new Error(
4271
+ 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`
4272
+ ),
4273
+ { kind: "not_found" }
4274
+ );
4275
+ }
4276
+ if (step.scroll && step.type !== "scroll") {
4277
+ await applyScroll(page, step, Math.min(2e3, remaining()));
4278
+ }
4279
+ const loc = await locate(page, step);
4280
+ await ensureTarget(page, loc, step, remaining());
4281
+ page.setDefaultTimeout(remaining());
4282
+ await applyAction(loc, step, value, { resolveFile: options.resolveFile, dropTarget: await locateDrop(page, step) });
4283
+ };
4284
+ if (options.pages) await options.pages.action(page, step, remaining(), act);
4285
+ else await act();
4286
+ return page;
4287
+ }
4288
+ async function navigateHistory(page, direction, timeout) {
4289
+ let traversed = false;
4290
+ const listener = (frame) => {
4291
+ if (frame === page.mainFrame()) traversed = true;
4292
+ };
4293
+ page.on("framenavigated", listener);
4294
+ try {
4295
+ const response = await (direction === "back" ? page.goBack({ waitUntil: "domcontentloaded", timeout }) : page.goForward({ waitUntil: "domcontentloaded", timeout }));
4296
+ if (!response && (!traversed || page.url() === "about:blank")) throw Object.assign(new Error(`no history entry to go ${direction} to`), { kind: "navigation" });
4297
+ } finally {
4298
+ page.off("framenavigated", listener);
4299
+ }
4300
+ }
4301
+ async function applyScroll(page, step, timeout) {
4302
+ const scroll = step.scroll;
4303
+ if (!scroll) return;
4304
+ try {
4305
+ const container = scroll.container;
4306
+ const subject = step.type === "scroll" ? step : container ? {
4307
+ ...step,
4308
+ type: "scroll",
4309
+ role: container.role,
4310
+ name: container.name,
4311
+ scope: container.scope ?? "",
4312
+ scopeRole: container.scopeRole,
4313
+ frame: container.frame,
4314
+ pageId: container.pageId,
4315
+ nameFrom: container.nameFrom,
4316
+ locator: container.locator,
4317
+ nth: container.nth,
4318
+ legacySelector: scroll.legacySelector ?? ""
4319
+ } : step;
4320
+ const root = rootFor(page, subject);
4321
+ const loc = subject.locator || subject.name ? await locate(page, subject) : root.locator("html");
4322
+ await loc.evaluate(
4323
+ (el, pos) => {
4324
+ el.scrollTo({ left: pos.x, top: pos.y, behavior: "instant" });
4325
+ },
4326
+ { x: scroll.x, y: scroll.y },
4327
+ { timeout }
4328
+ );
4329
+ } catch {
4330
+ }
4331
+ }
4332
+ async function ensureTarget(page, loc, step, timeout) {
4333
+ const firstWait = Math.max(500, Math.floor(timeout * 0.45));
4334
+ const recovery = Math.max(1e3, Math.floor(timeout * 0.35));
4335
+ try {
4336
+ await loc.waitFor({ state: "attached", timeout: firstWait });
4337
+ return;
4338
+ } catch (error) {
4339
+ if (/strict mode violation/.test(String(error))) {
4340
+ throw Object.assign(error, { kind: "ambiguous" });
4341
+ }
4342
+ }
4343
+ if (await scrollUntilFound(page, loc, recovery)) return;
4344
+ throw Object.assign(
4345
+ new Error(
4346
+ `no element named "${step.name}" \u2014 nothing on this page matches the target or its recorded scope`
4347
+ ),
4348
+ { kind: "not_found" }
4349
+ );
4350
+ }
4351
+ async function replayCase(opts) {
4352
+ const { occurrences, baseUrl } = opts;
4353
+ const started = Date.now();
4354
+ const warnings = [];
4355
+ validateActors(opts.actors, opts.defaultActorId, occurrences.flatMap((o) => [o.actorId, ...actorIdsUsed(o.steps)]));
4356
+ const allSteps = [...occurrences.flatMap((o) => o.steps), ...actorPreparations(opts.actors)];
4357
+ for (const occurrence of occurrences) {
4358
+ if (occurrence.environment && Object.keys(occurrence.environment).length > 0) {
4359
+ warnings.push(
4360
+ `"${occurrence.name}" was recorded with its own browser settings; the case's settings were used instead`
4361
+ );
4362
+ }
4363
+ }
4364
+ const caseHydrated = keptSessionFor(
4365
+ { ...opts, baseUrl },
4366
+ opts.actors?.find((a) => a.id === (opts.defaultActorId ?? "default"))?.startSignedIn
4367
+ );
4368
+ const session = await openRunSession({
4369
+ steps: allSteps,
4370
+ ...caseHydrated?.state ? { storageState: caseHydrated.state } : {},
4371
+ ...opts.resolveAuth ? { resolveAuth: opts.resolveAuth } : {},
4372
+ ...opts.environment === void 0 ? {} : { environment: opts.environment },
4373
+ ...opts.headless === void 0 ? {} : { headless: opts.headless },
4374
+ ...opts.slowMo === void 0 ? {} : { slowMo: opts.slowMo },
4375
+ ...opts.artifacts === void 0 ? {} : { artifacts: opts.artifacts },
4376
+ ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4377
+ ...opts.resolveSecret === void 0 ? {} : { resolveSecret: opts.resolveSecret }
4378
+ });
4379
+ const actors = new RunActors(session, {
4380
+ ...opts,
4381
+ steps: allSteps,
4382
+ startUrl: resolveStartUrl(baseUrl, occurrences[0]?.startPath ?? "/"),
4383
+ hydrated: caseHydrated ? { ...caseHydrated, id: opts.defaultActorId ?? "default" } : null
4384
+ });
4385
+ const outcomes = [];
4386
+ let status2 = "passed";
4387
+ let error = null;
4388
+ try {
4389
+ for (const [index, occurrence] of occurrences.entries()) {
4390
+ if (status2 !== "passed" || opts.signal?.aborted) {
4391
+ const skipped = {
4392
+ occurrenceId: occurrence.occurrenceId,
4393
+ name: occurrence.name,
4394
+ status: "skipped",
4395
+ error: null,
4396
+ steps: [],
4397
+ stepsRun: 0,
4398
+ stepsTotal: occurrence.steps.length,
4399
+ durationMs: 0,
4400
+ continued: false
4401
+ };
4402
+ if (status2 === "passed" && opts.signal?.aborted) {
4403
+ status2 = "errored";
4404
+ error = "the run was cancelled";
4405
+ }
4406
+ outcomes.push(skipped);
4407
+ opts.onOccurrenceResult?.(skipped);
4408
+ continue;
4409
+ }
4410
+ const navigates = occurrence.navigateFirst;
4411
+ try {
4412
+ await actors.select(occurrence.actorId ?? opts.defaultActorId ?? "default", resolveStartUrl(occurrence.baseUrl ?? baseUrl, occurrence.startPath ?? "/"));
4413
+ } catch (failure) {
4414
+ status2 = "failed";
4415
+ error = failure.message;
4416
+ outcomes.push({ occurrenceId: occurrence.occurrenceId, name: occurrence.name, status: status2, error, steps: [], stepsRun: 0, stepsTotal: occurrence.steps.length, durationMs: 0, continued: false });
4417
+ continue;
4418
+ }
4419
+ const at0 = Date.now();
4420
+ const { results, status: ran, error: failed } = await runStepsIn(actors.current, occurrence.steps, {
4421
+ actors,
4422
+ baseUrl: occurrence.baseUrl ?? baseUrl,
4423
+ ...opts.resolveAuth ? { resolveAuth: opts.resolveAuth } : {},
4424
+ stepOffset: outcomes.reduce((count, previous) => count + previous.stepsTotal, 0),
4425
+ ...opts.resolveSecret === void 0 ? {} : { resolveSecret: opts.resolveSecret },
4426
+ ...opts.resolveFile === void 0 ? {} : { resolveFile: opts.resolveFile },
4427
+ ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4428
+ ...opts.signal === void 0 ? {} : { signal: opts.signal },
4429
+ ...opts.onStepResult ? { onStepResult: (result) => opts.onStepResult(result, occurrence.occurrenceId) } : {},
4430
+ startUrl: navigates ? resolveStartUrl(occurrence.baseUrl ?? baseUrl, occurrence.startPath ?? "/") : null
4431
+ });
4432
+ const outcome = {
4433
+ occurrenceId: occurrence.occurrenceId,
4434
+ name: occurrence.name,
4435
+ status: ran,
4436
+ error: failed,
4437
+ steps: results,
4438
+ stepsRun: results.filter((r) => r.status !== "skipped").length,
4439
+ stepsTotal: occurrence.steps.length,
4440
+ durationMs: Date.now() - at0,
4441
+ continued: index > 0 && !navigates
4442
+ };
4443
+ outcomes.push(outcome);
4444
+ opts.onOccurrenceResult?.(outcome);
4445
+ if (ran !== "passed") {
4446
+ status2 = ran;
4447
+ error = `${occurrence.name}: ${failed ?? "failed"}`;
4448
+ }
4449
+ }
4450
+ } finally {
4451
+ await actors.closeExtras(status2 === "passed");
4452
+ await closeRunSession(session, {
4453
+ ...opts.evidenceDir === void 0 ? {} : { evidenceDir: opts.evidenceDir },
4454
+ passed: status2 === "passed"
4455
+ });
4456
+ }
4457
+ if (opts.signal?.aborted) {
3868
4458
  status2 = "errored";
3869
- error = firstLine(err);
4459
+ error = "the run was cancelled";
3870
4460
  }
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
- });
4461
+ const steps = outcomes.flatMap((o) => o.steps);
3897
4462
  return {
3898
4463
  status: status2,
3899
4464
  error,
3900
- artifacts: runArtifacts,
4465
+ artifacts: session.runArtifacts,
3901
4466
  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
4467
+ occurrences: outcomes,
4468
+ steps,
4469
+ stepsRun: steps.filter((r) => r.status !== "skipped").length,
4470
+ stepsTotal: occurrences.reduce((n2, o) => n2 + o.steps.length, 0),
4471
+ warnings
3908
4472
  };
3909
4473
  }
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()));
4474
+ var OUTCOME_WINDOW_MS = 2500;
4475
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4476
+ var COMMITS = /* @__PURE__ */ new Set([
4477
+ "click",
4478
+ "dblclick",
4479
+ "rightclick",
4480
+ "drag",
4481
+ "check",
4482
+ "press",
4483
+ "upload",
4484
+ "back",
4485
+ "forward"
4486
+ ]);
4487
+ var cachedAccNameScript = null;
4488
+ var ACCNAME_ASSET = "accname.global.js";
4489
+ function accNameScript() {
4490
+ if (cachedAccNameScript === null) {
4491
+ cachedAccNameScript = fs22.readFileSync(accNameScriptPath(), "utf8");
4492
+ }
4493
+ return cachedAccNameScript;
4494
+ }
4495
+ function accNameScriptPath() {
4496
+ try {
4497
+ return createRequire2(import.meta.url).resolve("@flowriki/recorder/accname.global.js");
4498
+ } catch {
4499
+ const beside = path32.join(path32.dirname(fileURLToPath(import.meta.url)), ACCNAME_ASSET);
4500
+ if (fs22.existsSync(beside)) return beside;
4501
+ throw new Error(
4502
+ `this build is missing ${ACCNAME_ASSET}, which recording injects into the page \u2014 reinstall the agent`
4503
+ );
4504
+ }
4505
+ }
4506
+ async function startRecordingSession(opts) {
4507
+ const { headless = false, onStep, onState, onDisconnect, onStopRequested } = opts;
4508
+ const url = normalizeStartUrl(opts.url);
4509
+ const { browser, context, config } = await launchSession(opts.environment, headless);
4510
+ const steps = [];
4511
+ let pendingScrollPos = null;
4512
+ const pageIds = /* @__PURE__ */ new Map();
4513
+ let nextPageId = 0;
4514
+ let pendingDialog = null;
4515
+ let stopped = false;
4516
+ let paused = false;
4517
+ const frameChains = /* @__PURE__ */ new Map();
4518
+ const frameLabel = async (frame) => {
4519
+ const el = await frame.frameElement().catch(() => null);
4520
+ if (el) {
4521
+ for (const attr of ["title", "name", "id"]) {
4522
+ const value = (await el.getAttribute(attr).catch(() => null))?.trim();
4523
+ if (value) return value;
4524
+ }
4525
+ }
4526
+ const named = frame.name();
4527
+ if (named) return named;
4528
+ const siblings = frame.parentFrame()?.childFrames() ?? [];
4529
+ return `nth:${Math.max(0, siblings.indexOf(frame))}`;
4530
+ };
4531
+ const noteFrame = async (frame) => {
4532
+ try {
4533
+ const chain = [];
4534
+ let f = frame;
4535
+ while (f && f.parentFrame()) {
4536
+ chain.unshift(await frameLabel(f));
4537
+ f = f.parentFrame();
4538
+ }
4539
+ frameChains.set(frame, chain);
4540
+ } catch {
4541
+ }
4542
+ };
4543
+ const watchFrames = (page2) => {
4544
+ for (const f of page2.frames()) void noteFrame(f);
4545
+ page2.on("frameattached", (f) => void noteFrame(f));
4546
+ page2.on("framenavigated", (f) => void noteFrame(f));
4547
+ };
4548
+ const snapshot = () => ({
4549
+ status: "recording",
4550
+ cursor: null,
4551
+ breakpoints: [],
4552
+ steps: steps.map((x) => ({ ...x })),
4553
+ results: {},
4554
+ failure: null,
4555
+ recording: !paused,
4556
+ recorded: steps.map((x) => x.id),
4557
+ mode: "record",
4558
+ ...pendingDialog ? { dialog: { type: pendingDialog.dialog.type(), message: pendingDialog.dialog.message(), defaultValue: pendingDialog.dialog.defaultValue() } } : {}
4559
+ });
4560
+ const paint = () => {
4561
+ const safe2 = {
4562
+ ...snapshot(),
4563
+ // The characters typed into a password never leave this process.
4564
+ steps: snapshot().steps.map((st) => ({
4565
+ id: st.id,
4566
+ type: st.type,
4567
+ name: st.name,
4568
+ scope: st.scope,
4569
+ value: st.secret ? "" : st.value,
4570
+ secret: st.secret,
4571
+ // The sentence, written here rather than in the page: the catalogue's
4572
+ // wording lives in core, and a panel that phrased checks its own way
4573
+ // would say something different from the flow editor about the same
4574
+ // step.
4575
+ ...st.type === "assert" && st.proposals[0] ? { detail: describeAssertion(st.proposals[0], st.name) } : {}
4576
+ }))
4577
+ };
4578
+ for (const p of context.pages()) {
4579
+ p.evaluate(
4580
+ (v) => window.__flDebugRender?.(v),
4581
+ safe2
4582
+ ).catch(() => {
4583
+ });
4584
+ }
4585
+ onState?.(snapshot());
4586
+ };
4587
+ const toPanel = async (msg) => {
4588
+ await context.pages()[0]?.evaluate(
4589
+ (v) => window.__flPanelMessage?.(v),
4590
+ msg
4591
+ ).catch(() => {
4592
+ });
4593
+ };
4594
+ let signature = null;
4595
+ browser.on("disconnected", () => {
4596
+ if (!stopped) onDisconnect?.();
4597
+ });
4598
+ let pending = null;
4599
+ const finish = (after) => {
4600
+ if (!pending) return;
4601
+ const { step, before, timer } = pending;
4602
+ clearTimeout(timer);
4603
+ pending = null;
4604
+ step.proposals = proposeAssertions(before, after).map(
4605
+ (p) => (
4606
+ // Inferred from a text diff, so it arrives unticked for a human to
4607
+ // confirm. Everything else the page stated about itself and is safe to
4608
+ // run as recorded.
4609
+ p.confidence === "low" ? { ...p, enabled: false } : p
4610
+ )
4611
+ );
4612
+ onStep?.(step);
4613
+ };
4614
+ const consider = (sig) => {
4615
+ signature = sig;
4616
+ if (!pending) return;
4617
+ if (hasObservableChange(pending.before, sig)) finish(sig);
4618
+ };
4619
+ const begin = (step, before) => {
4620
+ finish(signature);
4621
+ const timer = setTimeout(() => finish(signature), OUTCOME_WINDOW_MS);
4622
+ timer.unref?.();
4623
+ pending = { step, before, timer };
4624
+ };
4625
+ const targetIds = /* @__PURE__ */ new Map();
4626
+ const record = (payload, before) => {
4627
+ if (["fill", "select", "setRange"].includes(payload.type)) {
4628
+ const prev = steps[steps.length - 1];
4629
+ 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)) {
4630
+ prev.value = payload.value ?? "";
4631
+ if (payload.values) prev.values = payload.values;
4632
+ onStep?.(prev);
4633
+ paint();
4634
+ return;
4635
+ }
4636
+ }
4637
+ if (payload.type === "scroll") {
4638
+ pendingScrollPos = payload.scroll;
3921
4639
  return;
3922
4640
  }
3923
- if (step.type === "navigate") {
3924
- await page.goto(new URL(step.value, page.url()).href, { waitUntil: "domcontentloaded", timeout: navigationBudget() });
4641
+ if (payload.type === "dblclick") {
4642
+ const prev = steps[steps.length - 1];
4643
+ if (prev && prev.type === "click" && prev.name === payload.name && prev.scope === payload.scope) {
4644
+ steps.pop();
4645
+ }
4646
+ }
4647
+ const step = {
4648
+ id: `st_${crypto.randomUUID().slice(0, 8)}`,
4649
+ type: payload.type,
4650
+ role: payload.role ?? "",
4651
+ name: payload.name ?? "",
4652
+ ...payload.nameFrom ? { nameFrom: payload.nameFrom } : {},
4653
+ scope: payload.scope ?? "",
4654
+ ...payload.scopeRole ? { scopeRole: payload.scopeRole } : {},
4655
+ ...payload.frame?.length ? { frame: payload.frame } : {},
4656
+ ...payload.to ? { to: payload.to } : {},
4657
+ ...payload.locator ? { locator: payload.locator } : {},
4658
+ ...payload.values ? { values: payload.values } : {},
4659
+ ...payload.warning ? { warning: payload.warning } : {},
4660
+ ...payload.pageId ? { pageId: payload.pageId } : {},
4661
+ ...payload.scroll ? { scroll: payload.scroll } : {},
4662
+ value: payload.value ?? "",
4663
+ secret: Boolean(payload.secret),
4664
+ legacySelector: payload.legacySelector ?? "",
4665
+ proposals: []
4666
+ };
4667
+ RecordedStepSchema.parse(step);
4668
+ targetIds.set(step.id, payload.__targetId);
4669
+ if (pendingScrollPos) {
4670
+ step.scroll = pendingScrollPos;
4671
+ pendingScrollPos = null;
4672
+ }
4673
+ steps.push(step);
4674
+ onStep?.(step);
4675
+ paint();
4676
+ if (COMMITS.has(step.type)) begin(step, before);
4677
+ };
4678
+ const PRESS_GRACE_MS = 200;
4679
+ let held = null;
4680
+ const releaseHeld = () => {
4681
+ if (!held) return;
4682
+ const { payload, before, timer } = held;
4683
+ clearTimeout(timer);
4684
+ held = null;
4685
+ record(payload, before);
4686
+ };
4687
+ await context.exposeBinding("__flRecord", async (src, payload) => {
4688
+ if (paused || !payload || stopped && payload.__sig === void 0) return;
4689
+ if (src.frame && !frameChains.has(src.frame)) await noteFrame(src.frame);
4690
+ if (src.page) payload.pageId = pageIds.get(src.page) ?? "main";
4691
+ if (payload && payload.__sig !== void 0) {
4692
+ if (!pending || pending.step.pageId === payload.pageId) consider(payload.__sig || null);
3925
4693
  return;
3926
4694
  }
3927
- if (step.type === "closePage") {
3928
- await page.close();
4695
+ const before = payload.__before ?? signature;
4696
+ delete payload.__before;
4697
+ const chain = src?.frame ? frameChains.get(src.frame) : void 0;
4698
+ if (chain?.length) payload.frame = chain;
4699
+ if (payload.type === "press" && payload.value === "Enter") {
4700
+ releaseHeld();
4701
+ const timer = setTimeout(releaseHeld, PRESS_GRACE_MS);
4702
+ timer.unref?.();
4703
+ held = { payload, before, timer };
3929
4704
  return;
3930
4705
  }
3931
- if (step.type === "back" || step.type === "forward") {
3932
- await navigateHistory(page, step.type, navigationBudget());
3933
- return;
4706
+ if (payload.type === "click" && held) {
4707
+ clearTimeout(held.timer);
4708
+ held = null;
4709
+ } else if (held && payload.name !== held.payload.name) {
4710
+ releaseHeld();
3934
4711
  }
3935
- if (step.type === "scroll" && step.scroll) {
3936
- await applyScroll(page, step, remaining());
4712
+ record(payload, before);
4713
+ });
4714
+ await context.exposeBinding("__flPicked", async (src, payload) => {
4715
+ if (src.frame && !frameChains.has(src.frame)) await noteFrame(src.frame);
4716
+ if (payload?.cancelled) {
4717
+ void session.setPicking(false);
4718
+ void toPanel({ picked: null });
3937
4719
  return;
3938
4720
  }
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()));
4721
+ if (!payload?.target) return;
4722
+ const chain = src?.frame ? frameChains.get(src.frame) : void 0;
4723
+ if (chain?.length) payload.target.frame = chain;
4724
+ if (src.page) payload.target.pageId = pageIds.get(src.page) ?? "main";
4725
+ try {
4726
+ const on = src.page ?? page;
4727
+ const all = await locateAll(on, {
4728
+ id: "pick",
4729
+ type: "click",
4730
+ value: "",
4731
+ secret: false,
4732
+ legacySelector: "",
4733
+ proposals: [],
4734
+ scope: "",
4735
+ ...payload.target
4736
+ });
4737
+ const handles = await all.elementHandles();
4738
+ if (handles.length > 1) {
4739
+ for (const [i, handle] of handles.entries()) {
4740
+ const isIt = await handle.evaluate((el) => el === window.__flPickedEl).catch(() => false);
4741
+ if (isIt) {
4742
+ payload.target.nth = i;
4743
+ break;
4744
+ }
4745
+ }
4746
+ if (payload.target.nth === void 0) {
4747
+ payload.ambiguous = handles.length;
4748
+ }
4749
+ } else if (handles.length === 0) {
4750
+ payload.unresolvable = true;
4751
+ }
4752
+ await Promise.all(handles.map((h) => h.dispose().catch(() => {
4753
+ })));
4754
+ } catch {
3951
4755
  }
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" });
4756
+ void session.setPicking(false);
4757
+ void toPanel({ picked: payload });
4758
+ });
4759
+ await context.exposeBinding("__flStop", () => {
4760
+ onStopRequested?.(true);
4761
+ });
4762
+ await context.exposeBinding("__flCmd", (_src, msg) => {
4763
+ if (!msg || typeof msg.command !== "string") return;
4764
+ if (msg.command === "end") onStopRequested?.(msg.commit !== false);
4765
+ else if (msg.command === "deleteStep") void session.deleteStep(String(msg.stepId));
4766
+ else if (msg.command === "pick") void session.setPicking(msg.on !== false);
4767
+ else if (msg.command === "assert") {
4768
+ void session.setPicking(false);
4769
+ try {
4770
+ session.addAssertion(msg.assertion);
4771
+ } catch (err) {
4772
+ void toPanel({ pickError: firstLine2(err) });
4773
+ }
4774
+ } else if (msg.command === "record") void session.setPaused(!msg.on);
4775
+ });
4776
+ const INIT_SCRIPT = `${accNameScript()}
4777
+ ;window.__flChecks=${JSON.stringify(CHECKS)};
4778
+ ;(${installRecorder.toString()})();
4779
+ ;(${installDebugPanel.toString()})();`;
4780
+ const page = await context.newPage();
4781
+ if (opts.authentication) {
4782
+ try {
4783
+ await page.goto(url, { waitUntil: "domcontentloaded" });
4784
+ await executeStep(page, opts.authentication.step, { ...opts.authentication, baseUrl: url });
4785
+ 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: [] };
4786
+ steps.push(opts.authentication.step, resume);
4787
+ onStep?.(opts.authentication.step);
4788
+ onStep?.(resume);
4789
+ } catch (error) {
4790
+ await browser.close();
4791
+ throw error;
4014
4792
  }
4015
4793
  }
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
- );
4794
+ await context.addInitScript({ content: INIT_SCRIPT });
4795
+ const ensureInstalled = async (p) => {
4796
+ try {
4797
+ await p.evaluate((src) => {
4798
+ if (document.__flInstalled) return;
4799
+ (0, eval)(src);
4800
+ }, INIT_SCRIPT);
4801
+ } catch {
4034
4802
  }
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 }
4803
+ };
4804
+ pageIds.set(page, "main");
4805
+ const watchPage = (p) => {
4806
+ watchFrames(p);
4807
+ void ensureInstalled(p);
4808
+ p.on("domcontentloaded", () => void ensureInstalled(p));
4809
+ p.on("download", (download) => {
4810
+ const step = [...steps].reverse().find((s2) => (s2.pageId ?? "main") === pageIds.get(p));
4811
+ if (step) {
4812
+ (step.downloads ??= []).push(download.suggestedFilename());
4813
+ onStep?.(step);
4814
+ paint();
4815
+ }
4816
+ });
4817
+ p.on("dialog", (dialog) => {
4818
+ const step = [...steps].reverse().find((s2) => (s2.pageId ?? "main") === pageIds.get(p));
4819
+ if (!step) {
4820
+ void dialog.dismiss();
4821
+ record({ type: "unsupported", name: "Dialog before first action", warning: "Record an explicit navigation before a page-load dialog", pageId: pageIds.get(p) }, null);
4822
+ return;
4823
+ }
4824
+ pendingDialog = { dialog, step };
4825
+ onState?.(snapshot());
4826
+ });
4827
+ p.on("close", () => {
4828
+ if (!stopped && p !== page) record({ type: "closePage", pageId: pageIds.get(p) }, null);
4829
+ });
4830
+ };
4831
+ watchPage(page);
4832
+ context.on("page", (p) => {
4833
+ const id = `page_${++nextPageId}`;
4834
+ pageIds.set(p, id);
4835
+ watchPage(p);
4836
+ void p.opener().then((opener) => {
4837
+ const step = [...steps].reverse().find((s2) => (s2.pageId ?? "main") === pageIds.get(opener));
4838
+ if (step) {
4839
+ step.opensPage = id;
4840
+ onStep?.(step);
4841
+ paint();
4842
+ } else record({ type: "unsupported", name: "Unlinked browser tab", warning: "Open this tab through a recorded link or button", pageId: id }, null);
4843
+ });
4044
4844
  });
4045
- const outcomes = [];
4046
- let status2 = "passed";
4047
- let error = null;
4048
4845
  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
4846
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
4847
+ } catch (err) {
4848
+ stopped = true;
4849
+ await browser.close().catch(() => {
4850
+ });
4851
+ throw new Error(`could not open ${url} \u2014 ${firstLine2(err)}`);
4852
+ }
4853
+ const session = {
4854
+ environment: config,
4855
+ page,
4856
+ startUrl: url,
4857
+ state: snapshot,
4858
+ deleteStep(id) {
4859
+ const i = steps.findIndex((x) => x.id === id);
4860
+ if (i !== -1) steps.splice(i, 1);
4861
+ paint();
4862
+ return snapshot();
4863
+ },
4864
+ async setPicking(on) {
4865
+ const call = (fn, arg) => Promise.all(
4866
+ context.pages().flatMap(
4867
+ (p) => p.frames().map(
4868
+ (f) => f.evaluate(
4869
+ ([name, value]) => window[name]?.(value),
4870
+ [fn, arg]
4871
+ ).catch(() => {
4872
+ })
4873
+ )
4874
+ )
4875
+ );
4876
+ if (on) await call("__flSetMuted", true);
4877
+ await call("__flSetPicking", on);
4878
+ if (!on && !paused) await call("__flSetMuted", false);
4879
+ },
4880
+ addAssertion(assertion) {
4881
+ const parsed = RecordedAssertionSchema.parse({ enabled: true, ...assertion });
4882
+ const step = {
4883
+ id: `st_${crypto.randomUUID().slice(0, 8)}`,
4884
+ type: "assert",
4885
+ // Mirrored from the target so a step list can name what is being
4886
+ // checked without reaching inside the assertion to find out.
4887
+ role: parsed.target?.role ?? "",
4888
+ name: parsed.target?.name ?? "",
4889
+ ...parsed.target?.nameFrom ? { nameFrom: parsed.target.nameFrom } : {},
4890
+ scope: parsed.target?.scope ?? "",
4891
+ ...parsed.target?.scopeRole ? { scopeRole: parsed.target.scopeRole } : {},
4892
+ ...parsed.target?.frame?.length ? { frame: parsed.target.frame } : {},
4893
+ ...parsed.target?.locator ? { locator: parsed.target.locator } : {},
4894
+ ...parsed.target?.pageId ? { pageId: parsed.target.pageId } : {},
4895
+ value: "",
4896
+ secret: false,
4897
+ legacySelector: "",
4898
+ proposals: [parsed]
4091
4899
  };
4092
- outcomes.push(outcome);
4093
- opts.onOccurrenceResult?.(outcome);
4094
- if (ran !== "passed") {
4095
- status2 = ran;
4096
- error = `${occurrence.name}: ${failed ?? "failed"}`;
4900
+ if ((parsed.kind === "text" || parsed.kind === "class" || parsed.kind === "attribute") && parsed.target?.nameFrom === "text" && !parsed.target.locator) {
4901
+ 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.";
4902
+ }
4903
+ RecordedStepSchema.parse(step);
4904
+ if (pendingScrollPos) {
4905
+ step.scroll = pendingScrollPos;
4906
+ pendingScrollPos = null;
4097
4907
  }
4908
+ steps.push(step);
4909
+ onStep?.(step);
4910
+ paint();
4911
+ return snapshot();
4912
+ },
4913
+ async setPaused(next) {
4914
+ paused = next;
4915
+ await Promise.all(
4916
+ context.pages().map(
4917
+ (p) => p.evaluate(
4918
+ (v) => window.__flSetMuted?.(v),
4919
+ next
4920
+ ).catch(() => {
4921
+ })
4922
+ )
4923
+ );
4924
+ paint();
4925
+ return snapshot();
4926
+ },
4927
+ async answerDialog(accept, promptText) {
4928
+ if (!pendingDialog) throw new Error("No browser dialog is waiting");
4929
+ const { dialog, step } = pendingDialog;
4930
+ const answer = { type: dialog.type(), message: dialog.message(), accept, ...promptText !== void 0 ? { promptText } : {} };
4931
+ if (accept) await dialog.accept(promptText);
4932
+ else await dialog.dismiss();
4933
+ (step.dialogs ??= []).push(answer);
4934
+ pendingDialog = null;
4935
+ onStep?.(step);
4936
+ paint();
4937
+ },
4938
+ async stop() {
4939
+ if (pendingDialog) throw new Error("Answer the browser dialog before saving the recording");
4940
+ if (stopped) return steps;
4941
+ await Promise.all(context.pages().flatMap((p) => p.frames().map((f) => f.evaluate(() => window.__flFlush?.()).catch(() => {
4942
+ }))));
4943
+ stopped = true;
4944
+ releaseHeld();
4945
+ if (pending) await sleep(OUTCOME_WINDOW_MS + 300);
4946
+ finish(signature);
4947
+ await browser.close().catch(() => {
4948
+ });
4949
+ return steps;
4098
4950
  }
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
4951
  };
4952
+ page.on("domcontentloaded", () => paint());
4953
+ context.on("page", (p) => p.on("domcontentloaded", () => paint()));
4954
+ paint();
4955
+ return session;
4956
+ }
4957
+ function firstLine2(err) {
4958
+ return String(err?.message ?? err).split("\n")[0].trim();
4959
+ }
4960
+ var SESSION_STEPS = /* @__PURE__ */ new Set(["signIn", "signOut", "useActor", "resetActor", "capture"]);
4961
+ var usesAnOutput = (text) => Array.isArray(text) ? text.some(usesAnOutput) : String(text ?? "").includes("{{");
4962
+ function needsWholeRun(opts) {
4963
+ if (opts.actors?.length) return true;
4964
+ 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
4965
  }
4122
4966
  async function verifySteps(opts) {
4967
+ if (needsWholeRun(opts)) {
4968
+ const result = await replayFlow({ ...opts, onStepResult: (step) => opts.onStepResult?.({ stepId: step.stepId, status: step.status, matches: 0, detail: step.detail ?? "Action and assertions passed" }) });
4969
+ return {
4970
+ ok: result.status === "passed",
4971
+ error: result.error,
4972
+ failedAtStepId: result.steps.find((s2) => s2.status === "failed")?.stepId ?? null,
4973
+ results: result.steps.map((s2) => ({ stepId: s2.stepId, status: s2.status, matches: 0, detail: s2.detail ?? "Action and assertions passed" }))
4974
+ };
4975
+ }
4123
4976
  const {
4124
4977
  steps,
4125
4978
  baseUrl,
@@ -4153,7 +5006,7 @@ async function verifySteps(opts) {
4153
5006
  let matches = 0;
4154
5007
  try {
4155
5008
  const current = pages.pageFor(step);
4156
- if (!["back", "forward", "navigate", "closePage", "unsupported", "scroll", "assert"].includes(step.type)) {
5009
+ if (!["back", "forward", "navigate", "closePage", "unsupported", "scroll", "assert", "signIn", "signOut"].includes(step.type)) {
4157
5010
  const all = await locateAll(current, step);
4158
5011
  const budget = step.timeoutMs ?? timeoutMs;
4159
5012
  try {
@@ -4165,7 +5018,7 @@ async function verifySteps(opts) {
4165
5018
  matches = await all.count();
4166
5019
  if (matches > 1 && step.nth === void 0) throw new Error(`${matches} elements match this step \u2014 choose a unique target`);
4167
5020
  }
4168
- const active = await executeStep(page, step, { pages, resolveSecret, resolveFile, timeoutMs });
5021
+ const active = await executeStep(page, step, { pages, resolveSecret, resolveFile, timeoutMs, baseUrl, ...opts.resolveAuth ? { resolveAuth: opts.resolveAuth } : {} });
4169
5022
  for (const a of step.proposals) if (a.enabled !== false) await checkAssertion(active, a.kind, a.value, config.assertionTimeoutMs, a);
4170
5023
  report2({
4171
5024
  stepId: step.id,
@@ -4176,15 +5029,15 @@ async function verifySteps(opts) {
4176
5029
  } catch (caught) {
4177
5030
  const err = /Timeout/.test(String(caught)) ? new Error(`Target could not be found within the step timeout`) : caught;
4178
5031
  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)}`;
5032
+ error = `step ${index + 1} (${what}): ${firstLine2(err)}`;
4180
5033
  failedAtStepId = step.id;
4181
- report2({ stepId: step.id, status: "failed", matches, detail: firstLine(err) });
5034
+ report2({ stepId: step.id, status: "failed", matches, detail: firstLine2(err) });
4182
5035
  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
5036
  break;
4184
5037
  }
4185
5038
  }
4186
5039
  } catch (err) {
4187
- error = firstLine(err);
5040
+ error = firstLine2(err);
4188
5041
  } finally {
4189
5042
  await browser.close();
4190
5043
  }
@@ -4193,8 +5046,8 @@ async function verifySteps(opts) {
4193
5046
  var cachedAccName = null;
4194
5047
  function accNameScript2() {
4195
5048
  if (cachedAccName === null) {
4196
- const path33 = accNameScriptPath();
4197
- cachedAccName = fs32.readFileSync(path33, "utf8");
5049
+ const path42 = accNameScriptPath();
5050
+ cachedAccName = fs32.readFileSync(path42, "utf8");
4198
5051
  }
4199
5052
  return cachedAccName;
4200
5053
  }
@@ -4221,7 +5074,9 @@ async function startDebugSession(opts) {
4221
5074
  let stopRequested = false;
4222
5075
  const applied = /* @__PURE__ */ new Set();
4223
5076
  const recordedInSession = /* @__PURE__ */ new Set();
4224
- const { browser, context } = await launchSession({ ...opts.environment, timeoutMs }, headless, slowMo);
5077
+ validateActors(opts.actors, opts.defaultActorId, actorIdsUsed(steps));
5078
+ let actors;
5079
+ const { browser, context, config, artifacts } = await launchSession({ ...opts.environment, timeoutMs }, headless, slowMo);
4225
5080
  let closed = false;
4226
5081
  browser.on("disconnected", () => {
4227
5082
  if (!closed) {
@@ -4238,7 +5093,8 @@ async function startDebugSession(opts) {
4238
5093
  failure,
4239
5094
  recording,
4240
5095
  recorded: [...recordedInSession],
4241
- mode: "debug"
5096
+ mode: "debug",
5097
+ actorId: actors?.currentId ?? "default"
4242
5098
  });
4243
5099
  const publish = () => {
4244
5100
  const s2 = snapshot();
@@ -4261,7 +5117,7 @@ async function startDebugSession(opts) {
4261
5117
  }))
4262
5118
  };
4263
5119
  await Promise.all(
4264
- context.pages().map(
5120
+ (actors?.all().flatMap((s22) => s22.context.pages()) ?? context.pages()).map(
4265
5121
  (p) => p.evaluate(
4266
5122
  (v) => window.__flDebugRender?.(v),
4267
5123
  safe2
@@ -4271,68 +5127,82 @@ async function startDebugSession(opts) {
4271
5127
  );
4272
5128
  }
4273
5129
  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;
5130
+ async function installContext(context2) {
5131
+ await context2.exposeBinding("__flRecord", (_src, payload) => {
5132
+ if (!recording || !payload || payload.__sig !== void 0 || actors && _src.page.context() !== actors.current.context) return;
5133
+ const step = {
5134
+ id: `st_${crypto.randomUUID().slice(0, 8)}`,
5135
+ type: payload.type,
5136
+ role: payload.role ?? "",
5137
+ ...payload.nameFrom ? { nameFrom: payload.nameFrom } : {},
5138
+ name: payload.name ?? "",
5139
+ scope: payload.scope ?? "",
5140
+ frame: payload.frame,
5141
+ scopeRole: payload.scopeRole,
5142
+ to: payload.to,
5143
+ locator: payload.locator,
5144
+ values: payload.values,
5145
+ warning: payload.warning,
5146
+ scroll: payload.scroll,
5147
+ value: payload.value ?? "",
5148
+ secret: Boolean(payload.secret),
5149
+ legacySelector: payload.legacySelector ?? "",
5150
+ proposals: []
5151
+ };
5152
+ const at = cursor ? steps.findIndex((s2) => s2.id === cursor) : steps.length;
5153
+ const insertAt = at === -1 ? steps.length : at;
5154
+ if (["fill", "select", "setRange"].includes(step.type)) {
5155
+ const prev = steps[insertAt - 1];
5156
+ if (prev && applied.has(prev.id) && prev.type === step.type && payload.__targetId && targetIds.get(prev.id) === payload.__targetId) {
5157
+ prev.value = step.value;
5158
+ prev.values = step.values;
5159
+ prev.secret = step.secret;
5160
+ publish();
5161
+ return;
5162
+ }
4305
5163
  }
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()}
5164
+ RecordedStepSchema.parse(step);
5165
+ targetIds.set(step.id, payload.__targetId);
5166
+ steps.splice(insertAt, 0, step);
5167
+ applied.add(step.id);
5168
+ recordedInSession.add(step.id);
5169
+ results[step.id] = {
5170
+ stepId: step.id,
5171
+ status: "passed",
5172
+ detail: "recorded here",
5173
+ durationMs: 0,
5174
+ checks: []
5175
+ };
5176
+ publish();
5177
+ });
5178
+ await context2.exposeBinding("__flCmd", (_src, msg) => {
5179
+ void handleCommand(msg);
5180
+ });
5181
+ await context2.addInitScript({ content: `${accNameScript2()}
4325
5182
  ;(${installRecorder.toString()})();
4326
5183
  ;(${installDebugPanel.toString()})();` });
4327
- context.on("page", (p) => p.on("domcontentloaded", () => void paint(snapshot())));
5184
+ context2.on("page", (p) => p.on("domcontentloaded", () => void paint(snapshot())));
5185
+ }
5186
+ await installContext(context);
4328
5187
  const page = await context.newPage();
4329
5188
  const pages = new ExecutionPages(context, page);
4330
5189
  page.on("domcontentloaded", () => void paint(snapshot()));
4331
5190
  await setMuted(true);
4332
- await page.goto(resolveStartUrl(baseUrl, startPath), { waitUntil: "domcontentloaded" });
5191
+ actors = new RunActors({ browser, context, config, artifacts, page, pages, consoleLines: [], runArtifacts: [] }, {
5192
+ ...opts,
5193
+ startUrl: resolveStartUrl(baseUrl, startPath),
5194
+ onCreate: async (session2) => installContext(session2.context)
5195
+ });
5196
+ try {
5197
+ await actors.select(opts.defaultActorId ?? "default");
5198
+ } catch (error) {
5199
+ closed = true;
5200
+ await browser.close();
5201
+ throw error;
5202
+ }
4333
5203
  async function setMuted(on) {
4334
5204
  await Promise.all(
4335
- context.pages().map(
5205
+ (actors?.all().flatMap((s2) => s2.context.pages()) ?? context.pages()).map(
4336
5206
  (p) => p.evaluate((v) => window.__flSetMuted?.(v), on).catch(() => {
4337
5207
  })
4338
5208
  )
@@ -4399,8 +5269,10 @@ async function startDebugSession(opts) {
4399
5269
  checks: []
4400
5270
  };
4401
5271
  try {
5272
+ step = withRuntimeValues(step, actors?.values ?? /* @__PURE__ */ new Map());
4402
5273
  const fresh = step.secret && recordedInSession.has(step.id);
4403
- const active = await executeStep(page, fresh ? { ...step, secret: false } : step, { pages, resolveSecret, resolveFile, timeoutMs });
5274
+ const on = actors?.current ?? { page, pages };
5275
+ 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
5276
  for (const a of step.proposals ?? []) {
4405
5277
  if (a.enabled === false) continue;
4406
5278
  const label = describeAssertion(a, step.name);
@@ -4408,7 +5280,7 @@ async function startDebugSession(opts) {
4408
5280
  const detail = await checkAssertion(active, a.kind, a.value, step.timeoutMs ?? timeoutMs, a);
4409
5281
  result.checks.push({ kind: a.kind, expected: label, ok: true, soft: a.soft === true, detail });
4410
5282
  } catch (err) {
4411
- result.checks.push({ kind: a.kind, expected: label, ok: false, soft: a.soft === true, detail: firstLine(err) });
5283
+ result.checks.push({ kind: a.kind, expected: label, ok: false, soft: a.soft === true, detail: firstLine2(err) });
4412
5284
  }
4413
5285
  }
4414
5286
  const bad = result.checks.find((c) => !c.ok && !c.soft);
@@ -4418,7 +5290,7 @@ async function startDebugSession(opts) {
4418
5290
  }
4419
5291
  } catch (err) {
4420
5292
  result.status = "failed";
4421
- result.detail = firstLine(err);
5293
+ result.detail = firstLine2(err);
4422
5294
  }
4423
5295
  result.durationMs = Date.now() - t0;
4424
5296
  return result;
@@ -4458,7 +5330,9 @@ async function startDebugSession(opts) {
4458
5330
  return publish();
4459
5331
  }
4460
5332
  const session = {
4461
- page,
5333
+ get page() {
5334
+ return actors?.current.page ?? page;
5335
+ },
4462
5336
  state: snapshot,
4463
5337
  async play() {
4464
5338
  if (failure) {
@@ -4532,7 +5406,8 @@ async function startDebugSession(opts) {
4532
5406
  status2 = "idle";
4533
5407
  publish();
4534
5408
  await pages.reset();
4535
- await page.goto(resolveStartUrl(baseUrl, startPath), { waitUntil: "domcontentloaded" });
5409
+ await actors.reset();
5410
+ await actors.select(opts.defaultActorId ?? "default");
4536
5411
  return runUntil();
4537
5412
  },
4538
5413
  toggleBreakpoint(stepId) {
@@ -5416,7 +6291,7 @@ function toSummary(row) {
5416
6291
  };
5417
6292
  }
5418
6293
 
5419
- // ../repository-store/dist/chunk-EWIK7XW6.js
6294
+ // ../repository-store/dist/chunk-IXRMPDVC.js
5420
6295
  var FLOWRIKI_DIR = ".flowriki";
5421
6296
  var FEATURES_DIR = `${FLOWRIKI_DIR}/features`;
5422
6297
  var CONFIG_PATH = `${FLOWRIKI_DIR}/config.json`;
@@ -5425,6 +6300,8 @@ var FEATURE_FILE = "feature.json";
5425
6300
  var FLOW_SUFFIX = ".flow.json";
5426
6301
  var CASES_DIR = `${FLOWRIKI_DIR}/cases`;
5427
6302
  var SUITES_DIR = `${FLOWRIKI_DIR}/suites`;
6303
+ var AUTH_DIR = `${FLOWRIKI_DIR}/auth`;
6304
+ var ACCOUNTS_DIR = `${AUTH_DIR}/accounts`;
5428
6305
  function assertSlug(slug) {
5429
6306
  if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error(`invalid slug: ${slug}`);
5430
6307
  return slug;
@@ -5454,6 +6331,8 @@ function parseConfig(raw) {
5454
6331
  const source = raw && typeof raw === "object" ? raw : {};
5455
6332
  return {
5456
6333
  baseUrl: typeof source["baseUrl"] === "string" ? source["baseUrl"] : "",
6334
+ ...typeof source["applicationEnvironment"] === "string" ? { applicationEnvironment: source["applicationEnvironment"] } : {},
6335
+ reuseAuthentication: source["reuseAuthentication"] === true,
5457
6336
  headless: source["headless"] === true,
5458
6337
  environment: ExecutionEnvironmentSchema.parse(source["environment"] ?? {}),
5459
6338
  artifacts: ArtifactPolicySchema.parse(source["artifacts"] ?? {}),
@@ -5491,7 +6370,7 @@ import path6 from "path";
5491
6370
  import { execFileSync } from "child_process";
5492
6371
  import { execFileSync as execFileSync2 } from "child_process";
5493
6372
  import fs33 from "fs";
5494
- import path32 from "path";
6373
+ import path33 from "path";
5495
6374
  function uniqueRef(flowSlug, name, taken) {
5496
6375
  const base = `${flowSlug}_${slugify(name) || "secret"}`;
5497
6376
  if (!taken.has(base)) {
@@ -6040,7 +6919,7 @@ function tryGit(root, args) {
6040
6919
  }
6041
6920
  function readRepo(root) {
6042
6921
  const isRepo = tryGit(root, ["rev-parse", "--is-inside-work-tree"]) === "true";
6043
- const markerFile = path32.join(root, MARKER_PATH);
6922
+ const markerFile = path33.join(root, MARKER_PATH);
6044
6923
  let marker = null;
6045
6924
  let markerUnreadable = false;
6046
6925
  if (fs33.existsSync(markerFile)) {
@@ -6095,8 +6974,8 @@ function writeMarker(root, marker, opts = {}) {
6095
6974
  if (!facts.clean) {
6096
6975
  throw new ClaimRefused("this repository has uncommitted changes \u2014 commit or stash them first");
6097
6976
  }
6098
- const file = path32.join(root, MARKER_PATH);
6099
- fs33.mkdirSync(path32.dirname(file), { recursive: true });
6977
+ const file = path33.join(root, MARKER_PATH);
6978
+ fs33.mkdirSync(path33.dirname(file), { recursive: true });
6100
6979
  fs33.writeFileSync(file, JSON.stringify(marker, null, 2) + "\n");
6101
6980
  git2(root, ["add", "--", MARKER_PATH]);
6102
6981
  git2(root, ["commit", "-m", `Bind this repository to Flowriki project ${marker.projectId}`]);
@@ -6181,6 +7060,10 @@ async function executeFlow(opts) {
6181
7060
  // One resolution, shared with debug and verification. See Store.environmentFor.
6182
7061
  environment: store.environmentFor(flow),
6183
7062
  steps: flow.steps,
7063
+ ...flow.actors ? { actors: flow.actors } : {},
7064
+ ...flow.defaultActorId ? { defaultActorId: flow.defaultActorId } : {},
7065
+ ...store.resolveAuth ? { resolveAuth: store.resolveAuth } : {},
7066
+ ...opts.reuse ? { reuse: opts.reuse } : {},
6184
7067
  // The flow's own origin when it has one, the project's otherwise. A flow
6185
7068
  // that carries no origin is the normal case now: the environment belongs
6186
7069
  // to the project, so moving a suite from staging to production is a
@@ -6461,6 +7344,10 @@ async function executeCase(opts) {
6461
7344
  try {
6462
7345
  outcome = await replayCase({
6463
7346
  occurrences: caseOccurrences(testCase),
7347
+ ...testCase.actors ? { actors: testCase.actors } : {},
7348
+ ...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {},
7349
+ resolveAuth: authenticationResolver(opts.authentications),
7350
+ ...opts.reuse ? { reuse: opts.reuse } : {},
6464
7351
  baseUrl: testCase.baseUrl ?? baseUrl,
6465
7352
  ...testCase.environment ? { environment: testCase.environment } : {},
6466
7353
  headless,
@@ -6658,6 +7545,11 @@ var RecordingCoordinator = class {
6658
7545
  try {
6659
7546
  this.session = await startRecordingSession({
6660
7547
  url: target,
7548
+ ...opts.authentication ? { authentication: {
7549
+ step: { id: "auth-preparation", type: "signIn", auth: opts.authentication, name: "Sign in before recording", role: "", scope: "", value: "", secret: false, legacySelector: "", proposals: [] },
7550
+ resolveAuth: authenticationResolver(opts.authentications),
7551
+ resolveSecret: (ref) => opts.secrets?.[ref]
7552
+ } } : {},
6661
7553
  // The project's saved defaults, unless this recording asks for
6662
7554
  // something else. Without the fallback the settings screen would write
6663
7555
  // a file nothing ever read.
@@ -6847,6 +7739,9 @@ var DebugCoordinator = class {
6847
7739
  const secrets = store.readSecrets();
6848
7740
  const session = await startDebugSession({
6849
7741
  steps: flow.steps,
7742
+ ...flow.actors ? { actors: flow.actors } : {},
7743
+ ...flow.defaultActorId ? { defaultActorId: flow.defaultActorId } : {},
7744
+ ...store.resolveAuth ? { resolveAuth: store.resolveAuth } : {},
6850
7745
  environment: store.environmentFor(flow),
6851
7746
  resolveFile: (name) => store.resolveFile(name),
6852
7747
  baseUrl,
@@ -6968,7 +7863,7 @@ async function verifyFlowSteps(deps, opts) {
6968
7863
  const { store } = deps;
6969
7864
  const flow = store.getFlow(opts.flowId);
6970
7865
  if (!flow) throw new FlowNotFound("flow not found");
6971
- const steps = (opts.steps ?? flow.steps).map((s2) => RecordedStepSchema.parse(s2));
7866
+ const steps = (opts.steps ?? flow.steps).map((s2) => DraftStepSchema.parse(s2));
6972
7867
  if (!opts.verifyIds.length) return { results: [], error: null, failedAtStepId: null, ok: true };
6973
7868
  const baseUrl = store.baseUrlFor(flow);
6974
7869
  const claim = deps.lease?.acquire("run");
@@ -6979,12 +7874,15 @@ async function verifyFlowSteps(deps, opts) {
6979
7874
  try {
6980
7875
  const secrets = store.readSecrets();
6981
7876
  return await verifySteps({
7877
+ ...store.resolveAuth ? { resolveAuth: store.resolveAuth } : {},
6982
7878
  environment: store.environmentFor(flow),
6983
7879
  resolveFile: (name) => store.resolveFile(name),
6984
7880
  // Streamed, so the editor fills in as the pass proceeds instead of
6985
7881
  // sitting blank for the twenty-odd seconds a replay takes.
6986
7882
  ...deps.onStepResult ? { onStepResult: deps.onStepResult } : {},
6987
7883
  steps,
7884
+ ...flow.actors ? { actors: flow.actors } : {},
7885
+ ...flow.defaultActorId ? { defaultActorId: flow.defaultActorId } : {},
6988
7886
  baseUrl,
6989
7887
  startPath: flow.startPath,
6990
7888
  verifyIds: opts.verifyIds,
@@ -7002,6 +7900,7 @@ async function verifyFlowSteps(deps, opts) {
7002
7900
  function deliveredRun(delivered) {
7003
7901
  const { flow, secrets, config } = delivered;
7004
7902
  return {
7903
+ resolveAuth: authenticationResolver(delivered.authentications),
7005
7904
  readConfig: () => config,
7006
7905
  // By id, and only the one that was sent. A machine serving no checkout has
7007
7906
  // no other flow to confuse it with.
@@ -7095,7 +7994,7 @@ function resolveCheckouts(dirs) {
7095
7994
  return { projects, skipped };
7096
7995
  }
7097
7996
 
7098
- // ../agent-core/dist/chunk-63IFXBPX.js
7997
+ // ../agent-core/dist/chunk-EJDGHASQ.js
7099
7998
  import { WebSocket, WebSocketServer } from "ws";
7100
7999
  var HEARTBEAT_MS = 3e4;
7101
8000
  function adapt(socket) {
@@ -7768,12 +8667,12 @@ function repositoryHandlers({ storeFor }) {
7768
8667
  return { ok: false, reason: "failed", detail: "no flow document was sent" };
7769
8668
  }
7770
8669
  try {
7771
- const path33 = store.saveFlow(flow, {
8670
+ const path34 = store.saveFlow(flow, {
7772
8671
  create: command.args["create"] === true,
7773
8672
  ...command.flowRevision !== void 0 ? { expectedRevision: command.flowRevision } : {}
7774
8673
  });
7775
8674
  const saved = store.getFlow(flow.id);
7776
- return { ok: true, result: { path: path33, revision: saved?.revision ?? null } };
8675
+ return { ok: true, result: { path: path34, revision: saved?.revision ?? null } };
7777
8676
  } catch (err) {
7778
8677
  if (err instanceof FlowConflict) {
7779
8678
  return { ok: false, reason: "stale_revision", detail: err.message };
@@ -7964,7 +8863,14 @@ function historyHandlers({ storage, artifactLimit }) {
7964
8863
  };
7965
8864
  }
7966
8865
  function executionHandlers(ctx) {
7967
- const { storeFor, storage, onActivity, lease, cancels } = ctx;
8866
+ const { storeFor, storage, onActivity, lease, cancels, authenticationReuse } = ctx;
8867
+ const reuseFor = (command, projectId, config) => {
8868
+ if (!authenticationReuse || config?.reuseAuthentication !== true) return void 0;
8869
+ const cancelKey = command.args["cancelKey"];
8870
+ const span = typeof cancelKey === "string" && cancelKey.startsWith("suite:") ? cancelKey : command.id;
8871
+ const environment = config?.applicationEnvironment ?? null;
8872
+ return { cache: authenticationReuse, scope: `${projectId}/${span}`, environment };
8873
+ };
7968
8874
  return {
7969
8875
  "flow.run": async (command, project, emit) => {
7970
8876
  if (command.flowRevision === void 0) {
@@ -7974,6 +8880,7 @@ function executionHandlers(ctx) {
7974
8880
  const carried = command.args["flow"];
7975
8881
  const source = carried ? deliveredRun({
7976
8882
  flow: carried,
8883
+ authentications: command.args["authentications"],
7977
8884
  secrets: command.args["secrets"] ?? {},
7978
8885
  config: command.args["config"] ?? {}
7979
8886
  }) : storeFor(project.root);
@@ -8005,8 +8912,10 @@ function executionHandlers(ctx) {
8005
8912
  if (typeof cancelKey === "string" && cancelKey.startsWith("suite:")) cancels.set(cancelKey, cancels.get(command.id));
8006
8913
  let runId = null;
8007
8914
  try {
8915
+ const keeping = reuseFor(command, project.id, source.readConfig());
8008
8916
  const outcome = await executeFlow({
8009
8917
  signal: aborter.signal,
8918
+ ...keeping ? { reuse: keeping } : {},
8010
8919
  ...typeof command.args["headless"] === "boolean" ? { headless: command.args["headless"] } : {},
8011
8920
  ...command.args["repeat"] ? { repeat: Number(command.args["repeat"]) } : {},
8012
8921
  store: source,
@@ -8074,8 +8983,10 @@ function executionHandlers(ctx) {
8074
8983
  if (typeof cancelKey === "string" && cancelKey.startsWith("suite:")) cancels.set(cancelKey, cancels.get(command.id));
8075
8984
  let runId = null;
8076
8985
  try {
8986
+ const keepingForCase = reuseFor(command, project.id, command.args["config"]);
8077
8987
  const { outcome, run } = await executeCase({
8078
8988
  testCase,
8989
+ authentications: command.args["authentications"],
8079
8990
  projectId: project.id,
8080
8991
  config: command.args["config"] ?? {},
8081
8992
  secrets: command.args["secrets"] ?? {},
@@ -8083,6 +8994,7 @@ function executionHandlers(ctx) {
8083
8994
  ...storage ? { storage } : {},
8084
8995
  trigger: "ui",
8085
8996
  ...typeof command.args["headless"] === "boolean" ? { headless: command.args["headless"] } : {},
8997
+ ...keepingForCase ? { reuse: keepingForCase } : {},
8086
8998
  signal: aborter.signal,
8087
8999
  onEvent: (event) => {
8088
9000
  const seen = event.runId;
@@ -8120,6 +9032,7 @@ function executionHandlers(ctx) {
8120
9032
  const cancelled = runId ? cancels.get(runId) : candidates.length === 1 ? candidates[0] : void 0;
8121
9033
  if (!cancelled || cancelled.projectId !== project.id) return { ok: false, reason: "failed", detail: `no run ${runId} is in progress on this machine` };
8122
9034
  cancelled.cancel();
9035
+ authenticationReuse?.clear();
8123
9036
  return { ok: true, result: { runId, projectId: project.id, stopping: true } };
8124
9037
  }
8125
9038
  };
@@ -8151,6 +9064,11 @@ function recordingHandlers({ recorder, onActivity }) {
8151
9064
  // returned on stop for the service to commit to the repository. A
8152
9065
  // machine that has the project checked out still records into it.
8153
9066
  root,
9067
+ ...command.args["authentication"] ? {
9068
+ authentication: AuthReferenceSchema.parse(command.args["authentication"]),
9069
+ authentications: command.args["authentications"],
9070
+ secrets: command.args["secrets"] ?? {}
9071
+ } : {},
8154
9072
  projectId: project.id,
8155
9073
  featureId,
8156
9074
  name,
@@ -8234,6 +9152,7 @@ function debuggingHandlers(ctx) {
8234
9152
  root: project.root,
8235
9153
  ...carried ? { delivered: {
8236
9154
  flow: carried,
9155
+ authentications: command.args["authentications"],
8237
9156
  secrets: command.args["secrets"] ?? {},
8238
9157
  config: command.args["config"] ?? {}
8239
9158
  } } : {},
@@ -8274,6 +9193,7 @@ function debuggingHandlers(ctx) {
8274
9193
  ...carried ? {
8275
9194
  delivered: {
8276
9195
  flow: carried,
9196
+ authentications: command.args["authentications"],
8277
9197
  secrets: command.args["secrets"] ?? {},
8278
9198
  config: command.args["config"] ?? {},
8279
9199
  scratchRoot: scratchRoot(project.id)
@@ -8342,7 +9262,11 @@ function buildHandlers(deps) {
8342
9262
  // A video is easily hundreds of megabytes, and nobody wants one pushed down
8343
9263
  // a control channel by accident.
8344
9264
  artifactLimit: deps.artifactLimitBytes ?? 25 * 1024 * 1024,
8345
- cancels: /* @__PURE__ */ new Map()
9265
+ cancels: /* @__PURE__ */ new Map(),
9266
+ // ONE PER RUNTIME, holding one suite's participants at a time. It never
9267
+ // reaches disk and never outlives the process; a project that has not
9268
+ // asked for reuse never puts anything in it.
9269
+ authenticationReuse: deps.authenticationReuse ?? new AuthenticationReuse()
8346
9270
  };
8347
9271
  const debug = debuggingHandlers(context);
8348
9272
  return {
@@ -8399,6 +9323,7 @@ function createAgentRuntime(options) {
8399
9323
  lease,
8400
9324
  store: deliveredRun({
8401
9325
  flow: delivered.flow,
9326
+ authentications: delivered.authentications,
8402
9327
  secrets: delivered.secrets,
8403
9328
  config: delivered.config
8404
9329
  }),
@@ -8476,9 +9401,11 @@ function createAgentRuntime(options) {
8476
9401
  },
8477
9402
  active: () => recordingIn?.active() ?? null
8478
9403
  };
9404
+ const keptSignIns = new AuthenticationReuse();
8479
9405
  function buildAgent() {
8480
9406
  const handlers = buildHandlers({
8481
9407
  lease,
9408
+ authenticationReuse: keptSignIns,
8482
9409
  storeFor: (r) => new Store(r),
8483
9410
  ...options.storage ? { storage: options.storage } : {},
8484
9411
  recorder,
@@ -8510,7 +9437,11 @@ function createAgentRuntime(options) {
8510
9437
  // drifts from what the agent can actually do is worse than none: it
8511
9438
  // would have the service dispatch work this build cannot perform, or
8512
9439
  // refuse work it can.
8513
- capabilities: Object.keys(handlers),
9440
+ // Commands, and then the FEATURES this build supports inside commands
9441
+ // it has always accepted. Named from the shared set rather than written
9442
+ // out here, so an agent cannot claim a feature the service has no gate
9443
+ // for, or miss one it has.
9444
+ capabilities: [...Object.keys(handlers), ...AGENT_FEATURES],
8514
9445
  // EVERY checkout, each named by its own marker. One agent run serves
8515
9446
  // all of them; the service routes each command to the folder that is
8516
9447
  // the project it names.
@@ -8520,6 +9451,11 @@ function createAgentRuntime(options) {
8520
9451
  }
8521
9452
  const agent = buildAgent();
8522
9453
  journal = agent.journal;
9454
+ const closeAgent = agent.close.bind(agent);
9455
+ agent.close = () => {
9456
+ closeAgent();
9457
+ keptSignIns.clear();
9458
+ };
8523
9459
  return agent;
8524
9460
  }
8525
9461