@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.
- 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 +20 -5
- 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 +14 -1
- package/dist/recorder/session.js +32 -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.js +38 -3
- 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 +28 -0
- package/dist/steps/parse.js +79 -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.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;
|
|
@@ -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
|
-
|
|
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": {
|
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";
|
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) {
|
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
|
-
}
|