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