@itmetsam/samurai-framework 0.7.1 → 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.
Files changed (51) hide show
  1. package/README.md +25 -6
  2. package/dist/api.d.ts +1 -0
  3. package/dist/api.js +1 -0
  4. package/dist/browser/file-chooser.d.ts +4 -3
  5. package/dist/browser/file-chooser.js +9 -8
  6. package/dist/browser/generated-files.d.ts +30 -0
  7. package/dist/browser/generated-files.js +116 -0
  8. package/dist/browser/page.d.ts +19 -0
  9. package/dist/browser/page.js +30 -0
  10. package/dist/cli/main.d.ts +1 -1
  11. package/dist/cli/main.js +20 -5
  12. package/dist/locator/locator.d.ts +17 -5
  13. package/dist/locator/locator.js +56 -8
  14. package/dist/project/files-store.d.ts +28 -0
  15. package/dist/project/files-store.js +106 -0
  16. package/dist/project/index.d.ts +1 -0
  17. package/dist/project/index.js +1 -0
  18. package/dist/recorder/apply.js +8 -6
  19. package/dist/recorder/files-capture.d.ts +26 -0
  20. package/dist/recorder/files-capture.js +92 -0
  21. package/dist/recorder/files-reader.d.ts +7 -0
  22. package/dist/recorder/files-reader.js +25 -0
  23. package/dist/recorder/index.d.ts +1 -1
  24. package/dist/recorder/index.js +1 -1
  25. package/dist/recorder/normalise.d.ts +26 -2
  26. package/dist/recorder/normalise.js +48 -1
  27. package/dist/recorder/recorder.d.ts +38 -0
  28. package/dist/recorder/recorder.js +160 -1
  29. package/dist/recorder/session.d.ts +14 -1
  30. package/dist/recorder/session.js +32 -6
  31. package/dist/recorder/toolbar/controller.d.ts +9 -0
  32. package/dist/recorder/toolbar/controller.js +22 -0
  33. package/dist/recorder/toolbar/protocol.d.ts +10 -0
  34. package/dist/recorder/toolbar/source.d.ts +1 -1
  35. package/dist/recorder/toolbar/source.js +35 -2
  36. package/dist/recorder/toolbar/styles.d.ts +1 -1
  37. package/dist/recorder/toolbar/styles.js +31 -0
  38. package/dist/runner/project.js +2 -0
  39. package/dist/steps/edit.js +38 -3
  40. package/dist/steps/emit.d.ts +3 -1
  41. package/dist/steps/emit.js +19 -0
  42. package/dist/steps/files.d.ts +8 -0
  43. package/dist/steps/files.js +33 -0
  44. package/dist/steps/index.d.ts +3 -2
  45. package/dist/steps/index.js +2 -1
  46. package/dist/steps/model.d.ts +28 -0
  47. package/dist/steps/parse.js +79 -0
  48. package/dist/types/config.d.ts +7 -2
  49. package/package.json +1 -1
  50. package/dist/browser/file-paths.d.ts +0 -4
  51. package/dist/browser/file-paths.js +0 -18
@@ -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;
@@ -73,6 +74,32 @@ function withFixtures(source, parsed, step) {
73
74
  : `{ ${list} }`;
74
75
  return splice(source, fixtures.at, fixtures.at, text);
75
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
+ }
76
103
  /**
77
104
  * Applies one edit and returns the new source. Only the touched statement changes, plus the test's parameters
78
105
  * when the step reads a fixture (`env`, `secrets`) they don't have yet: everything else, formatting and
@@ -84,10 +111,18 @@ export function applyEdit(source, edit) {
84
111
  case "replace": {
85
112
  const { start, end } = pickStep(parsed, edit.index);
86
113
  // The parameters come before the body: adding to them leaves the step's offsets valid
87
- return withFixtures(splice(source, start, end, stepToSource(edit.step)), parsed, edit.step);
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;
88
125
  }
89
- case "insert":
90
- return withFixtures(insertCode(source, parsed, edit.index, stepToSource(edit.step)), parsed, edit.step);
91
126
  case "remove":
92
127
  return removeStep(source, parsed, edit.index);
93
128
  case "move": {
@@ -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;
@@ -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
+ }
@@ -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";
@@ -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";
@@ -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";
@@ -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) {
@@ -45,8 +45,8 @@ export interface SamuraiTestConfig {
45
45
  */
46
46
  srcDir?: string;
47
47
  /**
48
- * The browser to use for all tests.
49
- * Is overwritten by the 'browser' in a group's config
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.7.1",
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
- }