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