@flowriki/agent 0.1.0-beta.47 → 0.1.0-beta.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/chunk-7X54JYUL.js +4103 -0
- package/dist/{chunk-AMXSHZYE.js → chunk-JUAJABZC.js} +697 -4588
- package/dist/cli.js +19 -17
- package/dist/dist-CZEWUTXJ.js +93 -0
- package/dist/engine.js +2 -1
- package/package.json +1 -1
|
@@ -0,0 +1,4103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../engine-appium/dist/index.js
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import net from "net";
|
|
6
|
+
import { execFile } from "child_process";
|
|
7
|
+
import fs from "fs";
|
|
8
|
+
import os from "os";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import fs2 from "fs";
|
|
11
|
+
import os2 from "os";
|
|
12
|
+
import path2 from "path";
|
|
13
|
+
import { spawn as spawn2 } from "child_process";
|
|
14
|
+
import fs3 from "fs";
|
|
15
|
+
import os3 from "os";
|
|
16
|
+
import path3 from "path";
|
|
17
|
+
import fs4 from "fs";
|
|
18
|
+
import path4 from "path";
|
|
19
|
+
|
|
20
|
+
// ../test-model/dist/chunk-QDXJLOQJ.js
|
|
21
|
+
import { z as z2 } from "zod";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
var PLATFORMS = ["web", "android", "ios"];
|
|
24
|
+
var MOBILE_PLATFORMS = ["android", "ios"];
|
|
25
|
+
var isMobile = (p) => p === "android" || p === "ios";
|
|
26
|
+
var MOBILE_STEP_TYPES = ["swipe", "longPress", "hideKeyboard", "launchApp", "terminateApp", "resetApp", "background", "rotate", "permission"];
|
|
27
|
+
var WEB_ONLY_STEP_TYPES = ["hover", "rightclick", "dblclick", "closePage", "forward", "upload", "drag", "setRange"];
|
|
28
|
+
function stepRefusedOn(type, platform) {
|
|
29
|
+
if (platform === "web") return MOBILE_STEP_TYPES.includes(type) ? `"${type}" is a phone step; this flow runs in a browser.` : null;
|
|
30
|
+
if (WEB_ONLY_STEP_TYPES.includes(type)) return `"${type}" has no meaning on a phone.`;
|
|
31
|
+
if (type === "back" && platform === "ios") return "iPhones have no back button \u2014 tap the app's own Back instead.";
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
var ProjectPlatformsSchema = z.object({
|
|
35
|
+
web: z.boolean().default(true),
|
|
36
|
+
android: z.boolean().default(false),
|
|
37
|
+
ios: z.boolean().default(false)
|
|
38
|
+
}).strict();
|
|
39
|
+
var platformsOf = (config) => {
|
|
40
|
+
const parsed = ProjectPlatformsSchema.safeParse(config?.platforms ?? {});
|
|
41
|
+
return parsed.success ? parsed.data : { web: true, android: false, ios: false };
|
|
42
|
+
};
|
|
43
|
+
var BuildSchema = z.string().trim().max(2e3).refine(
|
|
44
|
+
(v) => /^https?:\/\//.test(v) || v.startsWith("/") || v.startsWith("~") || /^[A-Za-z]:[\\/]/.test(v),
|
|
45
|
+
"a build is a full path on the agent's computer, or an http(s) link"
|
|
46
|
+
);
|
|
47
|
+
var AndroidAppSchema = z.object({
|
|
48
|
+
/** The app's package, e.g. "com.shop.app" — how it is launched and reset. */
|
|
49
|
+
appPackage: z.string().trim().regex(/^[A-Za-z][\w]*(\.[A-Za-z][\w]*)+$/, "a package looks like com.company.app"),
|
|
50
|
+
/** The activity to start; absent means the app's launcher activity. */
|
|
51
|
+
appActivity: z.string().trim().max(300).optional(),
|
|
52
|
+
/** An .apk to install before a run; absent means it is already installed. */
|
|
53
|
+
build: BuildSchema.optional()
|
|
54
|
+
}).strict();
|
|
55
|
+
var IosAppSchema = z.object({
|
|
56
|
+
/** The app's bundle id, e.g. "com.shop.app". */
|
|
57
|
+
bundleId: z.string().trim().regex(/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/, "a bundle id looks like com.company.app"),
|
|
58
|
+
/** An .ipa (real devices, cloud) or .app (simulators) to install; absent
|
|
59
|
+
* means it is already installed. */
|
|
60
|
+
build: BuildSchema.optional(),
|
|
61
|
+
/** Real iPhones only: the Apple team that signs Appium's helper app. */
|
|
62
|
+
teamId: z.string().trim().regex(/^[A-Z0-9]{10}$/, "an Apple team id is 10 letters and digits").optional()
|
|
63
|
+
}).strict();
|
|
64
|
+
var CLOUD_PROVIDERS = ["browserstack", "saucelabs", "lambdatest"];
|
|
65
|
+
var CLOUD_PROVIDER_NAME = { browserstack: "BrowserStack", saucelabs: "Sauce Labs", lambdatest: "LambdaTest" };
|
|
66
|
+
var CloudAccountSchema = z.object({
|
|
67
|
+
provider: z.enum(CLOUD_PROVIDERS),
|
|
68
|
+
username: z.string().trim().min(1).max(200),
|
|
69
|
+
/** Sauce Labs data centre: "us-west-1", "eu-central-1", "us-east-4". */
|
|
70
|
+
region: z.string().trim().max(40).optional(),
|
|
71
|
+
keyRef: z.string().regex(/^[a-z][a-z0-9_]*$/)
|
|
72
|
+
}).strict();
|
|
73
|
+
var DeviceChoiceSchema = z.discriminatedUnion("kind", [
|
|
74
|
+
z.object({
|
|
75
|
+
kind: z.literal("local"),
|
|
76
|
+
/** adb serial, AVD name, simulator or device UDID. */
|
|
77
|
+
id: z.string().min(1).max(200),
|
|
78
|
+
name: z.string().max(200).default("")
|
|
79
|
+
}).strict(),
|
|
80
|
+
z.object({
|
|
81
|
+
kind: z.literal("cloud"),
|
|
82
|
+
provider: z.enum(CLOUD_PROVIDERS),
|
|
83
|
+
/** The farm's device name, e.g. "Samsung Galaxy S23", "iPhone 15". */
|
|
84
|
+
device: z.string().min(1).max(200),
|
|
85
|
+
osVersion: z.string().max(40).optional()
|
|
86
|
+
}).strict()
|
|
87
|
+
]);
|
|
88
|
+
var MobileConfigSchema = z.object({
|
|
89
|
+
android: AndroidAppSchema.optional(),
|
|
90
|
+
ios: IosAppSchema.optional(),
|
|
91
|
+
/** Between runs: keep the app's data (stays signed in), or start clean. */
|
|
92
|
+
reset: z.enum(["keepData", "fresh"]).default("keepData"),
|
|
93
|
+
/** Where runs go when nobody says — absent means whatever is available. */
|
|
94
|
+
devices: z.object({ android: DeviceChoiceSchema.optional(), ios: DeviceChoiceSchema.optional() }).strict().default({}),
|
|
95
|
+
cloud: z.array(CloudAccountSchema).max(3).default([])
|
|
96
|
+
}).strict();
|
|
97
|
+
var mobileOf = (config) => {
|
|
98
|
+
const parsed = MobileConfigSchema.safeParse(config?.mobile ?? {});
|
|
99
|
+
return parsed.success ? parsed.data : MobileConfigSchema.parse({});
|
|
100
|
+
};
|
|
101
|
+
var DeviceInfoSchema = z.object({
|
|
102
|
+
id: z.string(),
|
|
103
|
+
name: z.string(),
|
|
104
|
+
platform: z.enum(MOBILE_PLATFORMS),
|
|
105
|
+
kind: z.enum(["emulator", "simulator", "device"]),
|
|
106
|
+
/** Booted and reachable now, or one that can be started. */
|
|
107
|
+
state: z.enum(["ready", "off", "unauthorized", "offline"]),
|
|
108
|
+
osVersion: z.string().default("")
|
|
109
|
+
}).strict();
|
|
110
|
+
function pickDevice(devices, platform, chosen) {
|
|
111
|
+
if (chosen) return chosen;
|
|
112
|
+
const mine = devices.filter((d) => d.platform === platform);
|
|
113
|
+
const rank = (d) => (d.state === "ready" ? 0 : d.state === "off" ? 2 : 9) + (d.kind === "device" ? 0 : 1);
|
|
114
|
+
const best = [...mine].filter((d) => d.state === "ready" || d.state === "off").sort((a, b) => rank(a) - rank(b))[0];
|
|
115
|
+
return best ? { kind: "local", id: best.id, name: best.name } : null;
|
|
116
|
+
}
|
|
117
|
+
var describeDevice = (choice) => !choice ? "no device" : choice.kind === "local" ? choice.name || choice.id : `${CLOUD_PROVIDER_NAME[choice.provider]}: ${choice.device}${choice.osVersion ? ` (${choice.osVersion})` : ""}`;
|
|
118
|
+
var LocatorOverrideSchema = z2.object({
|
|
119
|
+
strategy: z2.enum(["css", "xpath", "id", "testId", "text", "label", "placeholder", "altText", "title"]),
|
|
120
|
+
value: z2.string()
|
|
121
|
+
}).strict();
|
|
122
|
+
var CaptureModeSchema = z2.enum(["off", "on-failure", "always"]);
|
|
123
|
+
var ArtifactPolicySchema = z2.object({
|
|
124
|
+
/** Masked before the shutter opens — password fields are covered. */
|
|
125
|
+
screenshot: CaptureModeSchema.optional(),
|
|
126
|
+
/** NOT maskable. A video shows every character typed, so a flow holding a
|
|
127
|
+
* secret is skipped unless allowUnmaskedArtifacts says otherwise. */
|
|
128
|
+
video: CaptureModeSchema.optional(),
|
|
129
|
+
/** Also not maskable: a Playwright trace embeds DOM snapshots containing
|
|
130
|
+
* whatever was typed into the page. */
|
|
131
|
+
trace: CaptureModeSchema.optional(),
|
|
132
|
+
/** Cheap, small, and the first thing anyone asks for when a step fails for
|
|
133
|
+
* no visible reason. */
|
|
134
|
+
console: z2.boolean().optional(),
|
|
135
|
+
/** How many runs keep their artifacts. Video is megabytes per run, so an
|
|
136
|
+
* unbounded history fills a disk quietly. */
|
|
137
|
+
retainRuns: z2.number().int().positive().max(1e4).optional(),
|
|
138
|
+
/** Deliberate opt-out of the secret guard, for a project with nothing to
|
|
139
|
+
* protect. Off by default: leaking a password into a video that nobody
|
|
140
|
+
* thinks of as sensitive is not a mistake worth making by accident. */
|
|
141
|
+
allowUnmaskedArtifacts: z2.boolean().optional()
|
|
142
|
+
}).strict();
|
|
143
|
+
var ExecutionEnvironmentSchema = z2.object({
|
|
144
|
+
browser: z2.enum(["chromium", "firefox", "webkit"]).optional(),
|
|
145
|
+
viewport: z2.object({ width: z2.number().int().positive(), height: z2.number().int().positive() }).optional(),
|
|
146
|
+
locale: z2.string().optional(),
|
|
147
|
+
timezoneId: z2.string().optional(),
|
|
148
|
+
colorScheme: z2.enum(["light", "dark", "no-preference"]).optional(),
|
|
149
|
+
/** How long a single step may take to find its target and act. */
|
|
150
|
+
timeoutMs: z2.number().int().positive().max(3e5).optional(),
|
|
151
|
+
/** How long a page load or history traversal may take. Separate from the
|
|
152
|
+
* step timeout on purpose: "this page is slow to load" and "this element
|
|
153
|
+
* does not exist" are different problems, and one number for both made
|
|
154
|
+
* every real site fail during load rather than during the assertion. */
|
|
155
|
+
navigationTimeoutMs: z2.number().int().positive().max(3e5).optional(),
|
|
156
|
+
/** How long an assertion may wait for the state it expects. Usually shorter
|
|
157
|
+
* than a step — an outcome that has not appeared quickly usually is not
|
|
158
|
+
* coming — but slow back ends need it raised. */
|
|
159
|
+
assertionTimeoutMs: z2.number().int().positive().max(3e5).optional(),
|
|
160
|
+
openClosedShadowRoots: z2.boolean().optional(),
|
|
161
|
+
/** Let the browser window take the whole screen, and the page take the whole
|
|
162
|
+
* window.
|
|
163
|
+
*
|
|
164
|
+
* Off by default, deliberately: a FIXED viewport is what makes a run
|
|
165
|
+
* reproducible, and a maximised window is whatever size the machine that ran
|
|
166
|
+
* it happened to have. But a recording done at 1280x720 inside a large
|
|
167
|
+
* display is a small box in the middle of an empty screen, which is a
|
|
168
|
+
* miserable way to drive an app — so this is offered, with the trade stated.
|
|
169
|
+
*
|
|
170
|
+
* Recorded ON the flow, like the rest of the environment, so a run always
|
|
171
|
+
* says which of the two it was. */
|
|
172
|
+
fullWindow: z2.boolean().optional()
|
|
173
|
+
}).strict();
|
|
174
|
+
var RecordedTargetSchema = z2.object({
|
|
175
|
+
role: z2.string(),
|
|
176
|
+
name: z2.string(),
|
|
177
|
+
scope: z2.string().optional(),
|
|
178
|
+
scopeRole: z2.string().optional(),
|
|
179
|
+
frame: z2.array(z2.string()).optional(),
|
|
180
|
+
pageId: z2.string().optional(),
|
|
181
|
+
nameFrom: z2.enum(["adjacent-label", "text"]).optional(),
|
|
182
|
+
/** SET BY A PERSON, and exclusive.
|
|
183
|
+
*
|
|
184
|
+
* Somebody opened the editor and said "find it this way instead", which is
|
|
185
|
+
* a decision about semantics that were wrong for this element. Falling back
|
|
186
|
+
* to those semantics would undo it, so this replaces resolution entirely.
|
|
187
|
+
*
|
|
188
|
+
* Steps recorded before `candidates` existed also carry the recorder's own
|
|
189
|
+
* pick here, and are treated the same way: nothing in the document says
|
|
190
|
+
* which of the two put it there, and guessing wrong in the other direction
|
|
191
|
+
* would change what somebody's test does. */
|
|
192
|
+
locator: LocatorOverrideSchema.optional(),
|
|
193
|
+
/** WHAT THE RECORDER SAW, best first — preferences, not assertions.
|
|
194
|
+
*
|
|
195
|
+
* One locator is one way to be wrong. A test id is removed, a placeholder
|
|
196
|
+
* is reworded, a wrapper is added, and a step that had exactly one way to
|
|
197
|
+
* find its element has none — although the field is sitting in plain sight
|
|
198
|
+
* and two other things about it are unchanged.
|
|
199
|
+
*
|
|
200
|
+
* Tried in order, and ONLY where the one before did not identify a single
|
|
201
|
+
* element. EXACTLY ONE MATCH WINS: a locator matching several is
|
|
202
|
+
* disqualified rather than obeyed, so choosing between two elements never
|
|
203
|
+
* happens — and where nothing identifies one, the run stops and names the
|
|
204
|
+
* ambiguity. A step that resolves by anything other than its first choice
|
|
205
|
+
* says so in the result: a repair nobody can see is a repair nobody can
|
|
206
|
+
* trust. */
|
|
207
|
+
candidates: z2.array(LocatorOverrideSchema).max(4).optional(),
|
|
208
|
+
nth: z2.number().int().nonnegative().optional()
|
|
209
|
+
});
|
|
210
|
+
var RecordedAssertionKindSchema = z2.enum([
|
|
211
|
+
// Proposed automatically from a page-signature diff. Heuristic shapes, not
|
|
212
|
+
// things a person picks by name.
|
|
213
|
+
"screen",
|
|
214
|
+
"message",
|
|
215
|
+
"fields_visible",
|
|
216
|
+
// Element state.
|
|
217
|
+
"visible",
|
|
218
|
+
"hidden",
|
|
219
|
+
"attached",
|
|
220
|
+
"enabled",
|
|
221
|
+
"editable",
|
|
222
|
+
"focused",
|
|
223
|
+
"checked",
|
|
224
|
+
// Element content.
|
|
225
|
+
"text",
|
|
226
|
+
"value",
|
|
227
|
+
"attribute",
|
|
228
|
+
"class",
|
|
229
|
+
// Collections.
|
|
230
|
+
"count",
|
|
231
|
+
// The page itself.
|
|
232
|
+
"url",
|
|
233
|
+
"title",
|
|
234
|
+
"heading"
|
|
235
|
+
]);
|
|
236
|
+
var AssertionOpSchema = z2.enum(["equals", "contains", "matches", "gt", "gte", "lt", "lte"]);
|
|
237
|
+
var RecordedAssertionSchema = z2.object({
|
|
238
|
+
kind: RecordedAssertionKindSchema,
|
|
239
|
+
value: z2.union([z2.string(), z2.array(z2.string())]),
|
|
240
|
+
/** Defaults to "equals" when absent, so older flows keep their meaning. */
|
|
241
|
+
op: AssertionOpSchema.optional(),
|
|
242
|
+
/** Negates the whole assertion, which is what gives every reading a mirror
|
|
243
|
+
* without doubling the catalogue. `visible` + `not` IS `hidden`. */
|
|
244
|
+
not: z2.boolean().optional(),
|
|
245
|
+
/** Which attribute, for kind "attribute". Ignored otherwise. */
|
|
246
|
+
attr: z2.string().optional(),
|
|
247
|
+
/** A failure records the check and lets the run carry on, instead of
|
|
248
|
+
* stopping the flow. Chosen by whoever authored the assertion: some checks
|
|
249
|
+
* are the point of the test and some are worth knowing about. */
|
|
250
|
+
soft: z2.boolean().optional(),
|
|
251
|
+
why: z2.string().optional(),
|
|
252
|
+
enabled: z2.boolean().optional(),
|
|
253
|
+
confidence: z2.enum(["low", "high"]).optional(),
|
|
254
|
+
target: RecordedTargetSchema.optional()
|
|
255
|
+
}).strict();
|
|
256
|
+
var PageSignatureSchema = z2.object({
|
|
257
|
+
url: z2.string(),
|
|
258
|
+
title: z2.string(),
|
|
259
|
+
heading: z2.string(),
|
|
260
|
+
alerts: z2.array(z2.string()),
|
|
261
|
+
fields: z2.array(z2.string()),
|
|
262
|
+
/** Every short piece of visible text on the page, each tagged with enough
|
|
263
|
+
* identity to tell two elements apart.
|
|
264
|
+
*
|
|
265
|
+
* The generic net beneath `alerts`, which only sees a message when the page
|
|
266
|
+
* announced itself with role=alert or a class we happened to guess — so a
|
|
267
|
+
* bare <div id="error">Your username is invalid!</div> was invisible to it.
|
|
268
|
+
*
|
|
269
|
+
* `key` exists because comparing bare strings is not enough: a page that
|
|
270
|
+
* quotes its own error message in body copy (a docs page, help text, an
|
|
271
|
+
* empty state) already contains that string before anything happens, so the
|
|
272
|
+
* real message appearing looked like nothing had changed. The key is used
|
|
273
|
+
* ONLY to diff two snapshots of the same page a moment apart — never to
|
|
274
|
+
* find an element, which stays role + accessible name. */
|
|
275
|
+
texts: z2.array(z2.object({ key: z2.string(), text: z2.string() })).default([])
|
|
276
|
+
});
|
|
277
|
+
var AssertionKindSchema = z2.enum(["screen", "message", "heading", "fields_visible"]);
|
|
278
|
+
var AssertionSchema = z2.object({
|
|
279
|
+
id: z2.string(),
|
|
280
|
+
kind: AssertionKindSchema,
|
|
281
|
+
value: z2.union([z2.string(), z2.array(z2.string())]),
|
|
282
|
+
/** Human-readable reason it was proposed — shown next to the tick box. */
|
|
283
|
+
why: z2.string().optional(),
|
|
284
|
+
/** Present only on INFERRED proposals — ones derived from a text diff rather
|
|
285
|
+
* than from something the page stated about itself. They arrive unticked,
|
|
286
|
+
* because a guess that silently becomes a passing assertion is worse than no
|
|
287
|
+
* assertion: it makes the suite look green for a reason nobody chose. */
|
|
288
|
+
confidence: z2.literal("low").optional(),
|
|
289
|
+
/** Unticked assertions stay in the file as a record of what was offered. */
|
|
290
|
+
enabled: z2.boolean().default(true),
|
|
291
|
+
source: z2.enum(["proposed", "manual"]).default("proposed")
|
|
292
|
+
});
|
|
293
|
+
var AssertionProposalSchema = AssertionSchema.omit({ id: true, enabled: true, source: true });
|
|
294
|
+
var FeatureSchema = z2.object({
|
|
295
|
+
schemaVersion: z2.literal(1),
|
|
296
|
+
id: z2.string(),
|
|
297
|
+
/** Directory name. Stable even when the display name is edited. */
|
|
298
|
+
slug: z2.string(),
|
|
299
|
+
name: z2.string(),
|
|
300
|
+
description: z2.string().default(""),
|
|
301
|
+
/** Manual ordering in the UI; ties break by name. */
|
|
302
|
+
order: z2.number().int().default(0),
|
|
303
|
+
createdAt: z2.string(),
|
|
304
|
+
createdBy: z2.string().default("")
|
|
305
|
+
});
|
|
306
|
+
var SECRET_REF_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
|
307
|
+
var isSecretRefName = (value) => SECRET_REF_PATTERN.test(value);
|
|
308
|
+
var ScrollPrerequisiteSchema = z2.object({
|
|
309
|
+
x: z2.number(),
|
|
310
|
+
y: z2.number(),
|
|
311
|
+
container: RecordedTargetSchema.optional(),
|
|
312
|
+
legacySelector: z2.string().optional()
|
|
313
|
+
}).strict();
|
|
314
|
+
var DialogAnswerSchema = z2.object({
|
|
315
|
+
type: z2.enum(["alert", "confirm", "prompt", "beforeunload"]),
|
|
316
|
+
message: z2.string(),
|
|
317
|
+
accept: z2.boolean(),
|
|
318
|
+
promptText: z2.string().optional()
|
|
319
|
+
}).strict();
|
|
320
|
+
var RecordedStepSchema = RecordedTargetSchema.extend({
|
|
321
|
+
id: z2.string().min(1),
|
|
322
|
+
type: z2.enum(["fill", "select", "click", "dblclick", "rightclick", "drag", "check", "press", "hover", "upload", "scroll", "back", "forward", "navigate", "closePage", "unsupported", "setRange", "assert", "signIn", "signOut", "useActor", "resetActor", "capture", "useHelper", ...MOBILE_STEP_TYPES]),
|
|
323
|
+
scope: z2.string(),
|
|
324
|
+
value: z2.string(),
|
|
325
|
+
secret: z2.boolean(),
|
|
326
|
+
legacySelector: z2.string(),
|
|
327
|
+
proposals: z2.array(RecordedAssertionSchema),
|
|
328
|
+
values: z2.array(z2.string()).optional(),
|
|
329
|
+
to: RecordedTargetSchema.optional(),
|
|
330
|
+
manual: z2.boolean().optional(),
|
|
331
|
+
verified: z2.boolean().optional(),
|
|
332
|
+
/** Skip this step without deleting it.
|
|
333
|
+
*
|
|
334
|
+
* There is a real gap between "this step is wrong" and "this field is broken
|
|
335
|
+
* today". Deleting is the only way to express the second without this, which
|
|
336
|
+
* loses the step's identity, its assertions and its place in the order — and
|
|
337
|
+
* the person then has to re-record it once the field is fixed.
|
|
338
|
+
*
|
|
339
|
+
* Absent means enabled, so every flow recorded before this stays enabled
|
|
340
|
+
* without a migration. */
|
|
341
|
+
disabled: z2.boolean().optional(),
|
|
342
|
+
/** How long a single step may take to find its target and act. */
|
|
343
|
+
timeoutMs: z2.number().int().positive().max(3e5).optional(),
|
|
344
|
+
/** How long a page load or history traversal may take. Separate from the
|
|
345
|
+
* step timeout on purpose: "this page is slow to load" and "this element
|
|
346
|
+
* does not exist" are different problems, and one number for both made
|
|
347
|
+
* every real site fail during load rather than during the assertion. */
|
|
348
|
+
navigationTimeoutMs: z2.number().int().positive().max(3e5).optional(),
|
|
349
|
+
/** How long an assertion may wait for the state it expects. Usually shorter
|
|
350
|
+
* than a step — an outcome that has not appeared quickly usually is not
|
|
351
|
+
* coming — but slow back ends need it raised. */
|
|
352
|
+
assertionTimeoutMs: z2.number().int().positive().max(3e5).optional(),
|
|
353
|
+
// Popups are bound to the action that created them; subsequent steps use pageId.
|
|
354
|
+
opensPage: z2.string().optional(),
|
|
355
|
+
dialogs: z2.array(DialogAnswerSchema).optional(),
|
|
356
|
+
downloads: z2.array(z2.string()).optional(),
|
|
357
|
+
warning: z2.string().optional(),
|
|
358
|
+
scroll: ScrollPrerequisiteSchema.optional(),
|
|
359
|
+
/** The NAME of a secret held outside this document.
|
|
360
|
+
*
|
|
361
|
+
* Explicit, because `secret: true` with the reference living in `value` made
|
|
362
|
+
* a reference and a password the same shape. Nothing could then tell an
|
|
363
|
+
* unresolvable reference from a leaked literal, and the code that tried
|
|
364
|
+
* guessed from whether the local secret store happened to contain the key —
|
|
365
|
+
* so on a machine that had never held the secret, a perfectly good reference
|
|
366
|
+
* was "healed" into a password whose value was its own name.
|
|
367
|
+
*
|
|
368
|
+
* When this is set, `value` is empty and carries nothing: the secret is
|
|
369
|
+
* looked up by this name at run time, and a name that is not on this machine
|
|
370
|
+
* stays unresolved and is reported, never invented. */
|
|
371
|
+
secretRef: z2.string().regex(SECRET_REF_PATTERN).optional(),
|
|
372
|
+
/** WHICH PROCEDURE AND WHICH IDENTITY, for `signIn` and `signOut`.
|
|
373
|
+
*
|
|
374
|
+
* A reference, never an expansion: the sign-in's own steps are not copied
|
|
375
|
+
* in here. That is deliberate and is the one exception to a case holding
|
|
376
|
+
* editable copies — a login procedure is shared infrastructure, and
|
|
377
|
+
* duplicating it into forty tests recreates exactly the problem this
|
|
378
|
+
* feature exists to remove. */
|
|
379
|
+
auth: z2.object({
|
|
380
|
+
profileId: z2.string().min(1),
|
|
381
|
+
accountId: z2.string().optional(),
|
|
382
|
+
/** WHERE THIS SIGN-IN BEGINS — and it belongs here, not on the procedure.
|
|
383
|
+
*
|
|
384
|
+
* The same login serves both: taken to the login page when signing in is
|
|
385
|
+
* the first thing that happens, and performed from wherever the browser
|
|
386
|
+
* already is when a guest fills a basket and signs in at checkout. Asking
|
|
387
|
+
* the procedure meant choosing once, for every test that would ever use
|
|
388
|
+
* it. Absent means the procedure's own starting page. */
|
|
389
|
+
from: z2.enum(["loginPage", "here"]).optional()
|
|
390
|
+
}).strict().optional(),
|
|
391
|
+
/** Which participant this step belongs to, for `useActor`.
|
|
392
|
+
*
|
|
393
|
+
* Selecting an actor RESUMES its session; it does not sign in again. A step
|
|
394
|
+
* that named an actor and re-authenticated would make moving between two
|
|
395
|
+
* participants cost a login each time, which is the cost this removes. */
|
|
396
|
+
actorId: z2.string().optional(),
|
|
397
|
+
navigationBase: z2.enum(["page", "application"]).optional(),
|
|
398
|
+
capture: z2.object({ name: z2.string().regex(/^[a-zA-Z][a-zA-Z0-9_]*$/), source: z2.enum(["text", "value", "url"]) }).strict().optional(),
|
|
399
|
+
/** WHICH HELPER, for `useHelper`: the email code, the authenticator, the
|
|
400
|
+
* text, the API call — set up on the Helpers screen and referred to here by
|
|
401
|
+
* id, never copied, so fixing a helper fixes every test using it.
|
|
402
|
+
*
|
|
403
|
+
* `inputs` fills the helper's `{{names}}` and may itself use a value read
|
|
404
|
+
* earlier in the run (`{{email}}`). What comes back is kept under `saveAs`
|
|
405
|
+
* for later steps, exactly as a captured value is; `then: "open"` also
|
|
406
|
+
* takes the browser to it, which is what a verification link is for. */
|
|
407
|
+
helper: z2.object({
|
|
408
|
+
helperId: z2.string(),
|
|
409
|
+
inputs: z2.record(z2.string(), z2.string()).default({}),
|
|
410
|
+
saveAs: z2.string().regex(/^([a-zA-Z][a-zA-Z0-9_]*)?$/),
|
|
411
|
+
then: z2.enum(["remember", "open"]).default("remember")
|
|
412
|
+
}).strict().optional()
|
|
413
|
+
}).strict();
|
|
414
|
+
var DraftStepSchema = RecordedStepSchema.extend({
|
|
415
|
+
role: z2.string().default(""),
|
|
416
|
+
name: z2.string().default(""),
|
|
417
|
+
scope: z2.string().default(""),
|
|
418
|
+
value: z2.string().default(""),
|
|
419
|
+
secret: z2.boolean().default(false),
|
|
420
|
+
legacySelector: z2.string().default(""),
|
|
421
|
+
proposals: z2.array(RecordedAssertionSchema).default([])
|
|
422
|
+
});
|
|
423
|
+
function slugify(name) {
|
|
424
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
425
|
+
}
|
|
426
|
+
var ActorDefinitionSchema = z2.object({
|
|
427
|
+
id: z2.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/),
|
|
428
|
+
name: z2.string().min(1),
|
|
429
|
+
startSignedIn: z2.object({ profileId: z2.string().min(1), accountId: z2.string().optional() }).strict().optional()
|
|
430
|
+
}).strict();
|
|
431
|
+
var FlowSignInSchema = z2.object({
|
|
432
|
+
profileId: z2.string().min(1),
|
|
433
|
+
accountId: z2.string().optional(),
|
|
434
|
+
from: z2.enum(["loginPage", "here"]).optional()
|
|
435
|
+
}).strict();
|
|
436
|
+
var FlowPrerequisitesSchema = z2.array(z2.object({ flowId: z2.string().min(1) }).strict());
|
|
437
|
+
var CURRENT_SCHEMA_VERSION = 5;
|
|
438
|
+
var FlowDocumentSchema = z2.object({
|
|
439
|
+
schemaVersion: z2.literal(CURRENT_SCHEMA_VERSION),
|
|
440
|
+
id: z2.string().min(1),
|
|
441
|
+
featureId: z2.string().min(1),
|
|
442
|
+
name: z2.string().min(1),
|
|
443
|
+
slug: z2.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
444
|
+
/** Where this flow starts, when it is pinned to somewhere of its own.
|
|
445
|
+
*
|
|
446
|
+
* OPTIONAL, and normally absent. The origin belongs to the project, not to
|
|
447
|
+
* each flow: a flow that carries its own hard-codes an environment into a
|
|
448
|
+
* git-tracked file, and the POC proved how that ends — an ephemeral port,
|
|
449
|
+
* `http://127.0.0.1:65226/`, committed and unrunnable on any other machine.
|
|
450
|
+
* Absent means "the project's base URL", which is what a flow recorded today
|
|
451
|
+
* stores. Present is honoured, so every flow recorded before this keeps
|
|
452
|
+
* working exactly as it did. */
|
|
453
|
+
baseUrl: z2.string().url().optional(),
|
|
454
|
+
/** The route, resolved against whichever base URL applies. On a phone, a
|
|
455
|
+
* deep link the app opens at — or empty, the app's own first screen. */
|
|
456
|
+
startPath: z2.string(),
|
|
457
|
+
/** WHERE IT RUNS: a browser, or the project's Android or iOS app. Absent
|
|
458
|
+
* means web — every flow recorded before phones. Fixed when the flow is
|
|
459
|
+
* made: a run on the other kind is refused before it starts, never found
|
|
460
|
+
* out as a step that cannot find its button. */
|
|
461
|
+
platform: z2.enum(PLATFORMS).optional(),
|
|
462
|
+
recordedAt: z2.string(),
|
|
463
|
+
recordedBy: z2.string(),
|
|
464
|
+
updatedAt: z2.string(),
|
|
465
|
+
steps: z2.array(RecordedStepSchema),
|
|
466
|
+
environment: ExecutionEnvironmentSchema.optional(),
|
|
467
|
+
/** WHICH DEPLOYMENT THIS WAS RECORDED AGAINST, by name — "staging",
|
|
468
|
+
* "production", whatever the project calls them.
|
|
469
|
+
*
|
|
470
|
+
* A record of fact, not a constraint: the flow can still be run against any
|
|
471
|
+
* environment, which is the point of naming them. What it answers is the
|
|
472
|
+
* question somebody asks of a test that fails only in one place — "where
|
|
473
|
+
* was this made?" — which was previously unanswerable, because the address
|
|
474
|
+
* it used belonged to a setting that had since moved on.
|
|
475
|
+
*
|
|
476
|
+
* Deliberately NOT the address. An origin in a git-tracked file is the
|
|
477
|
+
* mistake documented on `baseUrl` above; a name survives the deployment
|
|
478
|
+
* moving. Absent on everything recorded before this, and on a project that
|
|
479
|
+
* has never named its environments. */
|
|
480
|
+
recordedIn: z2.string().min(1).optional(),
|
|
481
|
+
path: z2.string().optional(),
|
|
482
|
+
actors: z2.array(ActorDefinitionSchema).optional(),
|
|
483
|
+
defaultActorId: z2.string().optional(),
|
|
484
|
+
/** SIGN IN FIRST: the account this flow starts as, before anything else runs.
|
|
485
|
+
*
|
|
486
|
+
* A setting rather than step one, which is what it used to be saved as. As a
|
|
487
|
+
* step it could not be switched off without deleting it, and a flow reused
|
|
488
|
+
* as somebody else's prerequisite could not tell "my own sign-in" from a
|
|
489
|
+
* sign-in that is part of what it tests. `parseFlowDocument` lifts an old
|
|
490
|
+
* step one into this field, so both spellings read the same. A reference to
|
|
491
|
+
* a profile, never a copy of its steps. */
|
|
492
|
+
signIn: FlowSignInSchema.optional(),
|
|
493
|
+
/** Flows replayed, in this order, after signing in and before this flow's
|
|
494
|
+
* own steps — to reach the state it starts from.
|
|
495
|
+
*
|
|
496
|
+
* LINKS, NOT COPIES: re-record "Save basic info" once and every flow that
|
|
497
|
+
* starts after it follows. Each runs WITHOUT its own sign-in; this flow's
|
|
498
|
+
* is the only one. Explicit and flat — nothing here pulls in a
|
|
499
|
+
* prerequisite's prerequisites at run time, so what runs is exactly this
|
|
500
|
+
* list. The editor fills those in when a flow is added. */
|
|
501
|
+
prerequisites: FlowPrerequisitesSchema.optional(),
|
|
502
|
+
/** Bumped on every save. An edit carries the revision it was made against,
|
|
503
|
+
* so a save built on a stale copy is refused instead of overwriting work
|
|
504
|
+
* somebody else — or a debug session on the same machine — has since done.
|
|
505
|
+
* Documents written before this default to 0. */
|
|
506
|
+
revision: z2.number().int().nonnegative().default(0)
|
|
507
|
+
}).strict().superRefine((flow, ctx) => {
|
|
508
|
+
const ids = /* @__PURE__ */ new Set();
|
|
509
|
+
flow.steps.forEach((s, i) => {
|
|
510
|
+
if (ids.has(s.id)) ctx.addIssue({ code: "custom", path: ["steps", i, "id"], message: "Step IDs must be unique" });
|
|
511
|
+
ids.add(s.id);
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
var surfaceOf = (s) => JSON.stringify([s["pageId"] ?? "", s["frame"] ?? []]);
|
|
515
|
+
var containerOf = (s) => JSON.stringify([targetOfStep(s), s["legacySelector"] ?? ""]);
|
|
516
|
+
function targetOfStep(s) {
|
|
517
|
+
const t = { role: s["role"] ?? "", name: s["name"] ?? "", scope: s["scope"] ?? "" };
|
|
518
|
+
for (const key of ["scopeRole", "frame", "pageId", "nameFrom", "locator", "nth"]) {
|
|
519
|
+
if (s[key] !== void 0) t[key] = s[key];
|
|
520
|
+
}
|
|
521
|
+
return t;
|
|
522
|
+
}
|
|
523
|
+
function migrateScrollPrerequisites(doc) {
|
|
524
|
+
const steps = Array.isArray(doc.steps) ? doc.steps : [];
|
|
525
|
+
const out = [];
|
|
526
|
+
const pending = [];
|
|
527
|
+
const isScroll = (s) => s["type"] === "scroll" && s["scroll"] !== void 0 && s["scroll"] !== null;
|
|
528
|
+
const hasChecks = (s) => Array.isArray(s["proposals"]) && s["proposals"].length > 0;
|
|
529
|
+
const carry = (s) => {
|
|
530
|
+
const pos = s["scroll"] ?? {};
|
|
531
|
+
const prerequisite = { x: Number(pos.x ?? 0), y: Number(pos.y ?? 0), container: targetOfStep(s) };
|
|
532
|
+
if (typeof s["legacySelector"] === "string" && s["legacySelector"]) prerequisite["legacySelector"] = s["legacySelector"];
|
|
533
|
+
return prerequisite;
|
|
534
|
+
};
|
|
535
|
+
for (const step of steps) {
|
|
536
|
+
if (isScroll(step)) {
|
|
537
|
+
const previous = pending.at(-1);
|
|
538
|
+
const same = previous && !hasChecks(previous) && !hasChecks(step) && surfaceOf(previous) === surfaceOf(step) && containerOf(previous) === containerOf(step) && Boolean(previous["disabled"]) === Boolean(step["disabled"]);
|
|
539
|
+
if (same) pending[pending.length - 1] = step;
|
|
540
|
+
else pending.push(step);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const last = pending.at(-1);
|
|
544
|
+
if (last && !hasChecks(last) && !step["scroll"] && surfaceOf(last) === surfaceOf(step) && Boolean(last["disabled"]) === Boolean(step["disabled"])) {
|
|
545
|
+
step["scroll"] = carry(pending.pop());
|
|
546
|
+
}
|
|
547
|
+
for (const p of pending.splice(0)) out.push(p);
|
|
548
|
+
out.push(step);
|
|
549
|
+
}
|
|
550
|
+
for (const p of pending.splice(0)) out.push(p);
|
|
551
|
+
doc.steps = out;
|
|
552
|
+
}
|
|
553
|
+
function migrateRadioCheckedState(doc) {
|
|
554
|
+
for (const step of Array.isArray(doc.steps) ? doc.steps : []) {
|
|
555
|
+
if (step["type"] === "check" && step["role"] === "radio" && step["value"] === "") step["value"] = "true";
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
function migrateExplicitSecretRefs(doc) {
|
|
559
|
+
for (const step of Array.isArray(doc.steps) ? doc.steps : []) {
|
|
560
|
+
if (step["secret"] !== true) continue;
|
|
561
|
+
if (typeof step["secretRef"] === "string" && step["secretRef"]) continue;
|
|
562
|
+
const value = typeof step["value"] === "string" ? step["value"] : "";
|
|
563
|
+
if (value && isSecretRefName(value)) {
|
|
564
|
+
step["secretRef"] = value;
|
|
565
|
+
step["value"] = "";
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function migrateBareModifierPresses(doc) {
|
|
570
|
+
const MODIFIERS = ["Meta", "Control", "Alt", "Shift"];
|
|
571
|
+
const invented = (step) => step["type"] === "press" && typeof step["value"] === "string" && MODIFIERS.some((m) => step["value"] === `${m}+${m}` || step["value"] === m);
|
|
572
|
+
doc.steps = (Array.isArray(doc.steps) ? doc.steps : []).filter((step) => !invented(step));
|
|
573
|
+
}
|
|
574
|
+
var MIGRATIONS = {
|
|
575
|
+
2: (doc) => {
|
|
576
|
+
migrateScrollPrerequisites(doc);
|
|
577
|
+
migrateRadioCheckedState(doc);
|
|
578
|
+
},
|
|
579
|
+
3: migrateExplicitSecretRefs,
|
|
580
|
+
4: migrateBareModifierPresses
|
|
581
|
+
};
|
|
582
|
+
function parseFlowDocument(input) {
|
|
583
|
+
const raw = structuredClone(input);
|
|
584
|
+
let version = typeof raw?.schemaVersion === "number" ? raw.schemaVersion : 2;
|
|
585
|
+
if (version > CURRENT_SCHEMA_VERSION) {
|
|
586
|
+
throw new Error(
|
|
587
|
+
`this flow was written by a newer version of Flowriki (schema ${version}, this build understands ${CURRENT_SCHEMA_VERSION}) \u2014 upgrade to open it`
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
while (version < CURRENT_SCHEMA_VERSION) {
|
|
591
|
+
const migrate = MIGRATIONS[version];
|
|
592
|
+
if (!migrate) throw new Error(`no migration path from schema version ${version}`);
|
|
593
|
+
migrate(raw);
|
|
594
|
+
version += 1;
|
|
595
|
+
raw.schemaVersion = version;
|
|
596
|
+
}
|
|
597
|
+
liftSignInFirst(raw);
|
|
598
|
+
if (Array.isArray(raw.steps)) raw.steps = mirrorCheckTargets(raw.steps);
|
|
599
|
+
return FlowDocumentSchema.parse(raw);
|
|
600
|
+
}
|
|
601
|
+
var SIGN_IN_FIRST_STEP_ID = "sign-in-first";
|
|
602
|
+
function liftSignInFirst(raw) {
|
|
603
|
+
const first = raw.steps?.[0];
|
|
604
|
+
const auth = first?.auth;
|
|
605
|
+
if (!first || first.type !== "signIn" || first.disabled === true || first.actorId !== void 0) return;
|
|
606
|
+
if (!auth || typeof auth.profileId !== "string" || !auth.profileId) return;
|
|
607
|
+
if (raw["signIn"] !== void 0 || Array.isArray(raw["actors"]) && raw["actors"].length > 0) return;
|
|
608
|
+
raw["signIn"] = auth;
|
|
609
|
+
raw.steps = raw.steps.slice(1);
|
|
610
|
+
}
|
|
611
|
+
var CHECK_TARGET_FIELDS = [
|
|
612
|
+
"role",
|
|
613
|
+
"name",
|
|
614
|
+
"scope",
|
|
615
|
+
"scopeRole",
|
|
616
|
+
"frame",
|
|
617
|
+
"pageId",
|
|
618
|
+
"nameFrom",
|
|
619
|
+
"locator",
|
|
620
|
+
"candidates",
|
|
621
|
+
"nth"
|
|
622
|
+
];
|
|
623
|
+
var unset = (value) => value === void 0 || value === null || value === "" || Array.isArray(value) && value.length === 0;
|
|
624
|
+
function mirrorCheckTargets(steps) {
|
|
625
|
+
return steps.map((step) => {
|
|
626
|
+
const held = step;
|
|
627
|
+
if (held["type"] !== "assert") return step;
|
|
628
|
+
const proposals = held["proposals"];
|
|
629
|
+
if (!Array.isArray(proposals)) return step;
|
|
630
|
+
const first = proposals[0];
|
|
631
|
+
const target = first?.["target"];
|
|
632
|
+
if (!target) return step;
|
|
633
|
+
const fill = {};
|
|
634
|
+
for (const field of CHECK_TARGET_FIELDS) {
|
|
635
|
+
if (!unset(held[field]) || unset(target[field])) continue;
|
|
636
|
+
fill[field] = target[field];
|
|
637
|
+
}
|
|
638
|
+
return Object.keys(fill).length === 0 ? step : { ...held, ...fill };
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ../test-model/dist/index.js
|
|
643
|
+
import { z as z3 } from "zod";
|
|
644
|
+
import { z as z22 } from "zod";
|
|
645
|
+
import { z as z32 } from "zod";
|
|
646
|
+
import { z as z4 } from "zod";
|
|
647
|
+
import { z as z5 } from "zod";
|
|
648
|
+
import { z as z6 } from "zod";
|
|
649
|
+
import { z as z7 } from "zod";
|
|
650
|
+
import { z as z8 } from "zod";
|
|
651
|
+
import { z as z9 } from "zod";
|
|
652
|
+
var EngineSchema = z3.enum(["chromium", "firefox", "webkit", "android", "ios"]);
|
|
653
|
+
var CapabilitySchema = z3.object({
|
|
654
|
+
id: z3.string(),
|
|
655
|
+
provider: z3.string().default("local"),
|
|
656
|
+
engine: EngineSchema,
|
|
657
|
+
/** Branded local builds: "chrome", "msedge". */
|
|
658
|
+
channel: z3.string().optional(),
|
|
659
|
+
browserVersion: z3.string().optional(),
|
|
660
|
+
os: z3.string().optional(),
|
|
661
|
+
osVersion: z3.string().optional(),
|
|
662
|
+
device: z3.string().optional(),
|
|
663
|
+
viewport: z3.object({ width: z3.number().int(), height: z3.number().int() }).optional()
|
|
664
|
+
});
|
|
665
|
+
var ExecutionConfigSchema = z3.object({
|
|
666
|
+
concurrency: z3.number().int().positive().default(4),
|
|
667
|
+
defaultBrowser: z3.string().default("chromium"),
|
|
668
|
+
retries: z3.number().int().nonnegative().default(0),
|
|
669
|
+
browsers: z3.record(z3.string(), CapabilitySchema.omit({ id: true }).partial({ provider: true })),
|
|
670
|
+
suites: z3.record(
|
|
671
|
+
z3.string(),
|
|
672
|
+
z3.object({
|
|
673
|
+
flows: z3.array(z3.string()).default([]),
|
|
674
|
+
browser: z3.string().optional(),
|
|
675
|
+
/** Opt-in cross-browser fan-out for this suite only. */
|
|
676
|
+
matrix: z3.array(z3.string()).optional()
|
|
677
|
+
})
|
|
678
|
+
).default({})
|
|
679
|
+
});
|
|
680
|
+
var BUSINESS_KEYWORDS = ["Given", "When", "Then", "And", "But"];
|
|
681
|
+
var BusinessStepSchema = z22.object({
|
|
682
|
+
keyword: z22.enum(BUSINESS_KEYWORDS),
|
|
683
|
+
text: z22.string().min(1).max(2e3)
|
|
684
|
+
}).strict();
|
|
685
|
+
var TestCaseOriginSchema = z22.object({
|
|
686
|
+
kind: z22.enum(["import", "assistant"]),
|
|
687
|
+
at: z22.string(),
|
|
688
|
+
by: z22.string().default(""),
|
|
689
|
+
/** The file it was imported from, and its row or id there. */
|
|
690
|
+
file: z22.string().max(300).optional(),
|
|
691
|
+
row: z22.string().max(200).optional()
|
|
692
|
+
}).strict();
|
|
693
|
+
var KEYWORD = /^\s*(given|when|then|and|but)\b[\s:]*(.*)$/i;
|
|
694
|
+
var keywordOf = (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
|
|
695
|
+
function parseGherkin(text) {
|
|
696
|
+
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).filter((l) => !/^(scenario|scenario outline|feature|background|examples)\s*:/i.test(l) && !l.startsWith("|"));
|
|
697
|
+
if (!lines.length) return null;
|
|
698
|
+
const steps = [];
|
|
699
|
+
for (const line of lines) {
|
|
700
|
+
const m = KEYWORD.exec(line);
|
|
701
|
+
if (!m || !m[2].trim()) return null;
|
|
702
|
+
steps.push({ keyword: keywordOf(m[1]), text: m[2].trim() });
|
|
703
|
+
}
|
|
704
|
+
return steps;
|
|
705
|
+
}
|
|
706
|
+
var FlowOccurrenceSchema = z32.object({
|
|
707
|
+
/** This copy's own identity. Not the source flow's — a flow added twice is
|
|
708
|
+
* two occurrences, and editing, breakpoints, results and deletion must be
|
|
709
|
+
* able to tell them apart. */
|
|
710
|
+
occurrenceId: z32.string().min(1),
|
|
711
|
+
actorId: z32.string().optional(),
|
|
712
|
+
/** Editable, and initially the source flow's name. */
|
|
713
|
+
name: z32.string().min(1),
|
|
714
|
+
/** Where this copy starts, when it is told to navigate before running.
|
|
715
|
+
* Absent means continue from wherever the previous row left the browser. */
|
|
716
|
+
startPath: z32.string().optional(),
|
|
717
|
+
baseUrl: z32.string().url().optional(),
|
|
718
|
+
/** GO SOMEWHERE FIRST, or carry on where the last row ended.
|
|
719
|
+
*
|
|
720
|
+
* SUPERSEDED BY `start`, and still read so that every case written before
|
|
721
|
+
* it keeps running unchanged. `startOf` answers for both shapes; nothing
|
|
722
|
+
* should consult this field directly.
|
|
723
|
+
*
|
|
724
|
+
* Off by default, because a case is a sequence in one browser: re-entering
|
|
725
|
+
* each flow's original starting URL would sign the tester out between rows
|
|
726
|
+
* and is exactly the behaviour composing flows is meant to avoid. */
|
|
727
|
+
navigateFirst: z32.boolean().default(false),
|
|
728
|
+
/** WHICH BROWSER THIS ROW RUNS IN.
|
|
729
|
+
*
|
|
730
|
+
* Two answers, because there are two: carry on in the browser this
|
|
731
|
+
* participant already has, or open them a new one. Where the page goes is a
|
|
732
|
+
* different question with its own field — folding them together produced a
|
|
733
|
+
* three-way choice in which two of the answers differed by something most
|
|
734
|
+
* people never set.
|
|
735
|
+
*
|
|
736
|
+
* A case is a sequence of flows performed by participants, and until now
|
|
737
|
+
* the only question a row could answer was whether to navigate. Whether it
|
|
738
|
+
* ran in the participant's EXISTING browser or a fresh one could not be
|
|
739
|
+
* said at all — it was implied (always the existing one) and could only be
|
|
740
|
+
* overridden by burying a `resetActor` step inside the flow.
|
|
741
|
+
*
|
|
742
|
+
* continue — the participant's browser, exactly where they left it
|
|
743
|
+
* navigate — the participant's browser, back at this flow's start path
|
|
744
|
+
* fresh — a new browser for them, signed in again if they start signed
|
|
745
|
+
* in, then the start path
|
|
746
|
+
*
|
|
747
|
+
* A participant's FIRST row is always fresh in effect: there is nothing to
|
|
748
|
+
* continue from. That is not stored, because it is a fact about the order
|
|
749
|
+
* rather than about the row — moving the row changes the answer. */
|
|
750
|
+
start: z32.enum(["continue", "navigate", "fresh"]).optional(),
|
|
751
|
+
/** WHETHER THIS COPY DOES ITS OWN SIGNING IN.
|
|
752
|
+
*
|
|
753
|
+
* A flow recorded with "sign in before recording" keeps that sign-in as its
|
|
754
|
+
* first step, because that is how it readies ITSELF when it runs alone.
|
|
755
|
+
* Inside a case the identity belongs to the person, so the same step is
|
|
756
|
+
* usually a second answer to a question already answered.
|
|
757
|
+
*
|
|
758
|
+
* skip — the person's account is the one used; this copy's own sign-ins
|
|
759
|
+
* lie dormant, and the run confirms the session rather than
|
|
760
|
+
* assuming it
|
|
761
|
+
* run — perform them anyway, whoever they name. Signing in as somebody
|
|
762
|
+
* else on this person's browser is a real thing to want, and
|
|
763
|
+
* before this there was no way to say it.
|
|
764
|
+
*
|
|
765
|
+
* ABSENT IS NOT "run". It is "nobody has said", which behaves as skip where
|
|
766
|
+
* the two agree on the account and is refused where they do not — so a case
|
|
767
|
+
* written before this keeps its meaning, and a contradiction stays a
|
|
768
|
+
* contradiction until somebody actually resolves it. */
|
|
769
|
+
ownSignIn: z32.enum(["skip", "run"]).optional(),
|
|
770
|
+
/** WHERE THIS ROW RUNS — copied from its flow. Absent is a browser; a
|
|
771
|
+
* phone row runs on a device, and its `actorId` names that device. */
|
|
772
|
+
platform: z32.enum(PLATFORMS).optional(),
|
|
773
|
+
steps: z32.array(RecordedStepSchema),
|
|
774
|
+
environment: ExecutionEnvironmentSchema.optional(),
|
|
775
|
+
/** Where this copy came from, so the editor can say whether the source has
|
|
776
|
+
* moved on — informational only, never a trigger to rewrite anything. */
|
|
777
|
+
origin: z32.object({
|
|
778
|
+
flowId: z32.string(),
|
|
779
|
+
/** The schema the source was written in, which may be older than today's. */
|
|
780
|
+
schemaVersion: z32.number().int().nonnegative(),
|
|
781
|
+
/** The source's own revision counter at the moment it was copied. */
|
|
782
|
+
revision: z32.number().int().nonnegative(),
|
|
783
|
+
/** The commit the copy was taken from, when one was known. */
|
|
784
|
+
commit: z32.string().optional(),
|
|
785
|
+
featureId: z32.string(),
|
|
786
|
+
/** Names as they were, so an old case still reads correctly after the
|
|
787
|
+
* source is renamed, moved or deleted. */
|
|
788
|
+
featureName: z32.string(),
|
|
789
|
+
flowName: z32.string(),
|
|
790
|
+
/** Source step id → the id it was given here. Kept so a later comparison
|
|
791
|
+
* against the source can line the two up; nothing depends on it to run. */
|
|
792
|
+
stepIds: z32.record(z32.string(), z32.string()).default({}),
|
|
793
|
+
copiedAt: z32.string()
|
|
794
|
+
}),
|
|
795
|
+
/** WHICH OF THIS ROW'S STEPS ARE WHAT RUNS BEFORE ITS FLOW — the flow's
|
|
796
|
+
* sign-in and the prerequisites copied in with it — so the row can be shown
|
|
797
|
+
* folded, the way the Flows screen shows the same flow.
|
|
798
|
+
*
|
|
799
|
+
* A description of steps the row already holds, and nothing more: they are
|
|
800
|
+
* ordinary copies in `steps` and run in order whatever this says. Kept by
|
|
801
|
+
* id, so editing or deleting one of them leaves the rest still grouped.
|
|
802
|
+
* Absent on every row copied before this existed, which simply show flat.
|
|
803
|
+
* Never sent to a machine — see `withoutFolding`. */
|
|
804
|
+
before: z32.object({
|
|
805
|
+
/** The flow's own sign-in, when the copy brought one. */
|
|
806
|
+
signInStepId: z32.string().optional(),
|
|
807
|
+
flows: z32.array(z32.object({
|
|
808
|
+
flowId: z32.string(),
|
|
809
|
+
name: z32.string(),
|
|
810
|
+
stepIds: z32.array(z32.string())
|
|
811
|
+
}).strict()).default([])
|
|
812
|
+
}).strict().optional()
|
|
813
|
+
}).strict();
|
|
814
|
+
var CASE_SCHEMA_VERSION = 1;
|
|
815
|
+
var TestCaseSchema = z32.object({
|
|
816
|
+
schemaVersion: z32.literal(CASE_SCHEMA_VERSION),
|
|
817
|
+
id: z32.string().min(1),
|
|
818
|
+
folderId: z32.string().min(1),
|
|
819
|
+
slug: z32.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
820
|
+
name: z32.string().min(1),
|
|
821
|
+
description: z32.string().default(""),
|
|
822
|
+
/** NO LONGER USED, and kept only so that a case written while they were
|
|
823
|
+
* parses.
|
|
824
|
+
*
|
|
825
|
+
* These overrode the FIRST row's start path — whichever row that happened
|
|
826
|
+
* to be. A case is reordered by dragging, so the override silently moved to
|
|
827
|
+
* whatever was dragged to the top, carrying a path chosen for a different
|
|
828
|
+
* flow. Nothing said so, and the row it landed on looked like every other
|
|
829
|
+
* row. A positional override of a list people reorder is a setting that
|
|
830
|
+
* cannot stay correct.
|
|
831
|
+
*
|
|
832
|
+
* Every row now starts where its own flow says, which is the same answer
|
|
833
|
+
* whatever the order. */
|
|
834
|
+
startPath: z32.string().default("/"),
|
|
835
|
+
useStartPath: z32.boolean().default(false),
|
|
836
|
+
baseUrl: z32.string().url().optional(),
|
|
837
|
+
environment: ExecutionEnvironmentSchema.optional(),
|
|
838
|
+
actors: z32.array(ActorDefinitionSchema).optional(),
|
|
839
|
+
defaultActorId: z32.string().optional(),
|
|
840
|
+
occurrences: z32.array(FlowOccurrenceSchema).default([]),
|
|
841
|
+
/** WHAT IT CHECKS, IN WORDS: Given / When / Then. Written for a case that is
|
|
842
|
+
* not automated yet — imported, or written by the assistant — and kept when
|
|
843
|
+
* it is. Never sent to a machine — see `withoutFolding`. */
|
|
844
|
+
businessSteps: z32.array(BusinessStepSchema).optional(),
|
|
845
|
+
/** Where it came from, when not recorded here. */
|
|
846
|
+
origin: TestCaseOriginSchema.optional(),
|
|
847
|
+
/** WHEN ITS STEPS CHANGED AFTER IT WAS AUTOMATED: the automation may no
|
|
848
|
+
* longer check what the words now say. Cleared when its flows are saved
|
|
849
|
+
* again, or somebody confirms the automation still matches. */
|
|
850
|
+
stepsChangedAt: z32.string().optional(),
|
|
851
|
+
createdAt: z32.string(),
|
|
852
|
+
createdBy: z32.string().default(""),
|
|
853
|
+
updatedAt: z32.string(),
|
|
854
|
+
/** The same optimistic-concurrency counter a flow carries, so an edit made
|
|
855
|
+
* against a stale copy is refused rather than overwriting. */
|
|
856
|
+
revision: z32.number().int().nonnegative().default(0)
|
|
857
|
+
}).strict().superRefine((testCase, ctx) => {
|
|
858
|
+
const seen = /* @__PURE__ */ new Set();
|
|
859
|
+
testCase.occurrences.forEach((occurrence, i) => {
|
|
860
|
+
if (seen.has(occurrence.occurrenceId)) {
|
|
861
|
+
ctx.addIssue({ code: "custom", path: ["occurrences", i, "occurrenceId"], message: "Occurrence IDs must be unique" });
|
|
862
|
+
}
|
|
863
|
+
seen.add(occurrence.occurrenceId);
|
|
864
|
+
const steps = /* @__PURE__ */ new Set();
|
|
865
|
+
occurrence.steps.forEach((step, s) => {
|
|
866
|
+
if (steps.has(step.id)) {
|
|
867
|
+
ctx.addIssue({ code: "custom", path: ["occurrences", i, "steps", s, "id"], message: "Step IDs must be unique within an occurrence" });
|
|
868
|
+
}
|
|
869
|
+
steps.add(step.id);
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
}).transform((testCase) => {
|
|
873
|
+
const occurrences = testCase.occurrences.map((occurrence) => {
|
|
874
|
+
const steps = mirrorCheckTargets(occurrence.steps);
|
|
875
|
+
return steps.some((step, at) => step !== occurrence.steps[at]) ? { ...occurrence, steps } : occurrence;
|
|
876
|
+
});
|
|
877
|
+
if (occurrences.some((occurrence, at) => occurrence !== testCase.occurrences[at])) {
|
|
878
|
+
testCase = { ...testCase, occurrences };
|
|
879
|
+
}
|
|
880
|
+
if (!testCase.defaultActorId || testCase.defaultActorId === "default") return testCase;
|
|
881
|
+
if (testCase.actors?.some((actor) => actor.id === testCase.defaultActorId)) return testCase;
|
|
882
|
+
const { defaultActorId: _gone, ...rest } = testCase;
|
|
883
|
+
return rest;
|
|
884
|
+
});
|
|
885
|
+
var CaseFolderSchema = z32.object({
|
|
886
|
+
schemaVersion: z32.literal(1),
|
|
887
|
+
id: z32.string().min(1),
|
|
888
|
+
slug: z32.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
889
|
+
name: z32.string().min(1),
|
|
890
|
+
description: z32.string().default(""),
|
|
891
|
+
order: z32.number().int().default(0),
|
|
892
|
+
createdAt: z32.string(),
|
|
893
|
+
createdBy: z32.string().default("")
|
|
894
|
+
}).strict();
|
|
895
|
+
var SUITE_SCHEMA_VERSION = 1;
|
|
896
|
+
var SuiteEntrySchema = z32.object({
|
|
897
|
+
entryId: z32.string().min(1),
|
|
898
|
+
kind: z32.enum(["flow", "testCase"]),
|
|
899
|
+
/** The document's ID — never its name or path, so renaming or moving what it
|
|
900
|
+
* points at cannot break the reference. */
|
|
901
|
+
ref: z32.string().min(1),
|
|
902
|
+
enabled: z32.boolean().default(true),
|
|
903
|
+
order: z32.number().int().nonnegative(),
|
|
904
|
+
/** What it was called when it was added, so a result stays readable after the
|
|
905
|
+
* source is renamed or deleted. */
|
|
906
|
+
nameSnapshot: z32.string().default("")
|
|
907
|
+
}).strict();
|
|
908
|
+
var SuiteSchema = z32.object({
|
|
909
|
+
schemaVersion: z32.literal(SUITE_SCHEMA_VERSION),
|
|
910
|
+
id: z32.string().min(1),
|
|
911
|
+
slug: z32.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
912
|
+
name: z32.string().min(1),
|
|
913
|
+
description: z32.string().default(""),
|
|
914
|
+
entries: z32.array(SuiteEntrySchema).default([]),
|
|
915
|
+
defaults: z32.object({
|
|
916
|
+
headless: z32.boolean().optional(),
|
|
917
|
+
/** Carry on through the remaining entries, or stop at the first failure.
|
|
918
|
+
* Continuing is the default: a suite is a list of independent things and
|
|
919
|
+
* one failure rarely invalidates the rest. */
|
|
920
|
+
failurePolicy: z32.enum(["continue", "stop"]).default("continue")
|
|
921
|
+
}).default({ failurePolicy: "continue" }),
|
|
922
|
+
createdAt: z32.string(),
|
|
923
|
+
createdBy: z32.string().default(""),
|
|
924
|
+
updatedAt: z32.string(),
|
|
925
|
+
revision: z32.number().int().nonnegative().default(0)
|
|
926
|
+
}).strict().superRefine((suite, ctx) => {
|
|
927
|
+
const seen = /* @__PURE__ */ new Set();
|
|
928
|
+
suite.entries.forEach((entry, i) => {
|
|
929
|
+
if (seen.has(entry.entryId)) {
|
|
930
|
+
ctx.addIssue({ code: "custom", path: ["entries", i, "entryId"], message: "Entry IDs must be unique" });
|
|
931
|
+
}
|
|
932
|
+
seen.add(entry.entryId);
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
function startOf(occurrence) {
|
|
936
|
+
return occurrence.start === "fresh" ? "fresh" : "continue";
|
|
937
|
+
}
|
|
938
|
+
function navigatesFirst(occurrence) {
|
|
939
|
+
return occurrence.start === "navigate" || occurrence.navigateFirst;
|
|
940
|
+
}
|
|
941
|
+
function sessionLanes(occurrences, defaultActorId) {
|
|
942
|
+
const mine = /* @__PURE__ */ new Map();
|
|
943
|
+
const owner = /* @__PURE__ */ new Map();
|
|
944
|
+
const incarnations = /* @__PURE__ */ new Map();
|
|
945
|
+
const lanes = /* @__PURE__ */ new Map();
|
|
946
|
+
const last = /* @__PURE__ */ new Map();
|
|
947
|
+
const rows = [];
|
|
948
|
+
let previous = null;
|
|
949
|
+
const open = (actorId) => {
|
|
950
|
+
const next = (incarnations.get(actorId) ?? -1) + 1;
|
|
951
|
+
incarnations.set(actorId, next);
|
|
952
|
+
const sessionId = `${actorId}#${next}`;
|
|
953
|
+
mine.set(actorId, sessionId);
|
|
954
|
+
owner.set(sessionId, actorId);
|
|
955
|
+
if (!lanes.has(sessionId)) lanes.set(sessionId, lanes.size);
|
|
956
|
+
return sessionId;
|
|
957
|
+
};
|
|
958
|
+
for (const occurrence of occurrences) {
|
|
959
|
+
const person = occurrence.actorId || defaultActorId || "default";
|
|
960
|
+
const phone = occurrence.platform === "android" || occurrence.platform === "ios" ? occurrence.platform : void 0;
|
|
961
|
+
const actorId = phone ? `${phone}:${person}` : person;
|
|
962
|
+
const continues = startOf(occurrence) === "continue";
|
|
963
|
+
const asked = occurrence.start === "continue";
|
|
964
|
+
const theirs = mine.get(actorId);
|
|
965
|
+
const adoptable = !phone && previous !== null && owner.get(previous) === "default" && actorId !== "default";
|
|
966
|
+
let sessionId;
|
|
967
|
+
if (continues && theirs) sessionId = theirs;
|
|
968
|
+
else if (asked && adoptable) {
|
|
969
|
+
sessionId = previous;
|
|
970
|
+
owner.set(sessionId, actorId);
|
|
971
|
+
mine.set(actorId, sessionId);
|
|
972
|
+
mine.delete("default");
|
|
973
|
+
} else sessionId = open(actorId);
|
|
974
|
+
const continuesFrom = last.get(sessionId) ?? null;
|
|
975
|
+
rows.push({
|
|
976
|
+
occurrenceId: occurrence.occurrenceId,
|
|
977
|
+
actorId: person,
|
|
978
|
+
...phone ? { phone } : {},
|
|
979
|
+
incarnation: Number(sessionId.split("#")[1]),
|
|
980
|
+
sessionId,
|
|
981
|
+
lane: lanes.get(sessionId),
|
|
982
|
+
continuesFrom,
|
|
983
|
+
opens: continuesFrom === null,
|
|
984
|
+
// WHETHER THERE IS A CHOICE TO MAKE. A row with nothing to continue —
|
|
985
|
+
// no browser of their own and no guest's to take over — is told so
|
|
986
|
+
// rather than offered an answer that cannot mean anything.
|
|
987
|
+
mayContinue: Boolean(theirs) || adoptable
|
|
988
|
+
});
|
|
989
|
+
last.set(sessionId, occurrence.occurrenceId);
|
|
990
|
+
previous = sessionId;
|
|
991
|
+
}
|
|
992
|
+
return rows;
|
|
993
|
+
}
|
|
994
|
+
function randomUUID() {
|
|
995
|
+
const crypto = globalThis.crypto;
|
|
996
|
+
if (crypto?.randomUUID) return crypto.randomUUID();
|
|
997
|
+
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2, 14)}`;
|
|
998
|
+
}
|
|
999
|
+
var mintPage = (remap, pageId) => {
|
|
1000
|
+
if (pageId === "main") return "main";
|
|
1001
|
+
const existing = remap.pages.get(pageId);
|
|
1002
|
+
if (existing) return existing;
|
|
1003
|
+
const fresh = `pg_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
1004
|
+
remap.pages.set(pageId, fresh);
|
|
1005
|
+
return fresh;
|
|
1006
|
+
};
|
|
1007
|
+
function retarget(target, remap) {
|
|
1008
|
+
if (!target.pageId) return target;
|
|
1009
|
+
return { ...target, pageId: mintPage(remap, target.pageId) };
|
|
1010
|
+
}
|
|
1011
|
+
function copyStep(step, remap) {
|
|
1012
|
+
const copied = retarget({ ...step, id: remap.steps.get(step.id) }, remap);
|
|
1013
|
+
if (copied.opensPage) copied.opensPage = mintPage(remap, copied.opensPage);
|
|
1014
|
+
if (copied.to) copied.to = retarget({ ...copied.to }, remap);
|
|
1015
|
+
if (copied.scroll?.container) {
|
|
1016
|
+
copied.scroll = { ...copied.scroll, container: retarget({ ...copied.scroll.container }, remap) };
|
|
1017
|
+
}
|
|
1018
|
+
copied.proposals = step.proposals.map((assertion) => ({ ...assertion }));
|
|
1019
|
+
return copied;
|
|
1020
|
+
}
|
|
1021
|
+
function copyFlowIntoCase(flow, context) {
|
|
1022
|
+
const remap = { steps: /* @__PURE__ */ new Map(), pages: /* @__PURE__ */ new Map() };
|
|
1023
|
+
for (const step of flow.steps) {
|
|
1024
|
+
remap.steps.set(step.id, `st_${randomUUID().replace(/-/g, "").slice(0, 12)}`);
|
|
1025
|
+
}
|
|
1026
|
+
const steps = flow.steps.map((step) => copyStep(step, remap));
|
|
1027
|
+
return {
|
|
1028
|
+
occurrenceId: `occ_${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
|
1029
|
+
name: flow.name,
|
|
1030
|
+
// WHO RUNS THIS COPY. The flow said which of its participants it belongs
|
|
1031
|
+
// to; dropping that here made every copy run as the main participant, so a
|
|
1032
|
+
// flow recorded as the approver silently became the buyer doing approvals.
|
|
1033
|
+
// The case has to be given the participant too — see `mergeParticipants`.
|
|
1034
|
+
...flow.defaultActorId && flow.defaultActorId !== "default" ? { actorId: flow.defaultActorId } : {},
|
|
1035
|
+
...flow.baseUrl ? { baseUrl: flow.baseUrl } : {},
|
|
1036
|
+
// A phone flow stays a phone row: it runs on a device, not in a browser.
|
|
1037
|
+
...flow.platform && flow.platform !== "web" ? { platform: flow.platform } : {},
|
|
1038
|
+
startPath: flow.startPath,
|
|
1039
|
+
navigateFirst: false,
|
|
1040
|
+
steps,
|
|
1041
|
+
...flow.environment ? { environment: flow.environment } : {},
|
|
1042
|
+
origin: {
|
|
1043
|
+
flowId: flow.id,
|
|
1044
|
+
schemaVersion: flow.schemaVersion,
|
|
1045
|
+
revision: flow.revision,
|
|
1046
|
+
...context.commit ? { commit: context.commit } : {},
|
|
1047
|
+
featureId: flow.featureId,
|
|
1048
|
+
featureName: context.featureName,
|
|
1049
|
+
flowName: flow.name,
|
|
1050
|
+
stepIds: Object.fromEntries(remap.steps),
|
|
1051
|
+
copiedAt: (context.at ?? /* @__PURE__ */ new Date()).toISOString()
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
function caseDebugPlan(testCase, baseUrl) {
|
|
1056
|
+
const owners = {};
|
|
1057
|
+
const steps = [];
|
|
1058
|
+
const occurrences = testCase.occurrences;
|
|
1059
|
+
for (const [index, item] of occurrences.entries()) {
|
|
1060
|
+
if (testCase.actors?.length) {
|
|
1061
|
+
const id = `case-actor:${encodeURIComponent(item.occurrenceId)}`;
|
|
1062
|
+
owners[id] = { occurrenceId: item.occurrenceId, stepId: null };
|
|
1063
|
+
steps.push({ id, type: "useActor", actorId: item.actorId ?? testCase.defaultActorId ?? "default", role: "", name: "Select participant", scope: "", value: "", secret: false, legacySelector: "", proposals: [] });
|
|
1064
|
+
}
|
|
1065
|
+
if (index > 0 && item.navigateFirst) {
|
|
1066
|
+
const id = `case-nav:${encodeURIComponent(item.occurrenceId)}`;
|
|
1067
|
+
owners[id] = { occurrenceId: item.occurrenceId, stepId: null };
|
|
1068
|
+
steps.push({ id, type: "navigate", role: "", name: `Go to ${item.name}`, scope: "", value: new URL(item.startPath ?? "/", item.baseUrl ?? testCase.baseUrl ?? baseUrl).href, secret: false, legacySelector: "", proposals: [] });
|
|
1069
|
+
}
|
|
1070
|
+
for (const step of item.steps) {
|
|
1071
|
+
const id = `case-step:${encodeURIComponent(item.occurrenceId)}:${encodeURIComponent(step.id)}`;
|
|
1072
|
+
owners[id] = { occurrenceId: item.occurrenceId, stepId: step.id };
|
|
1073
|
+
steps.push({ ...step, id });
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
const first = occurrences[0];
|
|
1077
|
+
const flow = FlowDocumentSchema.parse({
|
|
1078
|
+
schemaVersion: 5,
|
|
1079
|
+
id: testCase.id,
|
|
1080
|
+
featureId: testCase.folderId,
|
|
1081
|
+
slug: testCase.slug,
|
|
1082
|
+
name: testCase.name,
|
|
1083
|
+
revision: testCase.revision,
|
|
1084
|
+
recordedAt: testCase.createdAt,
|
|
1085
|
+
recordedBy: testCase.createdBy,
|
|
1086
|
+
updatedAt: testCase.updatedAt,
|
|
1087
|
+
steps,
|
|
1088
|
+
startPath: new URL(first?.startPath ?? "/", first?.baseUrl ?? testCase.baseUrl ?? baseUrl).href,
|
|
1089
|
+
...testCase.actors ? { actors: testCase.actors } : {},
|
|
1090
|
+
...testCase.defaultActorId ? { defaultActorId: testCase.defaultActorId } : {},
|
|
1091
|
+
...testCase.environment ? { environment: testCase.environment } : {}
|
|
1092
|
+
});
|
|
1093
|
+
return { flow, owners };
|
|
1094
|
+
}
|
|
1095
|
+
function restoreDebugCase(testCase, owners, steps) {
|
|
1096
|
+
const occurrences = testCase.occurrences.map((item) => ({ ...item, steps: [] }));
|
|
1097
|
+
const byId = new Map(occurrences.map((item) => [item.occurrenceId, item]));
|
|
1098
|
+
let current = occurrences[0];
|
|
1099
|
+
const present = new Set(steps.map((step) => step.id));
|
|
1100
|
+
for (const [id, owner] of Object.entries(owners)) {
|
|
1101
|
+
if (id.startsWith("case-nav:") && owner.stepId === null && !present.has(id)) byId.get(owner.occurrenceId).navigateFirst = false;
|
|
1102
|
+
}
|
|
1103
|
+
for (const step of steps) {
|
|
1104
|
+
const owner = owners[step.id];
|
|
1105
|
+
if (owner) current = byId.get(owner.occurrenceId);
|
|
1106
|
+
if (!current) throw new Error("debug step has no case occurrence");
|
|
1107
|
+
if (owner?.stepId === null) {
|
|
1108
|
+
if (step.id.startsWith("case-actor:")) {
|
|
1109
|
+
if (step.type !== "useActor") throw new Error("a participant boundary must remain a participant step");
|
|
1110
|
+
current.actorId = step.actorId;
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
if (step.type !== "navigate") throw new Error("a case boundary must remain a navigation step");
|
|
1114
|
+
current.startPath = step.value;
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
current.steps.push({ ...step, id: owner?.stepId ?? step.id });
|
|
1118
|
+
}
|
|
1119
|
+
return { ...testCase, occurrences };
|
|
1120
|
+
}
|
|
1121
|
+
var AuthNavigationSchema = z4.discriminatedUnion("kind", [
|
|
1122
|
+
/** Act from wherever the browser already is — a "Log in" link in the header.
|
|
1123
|
+
* The only kind that is safe part-way through a journey. */
|
|
1124
|
+
z4.object({ kind: z4.literal("continue") }),
|
|
1125
|
+
/** Open the procedure's own starting page first. Right when signing in is
|
|
1126
|
+
* the first thing that happens; it abandons a journey in progress. */
|
|
1127
|
+
z4.object({ kind: z4.literal("open"), path: z4.string().min(1) })
|
|
1128
|
+
]);
|
|
1129
|
+
var AuthFieldSchema = z4.object({
|
|
1130
|
+
/** The step in the recorded procedure this value is typed into. Where a
|
|
1131
|
+
* recording types into the same box more than once, this is the FIRST of
|
|
1132
|
+
* them — the one an account's values are keyed by. */
|
|
1133
|
+
stepId: z4.string().min(1),
|
|
1134
|
+
/** EVERY step that types into this box, when there is more than one.
|
|
1135
|
+
*
|
|
1136
|
+
* A login typed twice — a first attempt with a typo, a page that re-rendered
|
|
1137
|
+
* and was filled again — records two steps for one field, because keystrokes
|
|
1138
|
+
* coalesce only with the step immediately before them. Asking an account for
|
|
1139
|
+
* "Username" twice is the visible half of that; the dangerous half is that
|
|
1140
|
+
* substituting only the first leaves the second typing whatever was
|
|
1141
|
+
* recorded, so a run signs in as the person who made the recording.
|
|
1142
|
+
*
|
|
1143
|
+
* Empty means the one step in `stepId`, so every profile written before this
|
|
1144
|
+
* reads correctly without being migrated. */
|
|
1145
|
+
stepIds: z4.array(z4.string().min(1)).default([]),
|
|
1146
|
+
/** What it is called where somebody fills it in — the field's own name on
|
|
1147
|
+
* the page, so an account form reads "Username" rather than "st_9f2a". */
|
|
1148
|
+
label: z4.string().min(1),
|
|
1149
|
+
/** A secret arrives as a reference and is never written into a document. */
|
|
1150
|
+
secret: z4.boolean().default(false),
|
|
1151
|
+
/** What the recording typed. Empty for a secret, whose value never travels.
|
|
1152
|
+
* Kept so the first account can be made from what was recorded, with
|
|
1153
|
+
* nothing to fill in. */
|
|
1154
|
+
recorded: z4.string().default("")
|
|
1155
|
+
}).strict();
|
|
1156
|
+
var AUTH_PROFILE_SCHEMA_VERSION = 2;
|
|
1157
|
+
var AuthProfileSchema = z4.object({
|
|
1158
|
+
schemaVersion: z4.literal(AUTH_PROFILE_SCHEMA_VERSION),
|
|
1159
|
+
id: z4.string().min(1),
|
|
1160
|
+
slug: z4.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
1161
|
+
name: z4.string().min(1),
|
|
1162
|
+
description: z4.string().default(""),
|
|
1163
|
+
/** The recorded sign-in. A flow like any other, so it is edited, debugged
|
|
1164
|
+
* and replayed by everything that already knows how. */
|
|
1165
|
+
flowId: z4.string().min(1),
|
|
1166
|
+
/** Which of the values it types differ per identity. */
|
|
1167
|
+
fields: z4.array(AuthFieldSchema).default([]),
|
|
1168
|
+
/** Optional, and its own procedure with its own proof — most profiles will
|
|
1169
|
+
* not have one at first. The proof is read off that flow, the same way. */
|
|
1170
|
+
signOutFlowId: z4.string().optional(),
|
|
1171
|
+
/** Descriptive only — "admin", "staff". Never used to decide anything, so
|
|
1172
|
+
* two profiles may share a tag without becoming interchangeable. */
|
|
1173
|
+
tags: z4.array(z4.string()).default([]),
|
|
1174
|
+
createdAt: z4.string(),
|
|
1175
|
+
createdBy: z4.string().default(""),
|
|
1176
|
+
updatedAt: z4.string(),
|
|
1177
|
+
revision: z4.number().int().nonnegative().default(0)
|
|
1178
|
+
}).strict();
|
|
1179
|
+
var ACCOUNT_BINDING_SCHEMA_VERSION = 3;
|
|
1180
|
+
var AccountBindingSchema = z4.object({
|
|
1181
|
+
schemaVersion: z4.literal(ACCOUNT_BINDING_SCHEMA_VERSION),
|
|
1182
|
+
id: z4.string().min(1),
|
|
1183
|
+
slug: z4.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
1184
|
+
name: z4.string().min(1),
|
|
1185
|
+
profileId: z4.string().min(1),
|
|
1186
|
+
/** Which environments of the APPLICATION UNDER TEST these credentials work
|
|
1187
|
+
* in — never Flowriki's own dev or staging.
|
|
1188
|
+
*
|
|
1189
|
+
* EMPTY MEANS EVERY ONE, which is what a project with a single deployment
|
|
1190
|
+
* wants and what most accounts will always say.
|
|
1191
|
+
*
|
|
1192
|
+
* A LIST because the same person usually exists in more than one place. One
|
|
1193
|
+
* environment per account was the shape until version 3, and it forced a
|
|
1194
|
+
* duplicate account — same name, same password, same everything — for each
|
|
1195
|
+
* deployment the identity existed in, or a blank "works anywhere" that also
|
|
1196
|
+
* claimed environments it had never been created in. Neither was true. */
|
|
1197
|
+
environments: z4.array(z4.string().min(1)).default([]),
|
|
1198
|
+
/** Step id → what this identity types there. Ordinary values: a username is
|
|
1199
|
+
* not a credential, and keeping it here is what lets somebody read an
|
|
1200
|
+
* account and know who it is. */
|
|
1201
|
+
values: z4.record(z4.string(), z4.string()).default({}),
|
|
1202
|
+
/** Step id → secret REFERENCE. The value is sealed in the vault and never
|
|
1203
|
+
* enters the repository, whatever form it was typed into. */
|
|
1204
|
+
secrets: z4.record(z4.string(), z4.string().regex(/^[a-z][a-z0-9_]*$/i)).default({}),
|
|
1205
|
+
/** What this identity should look like once signed in — a displayed name, a
|
|
1206
|
+
* tenant. Not secret, and checked so that an already-authenticated browser
|
|
1207
|
+
* holding the WRONG account is reported rather than accepted. */
|
|
1208
|
+
identity: z4.object({
|
|
1209
|
+
label: z4.string().optional(),
|
|
1210
|
+
tenant: z4.string().optional()
|
|
1211
|
+
}).default({}),
|
|
1212
|
+
createdAt: z4.string(),
|
|
1213
|
+
createdBy: z4.string().default(""),
|
|
1214
|
+
updatedAt: z4.string(),
|
|
1215
|
+
revision: z4.number().int().nonnegative().default(0)
|
|
1216
|
+
}).strict();
|
|
1217
|
+
var AuthReferenceSchema = z4.object({
|
|
1218
|
+
profileId: z4.string().min(1),
|
|
1219
|
+
/** Where this use of it begins. See the step's own field. */
|
|
1220
|
+
from: z4.enum(["loginPage", "here"]).optional(),
|
|
1221
|
+
/** Absent means "the only account bound to this profile for the selected
|
|
1222
|
+
* environment", which is resolved before a browser opens and refused when
|
|
1223
|
+
* it is ambiguous — a silent choice between two identities is the failure
|
|
1224
|
+
* this field exists to prevent. */
|
|
1225
|
+
accountId: z4.string().optional()
|
|
1226
|
+
}).strict();
|
|
1227
|
+
var ActorSchema = ActorDefinitionSchema;
|
|
1228
|
+
var DeliveredAuthSchema = z4.object({
|
|
1229
|
+
profileId: z4.string().min(1),
|
|
1230
|
+
accountId: z4.string().min(1),
|
|
1231
|
+
name: z4.string(),
|
|
1232
|
+
accountName: z4.string(),
|
|
1233
|
+
steps: z4.array(RecordedStepSchema),
|
|
1234
|
+
navigation: AuthNavigationSchema,
|
|
1235
|
+
success: z4.array(RecordedAssertionSchema),
|
|
1236
|
+
secretRefs: z4.record(z4.string(), z4.string()),
|
|
1237
|
+
signOut: z4.object({ steps: z4.array(RecordedStepSchema), success: z4.array(RecordedAssertionSchema) }).optional()
|
|
1238
|
+
}).strict();
|
|
1239
|
+
function actorPreparations(actors = []) {
|
|
1240
|
+
return actors.flatMap((actor) => actor.startSignedIn ? [{
|
|
1241
|
+
id: `prepare:${actor.id}`,
|
|
1242
|
+
type: "signIn",
|
|
1243
|
+
auth: actor.startSignedIn,
|
|
1244
|
+
name: `Prepare ${actor.name}`,
|
|
1245
|
+
role: "",
|
|
1246
|
+
scope: "",
|
|
1247
|
+
value: "",
|
|
1248
|
+
secret: false,
|
|
1249
|
+
legacySelector: "",
|
|
1250
|
+
proposals: []
|
|
1251
|
+
}] : []);
|
|
1252
|
+
}
|
|
1253
|
+
function validateActors(actors = [], defaultId = "default", used = []) {
|
|
1254
|
+
ActorSchema.array().parse(actors);
|
|
1255
|
+
const ids = /* @__PURE__ */ new Set(["default"]);
|
|
1256
|
+
for (const actor of actors) {
|
|
1257
|
+
if (ids.has(actor.id)) throw new Error(`Duplicate or reserved actor: ${actor.id}`);
|
|
1258
|
+
ids.add(actor.id);
|
|
1259
|
+
}
|
|
1260
|
+
for (const id of [defaultId, ...used]) {
|
|
1261
|
+
if (id === UNSET_ACTOR) throw new Error("A step does not say who it is for \u2014 choose somebody before running");
|
|
1262
|
+
if (id && !ids.has(id)) throw new Error(`This test names somebody it does not have (${id}) \u2014 choose who runs that row before running`);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
var UNSET_ACTOR = "\0unset";
|
|
1266
|
+
function actorIdsUsed(steps) {
|
|
1267
|
+
return steps.flatMap((step) => step.disabled ? [] : step.type === "useActor" ? [step.actorId ?? UNSET_ACTOR] : step.type === "resetActor" && step.actorId ? [step.actorId] : []);
|
|
1268
|
+
}
|
|
1269
|
+
function sameIdentity(a, b) {
|
|
1270
|
+
if (a.profileId !== b.profileId) return false;
|
|
1271
|
+
if (a.accountId && b.accountId) return a.accountId === b.accountId;
|
|
1272
|
+
return true;
|
|
1273
|
+
}
|
|
1274
|
+
var PrerequisiteProblem = class extends Error {
|
|
1275
|
+
constructor(message) {
|
|
1276
|
+
super(message);
|
|
1277
|
+
this.name = "PrerequisiteProblem";
|
|
1278
|
+
}
|
|
1279
|
+
};
|
|
1280
|
+
function signInFirstStep(flow, id = SIGN_IN_FIRST_STEP_ID) {
|
|
1281
|
+
if (!flow.signIn) return null;
|
|
1282
|
+
return {
|
|
1283
|
+
id,
|
|
1284
|
+
type: "signIn",
|
|
1285
|
+
auth: flow.signIn,
|
|
1286
|
+
name: "Sign in first",
|
|
1287
|
+
role: "",
|
|
1288
|
+
scope: "",
|
|
1289
|
+
value: "",
|
|
1290
|
+
secret: false,
|
|
1291
|
+
legacySelector: "",
|
|
1292
|
+
proposals: []
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
function stepsWithoutSignIn(flow) {
|
|
1296
|
+
const first = flow.steps[0];
|
|
1297
|
+
return first && first.type === "signIn" && !first.disabled ? flow.steps.slice(1) : flow.steps;
|
|
1298
|
+
}
|
|
1299
|
+
var idsOf = (flow) => (flow.prerequisites ?? []).map((p) => p.flowId);
|
|
1300
|
+
function prerequisiteProblems(flow, lookup) {
|
|
1301
|
+
const problems = [];
|
|
1302
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1303
|
+
for (const id of idsOf(flow)) {
|
|
1304
|
+
if (id === flow.id) {
|
|
1305
|
+
problems.push(`"${flow.name}" cannot run before itself`);
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
if (seen.has(id)) {
|
|
1309
|
+
problems.push(`"${lookup(id)?.name ?? id}" is listed twice before "${flow.name}"`);
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
seen.add(id);
|
|
1313
|
+
const prerequisite = lookup(id);
|
|
1314
|
+
if (!prerequisite) {
|
|
1315
|
+
problems.push(`a flow "${flow.name}" runs after no longer exists \u2014 remove it from the list`);
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
if (reaches(prerequisite, flow.id, lookup, /* @__PURE__ */ new Set())) {
|
|
1319
|
+
problems.push(`"${prerequisite.name}" already runs after "${flow.name}", so it cannot also run before it`);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return problems;
|
|
1323
|
+
}
|
|
1324
|
+
function reaches(from, target, lookup, visited) {
|
|
1325
|
+
if (visited.has(from.id)) return false;
|
|
1326
|
+
visited.add(from.id);
|
|
1327
|
+
return idsOf(from).some((id) => {
|
|
1328
|
+
if (id === target) return true;
|
|
1329
|
+
const next = lookup(id);
|
|
1330
|
+
return next ? reaches(next, target, lookup, visited) : false;
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
function prerequisitesOf(flow, lookup) {
|
|
1334
|
+
const problems = prerequisiteProblems(flow, lookup);
|
|
1335
|
+
if (problems.length > 0) throw new PrerequisiteProblem(problems.join("; "));
|
|
1336
|
+
return idsOf(flow).map((id) => lookup(id));
|
|
1337
|
+
}
|
|
1338
|
+
function stepsForHost(prerequisite) {
|
|
1339
|
+
return copyFlowIntoCase({ ...prerequisite, steps: stepsWithoutSignIn(prerequisite) }, { featureName: "" }).steps;
|
|
1340
|
+
}
|
|
1341
|
+
function preparationFor(flow, lookup) {
|
|
1342
|
+
return {
|
|
1343
|
+
signIn: signInFirstStep(flow),
|
|
1344
|
+
steps: prerequisitesOf(flow, lookup).flatMap(stepsForHost)
|
|
1345
|
+
};
|
|
1346
|
+
}
|
|
1347
|
+
function expandForRun(flow, lookup) {
|
|
1348
|
+
const { signIn: _signIn, prerequisites: _prerequisites, ...rest } = flow;
|
|
1349
|
+
if (!flow.signIn && idsOf(flow).length === 0) return rest;
|
|
1350
|
+
const { signIn, steps } = preparationFor(flow, lookup);
|
|
1351
|
+
return { ...rest, steps: [...signIn ? [signIn] : [], ...steps, ...flow.steps] };
|
|
1352
|
+
}
|
|
1353
|
+
var HELPER_SCHEMA_VERSION = 1;
|
|
1354
|
+
var SecretRef = z5.string().regex(/^[a-z][a-z0-9_]*$/i);
|
|
1355
|
+
var PatternSchema = z5.string().max(500).refine((source) => {
|
|
1356
|
+
try {
|
|
1357
|
+
new RegExp(source);
|
|
1358
|
+
return true;
|
|
1359
|
+
} catch {
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
}, "that is not a valid pattern");
|
|
1363
|
+
var MailboxHelperSchema = z5.object({
|
|
1364
|
+
kind: z5.literal("mailbox"),
|
|
1365
|
+
/** IMAP, which every provider offers: Gmail, Outlook, Zoho, Fastmail… */
|
|
1366
|
+
host: z5.string().min(1),
|
|
1367
|
+
port: z5.number().int().positive().default(993),
|
|
1368
|
+
secure: z5.boolean().default(true),
|
|
1369
|
+
username: z5.string().min(1),
|
|
1370
|
+
passwordRef: SecretRef,
|
|
1371
|
+
folder: z5.string().min(1).default("INBOX"),
|
|
1372
|
+
/** Which email. All optional; the newest that matches every one given. */
|
|
1373
|
+
match: z5.object({
|
|
1374
|
+
to: z5.string().default(""),
|
|
1375
|
+
from: z5.string().default(""),
|
|
1376
|
+
subject: z5.string().default("")
|
|
1377
|
+
}).strict().default({}),
|
|
1378
|
+
/** What to take out of it. */
|
|
1379
|
+
extract: z5.object({
|
|
1380
|
+
take: z5.enum(["code", "link", "subject", "body"]).default("code"),
|
|
1381
|
+
/** For a code: the pattern, first group if it has one. Default: 4–8 digits.
|
|
1382
|
+
* For a link: only links containing this text. */
|
|
1383
|
+
pattern: z5.string().max(500).default("")
|
|
1384
|
+
}).strict().default({}),
|
|
1385
|
+
/** How long to wait for the email to arrive. */
|
|
1386
|
+
waitSeconds: z5.number().int().min(0).max(300).default(60)
|
|
1387
|
+
}).strict();
|
|
1388
|
+
var TotpHelperSchema = z5.object({
|
|
1389
|
+
kind: z5.literal("totp"),
|
|
1390
|
+
/** The authenticator secret (base32, from the QR code's "manual entry"). */
|
|
1391
|
+
secretRef: SecretRef,
|
|
1392
|
+
digits: z5.union([z5.literal(6), z5.literal(8)]).default(6),
|
|
1393
|
+
period: z5.number().int().min(10).max(120).default(30),
|
|
1394
|
+
algorithm: z5.enum(["SHA1", "SHA256", "SHA512"]).default("SHA1")
|
|
1395
|
+
}).strict();
|
|
1396
|
+
var SmsHelperSchema = z5.object({
|
|
1397
|
+
kind: z5.literal("sms"),
|
|
1398
|
+
provider: z5.literal("twilio").default("twilio"),
|
|
1399
|
+
accountSid: z5.string().regex(/^AC[0-9a-f]{32}$/i, "a Twilio account SID starts with AC followed by 32 characters"),
|
|
1400
|
+
authTokenRef: SecretRef,
|
|
1401
|
+
/** The Twilio number the application texts. */
|
|
1402
|
+
to: z5.string().min(3),
|
|
1403
|
+
match: z5.object({
|
|
1404
|
+
from: z5.string().default(""),
|
|
1405
|
+
contains: z5.string().default("")
|
|
1406
|
+
}).strict().default({}),
|
|
1407
|
+
extract: z5.object({ pattern: z5.string().max(500).default("") }).strict().default({}),
|
|
1408
|
+
waitSeconds: z5.number().int().min(0).max(300).default(60)
|
|
1409
|
+
}).strict();
|
|
1410
|
+
var HttpHelperSchema = z5.object({
|
|
1411
|
+
kind: z5.literal("http"),
|
|
1412
|
+
method: z5.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]).default("POST"),
|
|
1413
|
+
url: z5.string().min(1),
|
|
1414
|
+
/** Plain headers, in the repository. */
|
|
1415
|
+
headers: z5.record(z5.string(), z5.string()).default({}),
|
|
1416
|
+
/** Header name → vault reference, for tokens and keys. */
|
|
1417
|
+
secretHeaders: z5.record(z5.string(), SecretRef).default({}),
|
|
1418
|
+
body: z5.string().max(2e4).default(""),
|
|
1419
|
+
/** Refused unless the response has this status. 0 means any 2xx. */
|
|
1420
|
+
expectStatus: z5.number().int().min(0).max(599).default(0),
|
|
1421
|
+
extract: z5.object({
|
|
1422
|
+
take: z5.enum(["json", "pattern", "status", "nothing"]).default("nothing"),
|
|
1423
|
+
/** For json: a dot path — `data.order.id`, `items.0.code`. */
|
|
1424
|
+
path: z5.string().max(300).default(""),
|
|
1425
|
+
pattern: z5.string().max(500).default("")
|
|
1426
|
+
}).strict().default({})
|
|
1427
|
+
}).strict();
|
|
1428
|
+
var HelperConfigSchema = z5.discriminatedUnion("kind", [
|
|
1429
|
+
MailboxHelperSchema,
|
|
1430
|
+
TotpHelperSchema,
|
|
1431
|
+
SmsHelperSchema,
|
|
1432
|
+
HttpHelperSchema
|
|
1433
|
+
]);
|
|
1434
|
+
var HelperSchema = z5.object({
|
|
1435
|
+
schemaVersion: z5.literal(HELPER_SCHEMA_VERSION),
|
|
1436
|
+
id: z5.string().min(1),
|
|
1437
|
+
slug: z5.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
1438
|
+
name: z5.string().min(1).max(120),
|
|
1439
|
+
description: z5.string().max(1e3).default(""),
|
|
1440
|
+
config: HelperConfigSchema,
|
|
1441
|
+
createdAt: z5.string(),
|
|
1442
|
+
createdBy: z5.string().default(""),
|
|
1443
|
+
updatedAt: z5.string(),
|
|
1444
|
+
revision: z5.number().int().nonnegative().default(0)
|
|
1445
|
+
}).strict().superRefine((helper, ctx) => {
|
|
1446
|
+
const patterns = [
|
|
1447
|
+
helper.config.kind === "mailbox" && helper.config.extract.take === "code" ? helper.config.extract.pattern : "",
|
|
1448
|
+
helper.config.kind === "sms" ? helper.config.extract.pattern : "",
|
|
1449
|
+
helper.config.kind === "http" && helper.config.extract.take === "pattern" ? helper.config.extract.pattern : ""
|
|
1450
|
+
];
|
|
1451
|
+
for (const pattern of patterns) {
|
|
1452
|
+
if (pattern && !PatternSchema.safeParse(pattern).success) {
|
|
1453
|
+
ctx.addIssue({ code: "custom", path: ["config", "extract", "pattern"], message: "that is not a valid pattern" });
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
});
|
|
1457
|
+
var DeliveredHelperSchema = z5.object({
|
|
1458
|
+
id: z5.string().min(1),
|
|
1459
|
+
name: z5.string(),
|
|
1460
|
+
config: HelperConfigSchema
|
|
1461
|
+
}).strict();
|
|
1462
|
+
var LIBRARY_CASE_SCHEMA_VERSION = 1;
|
|
1463
|
+
var TEST_TYPES = ["functional", "negative", "edge", "regression", "smoke", "accessibility", "other"];
|
|
1464
|
+
var PRIORITIES = ["P0", "P1", "P2", "P3"];
|
|
1465
|
+
var CASE_STATUSES = ["draft", "ready", "deprecated"];
|
|
1466
|
+
var AUTOMATION_STATES = ["none", "planned", "automated", "not-automatable"];
|
|
1467
|
+
var LibraryStepSchema = z6.object({
|
|
1468
|
+
id: z6.string().min(1),
|
|
1469
|
+
action: z6.string().min(1),
|
|
1470
|
+
/** What to type or choose, when the action needs it. */
|
|
1471
|
+
data: z6.string().optional(),
|
|
1472
|
+
/** What should happen. Absent for a step that only gets somewhere. */
|
|
1473
|
+
expected: z6.string().optional()
|
|
1474
|
+
}).strict();
|
|
1475
|
+
var AutomationLinkSchema = z6.object({
|
|
1476
|
+
automationId: z6.string().min(1),
|
|
1477
|
+
caseRevision: z6.number().int().nonnegative(),
|
|
1478
|
+
/** The automation's revision when the link was made or last confirmed —
|
|
1479
|
+
* an automation edited since may no longer check what the case says. */
|
|
1480
|
+
automationRevision: z6.number().int().nonnegative().optional(),
|
|
1481
|
+
at: z6.string(),
|
|
1482
|
+
by: z6.string().default("")
|
|
1483
|
+
}).strict();
|
|
1484
|
+
var CaseOriginSchema = z6.discriminatedUnion("kind", [
|
|
1485
|
+
z6.object({ kind: z6.literal("manual") }).strict(),
|
|
1486
|
+
z6.object({
|
|
1487
|
+
kind: z6.literal("import"),
|
|
1488
|
+
/** "csv", "xlsx", "testrail", "zephyr", "xray", "text"… */
|
|
1489
|
+
format: z6.string().min(1),
|
|
1490
|
+
file: z6.string().min(1),
|
|
1491
|
+
/** Row or item in that file, to find it again. */
|
|
1492
|
+
row: z6.string().optional()
|
|
1493
|
+
}).strict(),
|
|
1494
|
+
z6.object({
|
|
1495
|
+
kind: z6.literal("generated"),
|
|
1496
|
+
sourceId: z6.string().min(1),
|
|
1497
|
+
sourceVersion: z6.number().int().positive(),
|
|
1498
|
+
requirementIds: z6.array(z6.string().min(1)).default([])
|
|
1499
|
+
}).strict()
|
|
1500
|
+
]);
|
|
1501
|
+
var TestDataSchema = z6.object({
|
|
1502
|
+
name: z6.string().min(1),
|
|
1503
|
+
value: z6.string().optional(),
|
|
1504
|
+
secretRef: z6.string().regex(/^[a-z][a-z0-9_]*$/i).optional()
|
|
1505
|
+
}).strict();
|
|
1506
|
+
var LibraryCaseSchema = z6.object({
|
|
1507
|
+
schemaVersion: z6.literal(LIBRARY_CASE_SCHEMA_VERSION),
|
|
1508
|
+
id: z6.string().regex(/^tcs_[A-Za-z0-9]+$/),
|
|
1509
|
+
/** "TC-142": what people say out loud. The id is what links point at. */
|
|
1510
|
+
key: z6.string().regex(/^TC-[1-9][0-9]*$/),
|
|
1511
|
+
/** The same features flows are filed under (`f_<slug>`), so one feature
|
|
1512
|
+
* holds both what to test and what tests it. */
|
|
1513
|
+
featureId: z6.string().min(1),
|
|
1514
|
+
slug: z6.string().regex(/^[a-z0-9][a-z0-9-]*$/),
|
|
1515
|
+
title: z6.string().min(1).max(300),
|
|
1516
|
+
description: z6.string().default(""),
|
|
1517
|
+
type: z6.enum(TEST_TYPES).default("functional"),
|
|
1518
|
+
priority: z6.enum(PRIORITIES).default("P2"),
|
|
1519
|
+
status: z6.enum(CASE_STATUSES).default("draft"),
|
|
1520
|
+
preconditions: z6.array(z6.string().min(1)).default([]),
|
|
1521
|
+
steps: z6.array(LibraryStepSchema).default([]),
|
|
1522
|
+
testData: z6.array(TestDataSchema).default([]),
|
|
1523
|
+
tags: z6.array(z6.string().min(1)).default([]),
|
|
1524
|
+
/** Requirement ids (Flowriki storage). The text is not copied here. */
|
|
1525
|
+
covers: z6.array(z6.string().min(1)).default([]),
|
|
1526
|
+
automation: z6.object({
|
|
1527
|
+
state: z6.enum(AUTOMATION_STATES).default("none"),
|
|
1528
|
+
/** Why it cannot be automated, when it cannot. */
|
|
1529
|
+
reason: z6.string().optional(),
|
|
1530
|
+
/** The plan for automating it: flows to reuse, flows to record, checks to
|
|
1531
|
+
* switch on, accounts and helpers it needs, and what cannot be automated.
|
|
1532
|
+
* Written (usually by the assistant) and approved before anything is
|
|
1533
|
+
* recorded. */
|
|
1534
|
+
plan: z6.string().max(2e4).optional(),
|
|
1535
|
+
planApproved: z6.boolean().optional(),
|
|
1536
|
+
links: z6.array(AutomationLinkSchema).default([])
|
|
1537
|
+
}).strict().default({}),
|
|
1538
|
+
origin: CaseOriginSchema.default({ kind: "manual" }),
|
|
1539
|
+
createdAt: z6.string(),
|
|
1540
|
+
createdBy: z6.string().default(""),
|
|
1541
|
+
updatedAt: z6.string(),
|
|
1542
|
+
updatedBy: z6.string().default(""),
|
|
1543
|
+
revision: z6.number().int().nonnegative().default(0)
|
|
1544
|
+
}).strict().superRefine((doc, ctx) => {
|
|
1545
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1546
|
+
doc.steps.forEach((step, i) => {
|
|
1547
|
+
if (seen.has(step.id)) ctx.addIssue({ code: "custom", path: ["steps", i, "id"], message: "Step IDs must be unique" });
|
|
1548
|
+
seen.add(step.id);
|
|
1549
|
+
});
|
|
1550
|
+
const linked = /* @__PURE__ */ new Set();
|
|
1551
|
+
doc.automation.links.forEach((link, i) => {
|
|
1552
|
+
if (linked.has(link.automationId)) {
|
|
1553
|
+
ctx.addIssue({ code: "custom", path: ["automation", "links", i], message: "An automation is linked once" });
|
|
1554
|
+
}
|
|
1555
|
+
linked.add(link.automationId);
|
|
1556
|
+
});
|
|
1557
|
+
if (doc.automation.state === "not-automatable" && !doc.automation.reason?.trim()) {
|
|
1558
|
+
ctx.addIssue({ code: "custom", path: ["automation", "reason"], message: "Say why it cannot be automated" });
|
|
1559
|
+
}
|
|
1560
|
+
});
|
|
1561
|
+
var SOURCE_KINDS = ["upload", "jira", "figma", "confluence", "linear", "github-issue", "notion", "url"];
|
|
1562
|
+
var SourceVersionSchema = z6.object({
|
|
1563
|
+
v: z6.number().int().positive(),
|
|
1564
|
+
hash: z6.string().min(1),
|
|
1565
|
+
extractedText: z6.string(),
|
|
1566
|
+
/** Where an uploaded file WAS kept. Only text is kept since 2026-10-11;
|
|
1567
|
+
* read so older records still parse, and cleared at start-up. */
|
|
1568
|
+
originalKey: z6.string().optional(),
|
|
1569
|
+
mediaType: z6.string().optional(),
|
|
1570
|
+
at: z6.string(),
|
|
1571
|
+
by: z6.string().default("")
|
|
1572
|
+
}).strict();
|
|
1573
|
+
var SourceSchema = z6.object({
|
|
1574
|
+
id: z6.string().regex(/^src_[A-Za-z0-9]+$/),
|
|
1575
|
+
projectId: z6.string().min(1),
|
|
1576
|
+
title: z6.string().min(1).max(300),
|
|
1577
|
+
kind: z6.enum(SOURCE_KINDS),
|
|
1578
|
+
versions: z6.array(SourceVersionSchema).min(1),
|
|
1579
|
+
integrationId: z6.string().optional(),
|
|
1580
|
+
externalId: z6.string().optional(),
|
|
1581
|
+
externalUrl: z6.string().url().optional(),
|
|
1582
|
+
createdAt: z6.string(),
|
|
1583
|
+
createdBy: z6.string().default("")
|
|
1584
|
+
}).strict();
|
|
1585
|
+
var RequirementSchema = z6.object({
|
|
1586
|
+
id: z6.string().regex(/^req_[A-Za-z0-9]+$/),
|
|
1587
|
+
projectId: z6.string().min(1),
|
|
1588
|
+
sourceId: z6.string().min(1),
|
|
1589
|
+
sourceVersion: z6.number().int().positive(),
|
|
1590
|
+
/** Where in the source: a heading path, a Jira key, a Figma node. */
|
|
1591
|
+
anchor: z6.string().min(1),
|
|
1592
|
+
text: z6.string().min(1),
|
|
1593
|
+
featureId: z6.string().optional(),
|
|
1594
|
+
/** As of the source's latest version: new in it, changed by it, unchanged
|
|
1595
|
+
* ("active"), or gone from it. */
|
|
1596
|
+
state: z6.enum(["new", "active", "changed", "removed"]).default("new"),
|
|
1597
|
+
history: z6.array(z6.object({
|
|
1598
|
+
sourceVersion: z6.number().int().positive(),
|
|
1599
|
+
text: z6.string(),
|
|
1600
|
+
state: z6.enum(["new", "active", "changed", "removed"])
|
|
1601
|
+
}).strict()).default([])
|
|
1602
|
+
}).strict();
|
|
1603
|
+
var MANUAL_OUTCOMES = ["untested", "passed", "failed", "blocked", "skipped"];
|
|
1604
|
+
var EvidenceSchema = z7.object({
|
|
1605
|
+
id: z7.string().regex(/^ev_[A-Za-z0-9]+$/),
|
|
1606
|
+
/** A file kept in Flowriki's evidence storage, or a link to one elsewhere. */
|
|
1607
|
+
kind: z7.enum(["file", "link"]),
|
|
1608
|
+
name: z7.string().min(1).max(200),
|
|
1609
|
+
mediaType: z7.string().optional(),
|
|
1610
|
+
size: z7.number().int().nonnegative().optional(),
|
|
1611
|
+
url: z7.string().url().optional(),
|
|
1612
|
+
/** The step it shows, when it belongs to one. */
|
|
1613
|
+
stepId: z7.string().optional(),
|
|
1614
|
+
at: z7.string(),
|
|
1615
|
+
by: z7.string().default("")
|
|
1616
|
+
}).strict();
|
|
1617
|
+
var StepOutcomeSchema = z7.object({
|
|
1618
|
+
stepId: z7.string().min(1),
|
|
1619
|
+
outcome: z7.enum(MANUAL_OUTCOMES),
|
|
1620
|
+
/** What actually happened, when it was not what was expected. */
|
|
1621
|
+
actual: z7.string().max(4e3).optional()
|
|
1622
|
+
}).strict();
|
|
1623
|
+
var ManualResultSchema = z7.object({
|
|
1624
|
+
outcome: z7.enum(MANUAL_OUTCOMES),
|
|
1625
|
+
steps: z7.array(StepOutcomeSchema).default([]),
|
|
1626
|
+
note: z7.string().max(8e3).default(""),
|
|
1627
|
+
evidence: z7.array(EvidenceSchema).default([]),
|
|
1628
|
+
bugIds: z7.array(z7.string().min(1)).default([]),
|
|
1629
|
+
tester: z7.string().default(""),
|
|
1630
|
+
at: z7.string()
|
|
1631
|
+
}).strict();
|
|
1632
|
+
var CycleEntrySchema = z7.object({
|
|
1633
|
+
testCaseId: z7.string().min(1),
|
|
1634
|
+
key: z7.string().min(1),
|
|
1635
|
+
title: z7.string().min(1),
|
|
1636
|
+
featureId: z7.string().min(1),
|
|
1637
|
+
priority: z7.enum(PRIORITIES),
|
|
1638
|
+
/** The case revision snapshot, so "the case changed since" is answerable. */
|
|
1639
|
+
caseRevision: z7.number().int().nonnegative(),
|
|
1640
|
+
preconditions: z7.array(z7.string()).default([]),
|
|
1641
|
+
steps: z7.array(z7.object({ id: z7.string(), action: z7.string(), data: z7.string().optional(), expected: z7.string().optional() }).strict()).default([]),
|
|
1642
|
+
assignee: z7.string().optional(),
|
|
1643
|
+
result: ManualResultSchema.nullable().default(null),
|
|
1644
|
+
/** Earlier results for this case in this cycle, newest last — a retest
|
|
1645
|
+
* keeps what the first attempt found. */
|
|
1646
|
+
history: z7.array(ManualResultSchema).default([])
|
|
1647
|
+
}).strict();
|
|
1648
|
+
var TestCycleSchema = z7.object({
|
|
1649
|
+
id: z7.string().regex(/^cyc_[A-Za-z0-9]+$/),
|
|
1650
|
+
projectId: z7.string().min(1),
|
|
1651
|
+
name: z7.string().min(1).max(200),
|
|
1652
|
+
description: z7.string().max(4e3).default(""),
|
|
1653
|
+
status: z7.enum(["open", "closed"]).default("open"),
|
|
1654
|
+
/** Which branch the cases were read from; null for the published tests. */
|
|
1655
|
+
changeId: z7.string().nullable().default(null),
|
|
1656
|
+
entries: z7.array(CycleEntrySchema).default([]),
|
|
1657
|
+
createdAt: z7.string(),
|
|
1658
|
+
createdBy: z7.string().default(""),
|
|
1659
|
+
updatedAt: z7.string(),
|
|
1660
|
+
closedAt: z7.string().nullable().default(null)
|
|
1661
|
+
}).strict();
|
|
1662
|
+
var BUG_STATUSES = ["open", "in-progress", "fixed", "verified", "closed", "wont-fix"];
|
|
1663
|
+
var SEVERITIES = ["critical", "major", "minor", "trivial"];
|
|
1664
|
+
var BugSchema = z7.object({
|
|
1665
|
+
id: z7.string().regex(/^bug_[A-Za-z0-9]+$/),
|
|
1666
|
+
key: z7.string().regex(/^BUG-[1-9][0-9]*$/),
|
|
1667
|
+
projectId: z7.string().min(1),
|
|
1668
|
+
title: z7.string().min(1).max(300),
|
|
1669
|
+
description: z7.string().max(2e4).default(""),
|
|
1670
|
+
severity: z7.enum(SEVERITIES).default("major"),
|
|
1671
|
+
status: z7.enum(BUG_STATUSES).default("open"),
|
|
1672
|
+
tracker: z7.enum(["flowriki", "jira", "linear", "github"]).default("flowriki"),
|
|
1673
|
+
externalKey: z7.string().optional(),
|
|
1674
|
+
url: z7.string().url().optional(),
|
|
1675
|
+
/** Where it was found: a case, the step in it, the cycle that ran it. */
|
|
1676
|
+
testCaseIds: z7.array(z7.string()).default([]),
|
|
1677
|
+
cycleIds: z7.array(z7.string()).default([]),
|
|
1678
|
+
runIds: z7.array(z7.string()).default([]),
|
|
1679
|
+
automationIds: z7.array(z7.string()).default([]),
|
|
1680
|
+
foundAt: z7.object({ cycleId: z7.string(), testCaseId: z7.string(), stepId: z7.string().optional() }).strict().optional(),
|
|
1681
|
+
createdAt: z7.string(),
|
|
1682
|
+
createdBy: z7.string().default(""),
|
|
1683
|
+
updatedAt: z7.string()
|
|
1684
|
+
}).strict();
|
|
1685
|
+
var STOP = new Set("a an the and or of to in on for with by is are be as at it this that from user users can should must shall will when then given".split(" "));
|
|
1686
|
+
var ConversationSchema = z8.object({
|
|
1687
|
+
id: z8.string().regex(/^conv_[A-Za-z0-9]+$/),
|
|
1688
|
+
projectId: z8.string().min(1),
|
|
1689
|
+
/** Whose: conversations are personal. */
|
|
1690
|
+
userId: z8.string().min(1),
|
|
1691
|
+
title: z8.string().min(1).max(200),
|
|
1692
|
+
mode: z8.enum(["ask", "do"]).default("ask"),
|
|
1693
|
+
/** The assistant's own session, to continue where it left off. */
|
|
1694
|
+
sessionId: z8.string().optional(),
|
|
1695
|
+
/** The conversation as the dashboard draws it. Kept as given: the shape
|
|
1696
|
+
* belongs to the screen, and the service only stores and returns it. */
|
|
1697
|
+
lines: z8.array(z8.unknown()).max(2e3).default([]),
|
|
1698
|
+
createdAt: z8.string(),
|
|
1699
|
+
updatedAt: z8.string()
|
|
1700
|
+
}).strict();
|
|
1701
|
+
var IMPORT_DRAFT_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1702
|
+
var ImportDraftCaseSchema = z9.object({
|
|
1703
|
+
name: z9.string().min(1).max(120),
|
|
1704
|
+
description: z9.string().max(4e3).default(""),
|
|
1705
|
+
businessSteps: z9.array(BusinessStepSchema).max(200),
|
|
1706
|
+
/** Its id or row in the file. */
|
|
1707
|
+
row: z9.string().max(200).default("")
|
|
1708
|
+
}).strict();
|
|
1709
|
+
var ImportDraftSchema = z9.object({
|
|
1710
|
+
id: z9.string().regex(/^imp_[A-Za-z0-9]+$/),
|
|
1711
|
+
projectId: z9.string().min(1),
|
|
1712
|
+
userId: z9.string().min(1),
|
|
1713
|
+
file: z9.string().max(300),
|
|
1714
|
+
/** Every sheet, every cell as text. */
|
|
1715
|
+
sheets: z9.array(z9.object({ name: z9.string().max(200), rows: z9.array(z9.array(z9.string())) }).strict()).max(200),
|
|
1716
|
+
status: z9.enum(["waiting", "converting", "done", "failed"]).default("waiting"),
|
|
1717
|
+
/** What the assistant made of it: features, each with its cases. */
|
|
1718
|
+
result: z9.array(z9.object({ feature: z9.string().min(1).max(120), cases: z9.array(ImportDraftCaseSchema) }).strict()).default([]),
|
|
1719
|
+
/** The assistant's word on anything it could not convert. */
|
|
1720
|
+
note: z9.string().max(4e3).default(""),
|
|
1721
|
+
createdAt: z9.string(),
|
|
1722
|
+
updatedAt: z9.string()
|
|
1723
|
+
}).strict();
|
|
1724
|
+
var CaseUpdateRequestSchema = z9.object({
|
|
1725
|
+
id: z9.string().regex(/^upd_[A-Za-z0-9]+$/),
|
|
1726
|
+
projectId: z9.string().min(1),
|
|
1727
|
+
userId: z9.string().min(1),
|
|
1728
|
+
instruction: z9.string().min(1).max(4e3),
|
|
1729
|
+
cases: z9.array(z9.object({
|
|
1730
|
+
id: z9.string().min(1),
|
|
1731
|
+
name: z9.string(),
|
|
1732
|
+
feature: z9.string(),
|
|
1733
|
+
businessSteps: z9.array(BusinessStepSchema)
|
|
1734
|
+
}).strict()).min(1).max(500),
|
|
1735
|
+
status: z9.enum(["waiting", "working", "done", "failed"]).default("waiting"),
|
|
1736
|
+
proposals: z9.array(z9.object({
|
|
1737
|
+
caseId: z9.string().min(1),
|
|
1738
|
+
businessSteps: z9.array(BusinessStepSchema).max(200),
|
|
1739
|
+
note: z9.string().max(2e3).default("")
|
|
1740
|
+
}).strict()).default([]),
|
|
1741
|
+
note: z9.string().max(4e3).default(""),
|
|
1742
|
+
createdAt: z9.string(),
|
|
1743
|
+
updatedAt: z9.string()
|
|
1744
|
+
}).strict();
|
|
1745
|
+
|
|
1746
|
+
// ../engine-appium/dist/index.js
|
|
1747
|
+
import path5 from "path";
|
|
1748
|
+
|
|
1749
|
+
// ../core/dist/index.js
|
|
1750
|
+
function proposeAssertions(before, after) {
|
|
1751
|
+
const out = [];
|
|
1752
|
+
if (!before || !after) return out;
|
|
1753
|
+
if (before.url !== after.url)
|
|
1754
|
+
out.push({
|
|
1755
|
+
kind: "screen",
|
|
1756
|
+
value: after.heading || after.title,
|
|
1757
|
+
why: "navigated to a new screen"
|
|
1758
|
+
});
|
|
1759
|
+
for (const a of after.alerts.filter((x) => !before.alerts.includes(x)))
|
|
1760
|
+
out.push({ kind: "message", value: a, why: "a message appeared that was not there before" });
|
|
1761
|
+
if (before.url === after.url && before.heading !== after.heading && after.heading)
|
|
1762
|
+
out.push({ kind: "heading", value: after.heading, why: "the heading changed in place" });
|
|
1763
|
+
const newFields = after.fields.filter((f) => !before.fields.includes(f));
|
|
1764
|
+
if (newFields.length)
|
|
1765
|
+
out.push({ kind: "fields_visible", value: newFields, why: "new fields became visible" });
|
|
1766
|
+
if (before.url === after.url) {
|
|
1767
|
+
const seenKeys = new Set(before.texts.map((t) => t.key));
|
|
1768
|
+
const covered = /* @__PURE__ */ new Set([after.heading, ...after.alerts]);
|
|
1769
|
+
const appeared = after.texts.filter((t) => !seenKeys.has(t.key) && !covered.has(t.text));
|
|
1770
|
+
for (const t of appeared.slice(0, 2))
|
|
1771
|
+
out.push({
|
|
1772
|
+
kind: "message",
|
|
1773
|
+
value: t.text,
|
|
1774
|
+
why: "this text appeared after the action",
|
|
1775
|
+
confidence: "low"
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
return out;
|
|
1779
|
+
}
|
|
1780
|
+
function hasObservableChange(before, after) {
|
|
1781
|
+
return proposeAssertions(before, after).some((p) => p.confidence !== "low");
|
|
1782
|
+
}
|
|
1783
|
+
function isLocalHost(host) {
|
|
1784
|
+
const h = host.toLowerCase();
|
|
1785
|
+
return h === "localhost" || h.endsWith(".localhost") || h === "127.0.0.1" || h === "[::1]" || h === "0.0.0.0";
|
|
1786
|
+
}
|
|
1787
|
+
function hasScheme(raw) {
|
|
1788
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) return true;
|
|
1789
|
+
return /^[a-zA-Z][a-zA-Z0-9+.-]*:(?!\d)/.test(raw);
|
|
1790
|
+
}
|
|
1791
|
+
function normalizeStartUrl(input) {
|
|
1792
|
+
const raw = (input ?? "").trim();
|
|
1793
|
+
if (!raw) throw new Error("enter a URL to record against");
|
|
1794
|
+
let candidate;
|
|
1795
|
+
if (hasScheme(raw)) {
|
|
1796
|
+
candidate = raw;
|
|
1797
|
+
} else {
|
|
1798
|
+
const bare = raw.replace(/^\/+/, "");
|
|
1799
|
+
const host = bare.split(/[/?#]/)[0] ?? "";
|
|
1800
|
+
candidate = `${isLocalHost(host.split(":")[0] ?? "") ? "http" : "https"}://${bare}`;
|
|
1801
|
+
}
|
|
1802
|
+
let parsed;
|
|
1803
|
+
try {
|
|
1804
|
+
parsed = new URL(candidate);
|
|
1805
|
+
} catch {
|
|
1806
|
+
throw new Error(`"${raw}" is not a valid URL`);
|
|
1807
|
+
}
|
|
1808
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1809
|
+
throw new Error(`only http:// and https:// URLs can be recorded (got ${parsed.protocol}//)`);
|
|
1810
|
+
}
|
|
1811
|
+
if (!parsed.hostname) throw new Error(`"${raw}" has no host`);
|
|
1812
|
+
return parsed.toString();
|
|
1813
|
+
}
|
|
1814
|
+
function resolveStartUrl(baseUrl, startPath) {
|
|
1815
|
+
const base = normalizeStartUrl(baseUrl);
|
|
1816
|
+
return new URL(startPath || "/", base).toString();
|
|
1817
|
+
}
|
|
1818
|
+
var INPUT_ROLES = /* @__PURE__ */ new Set(["textbox", "combobox", "checkbox", "radio", "spinbutton", "searchbox"]);
|
|
1819
|
+
var NAVIGATIONS = /* @__PURE__ */ new Set(["back", "forward"]);
|
|
1820
|
+
function planForOverride(o, overridden) {
|
|
1821
|
+
if (o && o.value.trim()) {
|
|
1822
|
+
const value = o.value.trim();
|
|
1823
|
+
switch (o.strategy) {
|
|
1824
|
+
// Playwright reads a leading `xpath=` prefix, and also bare paths that
|
|
1825
|
+
// start with `/` or `//`. Normalising here means the QA can paste either.
|
|
1826
|
+
case "xpath":
|
|
1827
|
+
return { method: "locator", arg: value.startsWith("xpath=") ? value : `xpath=${value}`, overridden };
|
|
1828
|
+
// Typed as a bare id, so nobody has to remember the `#`.
|
|
1829
|
+
case "id":
|
|
1830
|
+
return { method: "locator", arg: /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(value.replace(/^#/, "")) ? `#${value.replace(/^#/, "")}` : `[id=${JSON.stringify(value.replace(/^#/, ""))}]`, overridden };
|
|
1831
|
+
case "css":
|
|
1832
|
+
return { method: "locator", arg: value, overridden };
|
|
1833
|
+
case "testId":
|
|
1834
|
+
return { method: "getByTestId", arg: value, overridden };
|
|
1835
|
+
case "text":
|
|
1836
|
+
return { method: "getByText", arg: value, exact: true, overridden };
|
|
1837
|
+
case "label":
|
|
1838
|
+
return { method: "getByLabel", arg: value, exact: true, overridden };
|
|
1839
|
+
case "placeholder":
|
|
1840
|
+
return { method: "getByPlaceholder", arg: value, exact: true, overridden };
|
|
1841
|
+
case "altText":
|
|
1842
|
+
return { method: "getByAltText", arg: value, exact: true, overridden };
|
|
1843
|
+
case "title":
|
|
1844
|
+
return { method: "getByTitle", arg: value, exact: true, overridden };
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
return null;
|
|
1848
|
+
}
|
|
1849
|
+
function semanticPlan(step) {
|
|
1850
|
+
if (NAVIGATIONS.has(step.type)) {
|
|
1851
|
+
return { method: step.type === "back" ? "goBack" : "goForward", arg: "", overridden: false };
|
|
1852
|
+
}
|
|
1853
|
+
if (!step.name.trim()) return { method: "none", arg: "", overridden: false };
|
|
1854
|
+
if (step.nameFrom === "adjacent-label") {
|
|
1855
|
+
return {
|
|
1856
|
+
method: "getByText",
|
|
1857
|
+
arg: step.name,
|
|
1858
|
+
exact: true,
|
|
1859
|
+
overridden: false,
|
|
1860
|
+
viaAdjacentLabel: true
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
if (step.nameFrom === "text") {
|
|
1864
|
+
return {
|
|
1865
|
+
method: "getByText",
|
|
1866
|
+
arg: step.name,
|
|
1867
|
+
exact: step.type !== "hover",
|
|
1868
|
+
overridden: false,
|
|
1869
|
+
visible: true
|
|
1870
|
+
};
|
|
1871
|
+
}
|
|
1872
|
+
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 };
|
|
1873
|
+
}
|
|
1874
|
+
function locatorPlans(step) {
|
|
1875
|
+
const chosen = planForOverride(step.locator, true);
|
|
1876
|
+
if (chosen) return [chosen];
|
|
1877
|
+
const candidates = step.candidates ?? [];
|
|
1878
|
+
const plans = [
|
|
1879
|
+
...candidates.filter((each) => each.strategy === "testId"),
|
|
1880
|
+
...candidates.filter((each) => each.strategy !== "testId")
|
|
1881
|
+
];
|
|
1882
|
+
const first = plans.findIndex((each) => each.strategy !== "testId");
|
|
1883
|
+
const before = first === -1 ? plans : plans.slice(0, first);
|
|
1884
|
+
const after = first === -1 ? [] : plans.slice(first);
|
|
1885
|
+
const semantic = semanticPlan(step);
|
|
1886
|
+
const ordered = [
|
|
1887
|
+
...before.map((each) => planForOverride(each, false)),
|
|
1888
|
+
// Below a test id and above everything else. A name is what somebody
|
|
1889
|
+
// reading the test understands the step to be about, so a step that
|
|
1890
|
+
// resolves by it is the step still meaning what it says.
|
|
1891
|
+
semantic.method === "none" ? null : semantic,
|
|
1892
|
+
...after.map((each) => planForOverride(each, false))
|
|
1893
|
+
].filter((plan) => plan !== null);
|
|
1894
|
+
return ordered.length ? ordered : [semantic];
|
|
1895
|
+
}
|
|
1896
|
+
var quote = (s) => `'${s.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
1897
|
+
function locatorPlan(step) {
|
|
1898
|
+
return locatorPlans(step)[0];
|
|
1899
|
+
}
|
|
1900
|
+
function describeLocators(step) {
|
|
1901
|
+
return locatorPlans(step).map((plan) => describePlan(step, plan));
|
|
1902
|
+
}
|
|
1903
|
+
function describePlan(step, p) {
|
|
1904
|
+
if (p.method === "none") return "(nothing to resolve \u2014 no accessible name)";
|
|
1905
|
+
if (p.method === "goBack") return "page.goBack()";
|
|
1906
|
+
if (p.method === "goForward") return "page.goForward()";
|
|
1907
|
+
let root = "page";
|
|
1908
|
+
for (const frame of step.frame ?? []) root += `.frameLocator(${quote(frameSelector(frame))})`;
|
|
1909
|
+
if (!p.overridden && (step.scope || step.scopeRole)) root = scopeExpression(root, step.scope ?? "", step.scopeRole);
|
|
1910
|
+
let base = p.viaAdjacentLabel ? `${root}.getByText(${adjacentLabelPattern(p.arg)}).locator('xpath=..').getByRole(${quote(step.role || "textbox")})` : p.method === "getByRole" ? `${root}.getByRole(${quote(p.arg)}, { name: ${quote(p.name ?? "")}, exact: true })` : `${root}.${p.method}(${quote(p.arg)}${p.exact !== void 0 ? `, { exact: ${p.exact} }` : ""})`;
|
|
1911
|
+
if (p.inputRole) base = `${root}.getByRole(${quote(p.inputRole)}, { name: ${quote(p.arg)}, exact: true }).or(${base}.and(${root}.locator(${quote(inputSelector(p.inputRole))})))`;
|
|
1912
|
+
if (p.visible) base += ".filter({ visible: true })";
|
|
1913
|
+
if (step.nth !== void 0) base += `.nth(${step.nth})`;
|
|
1914
|
+
if (step.pageId && step.pageId !== "main") base += ` // page ${quote(step.pageId)}`;
|
|
1915
|
+
return base;
|
|
1916
|
+
}
|
|
1917
|
+
function adjacentLabelPattern(name) {
|
|
1918
|
+
const bare = name.trim().replace(/\s*\*+$/, "");
|
|
1919
|
+
return new RegExp(`^${bare.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}(\\s*\\*)?$`);
|
|
1920
|
+
}
|
|
1921
|
+
function frameSelector(name) {
|
|
1922
|
+
const positional = /^nth:(\d+)$/.exec(name);
|
|
1923
|
+
if (positional) return `iframe, frame >> nth=${positional[1]}`;
|
|
1924
|
+
const v = JSON.stringify(name);
|
|
1925
|
+
return `iframe[title=${v}], iframe[name=${v}], iframe[id=${v}], frame[title=${v}], frame[name=${v}], frame[id=${v}]`;
|
|
1926
|
+
}
|
|
1927
|
+
function inputSelector(role) {
|
|
1928
|
+
return role === "textbox" ? 'input, textarea, [contenteditable="true"], [role="textbox"]' : `input, select, [role="${role}"]`;
|
|
1929
|
+
}
|
|
1930
|
+
var SCOPE_ROLES = ["form", "group", "region", "dialog", "alertdialog"];
|
|
1931
|
+
function scopeExpression(root, name, role) {
|
|
1932
|
+
const parts = (role ? [role] : SCOPE_ROLES).map(
|
|
1933
|
+
(r) => name ? `${root}.getByRole(${quote(r)}, { name: ${quote(name)}, exact: ${!role} })` : `${root}.getByRole(${quote(r)})`
|
|
1934
|
+
);
|
|
1935
|
+
return parts.reduce((a, b) => `${a}.or(${b})`);
|
|
1936
|
+
}
|
|
1937
|
+
var CHECKS = [
|
|
1938
|
+
{ id: "visible", label: "is visible", kind: "visible", group: "state" },
|
|
1939
|
+
{ id: "not-visible", label: "is not visible", kind: "visible", not: true, group: "state" },
|
|
1940
|
+
{ id: "attached", label: "exists on the page", kind: "attached", group: "state" },
|
|
1941
|
+
{ id: "not-attached", label: "does not exist", kind: "attached", not: true, group: "state" },
|
|
1942
|
+
{ id: "enabled", label: "is enabled", kind: "enabled", group: "state" },
|
|
1943
|
+
{ id: "disabled", label: "is disabled", kind: "enabled", not: true, group: "state" },
|
|
1944
|
+
{ id: "editable", label: "is editable", kind: "editable", group: "state" },
|
|
1945
|
+
{ id: "readonly", label: "is read-only", kind: "editable", not: true, group: "state" },
|
|
1946
|
+
{ id: "focused", label: "is focused", kind: "focused", group: "state" },
|
|
1947
|
+
{ id: "checked", label: "is checked", kind: "checked", value: "true", group: "state" },
|
|
1948
|
+
{ id: "unchecked", label: "is not checked", kind: "checked", value: "false", group: "state" },
|
|
1949
|
+
{ id: "text", label: "text is exactly", kind: "text", input: true, group: "text" },
|
|
1950
|
+
{ id: "text-contains", label: "text contains", kind: "text", op: "contains", input: true, group: "text" },
|
|
1951
|
+
{ id: "text-matches", label: "text matches pattern", kind: "text", op: "matches", input: true, group: "text" },
|
|
1952
|
+
{ id: "text-not", label: "text is not", kind: "text", not: true, input: true, group: "text" },
|
|
1953
|
+
{ id: "text-not-contains", label: "text does not contain", kind: "text", op: "contains", not: true, input: true, group: "text" },
|
|
1954
|
+
{ id: "value", label: "value is exactly", kind: "value", input: true, group: "value" },
|
|
1955
|
+
{ id: "value-contains", label: "value contains", kind: "value", op: "contains", input: true, group: "value" },
|
|
1956
|
+
{ id: "value-not", label: "value is not", kind: "value", not: true, input: true, group: "value" },
|
|
1957
|
+
{ id: "attr", label: "attribute is", kind: "attribute", input: true, attr: true, group: "attributes" },
|
|
1958
|
+
{ id: "attr-contains", label: "attribute contains", kind: "attribute", op: "contains", input: true, attr: true, group: "attributes" },
|
|
1959
|
+
{ id: "attr-not", label: "attribute is not", kind: "attribute", not: true, input: true, attr: true, group: "attributes" },
|
|
1960
|
+
{ id: "class", label: "has class", kind: "class", input: true, group: "attributes" },
|
|
1961
|
+
{ id: "not-class", label: "does not have class", kind: "class", not: true, input: true, group: "attributes" },
|
|
1962
|
+
{ id: "count", label: "how many \xB7 is exactly", kind: "count", input: true, roleOnly: true, group: "count" },
|
|
1963
|
+
{ id: "count-gte", label: "how many \xB7 is at least", kind: "count", op: "gte", input: true, roleOnly: true, group: "count" },
|
|
1964
|
+
{ id: "count-lte", label: "how many \xB7 is at most", kind: "count", op: "lte", input: true, roleOnly: true, group: "count" },
|
|
1965
|
+
{ id: "url", label: "page URL is", kind: "url", input: true, page: true, group: "page" },
|
|
1966
|
+
{ id: "url-contains", label: "page URL contains", kind: "url", op: "contains", input: true, page: true, group: "page" },
|
|
1967
|
+
{ id: "title", label: "page title is", kind: "title", input: true, page: true, group: "page" },
|
|
1968
|
+
{ id: "title-contains", label: "page title contains", kind: "title", op: "contains", input: true, page: true, group: "page" }
|
|
1969
|
+
];
|
|
1970
|
+
function matchCheck(a) {
|
|
1971
|
+
const op = a.op ?? "equals";
|
|
1972
|
+
const value = Array.isArray(a.value) ? a.value.join(", ") : a.value;
|
|
1973
|
+
return CHECKS.find(
|
|
1974
|
+
(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
|
|
1975
|
+
// value, or the two would be indistinguishable.
|
|
1976
|
+
(c.value === void 0 || c.value === value)
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1979
|
+
var PROPOSED_LABELS = {
|
|
1980
|
+
screen: (v) => `screen is \u201C${v}\u201D`,
|
|
1981
|
+
heading: (v) => `heading is \u201C${v}\u201D`,
|
|
1982
|
+
message: (v) => `message \u201C${v}\u201D is shown`,
|
|
1983
|
+
fields_visible: (v) => `fields visible: ${v}`
|
|
1984
|
+
};
|
|
1985
|
+
function describeAssertion(a, subject) {
|
|
1986
|
+
const value = Array.isArray(a.value) ? a.value.join(", ") : a.value;
|
|
1987
|
+
const proposed = PROPOSED_LABELS[a.kind];
|
|
1988
|
+
if (proposed && !a.target) return proposed(value);
|
|
1989
|
+
const check = matchCheck(a);
|
|
1990
|
+
const who = a.target?.name || subject || "";
|
|
1991
|
+
if (!check) {
|
|
1992
|
+
return `${a.kind}${a.op && a.op !== "equals" ? ` ${a.op}` : ""}${a.not ? " not" : ""}${value ? ` \u201C${value}\u201D` : ""}`;
|
|
1993
|
+
}
|
|
1994
|
+
const attr = check.attr && a.attr ? ` ${a.attr}` : "";
|
|
1995
|
+
const expects = check.input ? ` \u201C${value}\u201D` : "";
|
|
1996
|
+
if (check.page) return `${check.label}${expects}`;
|
|
1997
|
+
if (check.roleOnly) {
|
|
1998
|
+
const role = a.target?.role || "element";
|
|
1999
|
+
return `${role}s \xB7 ${check.label.replace("how many \xB7 ", "")}${expects}`;
|
|
2000
|
+
}
|
|
2001
|
+
const label = check.label.replace("attribute", `attribute${attr}`);
|
|
2002
|
+
return who ? `\u201C${who}\u201D ${label}${expects}` : `${label}${expects}`;
|
|
2003
|
+
}
|
|
2004
|
+
var NAME_FROM_CONTENT = /* @__PURE__ */ new Set([
|
|
2005
|
+
"button",
|
|
2006
|
+
"link",
|
|
2007
|
+
"heading",
|
|
2008
|
+
"cell",
|
|
2009
|
+
"columnheader",
|
|
2010
|
+
"rowheader",
|
|
2011
|
+
"menuitem",
|
|
2012
|
+
"menuitemcheckbox",
|
|
2013
|
+
"menuitemradio",
|
|
2014
|
+
"option",
|
|
2015
|
+
"tab",
|
|
2016
|
+
"treeitem",
|
|
2017
|
+
"gridcell",
|
|
2018
|
+
"switch",
|
|
2019
|
+
"checkbox",
|
|
2020
|
+
"radio"
|
|
2021
|
+
]);
|
|
2022
|
+
function identifiedByOwnWords(target) {
|
|
2023
|
+
if (!target || target.locator || !target.name) return false;
|
|
2024
|
+
return target.nameFrom === "text" || NAME_FROM_CONTENT.has(target.role ?? "");
|
|
2025
|
+
}
|
|
2026
|
+
function textIndependent(candidates) {
|
|
2027
|
+
if (!candidates?.length) return void 0;
|
|
2028
|
+
const order = ["testId", "css", "id", "title", "altText"];
|
|
2029
|
+
for (const strategy of order) {
|
|
2030
|
+
const found = candidates.find((each) => each.strategy === strategy && each.value.trim());
|
|
2031
|
+
if (found) return found;
|
|
2032
|
+
}
|
|
2033
|
+
return void 0;
|
|
2034
|
+
}
|
|
2035
|
+
var STEP_PRESENTATION = {
|
|
2036
|
+
fill: { label: "Fill", tone: "accent" },
|
|
2037
|
+
select: { label: "Select", tone: "accent" },
|
|
2038
|
+
// Click, press and the double/right variants share a tone: all of them
|
|
2039
|
+
// commit the page and can produce an outcome.
|
|
2040
|
+
click: { label: "Click", tone: "pass" },
|
|
2041
|
+
press: { label: "Press", tone: "pass" },
|
|
2042
|
+
dblclick: { label: "Double-click", tone: "pass" },
|
|
2043
|
+
rightclick: { label: "Right-click", tone: "pass" },
|
|
2044
|
+
check: { label: "Check", tone: "warn" },
|
|
2045
|
+
hover: { label: "Hover", tone: "warn" },
|
|
2046
|
+
drag: { label: "Drag", tone: "accent" },
|
|
2047
|
+
upload: { label: "Upload", tone: "accent" },
|
|
2048
|
+
scroll: { label: "Scroll", tone: "skip" },
|
|
2049
|
+
setRange: { label: "Set", tone: "accent" },
|
|
2050
|
+
// A check, not an action. "assert" is the schema's word; "Check" is the
|
|
2051
|
+
// word the product uses everywhere a person reads it.
|
|
2052
|
+
assert: { label: "Check", tone: "accent" },
|
|
2053
|
+
back: { label: "Back", tone: "skip" },
|
|
2054
|
+
forward: { label: "Forward", tone: "skip" },
|
|
2055
|
+
navigate: { label: "Go to", tone: "skip" },
|
|
2056
|
+
closePage: { label: "Close tab", tone: "skip" },
|
|
2057
|
+
// Steps that act on the SESSION rather than on the page. Drawn apart from
|
|
2058
|
+
// the actions on purpose: these are the rows that explain why a later step
|
|
2059
|
+
// saw what it saw, and falling through to "Click" drew a sign-in as a mouse
|
|
2060
|
+
// click on a button nobody could find in the recording.
|
|
2061
|
+
signIn: { label: "Sign in", tone: "accent" },
|
|
2062
|
+
signOut: { label: "Sign out", tone: "accent" },
|
|
2063
|
+
useActor: { label: "Continue as", tone: "accent" },
|
|
2064
|
+
resetActor: { label: "Start over as", tone: "skip" },
|
|
2065
|
+
capture: { label: "Capture", tone: "warn" },
|
|
2066
|
+
unsupported: { label: "Unsupported", tone: "warn" },
|
|
2067
|
+
// On a phone. A tap is a `click`; these have no web equivalent.
|
|
2068
|
+
swipe: { label: "Swipe", tone: "skip" },
|
|
2069
|
+
longPress: { label: "Long press", tone: "pass" },
|
|
2070
|
+
hideKeyboard: { label: "Hide keyboard", tone: "skip" },
|
|
2071
|
+
launchApp: { label: "Open app", tone: "accent" },
|
|
2072
|
+
terminateApp: { label: "Close app", tone: "skip" },
|
|
2073
|
+
resetApp: { label: "Reset app", tone: "skip" },
|
|
2074
|
+
background: { label: "Background", tone: "skip" },
|
|
2075
|
+
rotate: { label: "Rotate", tone: "skip" },
|
|
2076
|
+
permission: { label: "Permission", tone: "warn" }
|
|
2077
|
+
};
|
|
2078
|
+
|
|
2079
|
+
// ../engine-appium/dist/index.js
|
|
2080
|
+
import path6 from "path";
|
|
2081
|
+
import fs5 from "fs";
|
|
2082
|
+
import path7 from "path";
|
|
2083
|
+
import { spawn as spawn3 } from "child_process";
|
|
2084
|
+
var WebDriverError = class extends Error {
|
|
2085
|
+
constructor(message, error, status) {
|
|
2086
|
+
super(message);
|
|
2087
|
+
this.error = error;
|
|
2088
|
+
this.status = status;
|
|
2089
|
+
}
|
|
2090
|
+
error;
|
|
2091
|
+
status;
|
|
2092
|
+
};
|
|
2093
|
+
var ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecc";
|
|
2094
|
+
async function call(endpoint, method, path8, body, timeoutMs = 12e4) {
|
|
2095
|
+
const headers = { "content-type": "application/json; charset=utf-8" };
|
|
2096
|
+
if (endpoint.username) headers["authorization"] = `Basic ${Buffer.from(`${endpoint.username}:${endpoint.password ?? ""}`).toString("base64")}`;
|
|
2097
|
+
const res = await fetch(`${endpoint.url.replace(/\/+$/, "")}${path8}`, {
|
|
2098
|
+
method,
|
|
2099
|
+
headers,
|
|
2100
|
+
...method === "POST" ? { body: JSON.stringify(body ?? {}) } : {},
|
|
2101
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
2102
|
+
});
|
|
2103
|
+
const text = await res.text();
|
|
2104
|
+
let json = {};
|
|
2105
|
+
try {
|
|
2106
|
+
json = text ? JSON.parse(text) : {};
|
|
2107
|
+
} catch {
|
|
2108
|
+
}
|
|
2109
|
+
const value = json.value;
|
|
2110
|
+
if (!res.ok || value && typeof value === "object" && typeof value.error === "string") {
|
|
2111
|
+
const message = value?.message?.split("\n")[0] || text.slice(0, 300) || res.statusText;
|
|
2112
|
+
throw new WebDriverError(message, value?.error ?? "unknown error", res.status);
|
|
2113
|
+
}
|
|
2114
|
+
return json.value;
|
|
2115
|
+
}
|
|
2116
|
+
var WebDriverSession = class _WebDriverSession {
|
|
2117
|
+
constructor(endpoint, id, capabilities) {
|
|
2118
|
+
this.endpoint = endpoint;
|
|
2119
|
+
this.id = id;
|
|
2120
|
+
this.capabilities = capabilities;
|
|
2121
|
+
}
|
|
2122
|
+
endpoint;
|
|
2123
|
+
id;
|
|
2124
|
+
capabilities;
|
|
2125
|
+
static async create(endpoint, capabilities, timeoutMs = 6e5) {
|
|
2126
|
+
const value = await call(
|
|
2127
|
+
endpoint,
|
|
2128
|
+
"POST",
|
|
2129
|
+
"/session",
|
|
2130
|
+
{ capabilities: { alwaysMatch: capabilities, firstMatch: [{}] } },
|
|
2131
|
+
timeoutMs
|
|
2132
|
+
);
|
|
2133
|
+
return new _WebDriverSession(endpoint, value.sessionId, value.capabilities ?? {});
|
|
2134
|
+
}
|
|
2135
|
+
/** Any other command — Appium's own extensions live under /appium/. */
|
|
2136
|
+
raw(method, path8, body, timeoutMs) {
|
|
2137
|
+
return this.req(method, path8, body, timeoutMs);
|
|
2138
|
+
}
|
|
2139
|
+
req(method, path8, body, timeoutMs) {
|
|
2140
|
+
return call(this.endpoint, method, `/session/${this.id}${path8}`, body, timeoutMs);
|
|
2141
|
+
}
|
|
2142
|
+
source() {
|
|
2143
|
+
return this.req("GET", "/source");
|
|
2144
|
+
}
|
|
2145
|
+
screenshot() {
|
|
2146
|
+
return this.req("GET", "/screenshot");
|
|
2147
|
+
}
|
|
2148
|
+
windowRect() {
|
|
2149
|
+
return this.req("GET", "/window/rect");
|
|
2150
|
+
}
|
|
2151
|
+
async find(using, value) {
|
|
2152
|
+
const found = await this.req("POST", "/elements", { using, value });
|
|
2153
|
+
return found.map((e) => e[ELEMENT_KEY] ?? Object.values(e)[0]).filter(Boolean);
|
|
2154
|
+
}
|
|
2155
|
+
click(element) {
|
|
2156
|
+
return this.req("POST", `/element/${element}/click`);
|
|
2157
|
+
}
|
|
2158
|
+
clear(element) {
|
|
2159
|
+
return this.req("POST", `/element/${element}/clear`);
|
|
2160
|
+
}
|
|
2161
|
+
type(element, text) {
|
|
2162
|
+
return this.req("POST", `/element/${element}/value`, { text, value: [...text] });
|
|
2163
|
+
}
|
|
2164
|
+
text(element) {
|
|
2165
|
+
return this.req("GET", `/element/${element}/text`);
|
|
2166
|
+
}
|
|
2167
|
+
execute(script, args = {}) {
|
|
2168
|
+
return this.req("POST", "/execute/sync", { script, args: [args] });
|
|
2169
|
+
}
|
|
2170
|
+
back() {
|
|
2171
|
+
return this.req("POST", "/back");
|
|
2172
|
+
}
|
|
2173
|
+
url(url) {
|
|
2174
|
+
return this.req("POST", "/url", { url });
|
|
2175
|
+
}
|
|
2176
|
+
setOrientation(orientation) {
|
|
2177
|
+
return this.req("POST", "/orientation", { orientation });
|
|
2178
|
+
}
|
|
2179
|
+
/** One finger, as a sequence of W3C pointer actions. */
|
|
2180
|
+
touch(actions) {
|
|
2181
|
+
return this.req("POST", "/actions", {
|
|
2182
|
+
actions: [{ type: "pointer", id: "finger1", parameters: { pointerType: "touch" }, actions: actions.map((a) => ({ origin: "viewport", ...a, ...a.type === "pointerDown" || a.type === "pointerUp" ? { button: 0 } : {} })) }]
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
keys(text) {
|
|
2186
|
+
return this.req("POST", "/actions", { actions: [{ type: "key", id: "keyboard", actions: [...text].flatMap((ch) => [{ type: "keyDown", value: ch }, { type: "keyUp", value: ch }]) }] });
|
|
2187
|
+
}
|
|
2188
|
+
async close() {
|
|
2189
|
+
await this.req("DELETE", "", void 0, 3e4).catch(() => {
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
};
|
|
2193
|
+
async function serverReady(endpoint) {
|
|
2194
|
+
try {
|
|
2195
|
+
await call(endpoint, "GET", "/status", void 0, 3e3);
|
|
2196
|
+
return true;
|
|
2197
|
+
} catch {
|
|
2198
|
+
return false;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
var AGENT_HOME = process.env["FLOWRIKI_HOME"] || path.join(os.homedir(), ".flowriki");
|
|
2202
|
+
var APPIUM_DIR = path.join(AGENT_HOME, "appium");
|
|
2203
|
+
var exists = (p) => Boolean(p && fs.existsSync(p));
|
|
2204
|
+
var exe = (name) => process.platform === "win32" ? `${name}.exe` : name;
|
|
2205
|
+
function androidHome() {
|
|
2206
|
+
const candidates = [
|
|
2207
|
+
process.env["ANDROID_HOME"],
|
|
2208
|
+
process.env["ANDROID_SDK_ROOT"],
|
|
2209
|
+
process.platform === "darwin" ? path.join(os.homedir(), "Library/Android/sdk") : null,
|
|
2210
|
+
process.platform === "linux" ? path.join(os.homedir(), "Android/Sdk") : null,
|
|
2211
|
+
process.platform === "win32" && process.env["LOCALAPPDATA"] ? path.join(process.env["LOCALAPPDATA"], "Android", "Sdk") : null
|
|
2212
|
+
];
|
|
2213
|
+
return candidates.find((c) => exists(c) && exists(path.join(c, "platform-tools"))) ?? null;
|
|
2214
|
+
}
|
|
2215
|
+
function adbPath() {
|
|
2216
|
+
const home = androidHome();
|
|
2217
|
+
const inSdk = home ? path.join(home, "platform-tools", exe("adb")) : null;
|
|
2218
|
+
return exists(inSdk) ? inSdk : onPath("adb");
|
|
2219
|
+
}
|
|
2220
|
+
function emulatorPath() {
|
|
2221
|
+
const home = androidHome();
|
|
2222
|
+
const inSdk = home ? path.join(home, "emulator", exe("emulator")) : null;
|
|
2223
|
+
return exists(inSdk) ? inSdk : onPath("emulator");
|
|
2224
|
+
}
|
|
2225
|
+
function onPath(name) {
|
|
2226
|
+
for (const dir of (process.env["PATH"] ?? "").split(path.delimiter)) {
|
|
2227
|
+
for (const candidate of process.platform === "win32" ? [`${name}.exe`, `${name}.cmd`, name] : [name]) {
|
|
2228
|
+
const full = path.join(dir, candidate);
|
|
2229
|
+
if (exists(full)) return full;
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
return null;
|
|
2233
|
+
}
|
|
2234
|
+
function appiumPath() {
|
|
2235
|
+
if (exists(process.env["FLOWRIKI_APPIUM"])) return { bin: process.env["FLOWRIKI_APPIUM"], home: process.env["APPIUM_HOME"] ?? null };
|
|
2236
|
+
const own = path.join(APPIUM_DIR, "node_modules", ".bin", process.platform === "win32" ? "appium.cmd" : "appium");
|
|
2237
|
+
if (exists(own)) return { bin: own, home: path.join(APPIUM_DIR, "home") };
|
|
2238
|
+
const global = onPath("appium");
|
|
2239
|
+
return global ? { bin: global, home: process.env["APPIUM_HOME"] ?? null } : null;
|
|
2240
|
+
}
|
|
2241
|
+
function javaMajor(home) {
|
|
2242
|
+
try {
|
|
2243
|
+
const release = fs.readFileSync(path.join(home, "release"), "utf8");
|
|
2244
|
+
const v = /JAVA_VERSION="(\d+)(?:\.(\d+))?/.exec(release);
|
|
2245
|
+
if (!v) return 0;
|
|
2246
|
+
return v[1] === "1" ? Number(v[2] ?? 0) : Number(v[1]);
|
|
2247
|
+
} catch {
|
|
2248
|
+
return 0;
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
async function javaHome() {
|
|
2252
|
+
const candidates = [];
|
|
2253
|
+
if (process.env["JAVA_HOME"]) candidates.push(process.env["JAVA_HOME"]);
|
|
2254
|
+
const onPathJava = onPath(exe("java"));
|
|
2255
|
+
if (onPathJava) {
|
|
2256
|
+
try {
|
|
2257
|
+
candidates.push(path.dirname(path.dirname(fs.realpathSync(onPathJava))));
|
|
2258
|
+
} catch {
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
if (process.platform === "darwin") candidates.push("/Applications/Android Studio.app/Contents/jbr/Contents/Home");
|
|
2262
|
+
if (process.platform === "win32" && process.env["ProgramFiles"]) candidates.push(path.join(process.env["ProgramFiles"], "Android", "Android Studio", "jbr"));
|
|
2263
|
+
if (process.platform === "linux") candidates.push(path.join(os.homedir(), "android-studio", "jbr"), "/opt/android-studio/jbr");
|
|
2264
|
+
if (process.platform === "darwin" && exists("/usr/libexec/java_home")) {
|
|
2265
|
+
const out = (await run("/usr/libexec/java_home", [], 5e3).catch(() => "")).trim();
|
|
2266
|
+
if (out) candidates.push(out);
|
|
2267
|
+
}
|
|
2268
|
+
const homes = candidates.filter((c) => exists(c));
|
|
2269
|
+
return homes.find((h) => javaMajor(h) >= 11) ?? homes[0] ?? null;
|
|
2270
|
+
}
|
|
2271
|
+
async function toolEnv() {
|
|
2272
|
+
const env = { ...process.env };
|
|
2273
|
+
const sdk = androidHome();
|
|
2274
|
+
if (sdk) {
|
|
2275
|
+
env["ANDROID_HOME"] = sdk;
|
|
2276
|
+
env["ANDROID_SDK_ROOT"] = sdk;
|
|
2277
|
+
}
|
|
2278
|
+
const java = await javaHome();
|
|
2279
|
+
if (java) env["JAVA_HOME"] = java;
|
|
2280
|
+
const appium = appiumPath();
|
|
2281
|
+
if (appium?.home) env["APPIUM_HOME"] = appium.home;
|
|
2282
|
+
return env;
|
|
2283
|
+
}
|
|
2284
|
+
function run(file, args, timeoutMs = 2e4, env) {
|
|
2285
|
+
return new Promise((resolve, reject) => {
|
|
2286
|
+
execFile(file, args, { timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024, ...env ? { env } : {} }, (error, stdout, stderr) => {
|
|
2287
|
+
if (error) reject(Object.assign(error, { stderr: String(stderr) }));
|
|
2288
|
+
else resolve(String(stdout));
|
|
2289
|
+
});
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
var running = null;
|
|
2293
|
+
var starting = null;
|
|
2294
|
+
var AppiumMissing = class extends Error {
|
|
2295
|
+
constructor() {
|
|
2296
|
+
super("Appium is not installed on this computer. Run `flowriki-agent mobile setup` once to install it.");
|
|
2297
|
+
}
|
|
2298
|
+
};
|
|
2299
|
+
var freePort = () => new Promise((resolve, reject) => {
|
|
2300
|
+
const server = net.createServer();
|
|
2301
|
+
server.unref();
|
|
2302
|
+
server.on("error", reject);
|
|
2303
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2304
|
+
const { port } = server.address();
|
|
2305
|
+
server.close(() => resolve(port));
|
|
2306
|
+
});
|
|
2307
|
+
});
|
|
2308
|
+
async function appiumServer() {
|
|
2309
|
+
if (running && running.child.exitCode === null) return running.endpoint;
|
|
2310
|
+
if (starting) return starting;
|
|
2311
|
+
starting = (async () => {
|
|
2312
|
+
const appium = appiumPath();
|
|
2313
|
+
if (!appium) throw new AppiumMissing();
|
|
2314
|
+
const port = await freePort();
|
|
2315
|
+
const env = await toolEnv();
|
|
2316
|
+
const child = spawn(appium.bin, ["--address", "127.0.0.1", "--port", String(port), "--log-level", "warn", "--log-no-colors"], {
|
|
2317
|
+
env,
|
|
2318
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2319
|
+
shell: process.platform === "win32"
|
|
2320
|
+
});
|
|
2321
|
+
const log = [];
|
|
2322
|
+
const keep = (chunk) => {
|
|
2323
|
+
log.push(...String(chunk).split("\n").filter(Boolean));
|
|
2324
|
+
if (log.length > 400) log.splice(0, log.length - 400);
|
|
2325
|
+
};
|
|
2326
|
+
child.stdout?.on("data", keep);
|
|
2327
|
+
child.stderr?.on("data", keep);
|
|
2328
|
+
child.unref();
|
|
2329
|
+
child.stdout?.unref?.();
|
|
2330
|
+
child.stderr?.unref?.();
|
|
2331
|
+
const endpoint = { url: `http://127.0.0.1:${port}` };
|
|
2332
|
+
const deadline = Date.now() + 6e4;
|
|
2333
|
+
while (Date.now() < deadline) {
|
|
2334
|
+
if (child.exitCode !== null) throw new Error(`Appium stopped while starting: ${log.slice(-5).join(" ") || `exit ${child.exitCode}`}`);
|
|
2335
|
+
if (await serverReady(endpoint)) {
|
|
2336
|
+
running = { endpoint, child, log };
|
|
2337
|
+
return endpoint;
|
|
2338
|
+
}
|
|
2339
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
2340
|
+
}
|
|
2341
|
+
child.kill();
|
|
2342
|
+
throw new Error("Appium did not start within a minute.");
|
|
2343
|
+
})().finally(() => {
|
|
2344
|
+
starting = null;
|
|
2345
|
+
});
|
|
2346
|
+
return starting;
|
|
2347
|
+
}
|
|
2348
|
+
var appiumLog = () => running?.log.slice(-20) ?? [];
|
|
2349
|
+
function stopAppium() {
|
|
2350
|
+
running?.child.kill();
|
|
2351
|
+
running = null;
|
|
2352
|
+
}
|
|
2353
|
+
process.once("exit", stopAppium);
|
|
2354
|
+
async function androidDevices() {
|
|
2355
|
+
const adb = adbPath();
|
|
2356
|
+
const out = [];
|
|
2357
|
+
const runningAvds = /* @__PURE__ */ new Set();
|
|
2358
|
+
if (adb) {
|
|
2359
|
+
const listed = await run(adb, ["devices", "-l"], 1e4).catch(() => "");
|
|
2360
|
+
for (const line of listed.split("\n").slice(1)) {
|
|
2361
|
+
const m = /^(\S+)\s+(device|unauthorized|offline)\b(.*)$/.exec(line.trim());
|
|
2362
|
+
if (!m) continue;
|
|
2363
|
+
const [, serial, status, rest] = m;
|
|
2364
|
+
const emulator2 = serial.startsWith("emulator-");
|
|
2365
|
+
let name = /model:(\S+)/.exec(rest)?.[1]?.replace(/_/g, " ") ?? serial;
|
|
2366
|
+
let osVersion = "";
|
|
2367
|
+
if (status === "device") {
|
|
2368
|
+
osVersion = (await run(adb, ["-s", serial, "shell", "getprop", "ro.build.version.release"], 8e3).catch(() => "")).trim();
|
|
2369
|
+
if (emulator2) {
|
|
2370
|
+
const avd = (await run(adb, ["-s", serial, "emu", "avd", "name"], 8e3).catch(() => "")).split("\n")[0]?.trim();
|
|
2371
|
+
if (avd && avd !== "OK") {
|
|
2372
|
+
runningAvds.add(avd);
|
|
2373
|
+
name = avd.replace(/_/g, " ");
|
|
2374
|
+
}
|
|
2375
|
+
} else {
|
|
2376
|
+
const model = (await run(adb, ["-s", serial, "shell", "getprop", "ro.product.model"], 8e3).catch(() => "")).trim();
|
|
2377
|
+
if (model) name = model;
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
out.push({
|
|
2381
|
+
id: serial,
|
|
2382
|
+
name,
|
|
2383
|
+
platform: "android",
|
|
2384
|
+
kind: emulator2 ? "emulator" : "device",
|
|
2385
|
+
state: status === "device" ? "ready" : status === "unauthorized" ? "unauthorized" : "offline",
|
|
2386
|
+
osVersion
|
|
2387
|
+
});
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
const emulator = emulatorPath();
|
|
2391
|
+
if (emulator) {
|
|
2392
|
+
const avds = await run(emulator, ["-list-avds"], 1e4, await toolEnv()).catch(() => "");
|
|
2393
|
+
for (const avd of avds.split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("INFO"))) {
|
|
2394
|
+
if (runningAvds.has(avd)) continue;
|
|
2395
|
+
out.push({ id: `avd:${avd}`, name: avd.replace(/_/g, " "), platform: "android", kind: "emulator", state: "off", osVersion: "" });
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
return out;
|
|
2399
|
+
}
|
|
2400
|
+
async function iosDevices() {
|
|
2401
|
+
if (process.platform !== "darwin") return [];
|
|
2402
|
+
const out = [];
|
|
2403
|
+
const sims = await run("xcrun", ["simctl", "list", "devices", "available", "--json"], 15e3).catch(() => "");
|
|
2404
|
+
try {
|
|
2405
|
+
const parsed = JSON.parse(sims);
|
|
2406
|
+
for (const [runtime, list] of Object.entries(parsed.devices)) {
|
|
2407
|
+
const m = /SimRuntime\.iOS-(\d+)-(\d+)/.exec(runtime);
|
|
2408
|
+
if (!m) continue;
|
|
2409
|
+
for (const d of list) {
|
|
2410
|
+
if (d.isAvailable === false) continue;
|
|
2411
|
+
out.push({ id: d.udid, name: d.name, platform: "ios", kind: "simulator", state: d.state === "Booted" ? "ready" : "off", osVersion: `${m[1]}.${m[2]}` });
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
} catch {
|
|
2415
|
+
}
|
|
2416
|
+
const file = path2.join(os2.tmpdir(), `flowriki-devicectl-${process.pid}.json`);
|
|
2417
|
+
await run("xcrun", ["devicectl", "list", "devices", "--json-output", file], 2e4).catch(() => "");
|
|
2418
|
+
try {
|
|
2419
|
+
const parsed = JSON.parse(fs2.readFileSync(file, "utf8"));
|
|
2420
|
+
for (const d of parsed.result?.devices ?? []) {
|
|
2421
|
+
const udid = d.hardwareProperties?.udid;
|
|
2422
|
+
if (!udid || d.hardwareProperties?.platform !== "iOS" || d.hardwareProperties?.reality !== "physical") continue;
|
|
2423
|
+
const paired = d.connectionProperties?.pairingState === "paired";
|
|
2424
|
+
const connected = d.connectionProperties?.tunnelState !== "unavailable";
|
|
2425
|
+
out.push({
|
|
2426
|
+
id: udid,
|
|
2427
|
+
name: d.deviceProperties?.name ?? "iPhone",
|
|
2428
|
+
platform: "ios",
|
|
2429
|
+
kind: "device",
|
|
2430
|
+
state: !paired ? "unauthorized" : connected ? "ready" : "offline",
|
|
2431
|
+
osVersion: d.deviceProperties?.osVersionNumber ?? ""
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
} catch {
|
|
2435
|
+
} finally {
|
|
2436
|
+
fs2.rmSync(file, { force: true });
|
|
2437
|
+
}
|
|
2438
|
+
return out.sort((a, b) => (a.state === b.state ? 0 : a.state === "ready" ? -1 : 1) || b.osVersion.localeCompare(a.osVersion, void 0, { numeric: true }));
|
|
2439
|
+
}
|
|
2440
|
+
async function listDevices() {
|
|
2441
|
+
const [android, ios] = await Promise.all([androidDevices(), iosDevices()]);
|
|
2442
|
+
return [...android, ...ios];
|
|
2443
|
+
}
|
|
2444
|
+
async function bootDevice(device, timeoutMs = 18e4) {
|
|
2445
|
+
if (device.state === "ready") return device.id;
|
|
2446
|
+
if (device.state !== "off") throw new Error(`${device.name} is ${device.state === "unauthorized" ? "not trusted yet \u2014 unlock it and allow this computer" : "not reachable"}.`);
|
|
2447
|
+
const deadline = Date.now() + timeoutMs;
|
|
2448
|
+
if (device.platform === "ios") {
|
|
2449
|
+
await run("xcrun", ["simctl", "boot", device.id], 12e4).catch((e) => {
|
|
2450
|
+
if (!/current state: Booted/.test(e.stderr ?? "")) throw new Error(`Could not start ${device.name}: ${(e.stderr ?? "").trim().split("\n")[0]}`);
|
|
2451
|
+
});
|
|
2452
|
+
await run("xcrun", ["simctl", "bootstatus", device.id, "-b"], timeoutMs).catch(() => {
|
|
2453
|
+
});
|
|
2454
|
+
spawn2("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", device.id], { detached: true, stdio: "ignore" }).unref();
|
|
2455
|
+
return device.id;
|
|
2456
|
+
}
|
|
2457
|
+
const emulator = emulatorPath();
|
|
2458
|
+
const adb = adbPath();
|
|
2459
|
+
if (!emulator || !adb) throw new Error("The Android emulator is not installed \u2014 install Android Studio, or plug in a phone.");
|
|
2460
|
+
const avd = device.id.replace(/^avd:/, "");
|
|
2461
|
+
const before = new Set((await listDevices()).filter((d) => d.platform === "android" && d.state === "ready").map((d) => d.id));
|
|
2462
|
+
spawn2(emulator, ["-avd", avd, "-no-snapshot-save"], { env: await toolEnv(), detached: true, stdio: "ignore" }).unref();
|
|
2463
|
+
while (Date.now() < deadline) {
|
|
2464
|
+
await new Promise((r) => setTimeout(r, 3e3));
|
|
2465
|
+
const listed = await run(adb, ["devices"], 1e4).catch(() => "");
|
|
2466
|
+
for (const serial of listed.split("\n").map((l) => /^(emulator-\d+)\s+device/.exec(l.trim())?.[1]).filter((s) => Boolean(s))) {
|
|
2467
|
+
if (before.has(serial)) continue;
|
|
2468
|
+
const booted = (await run(adb, ["-s", serial, "shell", "getprop", "sys.boot_completed"], 8e3).catch(() => "")).trim();
|
|
2469
|
+
if (booted === "1") return serial;
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
throw new Error(`${device.name} did not finish starting within ${Math.round(timeoutMs / 1e3)} seconds.`);
|
|
2473
|
+
}
|
|
2474
|
+
var CLOUD_HUBS = {
|
|
2475
|
+
browserstack: () => "https://hub-cloud.browserstack.com/wd/hub",
|
|
2476
|
+
saucelabs: (a) => `https://ondemand.${a.region || "us-west-1"}.saucelabs.com/wd/hub`,
|
|
2477
|
+
lambdatest: () => "https://mobile-hub.lambdatest.com/wd/hub"
|
|
2478
|
+
};
|
|
2479
|
+
function appOf(target) {
|
|
2480
|
+
if (target.platform === "android") {
|
|
2481
|
+
const app2 = target.mobile.android;
|
|
2482
|
+
if (!app2) throw new Error("This project has no Android app set up \u2014 add it in Settings \u2192 Mobile.");
|
|
2483
|
+
return { id: app2.appPackage, ...app2.appActivity ? { activity: app2.appActivity } : {}, ...app2.build ? { build: app2.build } : {} };
|
|
2484
|
+
}
|
|
2485
|
+
const app = target.mobile.ios;
|
|
2486
|
+
if (!app) throw new Error("This project has no iOS app set up \u2014 add it in Settings \u2192 Mobile.");
|
|
2487
|
+
return { id: app.bundleId, ...app.build ? { build: app.build } : {} };
|
|
2488
|
+
}
|
|
2489
|
+
async function localBuild(build) {
|
|
2490
|
+
if (!/^https?:\/\//.test(build)) {
|
|
2491
|
+
const file2 = build.startsWith("~") ? path3.join(os3.homedir(), build.slice(1)) : build;
|
|
2492
|
+
if (!fs3.existsSync(file2)) throw new Error(`The app build ${file2} is not on this computer.`);
|
|
2493
|
+
return file2;
|
|
2494
|
+
}
|
|
2495
|
+
const res = await fetch(build, { signal: AbortSignal.timeout(6e5) });
|
|
2496
|
+
if (!res.ok) throw new Error(`Could not download the app build (${res.status} ${res.statusText}).`);
|
|
2497
|
+
const name = decodeURIComponent(new URL(build).pathname.split("/").pop() || "app") || "app";
|
|
2498
|
+
const file = path3.join(fs3.mkdtempSync(path3.join(os3.tmpdir(), "flowriki-build-")), name);
|
|
2499
|
+
fs3.writeFileSync(file, Buffer.from(await res.arrayBuffer()));
|
|
2500
|
+
return file;
|
|
2501
|
+
}
|
|
2502
|
+
async function localCapabilities(target, udid, opts = {}) {
|
|
2503
|
+
const app = appOf(target);
|
|
2504
|
+
const fresh = target.fresh || target.mobile.reset === "fresh";
|
|
2505
|
+
const common = {
|
|
2506
|
+
"appium:udid": udid,
|
|
2507
|
+
"appium:newCommandTimeout": 600,
|
|
2508
|
+
"appium:noReset": !fresh,
|
|
2509
|
+
"appium:fullReset": false,
|
|
2510
|
+
...opts.buildFile ? { "appium:app": opts.buildFile } : {}
|
|
2511
|
+
};
|
|
2512
|
+
if (target.platform === "android") {
|
|
2513
|
+
return {
|
|
2514
|
+
platformName: "Android",
|
|
2515
|
+
"appium:automationName": "UiAutomator2",
|
|
2516
|
+
...common,
|
|
2517
|
+
"appium:appPackage": app.id,
|
|
2518
|
+
...app.activity ? { "appium:appActivity": app.activity } : {},
|
|
2519
|
+
"appium:appWaitActivity": "*",
|
|
2520
|
+
"appium:autoGrantPermissions": false,
|
|
2521
|
+
"appium:disableWindowAnimation": true,
|
|
2522
|
+
"appium:uiautomator2ServerLaunchTimeout": 12e4,
|
|
2523
|
+
"appium:adbExecTimeout": 6e4,
|
|
2524
|
+
// Compose and some RecyclerViews hide nodes from the default dump.
|
|
2525
|
+
"appium:settings[allowInvisibleElements]": false,
|
|
2526
|
+
"appium:settings[ignoreUnimportantViews]": false
|
|
2527
|
+
};
|
|
2528
|
+
}
|
|
2529
|
+
return {
|
|
2530
|
+
platformName: "iOS",
|
|
2531
|
+
"appium:automationName": "XCUITest",
|
|
2532
|
+
...common,
|
|
2533
|
+
"appium:bundleId": app.id,
|
|
2534
|
+
"appium:wdaLaunchTimeout": 24e4,
|
|
2535
|
+
"appium:wdaConnectionTimeout": 24e4,
|
|
2536
|
+
...target.mobile.ios?.teamId ? { "appium:xcodeOrgId": target.mobile.ios.teamId, "appium:xcodeSigningId": "Apple Development" } : {}
|
|
2537
|
+
};
|
|
2538
|
+
}
|
|
2539
|
+
function cloudCapabilities(target, account, key, appRef) {
|
|
2540
|
+
if (target.device.kind !== "cloud") throw new Error("not a cloud device");
|
|
2541
|
+
const app = appOf(target);
|
|
2542
|
+
const device = target.device;
|
|
2543
|
+
const android = target.platform === "android";
|
|
2544
|
+
const base = {
|
|
2545
|
+
platformName: android ? "Android" : "iOS",
|
|
2546
|
+
"appium:automationName": android ? "UiAutomator2" : "XCUITest",
|
|
2547
|
+
"appium:deviceName": device.device,
|
|
2548
|
+
...device.osVersion ? { "appium:platformVersion": device.osVersion } : {},
|
|
2549
|
+
"appium:noReset": !(target.fresh || target.mobile.reset === "fresh"),
|
|
2550
|
+
...appRef ? { "appium:app": appRef } : android ? { "appium:appPackage": app.id, ...app.activity ? { "appium:appActivity": app.activity } : {} } : { "appium:bundleId": app.id }
|
|
2551
|
+
};
|
|
2552
|
+
const build = `Flowriki ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
|
|
2553
|
+
const endpoint = { url: CLOUD_HUBS[account.provider](account), username: account.username, password: key };
|
|
2554
|
+
if (account.provider === "browserstack") {
|
|
2555
|
+
return { endpoint, capabilities: { ...base, "bstack:options": { userName: account.username, accessKey: key, projectName: "Flowriki", buildName: build, deviceName: device.device, ...device.osVersion ? { osVersion: device.osVersion } : {}, appiumVersion: "2.12.1" } } };
|
|
2556
|
+
}
|
|
2557
|
+
if (account.provider === "saucelabs") {
|
|
2558
|
+
return { endpoint, capabilities: { ...base, "sauce:options": { username: account.username, accessKey: key, build, appiumVersion: "latest" } } };
|
|
2559
|
+
}
|
|
2560
|
+
return { endpoint, capabilities: { ...base, "lt:options": { username: account.username, accessKey: key, build, project: "Flowriki", isRealMobile: true, platformName: base["platformName"], deviceName: device.device, ...device.osVersion ? { platformVersion: device.osVersion } : {}, w3c: true } } };
|
|
2561
|
+
}
|
|
2562
|
+
async function uploadToCloud(account, key, file) {
|
|
2563
|
+
const auth = `Basic ${Buffer.from(`${account.username}:${key}`).toString("base64")}`;
|
|
2564
|
+
const form = new FormData();
|
|
2565
|
+
form.append("file", new Blob([fs3.readFileSync(file)]), path3.basename(file));
|
|
2566
|
+
const post = async (url, body, headers = {}) => {
|
|
2567
|
+
const res = await fetch(url, { method: "POST", headers: { authorization: auth, ...headers }, body, signal: AbortSignal.timeout(9e5) });
|
|
2568
|
+
const text = await res.text();
|
|
2569
|
+
if (!res.ok) throw new Error(`Uploading the app to ${account.provider} failed (${res.status}): ${text.slice(0, 200)}`);
|
|
2570
|
+
return JSON.parse(text);
|
|
2571
|
+
};
|
|
2572
|
+
if (account.provider === "browserstack") {
|
|
2573
|
+
const out2 = await post("https://api-cloud.browserstack.com/app-automate/upload", form);
|
|
2574
|
+
return String(out2["app_url"]);
|
|
2575
|
+
}
|
|
2576
|
+
if (account.provider === "lambdatest") {
|
|
2577
|
+
form.append("name", path3.basename(file));
|
|
2578
|
+
const out2 = await post("https://manual-api.lambdatest.com/app/upload/realDevice", form);
|
|
2579
|
+
return String(out2["app_url"]);
|
|
2580
|
+
}
|
|
2581
|
+
const region = account.region || "us-west-1";
|
|
2582
|
+
const out = await post(`https://api.${region}.saucelabs.com/v1/storage/upload`, form);
|
|
2583
|
+
return `storage:${out.item?.id}`;
|
|
2584
|
+
}
|
|
2585
|
+
async function cloudDevices(account, key) {
|
|
2586
|
+
const auth = `Basic ${Buffer.from(`${account.username}:${key}`).toString("base64")}`;
|
|
2587
|
+
const get = async (url) => {
|
|
2588
|
+
const res = await fetch(url, { headers: { authorization: auth }, signal: AbortSignal.timeout(3e4) });
|
|
2589
|
+
if (res.status === 401 || res.status === 403) throw new Error(`${account.provider} did not accept that user name and key.`);
|
|
2590
|
+
if (!res.ok) throw new Error(`${account.provider} answered ${res.status}.`);
|
|
2591
|
+
return res.json();
|
|
2592
|
+
};
|
|
2593
|
+
const platformOf = (os4) => /android/i.test(os4) ? "android" : /ios/i.test(os4) ? "ios" : null;
|
|
2594
|
+
const out = [];
|
|
2595
|
+
if (account.provider === "browserstack") {
|
|
2596
|
+
for (const d of await get("https://api-cloud.browserstack.com/app-automate/devices.json")) {
|
|
2597
|
+
const p = platformOf(d.os);
|
|
2598
|
+
if (p) out.push({ name: d.device, platform: p, osVersion: d.os_version });
|
|
2599
|
+
}
|
|
2600
|
+
} else if (account.provider === "saucelabs") {
|
|
2601
|
+
const region = account.region || "us-west-1";
|
|
2602
|
+
for (const d of await get(`https://api.${region}.saucelabs.com/v1/rdc/devices`)) {
|
|
2603
|
+
const p = platformOf(d.os);
|
|
2604
|
+
if (p) out.push({ name: d.name, platform: p, osVersion: d.osVersion });
|
|
2605
|
+
}
|
|
2606
|
+
} else {
|
|
2607
|
+
for (const os4 of ["android", "ios"]) {
|
|
2608
|
+
const listed = await get(`https://mobile-api.lambdatest.com/mobile-automation/api/v1/list?region=us&os=${os4}`);
|
|
2609
|
+
for (const [, devices] of Object.entries(listed)) {
|
|
2610
|
+
if (!Array.isArray(devices)) continue;
|
|
2611
|
+
for (const d of devices) out.push({ name: d.deviceName, platform: os4, osVersion: d.platformVersion });
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
return out;
|
|
2616
|
+
}
|
|
2617
|
+
var ENTITY = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
|
|
2618
|
+
var decode = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (m, e) => e[0] === "#" ? String.fromCodePoint(e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10)) : ENTITY[e] ?? m);
|
|
2619
|
+
function parseXml(xml) {
|
|
2620
|
+
const root = { tag: "#root", attrs: {}, children: [] };
|
|
2621
|
+
const stack = [root];
|
|
2622
|
+
const tag = /<(\/?)([A-Za-z_][\w.:-]*)((?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|<\?[\s\S]*?\?>|<!--[\s\S]*?-->/g;
|
|
2623
|
+
const attr = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
2624
|
+
for (let m = tag.exec(xml); m; m = tag.exec(xml)) {
|
|
2625
|
+
if (!m[2]) continue;
|
|
2626
|
+
if (m[1]) {
|
|
2627
|
+
if (stack.length > 1) stack.pop();
|
|
2628
|
+
continue;
|
|
2629
|
+
}
|
|
2630
|
+
const attrs = {};
|
|
2631
|
+
for (let a = attr.exec(m[3] ?? ""); a; a = attr.exec(m[3] ?? "")) attrs[a[1]] = decode(a[2] ?? a[3] ?? "");
|
|
2632
|
+
attr.lastIndex = 0;
|
|
2633
|
+
const el = { tag: m[2], attrs, children: [] };
|
|
2634
|
+
stack[stack.length - 1].children.push(el);
|
|
2635
|
+
if (!m[4]) stack.push(el);
|
|
2636
|
+
}
|
|
2637
|
+
return root.children[0] ?? root;
|
|
2638
|
+
}
|
|
2639
|
+
var ANDROID_ROLES = [
|
|
2640
|
+
[/EditText|AutoCompleteTextView|SearchView\$SearchAutoComplete/, "textbox"],
|
|
2641
|
+
[/Switch/, "switch"],
|
|
2642
|
+
[/ToggleButton/, "switch"],
|
|
2643
|
+
[/RadioButton/, "radio"],
|
|
2644
|
+
[/CheckBox|CheckedTextView/, "checkbox"],
|
|
2645
|
+
[/ImageButton|FloatingActionButton|(^|\.)Button$|MaterialButton|Chip$/, "button"],
|
|
2646
|
+
[/Spinner/, "combobox"],
|
|
2647
|
+
[/SeekBar|Slider|RatingBar/, "slider"],
|
|
2648
|
+
[/ProgressBar/, "progressbar"],
|
|
2649
|
+
[/ImageView/, "image"],
|
|
2650
|
+
[/TabWidget|TabLayout/, "tablist"],
|
|
2651
|
+
[/TabView|TabLayout\$TabView/, "tab"],
|
|
2652
|
+
[/Toolbar|ActionBar/, "toolbar"],
|
|
2653
|
+
[/WebView/, "webview"],
|
|
2654
|
+
[/RecyclerView|ListView|GridView|ScrollView|ViewPager/, "list"],
|
|
2655
|
+
[/TextView/, "text"]
|
|
2656
|
+
];
|
|
2657
|
+
var IOS_ROLES = {
|
|
2658
|
+
Button: "button",
|
|
2659
|
+
TextField: "textbox",
|
|
2660
|
+
SecureTextField: "textbox",
|
|
2661
|
+
SearchField: "textbox",
|
|
2662
|
+
TextView: "textbox",
|
|
2663
|
+
Switch: "switch",
|
|
2664
|
+
Toggle: "switch",
|
|
2665
|
+
CheckBox: "checkbox",
|
|
2666
|
+
StaticText: "text",
|
|
2667
|
+
Image: "image",
|
|
2668
|
+
Icon: "image",
|
|
2669
|
+
Cell: "cell",
|
|
2670
|
+
Link: "link",
|
|
2671
|
+
Tab: "tab",
|
|
2672
|
+
TabBar: "tablist",
|
|
2673
|
+
SegmentedControl: "tablist",
|
|
2674
|
+
Slider: "slider",
|
|
2675
|
+
PickerWheel: "combobox",
|
|
2676
|
+
Picker: "combobox",
|
|
2677
|
+
DatePicker: "combobox",
|
|
2678
|
+
NavigationBar: "navigation",
|
|
2679
|
+
Toolbar: "toolbar",
|
|
2680
|
+
Alert: "alertdialog",
|
|
2681
|
+
Sheet: "dialog",
|
|
2682
|
+
Table: "list",
|
|
2683
|
+
CollectionView: "list",
|
|
2684
|
+
ScrollView: "list",
|
|
2685
|
+
ProgressIndicator: "progressbar",
|
|
2686
|
+
ActivityIndicator: "progressbar",
|
|
2687
|
+
WebView: "webview",
|
|
2688
|
+
MenuItem: "menuitem",
|
|
2689
|
+
RadioButton: "radio",
|
|
2690
|
+
Keyboard: "keyboard",
|
|
2691
|
+
Key: "key",
|
|
2692
|
+
PageIndicator: "text"
|
|
2693
|
+
};
|
|
2694
|
+
var ROLE_CARRIERS = /* @__PURE__ */ new Set(["button", "checkbox", "switch", "radio"]);
|
|
2695
|
+
function androidRole(cls, a) {
|
|
2696
|
+
for (const [re, role] of ANDROID_ROLES) if (re.test(cls)) return role;
|
|
2697
|
+
if (a.checkable) return "checkbox";
|
|
2698
|
+
if (a.clickable) return "button";
|
|
2699
|
+
if (a.scrollable) return "list";
|
|
2700
|
+
return "";
|
|
2701
|
+
}
|
|
2702
|
+
function iosRole(type) {
|
|
2703
|
+
return IOS_ROLES[type.replace(/^XCUIElementType/, "")] ?? "";
|
|
2704
|
+
}
|
|
2705
|
+
var flag = (v) => v === "true";
|
|
2706
|
+
var parseBounds = (b) => {
|
|
2707
|
+
const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(b ?? "");
|
|
2708
|
+
if (!m) return { x: 0, y: 0, width: 0, height: 0 };
|
|
2709
|
+
const [x1, y1, x2, y2] = m.slice(1).map(Number);
|
|
2710
|
+
return { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) };
|
|
2711
|
+
};
|
|
2712
|
+
var shortId = (id) => id.replace(/^[\w.]+:id\//, "");
|
|
2713
|
+
var clean = (s) => s.replace(/\s+/g, " ").trim();
|
|
2714
|
+
function readScreen(source, platform) {
|
|
2715
|
+
const xml = parseXml(source);
|
|
2716
|
+
const size = { width: Number(xml.attrs["width"]) || 0, height: Number(xml.attrs["height"]) || 0 };
|
|
2717
|
+
let app = "";
|
|
2718
|
+
const build = (el, parent, xpath) => {
|
|
2719
|
+
const a = el.attrs;
|
|
2720
|
+
let node;
|
|
2721
|
+
if (platform === "android") {
|
|
2722
|
+
const cls = a["class"] ?? el.tag;
|
|
2723
|
+
if (!app && a["package"]) app = a["package"];
|
|
2724
|
+
const clickable = flag(a["clickable"]) || flag(a["long-clickable"]);
|
|
2725
|
+
const checkable = flag(a["checkable"]);
|
|
2726
|
+
const scrollable = flag(a["scrollable"]);
|
|
2727
|
+
const role = androidRole(cls, { clickable, checkable, scrollable });
|
|
2728
|
+
const text = a["text"] ?? "";
|
|
2729
|
+
const isField = role === "textbox";
|
|
2730
|
+
const hint = a["hint"] ?? "";
|
|
2731
|
+
node = {
|
|
2732
|
+
role,
|
|
2733
|
+
name: "",
|
|
2734
|
+
value: isField && text !== hint ? text : "",
|
|
2735
|
+
id: shortId(a["resource-id"] ?? ""),
|
|
2736
|
+
label: a["content-desc"] ?? "",
|
|
2737
|
+
text: isField ? "" : text,
|
|
2738
|
+
hint,
|
|
2739
|
+
bounds: parseBounds(a["bounds"]),
|
|
2740
|
+
enabled: a["enabled"] !== "false",
|
|
2741
|
+
checked: checkable || /Switch|CheckBox|RadioButton|ToggleButton/.test(cls) ? flag(a["checked"]) : null,
|
|
2742
|
+
selected: flag(a["selected"]),
|
|
2743
|
+
focused: flag(a["focused"]),
|
|
2744
|
+
password: flag(a["password"]),
|
|
2745
|
+
scrollable,
|
|
2746
|
+
clickable,
|
|
2747
|
+
xpath,
|
|
2748
|
+
kind: cls,
|
|
2749
|
+
children: [],
|
|
2750
|
+
parent
|
|
2751
|
+
};
|
|
2752
|
+
if (a["displayed"] === "false") node.bounds = { x: 0, y: 0, width: 0, height: 0 };
|
|
2753
|
+
} else {
|
|
2754
|
+
const type = a["type"] ?? el.tag;
|
|
2755
|
+
const role = iosRole(type);
|
|
2756
|
+
const isField = role === "textbox";
|
|
2757
|
+
const placeholder = a["placeholderValue"] ?? "";
|
|
2758
|
+
const value = a["value"] ?? "";
|
|
2759
|
+
if (!app && type === "XCUIElementTypeApplication") app = a["name"] ?? "";
|
|
2760
|
+
node = {
|
|
2761
|
+
role,
|
|
2762
|
+
name: "",
|
|
2763
|
+
value: isField && value !== placeholder ? value : role === "switch" || role === "checkbox" || role === "slider" ? value : "",
|
|
2764
|
+
id: a["name"] && a["name"] !== a["label"] ? a["name"] : "",
|
|
2765
|
+
label: a["label"] ?? "",
|
|
2766
|
+
text: role === "text" ? a["label"] || value : "",
|
|
2767
|
+
hint: placeholder,
|
|
2768
|
+
bounds: { x: Number(a["x"]) || 0, y: Number(a["y"]) || 0, width: Number(a["width"]) || 0, height: Number(a["height"]) || 0 },
|
|
2769
|
+
enabled: a["enabled"] !== "false",
|
|
2770
|
+
checked: role === "switch" || role === "checkbox" ? value === "1" || value === "true" : null,
|
|
2771
|
+
selected: flag(a["selected"]),
|
|
2772
|
+
focused: flag(a["focused"]) || flag(a["hasFocus"]),
|
|
2773
|
+
password: type === "XCUIElementTypeSecureTextField",
|
|
2774
|
+
scrollable: role === "list",
|
|
2775
|
+
clickable: role === "button" || role === "cell" || role === "link" || role === "tab",
|
|
2776
|
+
xpath,
|
|
2777
|
+
kind: type,
|
|
2778
|
+
children: [],
|
|
2779
|
+
parent
|
|
2780
|
+
};
|
|
2781
|
+
if (a["visible"] === "false") node.bounds = { x: 0, y: 0, width: 0, height: 0 };
|
|
2782
|
+
if (type === "XCUIElementTypeApplication" || type === "XCUIElementTypeWindow") Object.assign(size, size.width ? {} : { width: node.bounds.width, height: node.bounds.height });
|
|
2783
|
+
}
|
|
2784
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2785
|
+
for (const child of el.children) {
|
|
2786
|
+
const t = child.tag;
|
|
2787
|
+
const n = (seen.get(t) ?? 0) + 1;
|
|
2788
|
+
seen.set(t, n);
|
|
2789
|
+
node.children.push(build(child, node, `${xpath}/${t}[${n}]`));
|
|
2790
|
+
}
|
|
2791
|
+
return node;
|
|
2792
|
+
};
|
|
2793
|
+
const top = build({ tag: xml.tag, attrs: {}, children: xml.children }, null, `/${xml.tag}`);
|
|
2794
|
+
top.role = "";
|
|
2795
|
+
const fold = (node) => {
|
|
2796
|
+
node.children.forEach(fold);
|
|
2797
|
+
if (platform === "android" && node.clickable && (node.role === "button" || node.role === "")) {
|
|
2798
|
+
const carrier = node.children.find((c) => !c.clickable && ROLE_CARRIERS.has(c.role) && !c.text && !c.label && c.children.length === 0);
|
|
2799
|
+
if (carrier) {
|
|
2800
|
+
node.role = carrier.role;
|
|
2801
|
+
if (carrier.checked !== null) node.checked = carrier.checked;
|
|
2802
|
+
node.children = node.children.filter((c) => c !== carrier);
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
};
|
|
2806
|
+
fold(top);
|
|
2807
|
+
const nodes = [];
|
|
2808
|
+
const visible = (n) => n.bounds.width > 0 && n.bounds.height > 0 && (!size.width || n.bounds.x < size.width && n.bounds.y < size.height && n.bounds.x + n.bounds.width > 0 && n.bounds.y + n.bounds.height > 0);
|
|
2809
|
+
const descendantText = (n) => n.children.flatMap((c) => visible(c) && !c.clickable ? [c.label || c.text, ...c.label ? [] : descendantText(c)].filter(Boolean) : []);
|
|
2810
|
+
const name = (n) => {
|
|
2811
|
+
if (n.role === "textbox") return clean(n.label || descendantText(n).filter((t) => t !== n.value).join(" ") || n.hint || labelBefore(n) || n.id.replace(/[_-]+/g, " "));
|
|
2812
|
+
const own = n.label || n.text;
|
|
2813
|
+
if (own) return clean(own);
|
|
2814
|
+
if (n.clickable || ROLE_CARRIERS.has(n.role) || n.role === "cell" || n.role === "tab") return clean(descendantText(n).join(" "));
|
|
2815
|
+
return "";
|
|
2816
|
+
};
|
|
2817
|
+
const walk = (n) => {
|
|
2818
|
+
n.name = name(n);
|
|
2819
|
+
n.children.forEach(walk);
|
|
2820
|
+
};
|
|
2821
|
+
walk(top);
|
|
2822
|
+
const collect = (n) => {
|
|
2823
|
+
const emptyRow = n.role === "cell" && !n.name;
|
|
2824
|
+
if (n.role && visible(n) && !absorbed(n) && !emptyRow) nodes.push(n);
|
|
2825
|
+
n.children.forEach(collect);
|
|
2826
|
+
};
|
|
2827
|
+
collect(top);
|
|
2828
|
+
return { platform, root: top, nodes, size, app, source };
|
|
2829
|
+
}
|
|
2830
|
+
function labelBefore(field) {
|
|
2831
|
+
const siblings = field.parent?.children ?? [];
|
|
2832
|
+
const at = siblings.indexOf(field);
|
|
2833
|
+
for (let i = at - 1; i >= 0 && i >= at - 2; i--) {
|
|
2834
|
+
const s = siblings[i];
|
|
2835
|
+
if (s.role === "text" && s.text && s.bounds.y + s.bounds.height <= field.bounds.y + 4) return s.text;
|
|
2836
|
+
}
|
|
2837
|
+
return "";
|
|
2838
|
+
}
|
|
2839
|
+
var absorbed = (n) => {
|
|
2840
|
+
for (let p = n.parent; p; p = p.parent) {
|
|
2841
|
+
if (p.role && p.clickable && p.name && (n.role === "text" || n.role === "image") && !n.clickable) return true;
|
|
2842
|
+
if (p.role && p.role !== "list") break;
|
|
2843
|
+
}
|
|
2844
|
+
return false;
|
|
2845
|
+
};
|
|
2846
|
+
var quote2 = (s) => JSON.stringify(s);
|
|
2847
|
+
function screenLines(screen, opts = {}) {
|
|
2848
|
+
const out = [];
|
|
2849
|
+
const visible = new Set(screen.nodes);
|
|
2850
|
+
const emit = (n, depth) => {
|
|
2851
|
+
let next = depth;
|
|
2852
|
+
if (visible.has(n) && !(n.role === "text" && !n.name) && n.role !== "keyboard" && n.role !== "key") {
|
|
2853
|
+
const states = [
|
|
2854
|
+
!n.enabled && "disabled",
|
|
2855
|
+
n.checked === true && "checked",
|
|
2856
|
+
n.checked === false && n.role !== "button" && "unchecked",
|
|
2857
|
+
n.selected && "selected",
|
|
2858
|
+
n.focused && "focused",
|
|
2859
|
+
n.password && "secret",
|
|
2860
|
+
opts.ids !== false && n.id && `id=${n.id}`
|
|
2861
|
+
].filter(Boolean);
|
|
2862
|
+
const value = n.role === "textbox" && n.value ? `: ${quote2(n.password ? "\u2022\u2022\u2022" : n.value)}` : "";
|
|
2863
|
+
const tail = states.length ? ` [${states.join("] [")}]` : "";
|
|
2864
|
+
const hasKids = n.role === "list" || n.role === "dialog" || n.role === "alertdialog" || n.role === "toolbar" || n.role === "navigation" || n.role === "tablist";
|
|
2865
|
+
out.push(`${" ".repeat(depth)}- ${n.role}${n.name ? ` ${quote2(n.name)}` : ""}${value}${tail}${n.scrollable ? " (scrolls)" : ""}${hasKids ? ":" : ""}`);
|
|
2866
|
+
if (hasKids) next = depth + 1;
|
|
2867
|
+
else if (n.clickable && n.name && n.role !== "textbox") return;
|
|
2868
|
+
}
|
|
2869
|
+
for (const c of n.children) emit(c, next);
|
|
2870
|
+
};
|
|
2871
|
+
emit(screen.root, 0);
|
|
2872
|
+
return out.join("\n");
|
|
2873
|
+
}
|
|
2874
|
+
function nodeAt(screen, x, y) {
|
|
2875
|
+
const inside = (n) => x >= n.bounds.x && y >= n.bounds.y && x <= n.bounds.x + n.bounds.width && y <= n.bounds.y + n.bounds.height;
|
|
2876
|
+
const hits = screen.nodes.filter((n) => inside(n) && n.role !== "list");
|
|
2877
|
+
hits.sort((a, b) => a.bounds.width * a.bounds.height - b.bounds.width * b.bounds.height || Number(b.clickable) - Number(a.clickable));
|
|
2878
|
+
const best = hits[0] ?? null;
|
|
2879
|
+
if (!best) return null;
|
|
2880
|
+
for (let p = best.parent; p; p = p.parent) if (p.clickable && p.role && p.name && screen.nodes.includes(p)) return p;
|
|
2881
|
+
return best;
|
|
2882
|
+
}
|
|
2883
|
+
var centre = (b) => ({ x: Math.round(b.x + b.width / 2), y: Math.round(b.y + b.height / 2) });
|
|
2884
|
+
var LocateError = class extends Error {
|
|
2885
|
+
constructor(message, kind) {
|
|
2886
|
+
super(message);
|
|
2887
|
+
this.kind = kind;
|
|
2888
|
+
}
|
|
2889
|
+
kind;
|
|
2890
|
+
};
|
|
2891
|
+
var norm = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
2892
|
+
function matchStrategy(screen, strategy, value, role) {
|
|
2893
|
+
const nodes = screen.nodes;
|
|
2894
|
+
switch (strategy) {
|
|
2895
|
+
case "role": {
|
|
2896
|
+
const exact = nodes.filter((n) => n.role === role && n.name === value);
|
|
2897
|
+
if (exact.length) return exact;
|
|
2898
|
+
return nodes.filter((n) => n.role === role && norm(n.name) === norm(value));
|
|
2899
|
+
}
|
|
2900
|
+
case "testId":
|
|
2901
|
+
case "id":
|
|
2902
|
+
return nodes.filter((n) => n.id && (n.id === value || n.id === value.replace(/^[\w.]+:id\//, "")));
|
|
2903
|
+
case "label":
|
|
2904
|
+
return nodes.filter((n) => n.label === value || n.role === "textbox" && n.name === value);
|
|
2905
|
+
case "text":
|
|
2906
|
+
return nodes.filter((n) => n.text === value || n.clickable && n.name === value);
|
|
2907
|
+
case "placeholder":
|
|
2908
|
+
return nodes.filter((n) => n.hint === value);
|
|
2909
|
+
case "title":
|
|
2910
|
+
case "altText":
|
|
2911
|
+
return nodes.filter((n) => n.label === value);
|
|
2912
|
+
case "css":
|
|
2913
|
+
case "xpath":
|
|
2914
|
+
return [];
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
function within(screen, target) {
|
|
2918
|
+
if (!target.scope && !target.scopeRole) return null;
|
|
2919
|
+
const containers = screen.nodes.filter((n) => (target.scopeRole ? n.role === target.scopeRole : true) && (!target.scope || n.name === target.scope));
|
|
2920
|
+
if (!containers.length) return [];
|
|
2921
|
+
const inside = /* @__PURE__ */ new Set();
|
|
2922
|
+
const add = (n) => {
|
|
2923
|
+
inside.add(n);
|
|
2924
|
+
n.children.forEach(add);
|
|
2925
|
+
};
|
|
2926
|
+
containers.forEach((c) => c.children.forEach(add));
|
|
2927
|
+
return [...inside];
|
|
2928
|
+
}
|
|
2929
|
+
function resolveOn(screen, target) {
|
|
2930
|
+
const scoped = within(screen, target);
|
|
2931
|
+
const pool = scoped ? { ...screen, nodes: screen.nodes.filter((n) => scoped.includes(n)) } : screen;
|
|
2932
|
+
const pick = (matches, how, first) => {
|
|
2933
|
+
if (!matches.length) return null;
|
|
2934
|
+
if (target.nth !== void 0) {
|
|
2935
|
+
const node = matches[target.nth];
|
|
2936
|
+
if (!node) return null;
|
|
2937
|
+
return { node, screen, ...first ? {} : { foundBy: how } };
|
|
2938
|
+
}
|
|
2939
|
+
if (matches.length > 1) throw new LocateError(`${matches.length} elements match ${how} \u2014 say which one (nth) or choose a more specific name.`, "ambiguous");
|
|
2940
|
+
return { node: matches[0], screen, ...first ? {} : { foundBy: how } };
|
|
2941
|
+
};
|
|
2942
|
+
if (target.locator) {
|
|
2943
|
+
const found = pick(matchStrategy(pool, target.locator.strategy, target.locator.value), `${target.locator.strategy} "${target.locator.value}"`, true);
|
|
2944
|
+
if (found) return found;
|
|
2945
|
+
throw new LocateError(`Nothing on the screen has ${target.locator.strategy} "${target.locator.value}".`, "not_found");
|
|
2946
|
+
}
|
|
2947
|
+
const ways = [];
|
|
2948
|
+
if (target.role && (target.name || target.nth !== void 0)) {
|
|
2949
|
+
ways.push({ how: `${target.role} "${target.name}"`, matches: () => target.name ? matchStrategy(pool, "role", target.name, target.role) : pool.nodes.filter((n) => n.role === target.role && !n.name) });
|
|
2950
|
+
}
|
|
2951
|
+
for (const c of target.candidates ?? []) ways.push({ how: `${c.strategy} "${c.value}"`, matches: () => matchStrategy(pool, c.strategy, c.value) });
|
|
2952
|
+
if (target.name) ways.push({ how: `anything named "${target.name}"`, matches: () => pool.nodes.filter((n) => n.name === target.name && n.clickable) });
|
|
2953
|
+
let ambiguous = null;
|
|
2954
|
+
for (const [i, way] of ways.entries()) {
|
|
2955
|
+
try {
|
|
2956
|
+
const found = pick(way.matches(), way.how, i === 0);
|
|
2957
|
+
if (found) return found;
|
|
2958
|
+
} catch (e) {
|
|
2959
|
+
if (e instanceof LocateError && e.kind === "ambiguous") {
|
|
2960
|
+
ambiguous ??= e;
|
|
2961
|
+
continue;
|
|
2962
|
+
}
|
|
2963
|
+
throw e;
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
if (ambiguous) throw ambiguous;
|
|
2967
|
+
throw new LocateError(`Nothing on the screen is ${describeTarget(target)}.`, "not_found");
|
|
2968
|
+
}
|
|
2969
|
+
var describeTarget = (t) => t.locator ? `${t.locator.strategy} "${t.locator.value}"` : `${t.role || "element"}${t.name ? ` "${t.name}"` : ""}${t.nth !== void 0 ? ` (number ${t.nth + 1})` : ""}`;
|
|
2970
|
+
async function locate(session, target, timeoutMs, opts = {}) {
|
|
2971
|
+
const deadline = Date.now() + timeoutMs;
|
|
2972
|
+
let last = null;
|
|
2973
|
+
let scrolls = 0;
|
|
2974
|
+
let lastSource = "";
|
|
2975
|
+
let uncovered = false;
|
|
2976
|
+
for (; ; ) {
|
|
2977
|
+
const screen = await session.screen();
|
|
2978
|
+
try {
|
|
2979
|
+
return resolveOn(screen, target);
|
|
2980
|
+
} catch (e) {
|
|
2981
|
+
last = e;
|
|
2982
|
+
if (e instanceof LocateError && e.kind === "ambiguous") throw e;
|
|
2983
|
+
}
|
|
2984
|
+
if (Date.now() > deadline) break;
|
|
2985
|
+
if (!uncovered) {
|
|
2986
|
+
uncovered = true;
|
|
2987
|
+
if (await session.uncover()) continue;
|
|
2988
|
+
}
|
|
2989
|
+
const scroller = opts.scroll !== false && Date.now() > deadline - timeoutMs / 2 ? screen.nodes.filter((n) => n.scrollable).sort((a, b) => b.bounds.height * b.bounds.width - a.bounds.height * a.bounds.width)[0] : void 0;
|
|
2990
|
+
if (scroller && scrolls < 8 && screen.source !== lastSource) {
|
|
2991
|
+
lastSource = screen.source;
|
|
2992
|
+
scrolls++;
|
|
2993
|
+
await session.swipe("up", scroller.bounds, 0.6);
|
|
2994
|
+
} else {
|
|
2995
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
throw last instanceof Error ? last : new LocateError(`Nothing on the screen is ${describeTarget(target)}.`, "not_found");
|
|
2999
|
+
}
|
|
3000
|
+
function targetFor(screen, node) {
|
|
3001
|
+
const candidates = [];
|
|
3002
|
+
if (node.id) candidates.push({ strategy: "testId", value: node.id });
|
|
3003
|
+
if (node.label && node.label !== node.name) candidates.push({ strategy: "label", value: node.label });
|
|
3004
|
+
if (node.text && node.text !== node.name) candidates.push({ strategy: "text", value: node.text });
|
|
3005
|
+
if (node.hint && node.hint !== node.name) candidates.push({ strategy: "placeholder", value: node.hint });
|
|
3006
|
+
const same = screen.nodes.filter((n) => n.role === node.role && n.name === node.name);
|
|
3007
|
+
return {
|
|
3008
|
+
role: node.role || "element",
|
|
3009
|
+
name: node.name,
|
|
3010
|
+
...same.length > 1 ? { nth: same.indexOf(node) } : {},
|
|
3011
|
+
...node.name && !node.label && node.role !== "textbox" ? { nameFrom: "text" } : {},
|
|
3012
|
+
...candidates.length ? { candidates: candidates.slice(0, 4) } : {}
|
|
3013
|
+
};
|
|
3014
|
+
}
|
|
3015
|
+
var CheckError = class extends Error {
|
|
3016
|
+
constructor(message, kind) {
|
|
3017
|
+
super(message);
|
|
3018
|
+
this.kind = kind;
|
|
3019
|
+
}
|
|
3020
|
+
kind;
|
|
3021
|
+
};
|
|
3022
|
+
var STATE_KINDS = /* @__PURE__ */ new Set(["visible", "hidden", "attached", "enabled", "editable", "focused", "checked"]);
|
|
3023
|
+
var NEEDS_TARGET = /* @__PURE__ */ new Set(["count", "attribute", "class", "value", "text", ...STATE_KINDS]);
|
|
3024
|
+
var NOT_ON_A_PHONE = {
|
|
3025
|
+
url: "A phone app has no web address \u2014 check something on the screen, or the screen name.",
|
|
3026
|
+
title: "A phone app has no page title \u2014 check a heading or text on the screen instead.",
|
|
3027
|
+
class: "A phone screen has no CSS classes \u2014 check text, state or an id instead."
|
|
3028
|
+
};
|
|
3029
|
+
function compare(actual, expected, op) {
|
|
3030
|
+
switch (op) {
|
|
3031
|
+
case "contains":
|
|
3032
|
+
return actual.includes(expected);
|
|
3033
|
+
case "matches":
|
|
3034
|
+
return new RegExp(expected).test(actual);
|
|
3035
|
+
case "gt":
|
|
3036
|
+
return Number(actual) > Number(expected);
|
|
3037
|
+
case "gte":
|
|
3038
|
+
return Number(actual) >= Number(expected);
|
|
3039
|
+
case "lt":
|
|
3040
|
+
return Number(actual) < Number(expected);
|
|
3041
|
+
case "lte":
|
|
3042
|
+
return Number(actual) <= Number(expected);
|
|
3043
|
+
default:
|
|
3044
|
+
return actual === expected;
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
function textOf(node) {
|
|
3048
|
+
const parts = [];
|
|
3049
|
+
const walk = (n) => {
|
|
3050
|
+
const own = n.text || (n.role === "text" ? n.label : "");
|
|
3051
|
+
if (own) parts.push(own);
|
|
3052
|
+
n.children.forEach(walk);
|
|
3053
|
+
};
|
|
3054
|
+
walk(node);
|
|
3055
|
+
return (parts.join(" ") || node.name).replace(/\s+/g, " ").trim();
|
|
3056
|
+
}
|
|
3057
|
+
function state(node, kind) {
|
|
3058
|
+
switch (kind) {
|
|
3059
|
+
case "enabled":
|
|
3060
|
+
return node.enabled;
|
|
3061
|
+
case "editable":
|
|
3062
|
+
return node.enabled && node.role === "textbox";
|
|
3063
|
+
case "focused":
|
|
3064
|
+
return node.focused;
|
|
3065
|
+
case "checked":
|
|
3066
|
+
return node.checked === true || node.checked === null && node.selected;
|
|
3067
|
+
default:
|
|
3068
|
+
return true;
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
function evaluate(screen, a, extra) {
|
|
3072
|
+
const op = a.op ?? (a.kind === "message" ? "contains" : "equals");
|
|
3073
|
+
const negated = a.not === true;
|
|
3074
|
+
const expected = Array.isArray(a.value) ? a.value.join(", ") : String(a.value);
|
|
3075
|
+
if (NOT_ON_A_PHONE[a.kind]) throw new CheckError(NOT_ON_A_PHONE[a.kind], "unknown");
|
|
3076
|
+
if (NEEDS_TARGET.has(a.kind) && !a.target) throw new CheckError(`a ${a.kind} check needs an element`, "unknown");
|
|
3077
|
+
const holds = (ok, detail) => {
|
|
3078
|
+
if (ok !== negated) return detail;
|
|
3079
|
+
throw new CheckError(negated ? `${detail} \u2014 expected it not to be` : detail, "assertion");
|
|
3080
|
+
};
|
|
3081
|
+
if (a.kind === "count") {
|
|
3082
|
+
const t = a.target;
|
|
3083
|
+
const n = screen.nodes.filter((x) => x.role === t.role && (!t.name || x.name === t.name)).length;
|
|
3084
|
+
return holds(compare(String(n), expected, op), `${n} on screen`);
|
|
3085
|
+
}
|
|
3086
|
+
if (a.kind === "fields_visible") {
|
|
3087
|
+
const missing = (Array.isArray(a.value) ? a.value : [a.value]).filter((name) => !screen.nodes.some((n) => n.role === "textbox" && n.name === name));
|
|
3088
|
+
return holds(!missing.length, missing.length ? `not on screen: ${missing.join(", ")}` : "all on screen");
|
|
3089
|
+
}
|
|
3090
|
+
if (a.kind === "screen" && !a.target) {
|
|
3091
|
+
if (extra.screenName && /^[.\w$]+$/.test(expected) && /[.$]|Activity|Screen/.test(expected)) {
|
|
3092
|
+
return holds(compare(extra.screenName, expected, op === "equals" ? "contains" : op), `on ${extra.screenName}`);
|
|
3093
|
+
}
|
|
3094
|
+
}
|
|
3095
|
+
if (!a.target) {
|
|
3096
|
+
const all = screen.nodes.map((n) => n.text || n.label || n.name).filter(Boolean);
|
|
3097
|
+
const found = all.some((t) => compare(t, expected, op === "equals" ? "equals" : op));
|
|
3098
|
+
return holds(found, found ? `"${expected}" is on the screen` : `"${expected}" is not on the screen`);
|
|
3099
|
+
}
|
|
3100
|
+
let node = null;
|
|
3101
|
+
try {
|
|
3102
|
+
node = resolveOn(screen, a.target).node;
|
|
3103
|
+
} catch (e) {
|
|
3104
|
+
if (e instanceof LocateError && e.kind === "ambiguous") throw new CheckError(e.message, "ambiguous");
|
|
3105
|
+
node = null;
|
|
3106
|
+
}
|
|
3107
|
+
const what = describeTarget(a.target);
|
|
3108
|
+
if (a.kind === "visible" || a.kind === "hidden" || a.kind === "attached") {
|
|
3109
|
+
const want = a.kind !== "hidden";
|
|
3110
|
+
const present = node !== null;
|
|
3111
|
+
return holds(present === want, present ? `${what} is on the screen` : `${what} is not on the screen`);
|
|
3112
|
+
}
|
|
3113
|
+
if (!node) throw new CheckError(`${what} is not on the screen`, "not_found");
|
|
3114
|
+
if (STATE_KINDS.has(a.kind)) {
|
|
3115
|
+
const want = expected !== "false";
|
|
3116
|
+
const actual2 = state(node, a.kind);
|
|
3117
|
+
return holds(actual2 === want, `${what} is ${actual2 ? "" : "not "}${a.kind}`);
|
|
3118
|
+
}
|
|
3119
|
+
if (a.kind === "value") {
|
|
3120
|
+
const actual2 = node.value;
|
|
3121
|
+
return holds(compare(actual2, expected, op), `${what} holds "${actual2}"`);
|
|
3122
|
+
}
|
|
3123
|
+
if (a.kind === "attribute") {
|
|
3124
|
+
const attrs = { id: node.id, label: node.label, text: node.text, hint: node.hint, value: node.value, "content-desc": node.label, "resource-id": node.id };
|
|
3125
|
+
const actual2 = attrs[a.attr ?? ""] ?? "";
|
|
3126
|
+
return holds(compare(actual2, expected, op), `${a.attr} is "${actual2}"`);
|
|
3127
|
+
}
|
|
3128
|
+
const actual = textOf(node);
|
|
3129
|
+
return holds(compare(actual, expected, op), `${what} says "${actual}"`);
|
|
3130
|
+
}
|
|
3131
|
+
async function checkMobileAssertion(session, a, timeoutMs = 3e3) {
|
|
3132
|
+
const deadline = Date.now() + timeoutMs;
|
|
3133
|
+
const screenName = a.kind === "screen" ? await session.currentScreenName() : "";
|
|
3134
|
+
let last;
|
|
3135
|
+
let uncovered = false;
|
|
3136
|
+
for (; ; ) {
|
|
3137
|
+
session.changed();
|
|
3138
|
+
const screen = await session.screen();
|
|
3139
|
+
try {
|
|
3140
|
+
return evaluate(screen, a, { screenName });
|
|
3141
|
+
} catch (e) {
|
|
3142
|
+
last = e;
|
|
3143
|
+
if (e instanceof CheckError && (e.kind === "unknown" || e.kind === "ambiguous")) throw e;
|
|
3144
|
+
}
|
|
3145
|
+
if (!uncovered) {
|
|
3146
|
+
uncovered = true;
|
|
3147
|
+
if (await session.uncover()) continue;
|
|
3148
|
+
}
|
|
3149
|
+
if (Date.now() > deadline) break;
|
|
3150
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
3151
|
+
}
|
|
3152
|
+
throw last;
|
|
3153
|
+
}
|
|
3154
|
+
var uploaded = /* @__PURE__ */ new Map();
|
|
3155
|
+
var MobileSession = class _MobileSession {
|
|
3156
|
+
constructor(driver, platform, appId, deviceName, cloud) {
|
|
3157
|
+
this.driver = driver;
|
|
3158
|
+
this.platform = platform;
|
|
3159
|
+
this.appId = appId;
|
|
3160
|
+
this.deviceName = deviceName;
|
|
3161
|
+
this.cloud = cloud;
|
|
3162
|
+
}
|
|
3163
|
+
driver;
|
|
3164
|
+
platform;
|
|
3165
|
+
appId;
|
|
3166
|
+
deviceName;
|
|
3167
|
+
cloud;
|
|
3168
|
+
cached = null;
|
|
3169
|
+
static async open(target, opts = {}) {
|
|
3170
|
+
const app = appOf(target);
|
|
3171
|
+
const say = opts.onProgress ?? (() => {
|
|
3172
|
+
});
|
|
3173
|
+
if (target.device.kind === "cloud") {
|
|
3174
|
+
const choice2 = target.device;
|
|
3175
|
+
const account = target.mobile.cloud.find((c) => c.provider === choice2.provider);
|
|
3176
|
+
if (!account) throw new Error(`${CLOUD_PROVIDER_NAME[choice2.provider]} is not connected \u2014 add it in Settings \u2192 Mobile.`);
|
|
3177
|
+
const key = opts.resolveSecret?.(account.keyRef);
|
|
3178
|
+
if (!key) throw new Error(`The ${CLOUD_PROVIDER_NAME[choice2.provider]} access key is not available to this run.`);
|
|
3179
|
+
let appRef = null;
|
|
3180
|
+
if (app.build) {
|
|
3181
|
+
const cacheKey = `${account.provider}|${app.build}`;
|
|
3182
|
+
appRef = uploaded.get(cacheKey) ?? null;
|
|
3183
|
+
if (!appRef) {
|
|
3184
|
+
say(`Uploading the app to ${CLOUD_PROVIDER_NAME[choice2.provider]}\u2026`);
|
|
3185
|
+
appRef = await uploadToCloud(account, key, await localBuild(app.build));
|
|
3186
|
+
uploaded.set(cacheKey, appRef);
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
say(`Opening ${choice2.device} on ${CLOUD_PROVIDER_NAME[choice2.provider]}\u2026`);
|
|
3190
|
+
const { endpoint: endpoint2, capabilities: capabilities2 } = cloudCapabilities(target, account, key, appRef);
|
|
3191
|
+
const driver2 = await WebDriverSession.create(endpoint2, capabilities2).catch((e) => {
|
|
3192
|
+
throw new Error(`${CLOUD_PROVIDER_NAME[choice2.provider]} could not open ${choice2.device}: ${e.message}`);
|
|
3193
|
+
});
|
|
3194
|
+
const cloudSession = new _MobileSession(driver2, target.platform, app.id, choice2.device, true);
|
|
3195
|
+
await cloudSession.settle(2e4);
|
|
3196
|
+
return cloudSession;
|
|
3197
|
+
}
|
|
3198
|
+
const choice = target.device;
|
|
3199
|
+
const devices = opts.devices ?? await listDevices();
|
|
3200
|
+
const known = devices.find((d) => d.id === choice.id || d.id === `avd:${choice.id}` || choice.name && d.name === choice.name && d.platform === target.platform);
|
|
3201
|
+
if (!known) throw new Error(`${choice.name || choice.id} is not connected to this computer.`);
|
|
3202
|
+
if (known.platform !== target.platform) throw new Error(`${known.name} is ${known.platform === "ios" ? "an iOS" : "an Android"} device; this flow runs on ${target.platform === "ios" ? "iOS" : "Android"}.`);
|
|
3203
|
+
if (known.state !== "ready") say(`Starting ${known.name}\u2026`);
|
|
3204
|
+
const udid = await bootDevice(known);
|
|
3205
|
+
const endpoint = await appiumServer();
|
|
3206
|
+
const buildFile = app.build ? await localBuild(app.build) : null;
|
|
3207
|
+
say(`Opening the app on ${known.name}\u2026`);
|
|
3208
|
+
const capabilities = await localCapabilities(target, udid, { buildFile });
|
|
3209
|
+
const driver = await WebDriverSession.create(endpoint, capabilities).catch((e) => {
|
|
3210
|
+
const tail = appiumLog().filter((l) => /error/i.test(l)).slice(-2).join(" ");
|
|
3211
|
+
throw new Error(explainStartFailure(e.message, target.platform) + (tail ? ` (${tail.slice(0, 300)})` : ""));
|
|
3212
|
+
});
|
|
3213
|
+
const session = new _MobileSession(driver, target.platform, app.id, known.name, false);
|
|
3214
|
+
await session.terminateApp().catch(() => {
|
|
3215
|
+
});
|
|
3216
|
+
await session.launchApp().catch(() => {
|
|
3217
|
+
});
|
|
3218
|
+
return session;
|
|
3219
|
+
}
|
|
3220
|
+
// ── Reading ────────────────────────────────────────────────────────────
|
|
3221
|
+
async screen() {
|
|
3222
|
+
if (this.cached) return this.cached;
|
|
3223
|
+
const source = await this.driver.source();
|
|
3224
|
+
this.cached = readScreen(source, this.platform);
|
|
3225
|
+
return this.cached;
|
|
3226
|
+
}
|
|
3227
|
+
/** Anything that changes the screen forgets the copy we had. */
|
|
3228
|
+
changed() {
|
|
3229
|
+
this.cached = null;
|
|
3230
|
+
}
|
|
3231
|
+
/** WAIT FOR THE SCREEN TO SETTLE: something on it, and the same twice in a
|
|
3232
|
+
* row — a splash screen or a screen still animating in has neither. */
|
|
3233
|
+
async settle(timeoutMs = 15e3) {
|
|
3234
|
+
const deadline = Date.now() + timeoutMs;
|
|
3235
|
+
let last = "";
|
|
3236
|
+
for (; ; ) {
|
|
3237
|
+
this.changed();
|
|
3238
|
+
const screen = await this.screen();
|
|
3239
|
+
const loading = screen.nodes.some((n) => n.role === "progressbar") || screen.nodes.length > 0 && screen.nodes.every((n) => n.role === "text" && /^(loading|please wait|just a moment)\b/i.test(n.name));
|
|
3240
|
+
if (screen.nodes.length > 0 && !loading && screen.source === last) return screen;
|
|
3241
|
+
last = screen.source;
|
|
3242
|
+
if (Date.now() > deadline) return screen;
|
|
3243
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
async screenshot(file) {
|
|
3247
|
+
const png = Buffer.from(await this.driver.screenshot(), "base64");
|
|
3248
|
+
if (file) {
|
|
3249
|
+
fs4.mkdirSync(path4.dirname(file), { recursive: true });
|
|
3250
|
+
fs4.writeFileSync(file, png);
|
|
3251
|
+
}
|
|
3252
|
+
return png;
|
|
3253
|
+
}
|
|
3254
|
+
/** Android: the activity on screen. iOS has none worth reading. */
|
|
3255
|
+
async currentScreenName() {
|
|
3256
|
+
if (this.platform !== "android") return "";
|
|
3257
|
+
return this.driver.raw("GET", "/appium/device/current_activity").catch(() => "");
|
|
3258
|
+
}
|
|
3259
|
+
// ── Touching ───────────────────────────────────────────────────────────
|
|
3260
|
+
async tapAt(x, y, holdMs = 60) {
|
|
3261
|
+
this.changed();
|
|
3262
|
+
await this.driver.touch([
|
|
3263
|
+
{ type: "pointerMove", x, y, duration: 0 },
|
|
3264
|
+
{ type: "pointerDown" },
|
|
3265
|
+
{ type: "pause", duration: holdMs },
|
|
3266
|
+
{ type: "pointerUp" }
|
|
3267
|
+
]);
|
|
3268
|
+
}
|
|
3269
|
+
async tap(node) {
|
|
3270
|
+
await this.clearKeyboardFrom(node);
|
|
3271
|
+
const { x, y } = centre(node.bounds);
|
|
3272
|
+
await this.tapAt(x, y);
|
|
3273
|
+
}
|
|
3274
|
+
async longPress(node, ms = 1e3) {
|
|
3275
|
+
await this.clearKeyboardFrom(node);
|
|
3276
|
+
const { x, y } = centre(node.bounds);
|
|
3277
|
+
await this.tapAt(x, y, Math.max(500, Math.min(ms, 1e4)));
|
|
3278
|
+
}
|
|
3279
|
+
/** A finger drawn across `area` (the screen when absent). "up" moves the
|
|
3280
|
+
* content up — what a person does to see what is further down. */
|
|
3281
|
+
async swipe(direction, area, fraction = 0.6) {
|
|
3282
|
+
const screen = await this.screen();
|
|
3283
|
+
const box = area ?? { x: 0, y: 0, width: screen.size.width, height: screen.size.height };
|
|
3284
|
+
const c = centre(box);
|
|
3285
|
+
const dx = Math.round(box.width * fraction / 2);
|
|
3286
|
+
const dy = Math.round(box.height * fraction / 2);
|
|
3287
|
+
const [from, to] = direction === "up" ? [{ x: c.x, y: c.y + dy }, { x: c.x, y: c.y - dy }] : direction === "down" ? [{ x: c.x, y: c.y - dy }, { x: c.x, y: c.y + dy }] : direction === "left" ? [{ x: c.x + dx, y: c.y }, { x: c.x - dx, y: c.y }] : [{ x: c.x - dx, y: c.y }, { x: c.x + dx, y: c.y }];
|
|
3288
|
+
this.changed();
|
|
3289
|
+
await this.driver.touch([
|
|
3290
|
+
{ type: "pointerMove", ...from, duration: 0 },
|
|
3291
|
+
{ type: "pointerDown" },
|
|
3292
|
+
{ type: "pause", duration: 80 },
|
|
3293
|
+
{ type: "pointerMove", ...to, duration: 450 },
|
|
3294
|
+
{ type: "pause", duration: 120 },
|
|
3295
|
+
{ type: "pointerUp" }
|
|
3296
|
+
]);
|
|
3297
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
3298
|
+
}
|
|
3299
|
+
/** The element's WebDriver id, for the few commands that need one. */
|
|
3300
|
+
async element(node) {
|
|
3301
|
+
const found = await this.driver.find("xpath", node.xpath);
|
|
3302
|
+
if (!found[0]) throw new Error("The element moved before it could be used.");
|
|
3303
|
+
return found[0];
|
|
3304
|
+
}
|
|
3305
|
+
/** Replace what a field holds with `text`. */
|
|
3306
|
+
async type(node, text) {
|
|
3307
|
+
this.changed();
|
|
3308
|
+
const element = await this.element(node).catch(() => null);
|
|
3309
|
+
if (element) {
|
|
3310
|
+
await this.driver.click(element).catch(() => {
|
|
3311
|
+
});
|
|
3312
|
+
await this.driver.clear(element).catch(() => {
|
|
3313
|
+
});
|
|
3314
|
+
await this.driver.type(element, text);
|
|
3315
|
+
return;
|
|
3316
|
+
}
|
|
3317
|
+
await this.tap(node);
|
|
3318
|
+
await this.driver.keys(text);
|
|
3319
|
+
}
|
|
3320
|
+
async back() {
|
|
3321
|
+
this.changed();
|
|
3322
|
+
await this.driver.back();
|
|
3323
|
+
}
|
|
3324
|
+
async pressKey(key) {
|
|
3325
|
+
this.changed();
|
|
3326
|
+
if (this.platform === "android") {
|
|
3327
|
+
const code = ANDROID_KEYS[key.toLowerCase()];
|
|
3328
|
+
if (code === void 0) throw new Error(`"${key}" is not a key a phone has \u2014 use Enter, Back, Tab, Delete, Search or Home.`);
|
|
3329
|
+
await this.driver.execute("mobile: pressKey", { keycode: code });
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
const ch = { enter: "\n", return: "\n", tab: " ", delete: "\b", backspace: "\b" }[key.toLowerCase()];
|
|
3333
|
+
if (!ch) throw new Error(`"${key}" is not a key an iPhone keyboard has \u2014 use Enter, Tab or Delete.`);
|
|
3334
|
+
await this.driver.keys(ch);
|
|
3335
|
+
}
|
|
3336
|
+
async keyboardShown() {
|
|
3337
|
+
return this.driver.raw("GET", "/appium/device/is_keyboard_shown").catch(() => false);
|
|
3338
|
+
}
|
|
3339
|
+
async hideKeyboard() {
|
|
3340
|
+
if (!await this.keyboardShown()) return;
|
|
3341
|
+
this.changed();
|
|
3342
|
+
if (this.platform === "android") await this.driver.raw("POST", "/appium/device/hide_keyboard", {}).catch(() => this.driver.back());
|
|
3343
|
+
else await this.driver.execute("mobile: hideKeyboard", { keys: ["done", "Done", "return", "Return"] }).catch(() => {
|
|
3344
|
+
});
|
|
3345
|
+
}
|
|
3346
|
+
/** Something not found while the keyboard is up may be under it — Android
|
|
3347
|
+
* leaves covered text out of the screen entirely. Closes the keyboard once
|
|
3348
|
+
* per screen; answers whether it did. */
|
|
3349
|
+
async uncover() {
|
|
3350
|
+
if (!await this.keyboardShown()) return false;
|
|
3351
|
+
await this.hideKeyboard();
|
|
3352
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
3353
|
+
this.changed();
|
|
3354
|
+
return true;
|
|
3355
|
+
}
|
|
3356
|
+
/** The keyboard covers the lower part of the screen; tapping something
|
|
3357
|
+
* under it would type a letter instead. */
|
|
3358
|
+
async clearKeyboardFrom(node) {
|
|
3359
|
+
if (node.role === "textbox") return;
|
|
3360
|
+
const screen = await this.screen();
|
|
3361
|
+
if (centre(node.bounds).y < screen.size.height * 0.55) return;
|
|
3362
|
+
if (await this.keyboardShown()) {
|
|
3363
|
+
await this.hideKeyboard();
|
|
3364
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
// ── The app ────────────────────────────────────────────────────────────
|
|
3368
|
+
appArg() {
|
|
3369
|
+
return this.platform === "android" ? { appId: this.appId } : { bundleId: this.appId };
|
|
3370
|
+
}
|
|
3371
|
+
async launchApp() {
|
|
3372
|
+
this.changed();
|
|
3373
|
+
await this.driver.execute("mobile: activateApp", this.appArg());
|
|
3374
|
+
await this.settle(2e4);
|
|
3375
|
+
}
|
|
3376
|
+
async terminateApp() {
|
|
3377
|
+
this.changed();
|
|
3378
|
+
await this.driver.execute("mobile: terminateApp", this.appArg());
|
|
3379
|
+
}
|
|
3380
|
+
async resetApp() {
|
|
3381
|
+
await this.terminateApp().catch(() => {
|
|
3382
|
+
});
|
|
3383
|
+
await this.driver.execute("mobile: clearApp", this.appArg()).catch((e) => {
|
|
3384
|
+
throw new Error(`This device cannot clear the app's data (${e.message}). Reinstall it, or choose "Start clean" in Settings \u2192 Mobile.`);
|
|
3385
|
+
});
|
|
3386
|
+
await this.launchApp();
|
|
3387
|
+
}
|
|
3388
|
+
async background(seconds) {
|
|
3389
|
+
this.changed();
|
|
3390
|
+
await this.driver.execute("mobile: backgroundApp", { seconds });
|
|
3391
|
+
}
|
|
3392
|
+
async rotate(to) {
|
|
3393
|
+
this.changed();
|
|
3394
|
+
await this.driver.setOrientation(to === "landscape" ? "LANDSCAPE" : "PORTRAIT");
|
|
3395
|
+
}
|
|
3396
|
+
async openLink(url) {
|
|
3397
|
+
this.changed();
|
|
3398
|
+
await this.driver.execute("mobile: deepLink", this.platform === "android" ? { url, package: this.appId } : { url, bundleId: this.appId });
|
|
3399
|
+
}
|
|
3400
|
+
/** Answer the system's permission prompt on screen. */
|
|
3401
|
+
async permission(answer) {
|
|
3402
|
+
this.changed();
|
|
3403
|
+
if (this.platform === "ios") {
|
|
3404
|
+
const buttons = await this.driver.execute("mobile: alert", { action: "getButtons" }).catch(() => []);
|
|
3405
|
+
if (!buttons.length) throw new Error("No permission prompt is showing.");
|
|
3406
|
+
const wanted = answer === "allow" ? buttons.find((b) => /allow while using|^allow$|^ok$|allow full access|allow once/i.test(b)) ?? buttons[buttons.length - 1] : buttons.find((b) => /don.t allow|^deny$|not now|cancel/i.test(b)) ?? buttons[0];
|
|
3407
|
+
await this.driver.execute("mobile: alert", { action: "accept", buttonLabel: wanted });
|
|
3408
|
+
return wanted;
|
|
3409
|
+
}
|
|
3410
|
+
const screen = await this.screen();
|
|
3411
|
+
const button = screen.nodes.filter((n) => n.clickable && n.role === "button").find((n) => answer === "allow" ? /permission_allow_(foreground_only_)?button|permission_allow_one_time_button/.test(n.id) || /^(while using the app|allow|only this time)$/i.test(n.name) : /permission_deny(_and_dont_ask_again)?_button/.test(n.id) || /^(don.t allow|deny)$/i.test(n.name));
|
|
3412
|
+
if (!button) throw new Error("No permission prompt is showing.");
|
|
3413
|
+
await this.tap(button);
|
|
3414
|
+
return button.name;
|
|
3415
|
+
}
|
|
3416
|
+
// ── Video ──────────────────────────────────────────────────────────────
|
|
3417
|
+
recording = false;
|
|
3418
|
+
/** Start filming the screen. Android's own recorder stops at three minutes
|
|
3419
|
+
* unless Appium can join pieces with ffmpeg; iOS needs ffmpeg at all. A
|
|
3420
|
+
* phone in a cloud farm is filmed by the farm. Answers whether it started. */
|
|
3421
|
+
async startVideo() {
|
|
3422
|
+
if (this.cloud) return false;
|
|
3423
|
+
const ffmpeg = Boolean(onPath("ffmpeg"));
|
|
3424
|
+
if (this.platform === "ios" && !ffmpeg) return false;
|
|
3425
|
+
const options = this.platform === "android" ? { timeLimit: ffmpeg ? "1800" : "180", bitRate: "3000000", forceRestart: true } : { timeLimit: "1800", videoType: "libx264", videoFps: 10, videoQuality: "medium", forceRestart: true };
|
|
3426
|
+
try {
|
|
3427
|
+
await this.driver.raw("POST", "/appium/start_recording_screen", { options });
|
|
3428
|
+
this.recording = true;
|
|
3429
|
+
} catch {
|
|
3430
|
+
this.recording = false;
|
|
3431
|
+
}
|
|
3432
|
+
return this.recording;
|
|
3433
|
+
}
|
|
3434
|
+
/** Stop filming; keep the film at `file`, or throw it away. */
|
|
3435
|
+
async stopVideo(file) {
|
|
3436
|
+
if (!this.recording) return null;
|
|
3437
|
+
this.recording = false;
|
|
3438
|
+
const b64 = await this.driver.raw("POST", "/appium/stop_recording_screen", { options: {} }, 12e4).catch(() => "");
|
|
3439
|
+
if (!file || !b64) return null;
|
|
3440
|
+
const bytes = Buffer.from(b64, "base64");
|
|
3441
|
+
fs4.mkdirSync(path4.dirname(file), { recursive: true });
|
|
3442
|
+
fs4.writeFileSync(file, bytes);
|
|
3443
|
+
return { relPath: path4.basename(file), bytes: bytes.length };
|
|
3444
|
+
}
|
|
3445
|
+
async close() {
|
|
3446
|
+
if (this.recording) await this.stopVideo(null);
|
|
3447
|
+
await this.driver.close();
|
|
3448
|
+
}
|
|
3449
|
+
};
|
|
3450
|
+
var ANDROID_KEYS = {
|
|
3451
|
+
enter: 66,
|
|
3452
|
+
return: 66,
|
|
3453
|
+
back: 4,
|
|
3454
|
+
tab: 61,
|
|
3455
|
+
delete: 67,
|
|
3456
|
+
backspace: 67,
|
|
3457
|
+
search: 84,
|
|
3458
|
+
home: 3,
|
|
3459
|
+
escape: 111,
|
|
3460
|
+
space: 62,
|
|
3461
|
+
arrowup: 19,
|
|
3462
|
+
arrowdown: 20,
|
|
3463
|
+
arrowleft: 21,
|
|
3464
|
+
arrowright: 22,
|
|
3465
|
+
menu: 82
|
|
3466
|
+
};
|
|
3467
|
+
function explainStartFailure(message, platform) {
|
|
3468
|
+
if (/Could not find a driver for automationName/i.test(message)) {
|
|
3469
|
+
return `Appium's ${platform === "android" ? "Android (uiautomator2)" : "iOS (xcuitest)"} driver is not installed \u2014 run \`flowriki-agent mobile setup\`.`;
|
|
3470
|
+
}
|
|
3471
|
+
if (/is not installed|does not exist or is not accessible|Activity name .* cannot be found|App with bundle identifier .* unknown/i.test(message)) {
|
|
3472
|
+
return "The app is not installed on that device. Install it, or add its build (.apk / .ipa / .app) in Settings \u2192 Mobile.";
|
|
3473
|
+
}
|
|
3474
|
+
if (/JAVA_HOME/i.test(message)) return "Java is needed for Android tests \u2014 install a JDK (Android Studio includes one).";
|
|
3475
|
+
if (/ANDROID_HOME|ANDROID_SDK_ROOT/i.test(message)) return "The Android SDK was not found \u2014 install Android Studio.";
|
|
3476
|
+
if (/xcodebuild|WebDriverAgent/i.test(message)) return `Appium could not start its helper on the iPhone: ${message}. For a real iPhone, set your Apple team id in Settings \u2192 Mobile.`;
|
|
3477
|
+
return `The app could not be opened: ${message}`;
|
|
3478
|
+
}
|
|
3479
|
+
var MOBILE_TIMEOUTS = { step: 15e3, assertion: 5e3 };
|
|
3480
|
+
var firstLine = (e) => String(e instanceof Error ? e.message : e).split("\n")[0].trim();
|
|
3481
|
+
function expand(text, values) {
|
|
3482
|
+
return text.replace(/\{\{([a-zA-Z][a-zA-Z0-9_]*)\}\}/g, (_, name) => {
|
|
3483
|
+
if (values.has(name)) return values.get(name);
|
|
3484
|
+
if (name === "unique") {
|
|
3485
|
+
const v = `${Date.now().toString(36)}${Math.random().toString(16).slice(2, 8)}`;
|
|
3486
|
+
values.set(name, v);
|
|
3487
|
+
return v;
|
|
3488
|
+
}
|
|
3489
|
+
throw new Error(`Runtime value "${name}" has not been captured yet`);
|
|
3490
|
+
});
|
|
3491
|
+
}
|
|
3492
|
+
function typedValue(step, opts) {
|
|
3493
|
+
if (step.secretRef || step.secret) {
|
|
3494
|
+
const ref = step.secretRef || step.value;
|
|
3495
|
+
const value = opts.resolveSecret?.(ref);
|
|
3496
|
+
if (value === void 0) throw new Error(`the secret "${ref}" is not available on this computer`);
|
|
3497
|
+
return value;
|
|
3498
|
+
}
|
|
3499
|
+
return expand(step.value, opts.values);
|
|
3500
|
+
}
|
|
3501
|
+
var failureKind = (e) => e instanceof LocateError ? e.kind : e instanceof CheckError ? e.kind === "assertion" ? "assertion" : e.kind === "unknown" ? "unknown" : e.kind : /timed? ?out/i.test(String(e)) ? "timeout" : "unknown";
|
|
3502
|
+
var DIRECTIONS = /* @__PURE__ */ new Set(["up", "down", "left", "right"]);
|
|
3503
|
+
async function performMobileStep(session, step, opts, onFound) {
|
|
3504
|
+
const refused = stepRefusedOn(step.type, session.platform);
|
|
3505
|
+
if (refused) throw new Error(refused);
|
|
3506
|
+
const timeout = step.timeoutMs ?? MOBILE_TIMEOUTS.step;
|
|
3507
|
+
const find = async () => {
|
|
3508
|
+
const found = await locate(session, step, timeout);
|
|
3509
|
+
if (found.foundBy) onFound?.(found.foundBy);
|
|
3510
|
+
return found.node;
|
|
3511
|
+
};
|
|
3512
|
+
switch (step.type) {
|
|
3513
|
+
case "click":
|
|
3514
|
+
return session.tap(await find());
|
|
3515
|
+
case "longPress":
|
|
3516
|
+
return session.longPress(await find(), Number(step.value) || 1e3);
|
|
3517
|
+
case "fill": {
|
|
3518
|
+
const node = await find();
|
|
3519
|
+
await session.type(node, typedValue(step, opts));
|
|
3520
|
+
return;
|
|
3521
|
+
}
|
|
3522
|
+
case "check": {
|
|
3523
|
+
const node = await find();
|
|
3524
|
+
const want = step.value !== "false";
|
|
3525
|
+
if (node.checked === true !== want) await session.tap(node);
|
|
3526
|
+
return;
|
|
3527
|
+
}
|
|
3528
|
+
case "select": {
|
|
3529
|
+
const node = await find();
|
|
3530
|
+
await session.tap(node);
|
|
3531
|
+
const option = expand(step.value, opts.values);
|
|
3532
|
+
const picked = await locate(session, { role: "", name: option, candidates: [{ strategy: "text", value: option }] }, timeout);
|
|
3533
|
+
await session.tap(picked.node);
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
case "press":
|
|
3537
|
+
return session.pressKey(step.value || "Enter");
|
|
3538
|
+
case "back":
|
|
3539
|
+
return session.back();
|
|
3540
|
+
case "navigate":
|
|
3541
|
+
return session.openLink(expand(step.value, opts.values));
|
|
3542
|
+
case "scroll":
|
|
3543
|
+
case "swipe": {
|
|
3544
|
+
const direction = (step.value || "up").toLowerCase();
|
|
3545
|
+
if (!DIRECTIONS.has(direction)) throw new Error(`swipe "${step.value}" \u2014 say up, down, left or right`);
|
|
3546
|
+
if (step.type === "scroll" && step.role) {
|
|
3547
|
+
await find();
|
|
3548
|
+
return;
|
|
3549
|
+
}
|
|
3550
|
+
const area = step.role ? (await find()).bounds : void 0;
|
|
3551
|
+
return session.swipe(direction, area);
|
|
3552
|
+
}
|
|
3553
|
+
case "hideKeyboard":
|
|
3554
|
+
return session.hideKeyboard();
|
|
3555
|
+
case "launchApp":
|
|
3556
|
+
return session.launchApp();
|
|
3557
|
+
case "terminateApp":
|
|
3558
|
+
return session.terminateApp();
|
|
3559
|
+
case "resetApp":
|
|
3560
|
+
return session.resetApp();
|
|
3561
|
+
case "background":
|
|
3562
|
+
return session.background(Math.max(1, Math.min(Number(step.value) || 3, 60)));
|
|
3563
|
+
case "rotate":
|
|
3564
|
+
return session.rotate(step.value === "landscape" ? "landscape" : "portrait");
|
|
3565
|
+
case "permission": {
|
|
3566
|
+
const answered = await session.permission(step.value === "deny" ? "deny" : "allow");
|
|
3567
|
+
return { detail: `answered "${answered}"` };
|
|
3568
|
+
}
|
|
3569
|
+
case "capture": {
|
|
3570
|
+
if (!step.capture) throw new Error("this capture does not say what to keep");
|
|
3571
|
+
if (step.capture.source === "url") throw new Error("a phone app has no address to capture \u2014 capture text from the screen");
|
|
3572
|
+
const node = await find();
|
|
3573
|
+
const value = step.capture.source === "value" ? node.value : textOf(node);
|
|
3574
|
+
opts.values.set(step.capture.name, value);
|
|
3575
|
+
return { value };
|
|
3576
|
+
}
|
|
3577
|
+
case "useHelper": {
|
|
3578
|
+
const helper = step.helper;
|
|
3579
|
+
if (!helper) throw new Error("this step does not say which helper to use");
|
|
3580
|
+
if (!opts.runHelper) throw new Error("this run was started without its helpers \u2014 run it again from Flowriki");
|
|
3581
|
+
const inputs = Object.fromEntries(Object.entries(helper.inputs).map(([k, v]) => [k, expand(v, opts.values)]));
|
|
3582
|
+
const got = await opts.runHelper(helper.helperId, inputs);
|
|
3583
|
+
if (helper.saveAs) opts.values.set(helper.saveAs, got.value);
|
|
3584
|
+
if (helper.then === "open") await session.openLink(got.value);
|
|
3585
|
+
return { value: got.value, detail: got.detail };
|
|
3586
|
+
}
|
|
3587
|
+
case "assert":
|
|
3588
|
+
return;
|
|
3589
|
+
case "useActor":
|
|
3590
|
+
case "resetActor":
|
|
3591
|
+
throw new Error("a flow on a phone has one device \u2014 use a test case to add a second device or a browser");
|
|
3592
|
+
case "unsupported":
|
|
3593
|
+
throw new Error(step.warning || "this step could not be recorded and cannot be replayed");
|
|
3594
|
+
default:
|
|
3595
|
+
throw new Error(`"${step.type}" cannot be done on a phone`);
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
async function performSignIn(session, step, opts) {
|
|
3599
|
+
const auth = step.auth ? opts.resolveAuth?.(step.auth) : void 0;
|
|
3600
|
+
if (!auth) return { ok: false, label: "Sign in", detail: "this run was delivered without the sign-in it needs \u2014 open it again so its procedure and account are resolved" };
|
|
3601
|
+
const out = step.type === "signOut";
|
|
3602
|
+
const label = out ? `Sign out ${auth.accountName}` : `Sign in as ${auth.accountName}`;
|
|
3603
|
+
const procedure = out ? auth.signOut : { steps: auth.steps, success: auth.success };
|
|
3604
|
+
if (!procedure) return { ok: false, label, detail: `"${auth.name}" has no sign-out recorded` };
|
|
3605
|
+
const checks = procedure.success.filter((c) => c.enabled !== false);
|
|
3606
|
+
if (!checks.length) return { ok: false, label, detail: "this sign-in has no check for whether it worked \u2014 add one so a failed login is not reported as a passed step" };
|
|
3607
|
+
if ((opts.authDepth ?? 0) > 0) return { ok: false, label, detail: `"${auth.name}" signs in from inside its own sign-in` };
|
|
3608
|
+
const proven = async (budget) => {
|
|
3609
|
+
for (const c of checks) await checkMobileAssertion(session, c, budget);
|
|
3610
|
+
};
|
|
3611
|
+
if (!out) {
|
|
3612
|
+
try {
|
|
3613
|
+
await proven(2500);
|
|
3614
|
+
return { ok: true, label, detail: "already signed in \u2014 the app kept its session" };
|
|
3615
|
+
} catch {
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
const inner = await runMobileSteps(session, procedure.steps, { ...opts, onStepResult: void 0, authDepth: (opts.authDepth ?? 0) + 1 });
|
|
3619
|
+
if (inner.status !== "passed") {
|
|
3620
|
+
const failed = inner.results.find((r) => r.status === "failed");
|
|
3621
|
+
return { ok: false, label, detail: `could not ${label.toLowerCase()}: ${failed ? `"${failed.name}" failed \u2014 ` : ""}${failed?.detail ?? inner.error ?? "no detail"}` };
|
|
3622
|
+
}
|
|
3623
|
+
try {
|
|
3624
|
+
await proven(MOBILE_TIMEOUTS.assertion * 2);
|
|
3625
|
+
} catch (e) {
|
|
3626
|
+
return { ok: false, label, detail: `could not ${label.toLowerCase()}: ${firstLine(e)}` };
|
|
3627
|
+
}
|
|
3628
|
+
return { ok: true, label, detail: "" };
|
|
3629
|
+
}
|
|
3630
|
+
async function runMobileSteps(session, steps, opts) {
|
|
3631
|
+
const results = [];
|
|
3632
|
+
let status = "passed";
|
|
3633
|
+
let error = null;
|
|
3634
|
+
try {
|
|
3635
|
+
for (const step of steps) {
|
|
3636
|
+
if (opts.signal?.aborted) break;
|
|
3637
|
+
const t0 = Date.now();
|
|
3638
|
+
const secret = Boolean(step.secret || step.secretRef);
|
|
3639
|
+
const result = {
|
|
3640
|
+
stepId: step.id,
|
|
3641
|
+
name: step.name,
|
|
3642
|
+
type: step.type,
|
|
3643
|
+
value: secret ? null : step.value || null,
|
|
3644
|
+
secret,
|
|
3645
|
+
status: "passed",
|
|
3646
|
+
detail: null,
|
|
3647
|
+
failureKind: null,
|
|
3648
|
+
durationMs: 0,
|
|
3649
|
+
checks: []
|
|
3650
|
+
};
|
|
3651
|
+
if (step.disabled) {
|
|
3652
|
+
result.status = "skipped";
|
|
3653
|
+
result.detail = "turned off";
|
|
3654
|
+
results.push(result);
|
|
3655
|
+
opts.onStepResult?.(result);
|
|
3656
|
+
continue;
|
|
3657
|
+
}
|
|
3658
|
+
try {
|
|
3659
|
+
if (step.type === "signIn" || step.type === "signOut") {
|
|
3660
|
+
const signed = await performSignIn(session, step, opts);
|
|
3661
|
+
result.name = signed.label;
|
|
3662
|
+
if (!signed.ok) throw Object.assign(new Error(signed.detail), { kind: "assertion" });
|
|
3663
|
+
if (signed.detail) result.detail = signed.detail;
|
|
3664
|
+
} else {
|
|
3665
|
+
const done = await performMobileStep(session, step, opts, (foundBy) => {
|
|
3666
|
+
result.foundBy = foundBy;
|
|
3667
|
+
});
|
|
3668
|
+
if (done?.value !== void 0 && !secret) result.value = done.value;
|
|
3669
|
+
if (done?.detail) result.detail = done.detail;
|
|
3670
|
+
}
|
|
3671
|
+
let hardKind = null;
|
|
3672
|
+
for (const a of step.proposals ?? []) {
|
|
3673
|
+
if (a.enabled === false) continue;
|
|
3674
|
+
const label = describeAssertion(a, step.name);
|
|
3675
|
+
const soft = a.soft === true;
|
|
3676
|
+
try {
|
|
3677
|
+
const detail = await checkMobileAssertion(session, a, step.assertionTimeoutMs ?? MOBILE_TIMEOUTS.assertion);
|
|
3678
|
+
result.checks.push({ kind: a.kind, expected: label, ok: true, soft, detail });
|
|
3679
|
+
} catch (e) {
|
|
3680
|
+
if (!soft) hardKind = failureKind(e);
|
|
3681
|
+
result.checks.push({ kind: a.kind, expected: label, ok: false, soft, detail: firstLine(e) });
|
|
3682
|
+
}
|
|
3683
|
+
}
|
|
3684
|
+
const hard = result.checks.find((c) => !c.ok && !c.soft);
|
|
3685
|
+
if (hard) {
|
|
3686
|
+
result.status = "failed";
|
|
3687
|
+
result.failureKind = hardKind ?? "assertion";
|
|
3688
|
+
result.detail = hard.detail;
|
|
3689
|
+
}
|
|
3690
|
+
} catch (e) {
|
|
3691
|
+
result.status = "failed";
|
|
3692
|
+
result.failureKind = e.kind ?? failureKind(e);
|
|
3693
|
+
result.detail = firstLine(e);
|
|
3694
|
+
}
|
|
3695
|
+
const shots = opts.screenshots ?? "on-failure";
|
|
3696
|
+
if (opts.evidenceDir && (shots === "always" || shots === "on-failure" && result.status === "failed")) {
|
|
3697
|
+
const file = `step-${String((opts.stepOffset ?? 0) + results.length + 1).padStart(2, "0")}.png`;
|
|
3698
|
+
try {
|
|
3699
|
+
await session.screenshot(path5.join(opts.evidenceDir, file));
|
|
3700
|
+
result.evidence = { screenshot: file, url: await session.currentScreenName(), title: session.deviceName };
|
|
3701
|
+
} catch {
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
result.durationMs = Date.now() - t0;
|
|
3705
|
+
results.push(result);
|
|
3706
|
+
opts.onStepResult?.(result);
|
|
3707
|
+
if (result.status === "failed") {
|
|
3708
|
+
status = "failed";
|
|
3709
|
+
error = step.type === "assert" ? `check ${describeAssertion(step.proposals[0] ?? { kind: "visible", value: "" }, step.name)}: ${result.detail}` : `${step.type}${step.name ? ` "${step.name}"` : ""}: ${result.detail}`;
|
|
3710
|
+
break;
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
} catch (e) {
|
|
3714
|
+
status = "errored";
|
|
3715
|
+
error = firstLine(e);
|
|
3716
|
+
}
|
|
3717
|
+
return { results, status, error };
|
|
3718
|
+
}
|
|
3719
|
+
var typesSecrets = (steps) => steps.some((s) => s.secret || Boolean(s.secretRef) || s.type === "signIn");
|
|
3720
|
+
var filmsThis = (policy, steps, evidenceDir) => Boolean(evidenceDir) && Boolean(policy) && policy.mode !== "off" && (policy.allowUnmasked === true || !typesSecrets(steps));
|
|
3721
|
+
async function finishVideo(session, policy, evidenceDir, passed, name = "phone") {
|
|
3722
|
+
const keep = Boolean(evidenceDir) && (policy?.mode === "always" || policy?.mode === "on-failure" && !passed);
|
|
3723
|
+
const saved = await session.stopVideo(keep ? path6.join(evidenceDir, `video-${name}.mp4`) : null).catch(() => null);
|
|
3724
|
+
return saved ? [{ kind: "video", relPath: saved.relPath, bytes: saved.bytes }] : [];
|
|
3725
|
+
}
|
|
3726
|
+
async function chooseDevice(platform, mobile, asked) {
|
|
3727
|
+
const chosen = asked ?? mobile.devices[platform];
|
|
3728
|
+
if (chosen) return chosen;
|
|
3729
|
+
const devices = await listDevices();
|
|
3730
|
+
const picked = pickDevice(devices, platform);
|
|
3731
|
+
if (picked) return picked;
|
|
3732
|
+
const farm = mobile.cloud[0];
|
|
3733
|
+
if (farm) throw new Error(`No ${platform === "ios" ? "iPhone or simulator" : "Android phone or emulator"} is connected to this computer. Choose a ${farm.provider} device in Settings \u2192 Mobile, or connect one.`);
|
|
3734
|
+
throw new Error(platform === "ios" ? "No iPhone or simulator is available \u2014 install Xcode for simulators, or plug in an iPhone." : "No Android phone or emulator is available \u2014 start an emulator in Android Studio, or plug in a phone with USB debugging on.");
|
|
3735
|
+
}
|
|
3736
|
+
async function openMobileSession(opts) {
|
|
3737
|
+
const device = await chooseDevice(opts.platform, opts.mobile, opts.device);
|
|
3738
|
+
return MobileSession.open(
|
|
3739
|
+
{ platform: opts.platform, device, mobile: opts.mobile, ...opts.fresh ? { fresh: true } : {} },
|
|
3740
|
+
{ ...opts.resolveSecret ? { resolveSecret: opts.resolveSecret } : {}, ...opts.onProgress ? { onProgress: opts.onProgress } : {} }
|
|
3741
|
+
);
|
|
3742
|
+
}
|
|
3743
|
+
async function replayMobileFlow(opts) {
|
|
3744
|
+
const started = Date.now();
|
|
3745
|
+
let session;
|
|
3746
|
+
try {
|
|
3747
|
+
session = await openMobileSession(opts);
|
|
3748
|
+
} catch (e) {
|
|
3749
|
+
return {
|
|
3750
|
+
status: "errored",
|
|
3751
|
+
error: e.message,
|
|
3752
|
+
durationMs: Date.now() - started,
|
|
3753
|
+
stepsRun: 0,
|
|
3754
|
+
stepsTotal: opts.steps.length,
|
|
3755
|
+
steps: [],
|
|
3756
|
+
device: describeDevice(opts.device ?? null)
|
|
3757
|
+
};
|
|
3758
|
+
}
|
|
3759
|
+
const filming = filmsThis(opts.video, opts.steps, opts.evidenceDir) && await session.startVideo();
|
|
3760
|
+
let outcome = null;
|
|
3761
|
+
try {
|
|
3762
|
+
if (opts.startPath && /^[a-z][\w+.-]*:\/\//i.test(opts.startPath)) await session.openLink(opts.startPath);
|
|
3763
|
+
outcome = await runMobileSteps(session, opts.steps, {
|
|
3764
|
+
values: /* @__PURE__ */ new Map(),
|
|
3765
|
+
...opts.resolveSecret ? { resolveSecret: opts.resolveSecret } : {},
|
|
3766
|
+
...opts.resolveAuth ? { resolveAuth: opts.resolveAuth } : {},
|
|
3767
|
+
...opts.runHelper ? { runHelper: opts.runHelper } : {},
|
|
3768
|
+
...opts.onStepResult ? { onStepResult: opts.onStepResult } : {},
|
|
3769
|
+
...opts.evidenceDir ? { evidenceDir: opts.evidenceDir } : {},
|
|
3770
|
+
...opts.screenshots ? { screenshots: opts.screenshots } : {},
|
|
3771
|
+
...opts.signal ? { signal: opts.signal } : {}
|
|
3772
|
+
});
|
|
3773
|
+
const { results, status, error } = outcome;
|
|
3774
|
+
const artifacts = filming ? await finishVideo(session, opts.video, opts.evidenceDir, status === "passed") : [];
|
|
3775
|
+
return {
|
|
3776
|
+
status,
|
|
3777
|
+
error,
|
|
3778
|
+
durationMs: Date.now() - started,
|
|
3779
|
+
stepsRun: results.filter((r) => r.status !== "skipped").length,
|
|
3780
|
+
stepsTotal: opts.steps.length,
|
|
3781
|
+
steps: results,
|
|
3782
|
+
device: session.deviceName,
|
|
3783
|
+
...artifacts.length ? { artifacts } : {}
|
|
3784
|
+
};
|
|
3785
|
+
} finally {
|
|
3786
|
+
await session.close();
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
async function installedDrivers() {
|
|
3790
|
+
const appium = appiumPath();
|
|
3791
|
+
if (!appium) return {};
|
|
3792
|
+
const out = await run(appium.bin, ["driver", "list", "--installed", "--json"], 6e4, await toolEnv()).catch(() => "{}");
|
|
3793
|
+
try {
|
|
3794
|
+
const parsed = JSON.parse(out.slice(out.indexOf("{")));
|
|
3795
|
+
return Object.fromEntries(Object.entries(parsed).filter(([, v]) => v.installed !== false).map(([k, v]) => [k, v.version ?? "?"]));
|
|
3796
|
+
} catch {
|
|
3797
|
+
return {};
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
async function mobileDoctor() {
|
|
3801
|
+
const appium = appiumPath();
|
|
3802
|
+
const drivers = await installedDrivers();
|
|
3803
|
+
const appiumVersion = appium ? (await run(appium.bin, ["--version"], 3e4, await toolEnv()).catch(() => "")).trim().split("\n").pop() ?? "" : "";
|
|
3804
|
+
const appiumLines = [
|
|
3805
|
+
appium ? { item: "Appium", ok: true, detail: `${appiumVersion || "installed"} (${appium.bin.startsWith(APPIUM_DIR) ? "installed by Flowriki" : appium.bin})` } : { item: "Appium", ok: false, detail: "not installed", fix: "flowriki-agent mobile setup" },
|
|
3806
|
+
onPath("ffmpeg") ? { item: "ffmpeg", ok: true, detail: "phone runs can be filmed", optional: true } : {
|
|
3807
|
+
item: "ffmpeg",
|
|
3808
|
+
ok: false,
|
|
3809
|
+
optional: true,
|
|
3810
|
+
detail: "not installed \u2014 Android videos stop at 3 minutes, iOS runs cannot be filmed",
|
|
3811
|
+
fix: process.platform === "darwin" ? "brew install ffmpeg" : process.platform === "win32" ? "winget install ffmpeg" : "install ffmpeg with your package manager"
|
|
3812
|
+
}
|
|
3813
|
+
];
|
|
3814
|
+
const sdk = androidHome();
|
|
3815
|
+
const adb = adbPath();
|
|
3816
|
+
const java = await javaHome();
|
|
3817
|
+
const devices = await listDevices().catch(() => []);
|
|
3818
|
+
const android = [
|
|
3819
|
+
sdk ? { item: "Android SDK", ok: true, detail: sdk } : { item: "Android SDK", ok: false, detail: "not found", fix: "Install Android Studio (it installs the SDK), or set ANDROID_HOME" },
|
|
3820
|
+
adb ? { item: "adb", ok: true, detail: adb } : { item: "adb", ok: false, detail: "not found", fix: "Android Studio \u2192 SDK Manager \u2192 SDK Platform-Tools" },
|
|
3821
|
+
java && javaMajor(java) >= 11 ? { item: "Java", ok: true, detail: java } : java ? { item: "Java", ok: false, detail: `${java} is too old`, fix: "Install a JDK 17 (Android Studio includes one), or point JAVA_HOME at one" } : { item: "Java", ok: false, detail: "not found", fix: "Install a JDK 17 (Android Studio includes one), or set JAVA_HOME" },
|
|
3822
|
+
drivers["uiautomator2"] ? { item: "Android driver", ok: true, detail: `uiautomator2 ${drivers["uiautomator2"]}` } : { item: "Android driver", ok: false, detail: "not installed", fix: "flowriki-agent mobile setup" }
|
|
3823
|
+
];
|
|
3824
|
+
const androids = devices.filter((d) => d.platform === "android");
|
|
3825
|
+
android.push(androids.length ? { item: "Devices", ok: true, detail: androids.map((d) => `${d.name} (${d.kind}${d.state === "ready" ? "" : `, ${d.state}`})`).join(", ") } : { item: "Devices", ok: false, detail: "none", fix: "Create an emulator in Android Studio \u2192 Device Manager, or plug in a phone with USB debugging on" });
|
|
3826
|
+
const ios = [];
|
|
3827
|
+
if (process.platform !== "darwin") {
|
|
3828
|
+
ios.push({ item: "Xcode", ok: false, detail: "iOS testing needs a Mac", fix: "Run the agent on a Mac, or use a cloud device farm" });
|
|
3829
|
+
} else {
|
|
3830
|
+
const xcode = (await run("xcodebuild", ["-version"], 2e4).catch(() => "")).split("\n")[0]?.trim();
|
|
3831
|
+
ios.push(xcode ? { item: "Xcode", ok: true, detail: xcode } : { item: "Xcode", ok: false, detail: "not installed", fix: "Install Xcode from the App Store, then run: sudo xcode-select -s /Applications/Xcode.app" });
|
|
3832
|
+
ios.push(drivers["xcuitest"] ? { item: "iOS driver", ok: true, detail: `xcuitest ${drivers["xcuitest"]}` } : { item: "iOS driver", ok: false, detail: "not installed", fix: "flowriki-agent mobile setup" });
|
|
3833
|
+
const iphones = devices.filter((d) => d.platform === "ios");
|
|
3834
|
+
ios.push(iphones.length ? { item: "Devices", ok: true, detail: `${iphones.filter((d) => d.kind === "simulator").length} simulators${iphones.some((d) => d.kind === "device") ? `, ${iphones.filter((d) => d.kind === "device").map((d) => d.name).join(", ")}` : ""}` } : { item: "Devices", ok: false, detail: "none", fix: "Xcode \u2192 Settings \u2192 Platforms \u2192 add an iOS simulator" });
|
|
3835
|
+
}
|
|
3836
|
+
const okAll = (lines) => lines.every((l) => l.ok || l.optional);
|
|
3837
|
+
return { appium: appiumLines, android, ios, androidReady: okAll(appiumLines) && okAll(android), iosReady: okAll(appiumLines) && okAll(ios) };
|
|
3838
|
+
}
|
|
3839
|
+
function stream(file, args, env, cwd, say) {
|
|
3840
|
+
return new Promise((resolve, reject) => {
|
|
3841
|
+
const child = spawn3(file, args, { env, cwd, stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32" });
|
|
3842
|
+
const each = (b) => String(b).split("\n").map((l) => l.trim()).filter(Boolean).forEach(say);
|
|
3843
|
+
child.stdout.on("data", each);
|
|
3844
|
+
child.stderr.on("data", each);
|
|
3845
|
+
child.on("error", reject);
|
|
3846
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${path7.basename(file)} ${args.join(" ")} failed (${code})`)));
|
|
3847
|
+
});
|
|
3848
|
+
}
|
|
3849
|
+
async function mobileSetup(say = console.log) {
|
|
3850
|
+
const npm = onPath("npm");
|
|
3851
|
+
if (!npm) throw new Error("npm was not found \u2014 install Node.js 20 or newer.");
|
|
3852
|
+
fs5.mkdirSync(path7.join(APPIUM_DIR, "home"), { recursive: true });
|
|
3853
|
+
if (!fs5.existsSync(path7.join(APPIUM_DIR, "package.json"))) {
|
|
3854
|
+
fs5.writeFileSync(path7.join(APPIUM_DIR, "package.json"), JSON.stringify({ name: "flowriki-appium", private: true }, null, 2));
|
|
3855
|
+
}
|
|
3856
|
+
const env = { ...await toolEnv(), APPIUM_HOME: path7.join(APPIUM_DIR, "home") };
|
|
3857
|
+
say("Installing Appium\u2026");
|
|
3858
|
+
await stream(npm, ["install", "--no-audit", "--no-fund", "appium@2"], env, APPIUM_DIR, say);
|
|
3859
|
+
const bin = path7.join(APPIUM_DIR, "node_modules", ".bin", process.platform === "win32" ? "appium.cmd" : "appium");
|
|
3860
|
+
const have = await installedDrivers();
|
|
3861
|
+
const wanted = ["uiautomator2", ...process.platform === "darwin" ? ["xcuitest"] : []];
|
|
3862
|
+
for (const driver of wanted) {
|
|
3863
|
+
if (have[driver]) {
|
|
3864
|
+
say(`${driver} is already installed.`);
|
|
3865
|
+
continue;
|
|
3866
|
+
}
|
|
3867
|
+
say(`Installing the ${driver === "xcuitest" ? "iOS" : "Android"} driver (${driver})\u2026`);
|
|
3868
|
+
await stream(bin, ["driver", "install", driver], env, APPIUM_DIR, say).catch((e) => say(`Could not install ${driver}: ${e.message}`));
|
|
3869
|
+
}
|
|
3870
|
+
return mobileDoctor();
|
|
3871
|
+
}
|
|
3872
|
+
var MobileLane = class {
|
|
3873
|
+
constructor(opts) {
|
|
3874
|
+
this.opts = opts;
|
|
3875
|
+
this.values = opts.values ?? /* @__PURE__ */ new Map();
|
|
3876
|
+
}
|
|
3877
|
+
opts;
|
|
3878
|
+
sessions = /* @__PURE__ */ new Map();
|
|
3879
|
+
filming = /* @__PURE__ */ new Set();
|
|
3880
|
+
failed = false;
|
|
3881
|
+
values;
|
|
3882
|
+
/** Run one phone row. `deviceKey` is the row's device — its actor id. */
|
|
3883
|
+
async run(occurrence, ctx) {
|
|
3884
|
+
const platform = occurrence.platform;
|
|
3885
|
+
if (!isMobile(platform)) return { status: "errored", error: `"${occurrence.name}" is not a phone flow`, steps: [] };
|
|
3886
|
+
let session = this.sessions.get(ctx.deviceKey);
|
|
3887
|
+
const start = ctx.first || !session ? "fresh" : startOf(occurrence) === "fresh" ? "fresh" : navigatesFirst(occurrence) ? "navigate" : "continue";
|
|
3888
|
+
try {
|
|
3889
|
+
if (session && session.platform !== platform) {
|
|
3890
|
+
return { status: "errored", error: `"${occurrence.name}" runs on ${platform}, but its device is ${session.platform === "ios" ? "an iPhone" : "an Android phone"} \u2014 give it a device of its own`, steps: [] };
|
|
3891
|
+
}
|
|
3892
|
+
if (!session) {
|
|
3893
|
+
session = await openMobileSession({
|
|
3894
|
+
platform,
|
|
3895
|
+
mobile: this.opts.mobile,
|
|
3896
|
+
...this.opts.devices?.[platform] ? { device: this.opts.devices[platform] } : {},
|
|
3897
|
+
...this.opts.resolveSecret ? { resolveSecret: this.opts.resolveSecret } : {}
|
|
3898
|
+
});
|
|
3899
|
+
this.sessions.set(ctx.deviceKey, session);
|
|
3900
|
+
if (filmsThis(this.opts.video, this.opts.allSteps ?? occurrence.steps, this.opts.evidenceDir) && await session.startVideo()) this.filming.add(ctx.deviceKey);
|
|
3901
|
+
} else if (start === "fresh" || start === "navigate") {
|
|
3902
|
+
await session.terminateApp().catch(() => void 0);
|
|
3903
|
+
await session.launchApp();
|
|
3904
|
+
await session.settle();
|
|
3905
|
+
}
|
|
3906
|
+
if (start !== "continue" && occurrence.startPath && /^[a-z][\w+.-]*:\/\//i.test(occurrence.startPath)) {
|
|
3907
|
+
await session.openLink(occurrence.startPath);
|
|
3908
|
+
}
|
|
3909
|
+
} catch (e) {
|
|
3910
|
+
return { status: "errored", error: e.message, steps: [] };
|
|
3911
|
+
}
|
|
3912
|
+
const done = await runMobileSteps(session, occurrence.steps, {
|
|
3913
|
+
values: this.values,
|
|
3914
|
+
stepOffset: ctx.stepOffset,
|
|
3915
|
+
...this.opts.resolveSecret ? { resolveSecret: this.opts.resolveSecret } : {},
|
|
3916
|
+
...this.opts.resolveAuth ? { resolveAuth: this.opts.resolveAuth } : {},
|
|
3917
|
+
...this.opts.runHelper ? { runHelper: this.opts.runHelper } : {},
|
|
3918
|
+
...ctx.onStepResult ? { onStepResult: ctx.onStepResult } : {},
|
|
3919
|
+
...this.opts.evidenceDir ? { evidenceDir: this.opts.evidenceDir } : {},
|
|
3920
|
+
...this.opts.screenshots ? { screenshots: this.opts.screenshots } : {},
|
|
3921
|
+
...this.opts.signal ? { signal: this.opts.signal } : {}
|
|
3922
|
+
});
|
|
3923
|
+
if (done.status !== "passed") this.failed = true;
|
|
3924
|
+
return { status: done.status, error: done.error, steps: done.results };
|
|
3925
|
+
}
|
|
3926
|
+
/** Close every phone; answers the films kept. `passed` is the whole
|
|
3927
|
+
* case's result — a film is about the case, not one row. */
|
|
3928
|
+
async close(passed = !this.failed) {
|
|
3929
|
+
const all = [...this.sessions.entries()];
|
|
3930
|
+
this.sessions.clear();
|
|
3931
|
+
const films = [];
|
|
3932
|
+
let n = 0;
|
|
3933
|
+
for (const [key, session] of all) {
|
|
3934
|
+
n++;
|
|
3935
|
+
if (this.filming.has(key)) films.push(...await finishVideo(session, this.opts.video, this.opts.evidenceDir, passed, `phone-${n}`));
|
|
3936
|
+
await session.close().catch(() => void 0);
|
|
3937
|
+
}
|
|
3938
|
+
this.filming.clear();
|
|
3939
|
+
return films;
|
|
3940
|
+
}
|
|
3941
|
+
};
|
|
3942
|
+
async function replayMobileCase(opts) {
|
|
3943
|
+
const started = Date.now();
|
|
3944
|
+
const lane = new MobileLane(opts);
|
|
3945
|
+
const outcomes = [];
|
|
3946
|
+
const opened = /* @__PURE__ */ new Set();
|
|
3947
|
+
let status = "passed";
|
|
3948
|
+
let error = null;
|
|
3949
|
+
let artifacts = [];
|
|
3950
|
+
try {
|
|
3951
|
+
for (const occurrence of opts.occurrences) {
|
|
3952
|
+
if (status !== "passed" || opts.signal?.aborted) {
|
|
3953
|
+
if (status === "passed") {
|
|
3954
|
+
status = "errored";
|
|
3955
|
+
error = "the run was cancelled";
|
|
3956
|
+
}
|
|
3957
|
+
const skipped = { occurrenceId: occurrence.occurrenceId, name: occurrence.name, status: "skipped", error: null, steps: [], stepsRun: 0, stepsTotal: occurrence.steps.length, durationMs: 0, continued: false };
|
|
3958
|
+
outcomes.push(skipped);
|
|
3959
|
+
opts.onOccurrenceResult?.(skipped);
|
|
3960
|
+
continue;
|
|
3961
|
+
}
|
|
3962
|
+
const key = occurrence.actorId ?? opts.defaultActorId ?? "default";
|
|
3963
|
+
const at = Date.now();
|
|
3964
|
+
const ran = await lane.run(occurrence, {
|
|
3965
|
+
deviceKey: key,
|
|
3966
|
+
first: !opened.has(key),
|
|
3967
|
+
stepOffset: outcomes.reduce((n, o) => n + o.stepsTotal, 0),
|
|
3968
|
+
...opts.onStepResult ? { onStepResult: (r) => opts.onStepResult(r, occurrence.occurrenceId) } : {}
|
|
3969
|
+
});
|
|
3970
|
+
opened.add(key);
|
|
3971
|
+
const outcome = {
|
|
3972
|
+
occurrenceId: occurrence.occurrenceId,
|
|
3973
|
+
name: occurrence.name,
|
|
3974
|
+
status: ran.status,
|
|
3975
|
+
error: ran.error,
|
|
3976
|
+
steps: ran.steps,
|
|
3977
|
+
stepsRun: ran.steps.filter((r) => r.status !== "skipped").length,
|
|
3978
|
+
stepsTotal: occurrence.steps.length,
|
|
3979
|
+
durationMs: Date.now() - at,
|
|
3980
|
+
continued: outcomes.length > 0 && startOf(occurrence) === "continue"
|
|
3981
|
+
};
|
|
3982
|
+
outcomes.push(outcome);
|
|
3983
|
+
opts.onOccurrenceResult?.(outcome);
|
|
3984
|
+
if (ran.status !== "passed") {
|
|
3985
|
+
status = ran.status;
|
|
3986
|
+
error = `${occurrence.name}: ${ran.error ?? "failed"}`;
|
|
3987
|
+
}
|
|
3988
|
+
}
|
|
3989
|
+
} finally {
|
|
3990
|
+
artifacts = await lane.close(status === "passed");
|
|
3991
|
+
}
|
|
3992
|
+
const steps = outcomes.flatMap((o) => o.steps);
|
|
3993
|
+
return {
|
|
3994
|
+
status,
|
|
3995
|
+
error,
|
|
3996
|
+
artifacts,
|
|
3997
|
+
durationMs: Date.now() - started,
|
|
3998
|
+
occurrences: outcomes,
|
|
3999
|
+
steps,
|
|
4000
|
+
stepsRun: steps.filter((r) => r.status !== "skipped").length,
|
|
4001
|
+
stepsTotal: opts.occurrences.reduce((n, o) => n + o.steps.length, 0),
|
|
4002
|
+
warnings: []
|
|
4003
|
+
};
|
|
4004
|
+
}
|
|
4005
|
+
|
|
4006
|
+
export {
|
|
4007
|
+
isMobile,
|
|
4008
|
+
stepRefusedOn,
|
|
4009
|
+
platformsOf,
|
|
4010
|
+
CLOUD_PROVIDERS,
|
|
4011
|
+
CLOUD_PROVIDER_NAME,
|
|
4012
|
+
DeviceChoiceSchema,
|
|
4013
|
+
mobileOf,
|
|
4014
|
+
ArtifactPolicySchema,
|
|
4015
|
+
ExecutionEnvironmentSchema,
|
|
4016
|
+
RecordedAssertionSchema,
|
|
4017
|
+
FeatureSchema,
|
|
4018
|
+
isSecretRefName,
|
|
4019
|
+
RecordedStepSchema,
|
|
4020
|
+
DraftStepSchema,
|
|
4021
|
+
slugify,
|
|
4022
|
+
CURRENT_SCHEMA_VERSION,
|
|
4023
|
+
FlowDocumentSchema,
|
|
4024
|
+
parseFlowDocument,
|
|
4025
|
+
mirrorCheckTargets,
|
|
4026
|
+
CapabilitySchema,
|
|
4027
|
+
parseGherkin,
|
|
4028
|
+
TestCaseSchema,
|
|
4029
|
+
startOf,
|
|
4030
|
+
navigatesFirst,
|
|
4031
|
+
sessionLanes,
|
|
4032
|
+
caseDebugPlan,
|
|
4033
|
+
restoreDebugCase,
|
|
4034
|
+
AuthReferenceSchema,
|
|
4035
|
+
DeliveredAuthSchema,
|
|
4036
|
+
actorPreparations,
|
|
4037
|
+
validateActors,
|
|
4038
|
+
actorIdsUsed,
|
|
4039
|
+
sameIdentity,
|
|
4040
|
+
PrerequisiteProblem,
|
|
4041
|
+
expandForRun,
|
|
4042
|
+
DeliveredHelperSchema,
|
|
4043
|
+
proposeAssertions,
|
|
4044
|
+
hasObservableChange,
|
|
4045
|
+
normalizeStartUrl,
|
|
4046
|
+
resolveStartUrl,
|
|
4047
|
+
locatorPlans,
|
|
4048
|
+
locatorPlan,
|
|
4049
|
+
describeLocators,
|
|
4050
|
+
adjacentLabelPattern,
|
|
4051
|
+
frameSelector,
|
|
4052
|
+
inputSelector,
|
|
4053
|
+
SCOPE_ROLES,
|
|
4054
|
+
CHECKS,
|
|
4055
|
+
describeAssertion,
|
|
4056
|
+
identifiedByOwnWords,
|
|
4057
|
+
textIndependent,
|
|
4058
|
+
STEP_PRESENTATION,
|
|
4059
|
+
WebDriverError,
|
|
4060
|
+
WebDriverSession,
|
|
4061
|
+
AGENT_HOME,
|
|
4062
|
+
APPIUM_DIR,
|
|
4063
|
+
androidHome,
|
|
4064
|
+
adbPath,
|
|
4065
|
+
appiumPath,
|
|
4066
|
+
javaHome,
|
|
4067
|
+
toolEnv,
|
|
4068
|
+
run,
|
|
4069
|
+
AppiumMissing,
|
|
4070
|
+
appiumServer,
|
|
4071
|
+
stopAppium,
|
|
4072
|
+
listDevices,
|
|
4073
|
+
bootDevice,
|
|
4074
|
+
CLOUD_HUBS,
|
|
4075
|
+
appOf,
|
|
4076
|
+
cloudDevices,
|
|
4077
|
+
readScreen,
|
|
4078
|
+
screenLines,
|
|
4079
|
+
nodeAt,
|
|
4080
|
+
centre,
|
|
4081
|
+
LocateError,
|
|
4082
|
+
resolveOn,
|
|
4083
|
+
describeTarget,
|
|
4084
|
+
locate,
|
|
4085
|
+
targetFor,
|
|
4086
|
+
CheckError,
|
|
4087
|
+
textOf,
|
|
4088
|
+
checkMobileAssertion,
|
|
4089
|
+
MobileSession,
|
|
4090
|
+
MOBILE_TIMEOUTS,
|
|
4091
|
+
performMobileStep,
|
|
4092
|
+
runMobileSteps,
|
|
4093
|
+
typesSecrets,
|
|
4094
|
+
filmsThis,
|
|
4095
|
+
finishVideo,
|
|
4096
|
+
chooseDevice,
|
|
4097
|
+
openMobileSession,
|
|
4098
|
+
replayMobileFlow,
|
|
4099
|
+
mobileDoctor,
|
|
4100
|
+
mobileSetup,
|
|
4101
|
+
MobileLane,
|
|
4102
|
+
replayMobileCase
|
|
4103
|
+
};
|