@itmetsam/samurai-framework 0.7.0 → 0.8.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 +25 -6
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/browser/file-chooser.d.ts +4 -3
- package/dist/browser/file-chooser.js +9 -8
- package/dist/browser/generated-files.d.ts +30 -0
- package/dist/browser/generated-files.js +116 -0
- package/dist/browser/page.d.ts +19 -0
- package/dist/browser/page.js +30 -0
- package/dist/cli/main.d.ts +1 -1
- package/dist/cli/main.js +22 -7
- package/dist/locator/locator.d.ts +17 -5
- package/dist/locator/locator.js +56 -8
- package/dist/project/files-store.d.ts +28 -0
- package/dist/project/files-store.js +106 -0
- package/dist/project/index.d.ts +1 -0
- package/dist/project/index.js +1 -0
- package/dist/recorder/apply.js +8 -6
- package/dist/recorder/files-capture.d.ts +26 -0
- package/dist/recorder/files-capture.js +92 -0
- package/dist/recorder/files-reader.d.ts +7 -0
- package/dist/recorder/files-reader.js +25 -0
- package/dist/recorder/index.d.ts +1 -1
- package/dist/recorder/index.js +1 -1
- package/dist/recorder/normalise.d.ts +26 -2
- package/dist/recorder/normalise.js +48 -1
- package/dist/recorder/recorder.d.ts +38 -0
- package/dist/recorder/recorder.js +160 -1
- package/dist/recorder/session.d.ts +16 -1
- package/dist/recorder/session.js +36 -6
- package/dist/recorder/toolbar/controller.d.ts +9 -0
- package/dist/recorder/toolbar/controller.js +22 -0
- package/dist/recorder/toolbar/protocol.d.ts +10 -0
- package/dist/recorder/toolbar/source.d.ts +1 -1
- package/dist/recorder/toolbar/source.js +35 -2
- package/dist/recorder/toolbar/styles.d.ts +1 -1
- package/dist/recorder/toolbar/styles.js +31 -0
- package/dist/runner/project.js +2 -0
- package/dist/steps/edit.d.ts +3 -2
- package/dist/steps/edit.js +64 -5
- package/dist/steps/emit.d.ts +3 -1
- package/dist/steps/emit.js +19 -0
- package/dist/steps/files.d.ts +8 -0
- package/dist/steps/files.js +33 -0
- package/dist/steps/index.d.ts +3 -2
- package/dist/steps/index.js +2 -1
- package/dist/steps/model.d.ts +38 -0
- package/dist/steps/parse.js +102 -0
- package/dist/types/config.d.ts +7 -2
- package/package.json +1 -1
- package/dist/browser/file-paths.d.ts +0 -4
- package/dist/browser/file-paths.js +0 -18
package/dist/steps/edit.d.ts
CHANGED
|
@@ -24,7 +24,8 @@ export type StepEdit = {
|
|
|
24
24
|
to: number;
|
|
25
25
|
};
|
|
26
26
|
/**
|
|
27
|
-
* Applies one edit and returns the new source. Only the touched statement changes
|
|
28
|
-
*
|
|
27
|
+
* Applies one edit and returns the new source. Only the touched statement changes, plus the test's parameters
|
|
28
|
+
* when the step reads a fixture (`env`, `secrets`) they don't have yet: everything else, formatting and
|
|
29
|
+
* comments included, stays byte for byte.
|
|
29
30
|
*/
|
|
30
31
|
export declare function applyEdit(source: string, edit: StepEdit): string;
|
package/dist/steps/edit.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
1
2
|
import { stepToSource } from "./emit.js";
|
|
2
3
|
import { parseSpec } from "./parse.js";
|
|
3
4
|
const lineStart = (source, offset) => source.lastIndexOf("\n", offset - 1) + 1;
|
|
@@ -51,19 +52,77 @@ function removeStep(source, parsed, index) {
|
|
|
51
52
|
const { anchor, end } = pickStep(parsed, index);
|
|
52
53
|
return splice(source, lineStart(source, anchor), lineEnd(source, end), "");
|
|
53
54
|
}
|
|
55
|
+
/** The fixtures a step's statement reads */
|
|
56
|
+
function fixturesOf(step) {
|
|
57
|
+
if (step.kind !== "fill" || step.value.kind === "literal")
|
|
58
|
+
return [];
|
|
59
|
+
return [step.value.kind === "env" ? "env" : "secrets"];
|
|
60
|
+
}
|
|
61
|
+
/** Adds the fixtures the step reads to the test's parameters, when they aren't there yet */
|
|
62
|
+
function withFixtures(source, parsed, step) {
|
|
63
|
+
const { fixtures } = parsed;
|
|
64
|
+
if (!fixtures)
|
|
65
|
+
return source;
|
|
66
|
+
const missing = fixturesOf(step).filter((name) => !fixtures.names.includes(name));
|
|
67
|
+
if (missing.length === 0)
|
|
68
|
+
return source;
|
|
69
|
+
const list = missing.join(", ");
|
|
70
|
+
const text = fixtures.shape === "list"
|
|
71
|
+
? `, ${list}`
|
|
72
|
+
: fixtures.shape === "empty"
|
|
73
|
+
? ` ${list} `
|
|
74
|
+
: `{ ${list} }`;
|
|
75
|
+
return splice(source, fixtures.at, fixtures.at, text);
|
|
76
|
+
}
|
|
77
|
+
const usesGeneratedFile = (step) => (step.kind === "setInputFiles" || step.kind === "dropFiles") &&
|
|
78
|
+
step.files.some((file) => "generate" in file);
|
|
79
|
+
const FRAMEWORK = "@itmetsam/samurai-framework";
|
|
80
|
+
/** Adds `generatedFile` to the framework import (or a new one) unless the file already imports it under its own name */
|
|
81
|
+
function withGeneratedFileImport(source) {
|
|
82
|
+
const file = ts.createSourceFile("spec.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
83
|
+
let frameworkImport;
|
|
84
|
+
for (const statement of file.statements) {
|
|
85
|
+
if (!ts.isImportDeclaration(statement))
|
|
86
|
+
continue;
|
|
87
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
88
|
+
statement.moduleSpecifier.text !== FRAMEWORK)
|
|
89
|
+
continue;
|
|
90
|
+
const named = statement.importClause?.namedBindings;
|
|
91
|
+
if (!named || !ts.isNamedImports(named))
|
|
92
|
+
continue;
|
|
93
|
+
if (named.elements.some((e) => e.name.text === "generatedFile" &&
|
|
94
|
+
(!e.propertyName || e.propertyName.text === e.name.text)))
|
|
95
|
+
return source;
|
|
96
|
+
frameworkImport ??= named;
|
|
97
|
+
}
|
|
98
|
+
if (!frameworkImport || frameworkImport.elements.length === 0)
|
|
99
|
+
return `import { generatedFile } from "${FRAMEWORK}";\n${source}`;
|
|
100
|
+
const last = frameworkImport.elements[frameworkImport.elements.length - 1];
|
|
101
|
+
return splice(source, last.end, last.end, ", generatedFile");
|
|
102
|
+
}
|
|
54
103
|
/**
|
|
55
|
-
* Applies one edit and returns the new source. Only the touched statement changes
|
|
56
|
-
*
|
|
104
|
+
* Applies one edit and returns the new source. Only the touched statement changes, plus the test's parameters
|
|
105
|
+
* when the step reads a fixture (`env`, `secrets`) they don't have yet: everything else, formatting and
|
|
106
|
+
* comments included, stays byte for byte.
|
|
57
107
|
*/
|
|
58
108
|
export function applyEdit(source, edit) {
|
|
59
109
|
const parsed = pickTest(source, edit.test);
|
|
60
110
|
switch (edit.op) {
|
|
61
111
|
case "replace": {
|
|
62
112
|
const { start, end } = pickStep(parsed, edit.index);
|
|
63
|
-
|
|
113
|
+
// The parameters come before the body: adding to them leaves the step's offsets valid
|
|
114
|
+
const next = withFixtures(splice(source, start, end, stepToSource(edit.step)), parsed, edit.step);
|
|
115
|
+
// Last: the import shifts every offset
|
|
116
|
+
return usesGeneratedFile(edit.step)
|
|
117
|
+
? withGeneratedFileImport(next)
|
|
118
|
+
: next;
|
|
119
|
+
}
|
|
120
|
+
case "insert": {
|
|
121
|
+
const next = withFixtures(insertCode(source, parsed, edit.index, stepToSource(edit.step)), parsed, edit.step);
|
|
122
|
+
return usesGeneratedFile(edit.step)
|
|
123
|
+
? withGeneratedFileImport(next)
|
|
124
|
+
: next;
|
|
64
125
|
}
|
|
65
|
-
case "insert":
|
|
66
|
-
return insertCode(source, parsed, edit.index, stepToSource(edit.step));
|
|
67
126
|
case "remove":
|
|
68
127
|
return removeStep(source, parsed, edit.index);
|
|
69
128
|
case "move": {
|
package/dist/steps/emit.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { LocatorCall, LocatorSpec, Step, TextValue } from "./model.js";
|
|
1
|
+
import type { FileEntry, LocatorCall, LocatorSpec, Step, TextValue } from "./model.js";
|
|
2
|
+
/** The argument of `setInputFiles` / `dropFiles`: one entry bare, else an array */
|
|
3
|
+
export declare function filesToSource(files: readonly FileEntry[]): string;
|
|
2
4
|
export declare function callToSource(call: LocatorCall): string;
|
|
3
5
|
export declare function locatorToSource({ chain, fallbacks }: LocatorSpec): string;
|
|
4
6
|
export declare function valueToSource(value: TextValue): string;
|
package/dist/steps/emit.js
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
|
+
import { formatSize, sizeFromSource } from "./files.js";
|
|
1
2
|
const str = (value) => JSON.stringify(value);
|
|
3
|
+
function fileEntryToSource(entry) {
|
|
4
|
+
if ("path" in entry)
|
|
5
|
+
return str(entry.path);
|
|
6
|
+
const { name, size, type, sizeSource } = entry.generate;
|
|
7
|
+
const sizeText = sizeSource !== undefined && sizeFromSource(sizeSource) === size
|
|
8
|
+
? sizeSource
|
|
9
|
+
: str(formatSize(size));
|
|
10
|
+
return `generatedFile({ name: ${str(name)}, size: ${sizeText}${type === undefined ? "" : `, type: ${str(type)}`} })`;
|
|
11
|
+
}
|
|
12
|
+
/** The argument of `setInputFiles` / `dropFiles`: one entry bare, else an array */
|
|
13
|
+
export function filesToSource(files) {
|
|
14
|
+
return files.length === 1
|
|
15
|
+
? fileEntryToSource(files[0])
|
|
16
|
+
: `[${files.map(fileEntryToSource).join(", ")}]`;
|
|
17
|
+
}
|
|
2
18
|
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
3
19
|
function textOptions(call) {
|
|
4
20
|
const parts = [
|
|
@@ -73,6 +89,9 @@ export function stepToSource(step) {
|
|
|
73
89
|
return `await page.waitForTimeout(${step.ms});`;
|
|
74
90
|
case "screenshot":
|
|
75
91
|
return `await page.screenshot({ name: ${str(step.name)}${step.fullPage ? ", fullPage: true" : ""} });`;
|
|
92
|
+
case "setInputFiles":
|
|
93
|
+
case "dropFiles":
|
|
94
|
+
return `await ${locatorToSource(step.locator)}.${step.kind}(${filesToSource(step.files)});`;
|
|
76
95
|
case "custom":
|
|
77
96
|
return step.code;
|
|
78
97
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Bytes for a size: a whole number of bytes, or a text like "60 MB", "1.5 GB", "512 KB" (powers of 1024) */
|
|
2
|
+
export declare function parseSize(size: number | string): number | undefined;
|
|
3
|
+
/** A size in the largest unit that gives a whole number: 62914560 → "60 MB", 1536 → "1536 B" */
|
|
4
|
+
export declare function formatSize(bytes: number): string;
|
|
5
|
+
/** A size for people: the largest unit it reaches, one decimal at most ("300 MB", "1.3 GB") */
|
|
6
|
+
export declare function humanSize(bytes: number): string;
|
|
7
|
+
/** The bytes a size's source text means: a number literal, or a quoted size text */
|
|
8
|
+
export declare function sizeFromSource(text: string): number | undefined;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const UNITS = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 };
|
|
2
|
+
/** Bytes for a size: a whole number of bytes, or a text like "60 MB", "1.5 GB", "512 KB" (powers of 1024) */
|
|
3
|
+
export function parseSize(size) {
|
|
4
|
+
if (typeof size === "number")
|
|
5
|
+
return Number.isInteger(size) && size >= 0 ? size : undefined;
|
|
6
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(B|KB|MB|GB)\s*$/i.exec(size);
|
|
7
|
+
if (!match)
|
|
8
|
+
return undefined;
|
|
9
|
+
return Math.round(Number(match[1]) * UNITS[match[2].toUpperCase()]);
|
|
10
|
+
}
|
|
11
|
+
/** A size in the largest unit that gives a whole number: 62914560 → "60 MB", 1536 → "1536 B" */
|
|
12
|
+
export function formatSize(bytes) {
|
|
13
|
+
for (const unit of ["GB", "MB", "KB"]) {
|
|
14
|
+
if (bytes >= UNITS[unit] && bytes % UNITS[unit] === 0)
|
|
15
|
+
return `${bytes / UNITS[unit]} ${unit}`;
|
|
16
|
+
}
|
|
17
|
+
return `${bytes} B`;
|
|
18
|
+
}
|
|
19
|
+
/** A size for people: the largest unit it reaches, one decimal at most ("300 MB", "1.3 GB") */
|
|
20
|
+
export function humanSize(bytes) {
|
|
21
|
+
for (const unit of ["GB", "MB", "KB"]) {
|
|
22
|
+
if (bytes >= UNITS[unit])
|
|
23
|
+
return `${Math.round((bytes / UNITS[unit]) * 10) / 10} ${unit}`;
|
|
24
|
+
}
|
|
25
|
+
return `${bytes} B`;
|
|
26
|
+
}
|
|
27
|
+
/** The bytes a size's source text means: a number literal, or a quoted size text */
|
|
28
|
+
export function sizeFromSource(text) {
|
|
29
|
+
if (/^\d+$/.test(text))
|
|
30
|
+
return parseSize(Number(text));
|
|
31
|
+
const quoted = /^(["'`])(.*)\1$/s.exec(text);
|
|
32
|
+
return quoted ? parseSize(quoted[2]) : undefined;
|
|
33
|
+
}
|
package/dist/steps/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { parseSpec } from "./parse.js";
|
|
2
2
|
export { applyEdit, type StepEdit } from "./edit.js";
|
|
3
|
-
export { stepToSource, locatorToSource, callToSource, valueToSource, } from "./emit.js";
|
|
3
|
+
export { stepToSource, locatorToSource, callToSource, filesToSource, valueToSource, } from "./emit.js";
|
|
4
|
+
export { formatSize, humanSize, parseSize, sizeFromSource } from "./files.js";
|
|
4
5
|
export { locatorFromSpec } from "./build.js";
|
|
5
|
-
export type { Expectation, LocatorCall, LocatorSpec, ParsedStep, ParsedTest, Step, TextMatcher, TextValue, } from "./model.js";
|
|
6
|
+
export type { Expectation, FileEntry, GeneratedFileSpec, LocatorCall, LocatorSpec, ParsedStep, ParsedTest, Step, TextMatcher, TextValue, } from "./model.js";
|
package/dist/steps/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { parseSpec } from "./parse.js";
|
|
2
2
|
export { applyEdit } from "./edit.js";
|
|
3
|
-
export { stepToSource, locatorToSource, callToSource, valueToSource, } from "./emit.js";
|
|
3
|
+
export { stepToSource, locatorToSource, callToSource, filesToSource, valueToSource, } from "./emit.js";
|
|
4
|
+
export { formatSize, humanSize, parseSize, sizeFromSource } from "./files.js";
|
|
4
5
|
export { locatorFromSpec } from "./build.js";
|
package/dist/steps/model.d.ts
CHANGED
|
@@ -59,6 +59,22 @@ export type Expectation = {
|
|
|
59
59
|
matcher: "toHaveCount";
|
|
60
60
|
expected: number;
|
|
61
61
|
};
|
|
62
|
+
/** A file generated when the step runs: seeded random bytes of `size`, never stored in the project */
|
|
63
|
+
export interface GeneratedFileSpec {
|
|
64
|
+
name: string;
|
|
65
|
+
/** Bytes */
|
|
66
|
+
size: number;
|
|
67
|
+
/** MIME type; `dropFiles` gives it to the page, `setInputFiles` gets it from the name's extension */
|
|
68
|
+
type?: string;
|
|
69
|
+
/** How the size is written in the source (`"60 MB"`, `1048576`); written back while it still means `size` */
|
|
70
|
+
sizeSource?: string;
|
|
71
|
+
}
|
|
72
|
+
/** A file a step sets or drops: one stored in the project (relative to the project folder), or a generated one */
|
|
73
|
+
export type FileEntry = {
|
|
74
|
+
path: string;
|
|
75
|
+
} | {
|
|
76
|
+
generate: GeneratedFileSpec;
|
|
77
|
+
};
|
|
62
78
|
/** The restricted set of statements the recorder writes. Anything else in a test is a `custom` step */
|
|
63
79
|
export type Step = {
|
|
64
80
|
kind: "goto";
|
|
@@ -95,6 +111,18 @@ export type Step = {
|
|
|
95
111
|
name: string;
|
|
96
112
|
fullPage: boolean;
|
|
97
113
|
}
|
|
114
|
+
/** Sets the files of an `<input type=file>`; no files clears it */
|
|
115
|
+
| {
|
|
116
|
+
kind: "setInputFiles";
|
|
117
|
+
locator: LocatorSpec;
|
|
118
|
+
files: FileEntry[];
|
|
119
|
+
}
|
|
120
|
+
/** Drops files on an element, as dragging them from the desktop does */
|
|
121
|
+
| {
|
|
122
|
+
kind: "dropFiles";
|
|
123
|
+
locator: LocatorSpec;
|
|
124
|
+
files: FileEntry[];
|
|
125
|
+
}
|
|
98
126
|
/** A statement the codec does not understand, kept verbatim */
|
|
99
127
|
| {
|
|
100
128
|
kind: "custom";
|
|
@@ -117,4 +145,14 @@ export interface ParsedTest {
|
|
|
117
145
|
/** Offset just after the `{` of the test body, and of its closing `}` */
|
|
118
146
|
bodyStart: number;
|
|
119
147
|
bodyEnd: number;
|
|
148
|
+
/**
|
|
149
|
+
* The fixtures the test's callback destructures, and the offset where another one goes: after the last one
|
|
150
|
+
* (`list`), inside an empty `{}` (`empty`) or inside the empty `()` of a callback without parameters (`none`).
|
|
151
|
+
* Undefined when the parameter can't take one: a plain identifier, or a rest element
|
|
152
|
+
*/
|
|
153
|
+
fixtures?: {
|
|
154
|
+
names: string[];
|
|
155
|
+
at: number;
|
|
156
|
+
shape: "list" | "empty" | "none";
|
|
157
|
+
};
|
|
120
158
|
}
|
package/dist/steps/parse.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
|
+
import { parseSize } from "./files.js";
|
|
2
3
|
const TEXT_MATCHERS = ["toHaveText", "toContainText", "toHaveValue"];
|
|
3
4
|
function stringOf(node) {
|
|
4
5
|
return node &&
|
|
@@ -174,6 +175,77 @@ function readExpectation(matcher, args) {
|
|
|
174
175
|
}
|
|
175
176
|
return undefined;
|
|
176
177
|
}
|
|
178
|
+
/** `"path"` or `generatedFile({ name, size, type? })`; undefined for anything else */
|
|
179
|
+
function readFileEntry(node) {
|
|
180
|
+
const path = stringOf(node);
|
|
181
|
+
if (path !== undefined)
|
|
182
|
+
return { path };
|
|
183
|
+
if (!ts.isCallExpression(node) ||
|
|
184
|
+
!ts.isIdentifier(node.expression) ||
|
|
185
|
+
node.expression.text !== "generatedFile" ||
|
|
186
|
+
node.arguments.length !== 1 ||
|
|
187
|
+
!ts.isObjectLiteralExpression(node.arguments[0]))
|
|
188
|
+
return undefined;
|
|
189
|
+
let name;
|
|
190
|
+
let type;
|
|
191
|
+
let size;
|
|
192
|
+
let sizeSource;
|
|
193
|
+
for (const property of node.arguments[0].properties) {
|
|
194
|
+
if (!ts.isPropertyAssignment(property))
|
|
195
|
+
return undefined;
|
|
196
|
+
const value = property.initializer;
|
|
197
|
+
switch (property.name.getText()) {
|
|
198
|
+
case "name":
|
|
199
|
+
name = stringOf(value);
|
|
200
|
+
if (name === undefined)
|
|
201
|
+
return undefined;
|
|
202
|
+
break;
|
|
203
|
+
case "type":
|
|
204
|
+
type = stringOf(value);
|
|
205
|
+
if (type === undefined)
|
|
206
|
+
return undefined;
|
|
207
|
+
break;
|
|
208
|
+
case "size": {
|
|
209
|
+
const text = stringOf(value);
|
|
210
|
+
size =
|
|
211
|
+
text !== undefined
|
|
212
|
+
? parseSize(text)
|
|
213
|
+
: ts.isNumericLiteral(value)
|
|
214
|
+
? parseSize(Number(value.text))
|
|
215
|
+
: undefined;
|
|
216
|
+
if (size === undefined)
|
|
217
|
+
return undefined;
|
|
218
|
+
sizeSource = value.getText();
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
default:
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (name === undefined || size === undefined)
|
|
226
|
+
return undefined;
|
|
227
|
+
return {
|
|
228
|
+
generate: {
|
|
229
|
+
name,
|
|
230
|
+
size,
|
|
231
|
+
...(type !== undefined && { type }),
|
|
232
|
+
...(sizeSource !== undefined && { sizeSource }),
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** One file entry, or an array of them */
|
|
237
|
+
function readFiles(node) {
|
|
238
|
+
if (!node)
|
|
239
|
+
return undefined;
|
|
240
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
241
|
+
const entries = node.elements.map(readFileEntry);
|
|
242
|
+
return entries.every((entry) => entry !== undefined)
|
|
243
|
+
? entries
|
|
244
|
+
: undefined;
|
|
245
|
+
}
|
|
246
|
+
const entry = readFileEntry(node);
|
|
247
|
+
return entry && [entry];
|
|
248
|
+
}
|
|
177
249
|
/** The step a statement stands for, or undefined when it is outside the recorder's set */
|
|
178
250
|
function readStep(statement) {
|
|
179
251
|
if (!ts.isExpressionStatement(statement) ||
|
|
@@ -241,6 +313,13 @@ function readStep(statement) {
|
|
|
241
313
|
const value = readValue(args[0]);
|
|
242
314
|
return value ? { kind: "fill", locator, value } : undefined;
|
|
243
315
|
}
|
|
316
|
+
if ((name.text === "setInputFiles" || name.text === "dropFiles") &&
|
|
317
|
+
args.length === 1) {
|
|
318
|
+
const files = readFiles(args[0]);
|
|
319
|
+
return files
|
|
320
|
+
? { kind: name.text, locator, files }
|
|
321
|
+
: undefined;
|
|
322
|
+
}
|
|
244
323
|
return undefined;
|
|
245
324
|
}
|
|
246
325
|
function parseStatement(source, statement, sourceFile) {
|
|
@@ -257,6 +336,28 @@ function parseStatement(source, statement, sourceFile) {
|
|
|
257
336
|
anchor: comments?.[0]?.pos ?? start,
|
|
258
337
|
};
|
|
259
338
|
}
|
|
339
|
+
function callbackFixtures(callback, sourceFile) {
|
|
340
|
+
const [first] = callback.parameters;
|
|
341
|
+
if (!first) {
|
|
342
|
+
const open = callback
|
|
343
|
+
.getChildren(sourceFile)
|
|
344
|
+
.find((child) => child.kind === ts.SyntaxKind.OpenParenToken);
|
|
345
|
+
return open ? { names: [], at: open.end, shape: "none" } : undefined;
|
|
346
|
+
}
|
|
347
|
+
const pattern = first.name;
|
|
348
|
+
if (!ts.isObjectBindingPattern(pattern))
|
|
349
|
+
return undefined;
|
|
350
|
+
if (pattern.elements.some((element) => element.dotDotDotToken))
|
|
351
|
+
return undefined;
|
|
352
|
+
const last = pattern.elements[pattern.elements.length - 1];
|
|
353
|
+
if (!last)
|
|
354
|
+
return { names: [], at: pattern.getStart(sourceFile) + 1, shape: "empty" };
|
|
355
|
+
return {
|
|
356
|
+
names: pattern.elements.map(({ propertyName, name }) => (propertyName ?? name).getText(sourceFile)),
|
|
357
|
+
at: last.end,
|
|
358
|
+
shape: "list",
|
|
359
|
+
};
|
|
360
|
+
}
|
|
260
361
|
function callbackBody(node) {
|
|
261
362
|
return node &&
|
|
262
363
|
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
|
|
@@ -280,6 +381,7 @@ export function parseSpec(source) {
|
|
|
280
381
|
steps: body.statements.map((statement) => parseStatement(source, statement, sourceFile)),
|
|
281
382
|
bodyStart: body.getStart(sourceFile) + 1,
|
|
282
383
|
bodyEnd: body.end - 1,
|
|
384
|
+
fixtures: callbackFixtures(node.arguments[1], sourceFile),
|
|
283
385
|
});
|
|
284
386
|
return;
|
|
285
387
|
}
|
package/dist/types/config.d.ts
CHANGED
|
@@ -45,8 +45,8 @@ export interface SamuraiTestConfig {
|
|
|
45
45
|
*/
|
|
46
46
|
srcDir?: string;
|
|
47
47
|
/**
|
|
48
|
-
* The browser to use for all tests.
|
|
49
|
-
*
|
|
48
|
+
* The browser to use for all tests. Only "firefox" can launch today
|
|
49
|
+
* @default "firefox"
|
|
50
50
|
*/
|
|
51
51
|
browser?: SupportedBrowser;
|
|
52
52
|
/**
|
|
@@ -96,6 +96,11 @@ export interface SamuraiTestConfig {
|
|
|
96
96
|
* @default "result/downloads"
|
|
97
97
|
*/
|
|
98
98
|
downloadsDir?: string;
|
|
99
|
+
/**
|
|
100
|
+
* Folder, relative to the project folder, where recorded uploads are stored and committed with the tests
|
|
101
|
+
* @default "files"
|
|
102
|
+
*/
|
|
103
|
+
filesDir?: string;
|
|
99
104
|
/** How pages are opened */
|
|
100
105
|
use?: {
|
|
101
106
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@itmetsam/samurai-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Browser test framework over WebDriver BiDi: auto-waiting locators, environments and secrets, a recorder, and a command line.",
|
|
5
5
|
"main": "dist/api.js",
|
|
6
6
|
"exports": {
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
/** Absolute paths for `files` (relative ones resolve against the working directory) */
|
|
2
|
-
export declare function resolveFiles(files: string | string[]): string[];
|
|
3
|
-
/** Throws unless `file` is an existing regular file; `operation` names the caller in the message */
|
|
4
|
-
export declare function assertIsFile(file: string, operation: string): Promise<void>;
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import { stat } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
/** Absolute paths for `files` (relative ones resolve against the working directory) */
|
|
4
|
-
export function resolveFiles(files) {
|
|
5
|
-
return (Array.isArray(files) ? files : [files]).map((file) => path.resolve(file));
|
|
6
|
-
}
|
|
7
|
-
/** Throws unless `file` is an existing regular file; `operation` names the caller in the message */
|
|
8
|
-
export async function assertIsFile(file, operation) {
|
|
9
|
-
let info;
|
|
10
|
-
try {
|
|
11
|
-
info = await stat(file);
|
|
12
|
-
}
|
|
13
|
-
catch {
|
|
14
|
-
throw new Error(`${operation}(): file not found: ${file}`);
|
|
15
|
-
}
|
|
16
|
-
if (!info.isFile())
|
|
17
|
-
throw new Error(`${operation}(): not a file: ${file}`);
|
|
18
|
-
}
|