@itmetsam/samurai-framework 0.5.0 → 0.7.0
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 +4 -4
- package/bin/samurai.mjs +25 -0
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/browser/browser-finder.d.ts +1 -0
- package/dist/browser/browser-finder.js +1 -0
- package/dist/browser/browser.d.ts +8 -0
- package/dist/browser/browser.js +33 -12
- package/dist/browser/evidence.d.ts +19 -0
- package/dist/browser/evidence.js +35 -0
- package/dist/browser/page.d.ts +6 -0
- package/dist/browser/page.js +20 -3
- package/dist/browser/screenshot.d.ts +2 -0
- package/dist/browser/screenshot.js +5 -2
- package/dist/browser/warm-up.d.ts +14 -0
- package/dist/browser/warm-up.js +37 -0
- package/dist/browsers/cache-dir.d.ts +2 -0
- package/dist/browsers/cache-dir.js +15 -0
- package/dist/browsers/downloader.d.ts +23 -0
- package/dist/browsers/downloader.js +52 -0
- package/dist/browsers/index.d.ts +6 -0
- package/dist/browsers/index.js +5 -0
- package/dist/browsers/overrides.d.ts +17 -0
- package/dist/browsers/overrides.js +15 -0
- package/dist/browsers/pins.d.ts +16 -0
- package/dist/browsers/pins.js +14 -0
- package/dist/browsers/resolve.d.ts +26 -0
- package/dist/browsers/resolve.js +56 -0
- package/dist/browsers/store.d.ts +52 -0
- package/dist/browsers/store.js +170 -0
- package/dist/cli/args.d.ts +2 -0
- package/dist/cli/args.js +2 -0
- package/dist/cli/browsers.d.ts +6 -0
- package/dist/cli/browsers.js +117 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +26 -2
- package/dist/project/config-edit.d.ts +16 -0
- package/dist/project/config-edit.js +218 -0
- package/dist/project/index.d.ts +3 -0
- package/dist/project/index.js +3 -0
- package/dist/project/secrets-file.d.ts +8 -0
- package/dist/project/secrets-file.js +69 -0
- package/dist/project/variables.d.ts +6 -0
- package/dist/project/variables.js +17 -0
- package/dist/recorder/candidates.d.ts +7 -0
- package/dist/recorder/candidates.js +118 -0
- package/dist/recorder/capture.d.ts +8 -1
- package/dist/recorder/capture.js +25 -116
- package/dist/recorder/index.d.ts +1 -1
- package/dist/recorder/normalise.d.ts +28 -1
- package/dist/recorder/normalise.js +40 -1
- package/dist/recorder/recorder.d.ts +54 -0
- package/dist/recorder/recorder.js +195 -10
- package/dist/recorder/session.d.ts +15 -0
- package/dist/recorder/session.js +61 -7
- package/dist/recorder/toolbar/controller.d.ts +136 -0
- package/dist/recorder/toolbar/controller.js +471 -0
- package/dist/recorder/toolbar/inspect.d.ts +12 -0
- package/dist/recorder/toolbar/inspect.js +94 -0
- package/dist/recorder/toolbar/protocol.d.ts +135 -0
- package/dist/recorder/toolbar/protocol.js +4 -0
- package/dist/recorder/toolbar/source.d.ts +32 -0
- package/dist/recorder/toolbar/source.js +856 -0
- package/dist/recorder/toolbar/styles.d.ts +6 -0
- package/dist/recorder/toolbar/styles.js +95 -0
- package/dist/runner/reporter.d.ts +5 -1
- package/dist/runner/reporter.js +4 -3
- package/dist/runner/test-runner.d.ts +9 -0
- package/dist/runner/test-runner.js +44 -4
- package/dist/steps/emit.js +4 -0
- package/dist/steps/model.d.ts +11 -0
- package/dist/steps/parse.js +22 -3
- package/dist/transport/bidi-connection.d.ts +2 -0
- package/dist/transport/bidi-connection.js +33 -2
- package/dist/types/bidi-modules/script.d.ts +20 -3
- package/dist/types/config.d.ts +5 -0
- package/dist/types/test.d.ts +4 -0
- package/package.json +10 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
/** The config can't be edited in place, e.g. it builds its object from variables or spreads */
|
|
3
|
+
export class ConfigEditError extends Error {
|
|
4
|
+
}
|
|
5
|
+
const KEY = /^[A-Za-z_$][\w$]*$/;
|
|
6
|
+
const keyText = (key) => (KEY.test(key) ? key : JSON.stringify(key));
|
|
7
|
+
function emit(value, indent, unit) {
|
|
8
|
+
if (typeof value !== "object")
|
|
9
|
+
return JSON.stringify(value);
|
|
10
|
+
if (Array.isArray(value)) {
|
|
11
|
+
if (value.length === 0)
|
|
12
|
+
return "[]";
|
|
13
|
+
const inner = indent + unit;
|
|
14
|
+
return `[\n${value.map((v) => `${inner}${emit(v, inner, unit)},`).join("\n")}\n${indent}]`;
|
|
15
|
+
}
|
|
16
|
+
const entries = Object.entries(value);
|
|
17
|
+
if (entries.length === 0)
|
|
18
|
+
return "{}";
|
|
19
|
+
const inner = indent + unit;
|
|
20
|
+
return `{\n${entries
|
|
21
|
+
.map(([k, v]) => `${inner}${keyText(k)}: ${emit(v, inner, unit)},`)
|
|
22
|
+
.join("\n")}\n${indent}}`;
|
|
23
|
+
}
|
|
24
|
+
function nameOf(prop) {
|
|
25
|
+
if (!ts.isPropertyAssignment(prop) &&
|
|
26
|
+
!ts.isShorthandPropertyAssignment(prop)) {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
const n = prop.name;
|
|
30
|
+
return ts.isIdentifier(n) ||
|
|
31
|
+
ts.isStringLiteral(n) ||
|
|
32
|
+
ts.isNoSubstitutionTemplateLiteral(n)
|
|
33
|
+
? n.text
|
|
34
|
+
: undefined;
|
|
35
|
+
}
|
|
36
|
+
/** The object `defineConfig({...})` or `export default {...}` is given */
|
|
37
|
+
function rootObject(sf) {
|
|
38
|
+
for (const statement of sf.statements) {
|
|
39
|
+
if (!ts.isExportAssignment(statement))
|
|
40
|
+
continue;
|
|
41
|
+
let node = statement.expression;
|
|
42
|
+
for (;;) {
|
|
43
|
+
if (ts.isParenthesizedExpression(node) ||
|
|
44
|
+
ts.isSatisfiesExpression(node) ||
|
|
45
|
+
ts.isAsExpression(node)) {
|
|
46
|
+
node = node.expression;
|
|
47
|
+
}
|
|
48
|
+
else
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
if (ts.isCallExpression(node) &&
|
|
52
|
+
node.arguments[0] &&
|
|
53
|
+
ts.isObjectLiteralExpression(node.arguments[0])) {
|
|
54
|
+
return node.arguments[0];
|
|
55
|
+
}
|
|
56
|
+
if (ts.isObjectLiteralExpression(node))
|
|
57
|
+
return node;
|
|
58
|
+
}
|
|
59
|
+
throw new ConfigEditError("samurai.config.ts doesn't export a plain object (defineConfig({ … })), so it can't be edited here.");
|
|
60
|
+
}
|
|
61
|
+
const lineIndent = (source, pos) => {
|
|
62
|
+
const start = source.lastIndexOf("\n", pos - 1) + 1;
|
|
63
|
+
return /^[ \t]*/.exec(source.slice(start))[0];
|
|
64
|
+
};
|
|
65
|
+
function unitOf(source, obj) {
|
|
66
|
+
const first = obj.properties[0];
|
|
67
|
+
if (first) {
|
|
68
|
+
const inner = lineIndent(source, first.getStart());
|
|
69
|
+
const outer = lineIndent(source, obj.getStart());
|
|
70
|
+
if (inner.length > outer.length)
|
|
71
|
+
return inner.slice(outer.length);
|
|
72
|
+
}
|
|
73
|
+
return " ";
|
|
74
|
+
}
|
|
75
|
+
function insertProperty(source, obj, key, value) {
|
|
76
|
+
const unit = unitOf(source, obj);
|
|
77
|
+
const outer = lineIndent(source, obj.getStart());
|
|
78
|
+
const inner = outer + unit;
|
|
79
|
+
const multiline = obj.getText().includes("\n");
|
|
80
|
+
const prop = `${keyText(key)}: ${emit(value, inner, unit)}`;
|
|
81
|
+
const last = obj.properties[obj.properties.length - 1];
|
|
82
|
+
if (!last) {
|
|
83
|
+
return {
|
|
84
|
+
start: obj.getStart(),
|
|
85
|
+
end: obj.end,
|
|
86
|
+
text: `{\n${inner}${prop},\n${outer}}`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const trailing = obj.properties.hasTrailingComma
|
|
90
|
+
? /^\s*,/.exec(source.slice(last.end))
|
|
91
|
+
: null;
|
|
92
|
+
if (!multiline) {
|
|
93
|
+
return trailing
|
|
94
|
+
? {
|
|
95
|
+
start: last.end + trailing[0].length,
|
|
96
|
+
end: last.end + trailing[0].length,
|
|
97
|
+
text: ` ${keyText(key)}: ${emit(value, outer, unit)},`,
|
|
98
|
+
}
|
|
99
|
+
: {
|
|
100
|
+
start: last.end,
|
|
101
|
+
end: last.end,
|
|
102
|
+
text: `, ${keyText(key)}: ${emit(value, outer, unit)}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return trailing
|
|
106
|
+
? {
|
|
107
|
+
start: last.end + trailing[0].length,
|
|
108
|
+
end: last.end + trailing[0].length,
|
|
109
|
+
text: `\n${inner}${prop},`,
|
|
110
|
+
}
|
|
111
|
+
: { start: last.end, end: last.end, text: `,\n${inner}${prop}` };
|
|
112
|
+
}
|
|
113
|
+
function removeProperty(source, prop) {
|
|
114
|
+
const s = prop.getStart();
|
|
115
|
+
let e = prop.end;
|
|
116
|
+
const comma = /^[ \t]*,/.exec(source.slice(e));
|
|
117
|
+
if (comma)
|
|
118
|
+
e += comma[0].length;
|
|
119
|
+
const lineStart = source.lastIndexOf("\n", s - 1) + 1;
|
|
120
|
+
const aloneBefore = /^[ \t]*$/.test(source.slice(lineStart, s));
|
|
121
|
+
const rest = /^[ \t]*(\r?\n)/.exec(source.slice(e));
|
|
122
|
+
if (aloneBefore && rest)
|
|
123
|
+
return { start: lineStart, end: e + rest[0].length, text: "" };
|
|
124
|
+
const space = /^[ \t]*/.exec(source.slice(e))[0];
|
|
125
|
+
return { start: s, end: e + space.length, text: "" };
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Sets (or with `undefined`, removes) one value in samurai.config.ts, addressed by its path, e.g.
|
|
129
|
+
* ["environments", "staging", "baseURL"]. Only that property's text changes; missing objects on the way are
|
|
130
|
+
* created. Throws ConfigEditError when the file isn't shaped so that this is safe.
|
|
131
|
+
*/
|
|
132
|
+
export function editConfig(source, path, value) {
|
|
133
|
+
if (path.length === 0)
|
|
134
|
+
throw new ConfigEditError("No setting given.");
|
|
135
|
+
const sf = ts.createSourceFile("samurai.config.ts", source, ts.ScriptTarget.Latest, true);
|
|
136
|
+
let obj = rootObject(sf);
|
|
137
|
+
for (let i = 0; i < path.length; i++) {
|
|
138
|
+
const key = path[i];
|
|
139
|
+
const prop = obj.properties.find((p) => nameOf(p) === key);
|
|
140
|
+
const isLast = i === path.length - 1;
|
|
141
|
+
if (prop && !ts.isPropertyAssignment(prop)) {
|
|
142
|
+
throw new ConfigEditError(`"${key}" in samurai.config.ts isn't a plain value (shorthand or spread), so it can't be edited here.`);
|
|
143
|
+
}
|
|
144
|
+
if (isLast) {
|
|
145
|
+
let splice;
|
|
146
|
+
if (value === undefined) {
|
|
147
|
+
if (!prop)
|
|
148
|
+
return source;
|
|
149
|
+
splice = removeProperty(source, prop);
|
|
150
|
+
}
|
|
151
|
+
else if (prop && ts.isPropertyAssignment(prop)) {
|
|
152
|
+
const init = prop.initializer;
|
|
153
|
+
const unit = unitOf(source, obj);
|
|
154
|
+
splice = {
|
|
155
|
+
start: init.getStart(),
|
|
156
|
+
end: init.end,
|
|
157
|
+
text: emit(value, lineIndent(source, prop.getStart()), unit),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
splice = insertProperty(source, obj, key, value);
|
|
162
|
+
}
|
|
163
|
+
return (source.slice(0, splice.start) + splice.text + source.slice(splice.end));
|
|
164
|
+
}
|
|
165
|
+
if (!prop) {
|
|
166
|
+
if (value === undefined)
|
|
167
|
+
return source;
|
|
168
|
+
// Build the rest of the path as one nested object and add it here
|
|
169
|
+
let nested = value;
|
|
170
|
+
for (let j = path.length - 1; j > i; j--)
|
|
171
|
+
nested = { [path[j]]: nested };
|
|
172
|
+
const splice = insertProperty(source, obj, key, nested);
|
|
173
|
+
return (source.slice(0, splice.start) + splice.text + source.slice(splice.end));
|
|
174
|
+
}
|
|
175
|
+
if (!ts.isObjectLiteralExpression(prop.initializer)) {
|
|
176
|
+
throw new ConfigEditError(`"${key}" in samurai.config.ts isn't a plain object, so it can't be edited here.`);
|
|
177
|
+
}
|
|
178
|
+
obj = prop.initializer;
|
|
179
|
+
}
|
|
180
|
+
return source;
|
|
181
|
+
}
|
|
182
|
+
/** The text of one property (`staging: { … }`), indentation removed, for showing a part of the file; undefined when missing */
|
|
183
|
+
export function propertyText(source, path) {
|
|
184
|
+
const sf = ts.createSourceFile("samurai.config.ts", source, ts.ScriptTarget.Latest, true);
|
|
185
|
+
let obj;
|
|
186
|
+
try {
|
|
187
|
+
obj = rootObject(sf);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
for (let i = 0; i < path.length; i++) {
|
|
193
|
+
const prop = obj.properties.find((p) => nameOf(p) === path[i]);
|
|
194
|
+
if (!prop)
|
|
195
|
+
return undefined;
|
|
196
|
+
if (i === path.length - 1) {
|
|
197
|
+
const indent = lineIndent(source, prop.getStart());
|
|
198
|
+
return prop
|
|
199
|
+
.getText()
|
|
200
|
+
.split("\n")
|
|
201
|
+
.map((line, n) => n === 0
|
|
202
|
+
? line
|
|
203
|
+
: line.startsWith(indent)
|
|
204
|
+
? line.slice(indent.length)
|
|
205
|
+
: line.trimStart())
|
|
206
|
+
.join("\n");
|
|
207
|
+
}
|
|
208
|
+
if (!ts.isPropertyAssignment(prop) ||
|
|
209
|
+
!ts.isObjectLiteralExpression(prop.initializer))
|
|
210
|
+
return undefined;
|
|
211
|
+
obj = prop.initializer;
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
/** Whether the property at this path exists in the file's source, walking the same object literals `editConfig` does */
|
|
216
|
+
export function hasConfigPath(source, path) {
|
|
217
|
+
return propertyText(source, path) !== undefined;
|
|
218
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare const SECRET_NAME: RegExp;
|
|
2
|
+
/** Whether .gitignore covers this file: its name, or `.env*` / `.env.*` */
|
|
3
|
+
export declare function isGitIgnored(projectDir: string, file: string): Promise<boolean>;
|
|
4
|
+
/**
|
|
5
|
+
* Sets SAMURAI_SECRET_<name> in .env.<environment>, replacing its line or adding one. The value is masked
|
|
6
|
+
* in this process's logs from then on, as a secret loaded from the file would be
|
|
7
|
+
*/
|
|
8
|
+
export declare function setSecret(projectDir: string, environment: string, name: string, value: string): Promise<void>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { registerSecret } from "../config/mask.js";
|
|
5
|
+
const PREFIX = "SAMURAI_SECRET_";
|
|
6
|
+
export const SECRET_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
7
|
+
const ENVIRONMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
8
|
+
function envFile(projectDir, environment) {
|
|
9
|
+
// The name ends up in a file name: keep it to plain characters
|
|
10
|
+
if (!ENVIRONMENT.test(environment))
|
|
11
|
+
throw new Error(`"${environment}" isn't a valid environment name.`);
|
|
12
|
+
return path.join(projectDir, `.env.${environment}`);
|
|
13
|
+
}
|
|
14
|
+
function checkName(name) {
|
|
15
|
+
if (!SECRET_NAME.test(name))
|
|
16
|
+
throw new Error("Use letters, digits and _; a secret is UPPER_SNAKE_CASE.");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* dotenv value: bare when that is safe, else in the quotes that need no escaping. Node's parser, which the
|
|
20
|
+
* framework reads these files with, can't escape a quote inside a quote, so the quote kind is picked to not
|
|
21
|
+
* occur in the value.
|
|
22
|
+
*/
|
|
23
|
+
function formatValue(value) {
|
|
24
|
+
if (/^[A-Za-z0-9_./:@%+=-]*$/.test(value))
|
|
25
|
+
return value;
|
|
26
|
+
if (!value.includes("'") && !/[\r\n]/.test(value))
|
|
27
|
+
return `'${value}'`;
|
|
28
|
+
if (!value.includes("`"))
|
|
29
|
+
return `\`${value}\``;
|
|
30
|
+
if (!value.includes('"') && !value.includes("\\") && !/[\r\n]/.test(value))
|
|
31
|
+
return `"${value}"`;
|
|
32
|
+
throw new Error("This value has both ' and ` characters (and more), which a .env file can't hold. Set it as a SAMURAI_SECRET_* environment variable instead.");
|
|
33
|
+
}
|
|
34
|
+
const lineFor = (name) => new RegExp(`^[ \\t]*(?:export[ \\t]+)?${PREFIX}${name}[ \\t]*=`);
|
|
35
|
+
/** Whether .gitignore covers this file: its name, or `.env*` / `.env.*` */
|
|
36
|
+
export async function isGitIgnored(projectDir, file) {
|
|
37
|
+
try {
|
|
38
|
+
const text = await readFile(path.join(projectDir, ".gitignore"), "utf8");
|
|
39
|
+
return text.split(/\r?\n/).some((l) => {
|
|
40
|
+
const line = l.trim().replace(/^\//, "");
|
|
41
|
+
return line === file || line === ".env*" || line === ".env.*";
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Sets SAMURAI_SECRET_<name> in .env.<environment>, replacing its line or adding one. The value is masked
|
|
50
|
+
* in this process's logs from then on, as a secret loaded from the file would be
|
|
51
|
+
*/
|
|
52
|
+
export async function setSecret(projectDir, environment, name, value) {
|
|
53
|
+
checkName(name);
|
|
54
|
+
if (value === "")
|
|
55
|
+
throw new Error("A secret can't be empty. Remove it instead.");
|
|
56
|
+
if (/\r|\n/.test(value) && !value.trim())
|
|
57
|
+
throw new Error("A secret can't be only line breaks.");
|
|
58
|
+
const file = envFile(projectDir, environment);
|
|
59
|
+
const text = existsSync(file) ? await readFile(file, "utf8") : "";
|
|
60
|
+
const lines = text === "" ? [] : text.replace(/\n$/, "").split("\n");
|
|
61
|
+
const line = `${PREFIX}${name}=${formatValue(value)}`;
|
|
62
|
+
const at = lines.findIndex((l) => lineFor(name).test(l));
|
|
63
|
+
if (at >= 0)
|
|
64
|
+
lines[at] = line;
|
|
65
|
+
else
|
|
66
|
+
lines.push(line);
|
|
67
|
+
await writeFile(file, lines.join("\n") + "\n", { mode: 0o600 });
|
|
68
|
+
registerSecret(name, value);
|
|
69
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The name is already set in that environment */
|
|
2
|
+
export declare class ExistsError extends Error {
|
|
3
|
+
}
|
|
4
|
+
export declare const VARIABLE_NAME: RegExp;
|
|
5
|
+
/** Adds a variable to one environment in samurai.config.ts; only that property's text changes */
|
|
6
|
+
export declare function addVariable(projectDir: string, environment: string, name: string, value: string): Promise<void>;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { editConfig, hasConfigPath } from "./config-edit.js";
|
|
4
|
+
/** The name is already set in that environment */
|
|
5
|
+
export class ExistsError extends Error {
|
|
6
|
+
}
|
|
7
|
+
export const VARIABLE_NAME = /^[A-Za-z_$][\w$]*$/;
|
|
8
|
+
/** Adds a variable to one environment in samurai.config.ts; only that property's text changes */
|
|
9
|
+
export async function addVariable(projectDir, environment, name, value) {
|
|
10
|
+
if (!VARIABLE_NAME.test(name))
|
|
11
|
+
throw new Error("Use letters, digits and _, starting with a letter.");
|
|
12
|
+
const file = path.join(projectDir, "samurai.config.ts");
|
|
13
|
+
const source = await readFile(file, "utf8");
|
|
14
|
+
if (hasConfigPath(source, ["environments", environment, "variables", name]))
|
|
15
|
+
throw new ExistsError(`"${name}" already exists`);
|
|
16
|
+
await writeFile(file, editConfig(source, ["environments", environment, "variables", name], value));
|
|
17
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarations that turn an element into locator candidates (test id, role and name, label, text, css and
|
|
3
|
+
* an absolute xpath), plus `inToolbar(event)`, which is true for events from the recorder toolbar. A string
|
|
4
|
+
* of plain declarations, interpolated into the capture script (page realm) and the toolbar script
|
|
5
|
+
* (sandbox realm), so no transpiler helper can end up in it.
|
|
6
|
+
*/
|
|
7
|
+
export declare const CANDIDATES_JS: string;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarations that turn an element into locator candidates (test id, role and name, label, text, css and
|
|
3
|
+
* an absolute xpath), plus `inToolbar(event)`, which is true for events from the recorder toolbar. A string
|
|
4
|
+
* of plain declarations, interpolated into the capture script (page realm) and the toolbar script
|
|
5
|
+
* (sandbox realm), so no transpiler helper can end up in it.
|
|
6
|
+
*/
|
|
7
|
+
export const CANDIDATES_JS = String.raw `
|
|
8
|
+
const norm = (text) => String(text == null ? "" : text).replace(/\s+/g, " ").trim();
|
|
9
|
+
const textOf = (node) => norm(node.innerText != null ? node.innerText : node.textContent);
|
|
10
|
+
const NO_ROLE_TYPES = ["hidden", "password", "file", "color", "date", "datetime-local", "month", "time", "week"];
|
|
11
|
+
const TEXT_TYPES = ["", "text", "email", "search", "tel", "url", "password", "number"];
|
|
12
|
+
|
|
13
|
+
const roleOf = (el) => {
|
|
14
|
+
const explicit = norm(el.getAttribute("role")).split(" ")[0];
|
|
15
|
+
if (explicit) return explicit;
|
|
16
|
+
const tag = el.tagName.toLowerCase();
|
|
17
|
+
if (tag === "a") return el.hasAttribute("href") ? "link" : undefined;
|
|
18
|
+
if (tag === "button" || tag === "summary") return "button";
|
|
19
|
+
if (tag === "select") return el.multiple || el.size > 1 ? "listbox" : "combobox";
|
|
20
|
+
if (tag === "textarea") return "textbox";
|
|
21
|
+
if (/^h[1-6]$/.test(tag)) return "heading";
|
|
22
|
+
if (tag === "img") return "img";
|
|
23
|
+
if (tag === "input") {
|
|
24
|
+
const type = (el.getAttribute("type") || "text").toLowerCase();
|
|
25
|
+
if (NO_ROLE_TYPES.indexOf(type) >= 0) return undefined;
|
|
26
|
+
if (type === "checkbox" || type === "radio") return type;
|
|
27
|
+
if (type === "button" || type === "submit" || type === "reset" || type === "image") return "button";
|
|
28
|
+
if (type === "range") return "slider";
|
|
29
|
+
if (type === "number") return "spinbutton";
|
|
30
|
+
if (type === "search") return "searchbox";
|
|
31
|
+
return "textbox";
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const labelTexts = (el) => {
|
|
37
|
+
const texts = [];
|
|
38
|
+
if (el.labels) for (const label of el.labels) texts.push(textOf(label));
|
|
39
|
+
const by = el.getAttribute("aria-labelledby");
|
|
40
|
+
if (by) {
|
|
41
|
+
for (const id of by.split(/\s+/)) {
|
|
42
|
+
const named = id && document.getElementById(id);
|
|
43
|
+
if (named) texts.push(textOf(named));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const own = el.getAttribute("aria-label");
|
|
47
|
+
if (own) texts.push(norm(own));
|
|
48
|
+
return texts.filter(Boolean);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const nameOf = (el, role) => {
|
|
52
|
+
const labelled = labelTexts(el)[0];
|
|
53
|
+
if (labelled) return labelled;
|
|
54
|
+
const tag = el.tagName.toLowerCase();
|
|
55
|
+
if (tag === "input") {
|
|
56
|
+
const type = (el.getAttribute("type") || "").toLowerCase();
|
|
57
|
+
return type === "submit" || type === "button" || type === "reset" ? norm(el.value) : "";
|
|
58
|
+
}
|
|
59
|
+
if (tag === "img") return norm(el.getAttribute("alt"));
|
|
60
|
+
if (role === "textbox" || role === "combobox" || role === "listbox") return "";
|
|
61
|
+
return textOf(el) || norm(el.getAttribute("title"));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const plain = (value) => value.indexOf('"') < 0 && value.indexOf("\\") < 0;
|
|
65
|
+
|
|
66
|
+
const xpathOf = (el) => {
|
|
67
|
+
const parts = [];
|
|
68
|
+
for (let node = el; node && node.nodeType === 1; node = node.parentElement) {
|
|
69
|
+
let index = 1;
|
|
70
|
+
for (let sibling = node.previousElementSibling; sibling; sibling = sibling.previousElementSibling) {
|
|
71
|
+
if (sibling.tagName === node.tagName) index++;
|
|
72
|
+
}
|
|
73
|
+
parts.unshift(node.tagName.toLowerCase() + "[" + index + "]");
|
|
74
|
+
}
|
|
75
|
+
return "/" + parts.join("/");
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const candidatesOf = (el) => {
|
|
79
|
+
const found = [];
|
|
80
|
+
const tag = el.tagName.toLowerCase();
|
|
81
|
+
const testId = el.getAttribute("data-testid");
|
|
82
|
+
if (testId) found.push({ method: "getByTestId", testId });
|
|
83
|
+
const role = roleOf(el);
|
|
84
|
+
if (role) {
|
|
85
|
+
const name = nameOf(el, role);
|
|
86
|
+
if (name && name.length <= 80) found.push({ method: "getByRole", role, name });
|
|
87
|
+
}
|
|
88
|
+
const label = labelTexts(el)[0];
|
|
89
|
+
if (label && label.length <= 80 && el.labels !== undefined) found.push({ method: "getByLabel", text: label });
|
|
90
|
+
const text = textOf(el);
|
|
91
|
+
if (text && text.length <= 60 && tag !== "input" && tag !== "textarea" && tag !== "select") {
|
|
92
|
+
found.push({ method: "getByText", text });
|
|
93
|
+
}
|
|
94
|
+
if (el.id && /^[A-Za-z][\w-]*$/.test(el.id) && !/\d{3,}/.test(el.id)) found.push({ method: "getByCss", css: "#" + el.id });
|
|
95
|
+
const name = el.getAttribute("name");
|
|
96
|
+
if (name && plain(name)) found.push({ method: "getByCss", css: tag + '[name="' + name + '"]' });
|
|
97
|
+
const placeholder = el.getAttribute("placeholder");
|
|
98
|
+
if (placeholder && plain(placeholder)) found.push({ method: "getByCss", css: tag + '[placeholder="' + placeholder + '"]' });
|
|
99
|
+
found.push({ method: "locator", xpath: xpathOf(el) });
|
|
100
|
+
return found;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const isTextEntry = (el) => {
|
|
104
|
+
const tag = el.tagName.toLowerCase();
|
|
105
|
+
if (tag === "textarea") return true;
|
|
106
|
+
if (tag === "input") return TEXT_TYPES.indexOf((el.getAttribute("type") || "").toLowerCase()) >= 0;
|
|
107
|
+
return Boolean(el.isContentEditable);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const INTERACTIVE = "a[href],button,input,select,textarea,label,summary,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=option],[role=switch]";
|
|
111
|
+
const interactiveAt = (target) => {
|
|
112
|
+
const found = target.closest(INTERACTIVE) || target;
|
|
113
|
+
return found.tagName === "LABEL" && found.control ? found.control : found;
|
|
114
|
+
};
|
|
115
|
+
const TOOLBAR_TAG = "SAMURAI-RECORDER-TOOLBAR";
|
|
116
|
+
const inToolbar = (event) =>
|
|
117
|
+
event.composedPath().some((node) => node && node.tagName === TOOLBAR_TAG);
|
|
118
|
+
`;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Function declaration installed as a preload script, called as `(send)` where `send` is the recorder's
|
|
3
|
-
* channel.
|
|
3
|
+
* channel. `send` stays in this closure and is never put on the page's window, so a page script cannot
|
|
4
|
+
* report anything. A second run in a document that was recorded before (a new recording) adds its own
|
|
5
|
+
* listeners; those of the earlier run go quiet, as their run is no longer the current one.
|
|
6
|
+
* Reports what the tester does in the top-level document:
|
|
4
7
|
*
|
|
5
8
|
* - `{ type: "click", target, candidates, textEntry }`: a click, retargeted to the nearest interactive
|
|
6
9
|
* element (a label click reports its control; the click the browser forwards to the control is dropped).
|
|
@@ -18,3 +21,7 @@
|
|
|
18
21
|
export declare const CAPTURE_SOURCE: string;
|
|
19
22
|
/** Run in every loaded document to stop its capture script reporting and swallowing Alt+clicks */
|
|
20
23
|
export declare const CAPTURE_OFF = "() => { window[Symbol.for(\"samurai.recorder.off\")] = true; }";
|
|
24
|
+
/** Run in every loaded document to pause its capture script (the toolbar's Pause, and while it types a value) */
|
|
25
|
+
export declare const CAPTURE_MUTE = "() => { window[Symbol.for(\"samurai.recorder.muted\")] = true; }";
|
|
26
|
+
/** Run in every loaded document to resume what `CAPTURE_MUTE` paused */
|
|
27
|
+
export declare const CAPTURE_UNMUTE = "() => { window[Symbol.for(\"samurai.recorder.muted\")] = false; }";
|