@itmetsam/samurai-framework 0.1.0 → 0.2.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 +129 -0
- package/bin/samurai.mjs +31 -0
- package/dist/api.js +3 -0
- package/dist/assert/assertion-error.d.ts +23 -0
- package/dist/assert/assertion-error.js +45 -0
- package/dist/assert/expect.d.ts +55 -0
- package/dist/assert/expect.js +188 -0
- package/dist/browser/browser-context.d.ts +91 -0
- package/dist/browser/browser-context.js +146 -0
- package/dist/browser/browser-finder.d.ts +2 -0
- package/dist/browser/browser-finder.js +95 -0
- package/dist/browser/browser.d.ts +95 -0
- package/dist/browser/browser.js +372 -0
- package/dist/browser/context-tree.d.ts +23 -0
- package/dist/browser/context-tree.js +60 -0
- package/dist/browser/dialog.d.ts +37 -0
- package/dist/browser/dialog.js +61 -0
- package/dist/browser/download.d.ts +71 -0
- package/dist/browser/download.js +137 -0
- package/dist/browser/downloads-dir.d.ts +8 -0
- package/dist/browser/downloads-dir.js +38 -0
- package/dist/browser/emulation.d.ts +61 -0
- package/dist/browser/emulation.js +144 -0
- package/dist/browser/file-chooser.d.ts +39 -0
- package/dist/browser/file-chooser.js +86 -0
- package/dist/browser/file-paths.d.ts +4 -0
- package/dist/browser/file-paths.js +18 -0
- package/dist/browser/index.d.ts +4 -0
- package/dist/browser/index.js +4 -0
- package/dist/browser/navigation-error.d.ts +13 -0
- package/dist/browser/navigation-error.js +19 -0
- package/dist/browser/page-logs.d.ts +86 -0
- package/dist/browser/page-logs.js +149 -0
- package/dist/browser/page-wait.d.ts +9 -0
- package/dist/browser/page-wait.js +17 -0
- package/dist/browser/page.d.ts +370 -0
- package/dist/browser/page.js +1038 -0
- package/dist/browser/pdf.d.ts +72 -0
- package/dist/browser/pdf.js +37 -0
- package/dist/browser/screenshot.d.ts +29 -0
- package/dist/browser/screenshot.js +42 -0
- package/dist/cli/args.d.ts +35 -0
- package/dist/cli/args.js +69 -0
- package/dist/cli/cli.cli-test.d.ts +1 -0
- package/dist/cli/cli.cli-test.js +170 -0
- package/dist/cli/console-reporter.d.ts +15 -0
- package/dist/cli/console-reporter.js +52 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +61 -0
- package/dist/cli/main.d.ts +12 -0
- package/dist/cli/main.js +156 -0
- package/dist/config/config.d.ts +24 -0
- package/dist/config/config.js +42 -0
- package/dist/config/default.d.ts +2 -0
- package/dist/config/default.js +3 -0
- package/dist/config/mask.d.ts +11 -0
- package/dist/config/mask.js +85 -0
- package/dist/config/run-settings.d.ts +28 -0
- package/dist/config/run-settings.js +61 -0
- package/dist/config/secrets.d.ts +10 -0
- package/dist/config/secrets.js +67 -0
- package/dist/config/variables.d.ts +11 -0
- package/dist/config/variables.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -0
- package/dist/locator/action-timeout-error.d.ts +25 -0
- package/dist/locator/action-timeout-error.js +35 -0
- package/dist/locator/element-state.d.ts +44 -0
- package/dist/locator/element-state.js +109 -0
- package/dist/locator/label-locator.d.ts +13 -0
- package/dist/locator/label-locator.js +49 -0
- package/dist/locator/locator.d.ts +168 -0
- package/dist/locator/locator.js +591 -0
- package/dist/locator/selector-errors.d.ts +9 -0
- package/dist/locator/selector-errors.js +15 -0
- package/dist/locator/selector.d.ts +60 -0
- package/dist/locator/selector.js +113 -0
- package/dist/locator/text-locator.d.ts +13 -0
- package/dist/logger/index.d.ts +2 -0
- package/dist/logger/index.js +30 -0
- package/dist/network/data-collector.d.ts +26 -0
- package/dist/network/data-collector.js +77 -0
- package/dist/network/network-tracker.d.ts +94 -0
- package/dist/network/network-tracker.js +198 -0
- package/dist/network/network-wait.d.ts +16 -0
- package/dist/network/network-wait.js +24 -0
- package/dist/network/response.d.ts +22 -0
- package/dist/network/response.js +42 -0
- package/dist/network/router.d.ts +120 -0
- package/dist/network/router.js +343 -0
- package/dist/network/url-match.d.ts +8 -0
- package/dist/network/url-match.js +38 -0
- package/dist/recorder/apply.d.ts +3 -0
- package/dist/recorder/apply.js +10 -0
- package/dist/recorder/capture.d.ts +18 -0
- package/dist/recorder/capture.js +198 -0
- package/dist/recorder/index.d.ts +3 -0
- package/dist/recorder/index.js +3 -0
- package/dist/recorder/normalise.d.ts +68 -0
- package/dist/recorder/normalise.js +104 -0
- package/dist/recorder/ranking.d.ts +9 -0
- package/dist/recorder/ranking.js +21 -0
- package/dist/recorder/recorder.d.ts +52 -0
- package/dist/recorder/recorder.js +143 -0
- package/dist/recorder/session.d.ts +49 -0
- package/dist/recorder/session.js +113 -0
- package/dist/runner/module-type.d.ts +6 -0
- package/dist/runner/module-type.js +29 -0
- package/dist/runner/page-logs-report.d.ts +40 -0
- package/dist/runner/page-logs-report.js +44 -0
- package/dist/runner/prepare-run.d.ts +14 -0
- package/dist/runner/prepare-run.js +20 -0
- package/dist/runner/project.d.ts +17 -0
- package/dist/runner/project.js +33 -0
- package/dist/runner/registry.d.ts +7 -0
- package/dist/runner/registry.js +40 -0
- package/dist/runner/reporter.d.ts +43 -0
- package/dist/runner/reporter.js +83 -0
- package/dist/runner/run.d.ts +27 -0
- package/dist/runner/run.js +34 -0
- package/dist/runner/test-error.d.ts +3 -0
- package/dist/runner/test-error.js +18 -0
- package/dist/runner/test-runner.d.ts +44 -0
- package/dist/runner/test-runner.js +205 -0
- package/dist/script/call-function.d.ts +34 -0
- package/dist/script/call-function.js +71 -0
- package/dist/script/element-handle.d.ts +10 -0
- package/{src/script/element-handle.ts → dist/script/element-handle.js} +4 -4
- package/dist/script/helpers.d.ts +28 -0
- package/dist/script/helpers.js +50 -0
- package/dist/script/preload.d.ts +31 -0
- package/dist/script/preload.js +71 -0
- package/dist/script/serialize.d.ts +29 -0
- package/dist/script/serialize.js +207 -0
- package/dist/steps/build.d.ts +5 -0
- package/dist/steps/build.js +38 -0
- package/dist/steps/edit.d.ts +30 -0
- package/dist/steps/edit.js +80 -0
- package/dist/steps/emit.d.ts +6 -0
- package/dist/steps/emit.js +73 -0
- package/dist/steps/index.d.ts +5 -0
- package/dist/steps/index.js +4 -0
- package/dist/steps/model.d.ts +103 -0
- package/dist/steps/model.js +1 -0
- package/dist/steps/parse.d.ts +3 -0
- package/dist/steps/parse.js +272 -0
- package/dist/transport/bidi-connection.d.ts +46 -0
- package/dist/transport/bidi-connection.js +176 -0
- package/dist/transport/bidi-error.d.ts +10 -0
- package/dist/transport/bidi-error.js +10 -0
- package/dist/transport/subscriptions.d.ts +36 -0
- package/dist/transport/subscriptions.js +81 -0
- package/{src → dist}/types/test.d.ts +10 -4
- package/dist/wait/wait-until.d.ts +20 -0
- package/dist/wait/wait-until.js +63 -0
- package/package.json +44 -7
- package/.claude/settings.local.json +0 -7
- package/.env.example +0 -3
- package/.github/actions/setup/action.yml +0 -16
- package/.github/workflows/ci.yml +0 -71
- package/.trunk/configs/.markdownlint.yaml +0 -2
- package/.trunk/configs/.yamllint.yaml +0 -7
- package/.trunk/configs/svgo.config.mjs +0 -14
- package/.trunk/trunk.yaml +0 -34
- package/docs/superpowers/bidi-roadmap.md +0 -60
- package/docs/superpowers/plans/2026-09-29-auto-wait.md +0 -1827
- package/docs/superpowers/plans/2026-09-29-expect.md +0 -1350
- package/docs/superpowers/plans/2026-10-01-ci.md +0 -350
- package/docs/superpowers/plans/2026-10-01-test-api-alignment.md +0 -1821
- package/docs/superpowers/recorder-roadmap.md +0 -52
- package/docs/superpowers/specs/2026-09-29-auto-wait-design.md +0 -185
- package/docs/superpowers/specs/2026-09-29-bidi-foundation-design.md +0 -130
- package/docs/superpowers/specs/2026-09-29-contexts-and-storage-design.md +0 -88
- package/docs/superpowers/specs/2026-09-29-downloads-and-file-dialogs-design.md +0 -94
- package/docs/superpowers/specs/2026-09-29-emulation-design.md +0 -88
- package/docs/superpowers/specs/2026-09-29-expect-design.md +0 -130
- package/docs/superpowers/specs/2026-09-29-locate-nodes-design.md +0 -90
- package/docs/superpowers/specs/2026-09-29-logs-and-errors-design.md +0 -87
- package/docs/superpowers/specs/2026-09-29-navigation-waits-design.md +0 -83
- package/docs/superpowers/specs/2026-09-29-network-data-design.md +0 -78
- package/docs/superpowers/specs/2026-09-29-network-interception-design.md +0 -91
- package/docs/superpowers/specs/2026-09-29-network-tracking-design.md +0 -86
- package/docs/superpowers/specs/2026-09-29-page-api-design.md +0 -90
- package/docs/superpowers/specs/2026-09-29-preload-scripts-design.md +0 -82
- package/docs/superpowers/specs/2026-09-29-script-call-function-design.md +0 -107
- package/docs/superpowers/specs/2026-09-29-set-files-design.md +0 -62
- package/docs/superpowers/specs/2026-09-29-user-prompts-design.md +0 -75
- package/docs/superpowers/specs/2026-10-01-ci-design.md +0 -106
- package/docs/superpowers/specs/2026-10-01-test-api-alignment-design.md +0 -241
- package/public/logo-dark.svg +0 -236
- package/public/logo-light.svg +0 -262
- package/samurai.config.ts +0 -7
- package/src/assert/assertion-error.test.ts +0 -55
- package/src/assert/assertion-error.ts +0 -62
- package/src/assert/expect-locator.test.ts +0 -217
- package/src/assert/expect.test.ts +0 -73
- package/src/assert/expect.ts +0 -299
- package/src/browser/browser-context.browser-test.ts +0 -201
- package/src/browser/browser-context.test.ts +0 -407
- package/src/browser/browser-context.ts +0 -208
- package/src/browser/browser-finder.ts +0 -110
- package/src/browser/browser.test.ts +0 -88
- package/src/browser/browser.ts +0 -515
- package/src/browser/context-tree.test.ts +0 -86
- package/src/browser/context-tree.ts +0 -70
- package/src/browser/dialog.browser-test.ts +0 -144
- package/src/browser/dialog.test.ts +0 -322
- package/src/browser/dialog.ts +0 -83
- package/src/browser/download.test.ts +0 -161
- package/src/browser/download.ts +0 -172
- package/src/browser/downloads-dir.test.ts +0 -75
- package/src/browser/downloads-dir.ts +0 -39
- package/src/browser/emulation.browser-test.ts +0 -186
- package/src/browser/emulation.test.ts +0 -200
- package/src/browser/emulation.ts +0 -204
- package/src/browser/file-chooser.test.ts +0 -142
- package/src/browser/file-chooser.ts +0 -99
- package/src/browser/file-paths.ts +0 -18
- package/src/browser/goto.browser-test.ts +0 -44
- package/src/browser/navigation-error.ts +0 -26
- package/src/browser/navigation.browser-test.ts +0 -172
- package/src/browser/page-api.browser-test.ts +0 -228
- package/src/browser/page-logs.browser-test.ts +0 -119
- package/src/browser/page-logs.test.ts +0 -212
- package/src/browser/page-logs.ts +0 -208
- package/src/browser/page-wait.ts +0 -19
- package/src/browser/page.test.ts +0 -1567
- package/src/browser/page.ts +0 -1176
- package/src/browser/pdf.ts +0 -62
- package/src/browser/screenshot.ts +0 -65
- package/src/browser/transfers.browser-test.ts +0 -180
- package/src/config/config.ts +0 -33
- package/src/config/default.ts +0 -5
- package/src/config/fixtures.test.ts +0 -137
- package/src/config/mask.test.ts +0 -143
- package/src/config/mask.ts +0 -87
- package/src/config/run-settings.test.ts +0 -163
- package/src/config/run-settings.ts +0 -114
- package/src/config/secrets.ts +0 -70
- package/src/config/variables.ts +0 -31
- package/src/index.ts +0 -10
- package/src/locator/action-timeout-error.test.ts +0 -83
- package/src/locator/action-timeout-error.ts +0 -72
- package/src/locator/element-state.test.ts +0 -181
- package/src/locator/element-state.ts +0 -159
- package/src/locator/locate-nodes.browser-test.ts +0 -169
- package/src/locator/locator-actions.test.ts +0 -355
- package/src/locator/locator-chain.test.ts +0 -292
- package/src/locator/locator.browser-test.ts +0 -137
- package/src/locator/locator.test.ts +0 -179
- package/src/locator/locator.ts +0 -656
- package/src/locator/selector-errors.ts +0 -18
- package/src/locator/selector.test.ts +0 -124
- package/src/locator/selector.ts +0 -107
- package/src/locator/set-files.browser-test.ts +0 -141
- package/src/locator/set-files.test.ts +0 -191
- package/src/locator/text-locator.test.ts +0 -118
- package/src/logger/index.test.ts +0 -28
- package/src/logger/index.ts +0 -40
- package/src/network/data-collector.test.ts +0 -111
- package/src/network/data-collector.ts +0 -92
- package/src/network/network-data.browser-test.ts +0 -192
- package/src/network/network-tracker.browser-test.ts +0 -151
- package/src/network/network-tracker.test.ts +0 -307
- package/src/network/network-tracker.ts +0 -286
- package/src/network/network-wait.ts +0 -28
- package/src/network/response.test.ts +0 -93
- package/src/network/response.ts +0 -58
- package/src/network/router.browser-test.ts +0 -260
- package/src/network/router.test.ts +0 -528
- package/src/network/router.ts +0 -420
- package/src/network/url-match.test.ts +0 -69
- package/src/network/url-match.ts +0 -41
- package/src/runner/page-logs-report.test.ts +0 -144
- package/src/runner/page-logs-report.ts +0 -82
- package/src/runner/prepare-run.test.ts +0 -33
- package/src/runner/prepare-run.ts +0 -36
- package/src/runner/registry.test.ts +0 -59
- package/src/runner/registry.ts +0 -46
- package/src/runner/reporter.test.ts +0 -73
- package/src/runner/reporter.ts +0 -109
- package/src/runner/test-error.test.ts +0 -39
- package/src/runner/test-error.ts +0 -20
- package/src/runner/test-runner.ts +0 -184
- package/src/script/call-function.browser-test.ts +0 -112
- package/src/script/call-function.test.ts +0 -174
- package/src/script/call-function.ts +0 -113
- package/src/script/helpers.test.ts +0 -116
- package/src/script/helpers.ts +0 -68
- package/src/script/preload.browser-test.ts +0 -144
- package/src/script/preload.test.ts +0 -180
- package/src/script/preload.ts +0 -102
- package/src/script/serialize.test.ts +0 -182
- package/src/script/serialize.ts +0 -203
- package/src/testing/browser-fixture.ts +0 -18
- package/src/testing/fake-websocket.ts +0 -72
- package/src/testing/stub-connector.ts +0 -154
- package/src/tests/index.spec.ts +0 -30
- package/src/transport/bidi-connection.test.ts +0 -155
- package/src/transport/bidi-connection.ts +0 -256
- package/src/transport/bidi-error.ts +0 -23
- package/src/transport/foundation.browser-test.ts +0 -73
- package/src/transport/subscriptions.test.ts +0 -140
- package/src/transport/subscriptions.ts +0 -110
- package/src/types/bidi-commands.test-d.ts +0 -55
- package/src/wait/wait-until.test.ts +0 -117
- package/src/wait/wait-until.ts +0 -86
- package/tsconfig.json +0 -44
- /package/{src/api.ts → dist/api.d.ts} +0 -0
- /package/{src/locator/text-locator.ts → dist/locator/text-locator.js} +0 -0
- /package/{src → dist}/types/bidi-modules/browser.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/browsing-context.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/emulation.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/input.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/log.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/network.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/permissions.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/script.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/session.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/storage.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/web-extensions.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/command.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/error.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/event.d.ts +0 -0
- /package/{src → dist}/types/bidi.d.ts +0 -0
- /package/{src → dist}/types/browser.d.ts +0 -0
- /package/{src → dist}/types/config.d.ts +0 -0
- /package/{src → dist}/types/env.d.ts +0 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
const TEXT_MATCHERS = ["toHaveText", "toContainText", "toHaveValue"];
|
|
3
|
+
function stringOf(node) {
|
|
4
|
+
return node &&
|
|
5
|
+
(ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
6
|
+
? node.text
|
|
7
|
+
: undefined;
|
|
8
|
+
}
|
|
9
|
+
/** `{ key: literal, … }` with only the allowed keys, or undefined when it has anything else */
|
|
10
|
+
function readObject(node, allowed) {
|
|
11
|
+
if (!node || !ts.isObjectLiteralExpression(node))
|
|
12
|
+
return undefined;
|
|
13
|
+
const result = {};
|
|
14
|
+
for (const property of node.properties) {
|
|
15
|
+
if (!ts.isPropertyAssignment(property) ||
|
|
16
|
+
!allowed.includes(property.name.getText()))
|
|
17
|
+
return undefined;
|
|
18
|
+
const value = stringOf(property.initializer);
|
|
19
|
+
if (value !== undefined)
|
|
20
|
+
result[property.name.getText()] = value;
|
|
21
|
+
else if (property.initializer.kind === ts.SyntaxKind.TrueKeyword)
|
|
22
|
+
result[property.name.getText()] = true;
|
|
23
|
+
else if (property.initializer.kind === ts.SyntaxKind.FalseKeyword)
|
|
24
|
+
result[property.name.getText()] = false;
|
|
25
|
+
else
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
function readCall(method, args) {
|
|
31
|
+
const first = stringOf(args[0]);
|
|
32
|
+
if (first === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
switch (method) {
|
|
35
|
+
case "locator":
|
|
36
|
+
return args.length === 1 ? { method, xpath: first } : undefined;
|
|
37
|
+
case "getByCss":
|
|
38
|
+
return args.length === 1 ? { method, css: first } : undefined;
|
|
39
|
+
case "getByTestId":
|
|
40
|
+
return args.length === 1 ? { method, testId: first } : undefined;
|
|
41
|
+
case "getByText":
|
|
42
|
+
case "getByLabel": {
|
|
43
|
+
if (args.length > 2)
|
|
44
|
+
return undefined;
|
|
45
|
+
const options = args.length === 2 ? readObject(args[1], ["match", "ignoreCase"]) : {};
|
|
46
|
+
if (!options)
|
|
47
|
+
return undefined;
|
|
48
|
+
const { match, ignoreCase } = options;
|
|
49
|
+
if ((match !== undefined && match !== "full" && match !== "partial") ||
|
|
50
|
+
(ignoreCase !== undefined && typeof ignoreCase !== "boolean")) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
method,
|
|
55
|
+
text: first,
|
|
56
|
+
...(match !== undefined && { match: match }),
|
|
57
|
+
...(ignoreCase !== undefined && { ignoreCase: ignoreCase }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
case "getByRole": {
|
|
61
|
+
if (args.length > 2)
|
|
62
|
+
return undefined;
|
|
63
|
+
const options = args.length === 2 ? readObject(args[1], ["name"]) : {};
|
|
64
|
+
if (!options ||
|
|
65
|
+
(options.name !== undefined && typeof options.name !== "string"))
|
|
66
|
+
return undefined;
|
|
67
|
+
return {
|
|
68
|
+
method,
|
|
69
|
+
role: first,
|
|
70
|
+
...(options.name !== undefined && { name: options.name }),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
default:
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** `page.a(…).b(…)` as calls in order; undefined when it is anything else */
|
|
78
|
+
function readChain(node) {
|
|
79
|
+
const calls = [];
|
|
80
|
+
let current = node;
|
|
81
|
+
while (ts.isCallExpression(current)) {
|
|
82
|
+
if (!ts.isPropertyAccessExpression(current.expression))
|
|
83
|
+
return undefined;
|
|
84
|
+
const call = readCall(current.expression.name.text, current.arguments);
|
|
85
|
+
if (!call)
|
|
86
|
+
return undefined;
|
|
87
|
+
calls.unshift(call);
|
|
88
|
+
current = current.expression.expression;
|
|
89
|
+
}
|
|
90
|
+
return ts.isIdentifier(current) && current.text === "page" && calls.length > 0
|
|
91
|
+
? calls
|
|
92
|
+
: undefined;
|
|
93
|
+
}
|
|
94
|
+
function readLocator(node) {
|
|
95
|
+
if (ts.isCallExpression(node) &&
|
|
96
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
97
|
+
node.expression.name.text === "withFallbacks") {
|
|
98
|
+
const chain = readChain(node.expression.expression);
|
|
99
|
+
const fallbacks = node.arguments.map(readChain);
|
|
100
|
+
if (!chain ||
|
|
101
|
+
node.arguments.length === 0 ||
|
|
102
|
+
fallbacks.some((fallback) => !fallback))
|
|
103
|
+
return undefined;
|
|
104
|
+
return { chain, fallbacks: fallbacks };
|
|
105
|
+
}
|
|
106
|
+
const chain = readChain(node);
|
|
107
|
+
return chain && { chain, fallbacks: [] };
|
|
108
|
+
}
|
|
109
|
+
/** `env.NAME`, `env["NAME"]`, `secrets.NAME`, `secrets["NAME"]` or a string literal */
|
|
110
|
+
function readValue(node) {
|
|
111
|
+
if (!node)
|
|
112
|
+
return undefined;
|
|
113
|
+
const literal = stringOf(node);
|
|
114
|
+
if (literal !== undefined)
|
|
115
|
+
return { kind: "literal", value: literal };
|
|
116
|
+
let object;
|
|
117
|
+
let name;
|
|
118
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
119
|
+
object = node.expression;
|
|
120
|
+
name = node.name.text;
|
|
121
|
+
}
|
|
122
|
+
else if (ts.isElementAccessExpression(node)) {
|
|
123
|
+
object = node.expression;
|
|
124
|
+
name = stringOf(node.argumentExpression);
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
return undefined;
|
|
128
|
+
if (name === undefined || !ts.isIdentifier(object))
|
|
129
|
+
return undefined;
|
|
130
|
+
if (object.text === "env")
|
|
131
|
+
return { kind: "env", name };
|
|
132
|
+
if (object.text === "secrets")
|
|
133
|
+
return { kind: "secret", name };
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
function readTextMatcher(node) {
|
|
137
|
+
if (!node)
|
|
138
|
+
return undefined;
|
|
139
|
+
const literal = stringOf(node);
|
|
140
|
+
if (literal !== undefined)
|
|
141
|
+
return literal;
|
|
142
|
+
if (ts.isRegularExpressionLiteral(node)) {
|
|
143
|
+
const end = node.text.lastIndexOf("/");
|
|
144
|
+
return {
|
|
145
|
+
regex: {
|
|
146
|
+
source: node.text.slice(1, end),
|
|
147
|
+
flags: node.text.slice(end + 1),
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
function readExpectation(matcher, args) {
|
|
154
|
+
if (matcher === "toBeVisible")
|
|
155
|
+
return args.length === 0 ? { matcher } : undefined;
|
|
156
|
+
if (matcher === "toHaveCount") {
|
|
157
|
+
const [count] = args;
|
|
158
|
+
return args.length === 1 && count && ts.isNumericLiteral(count)
|
|
159
|
+
? { matcher, expected: Number(count.text) }
|
|
160
|
+
: undefined;
|
|
161
|
+
}
|
|
162
|
+
if (matcher === "toHaveAttribute") {
|
|
163
|
+
const name = stringOf(args[0]);
|
|
164
|
+
const expected = readTextMatcher(args[1]);
|
|
165
|
+
return args.length === 2 && name !== undefined && expected !== undefined
|
|
166
|
+
? { matcher, name, expected }
|
|
167
|
+
: undefined;
|
|
168
|
+
}
|
|
169
|
+
if (TEXT_MATCHERS.includes(matcher)) {
|
|
170
|
+
const expected = readTextMatcher(args[0]);
|
|
171
|
+
return args.length === 1 && expected !== undefined
|
|
172
|
+
? { matcher: matcher, expected }
|
|
173
|
+
: undefined;
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
/** The step a statement stands for, or undefined when it is outside the recorder's set */
|
|
178
|
+
function readStep(statement) {
|
|
179
|
+
if (!ts.isExpressionStatement(statement) ||
|
|
180
|
+
!ts.isAwaitExpression(statement.expression))
|
|
181
|
+
return undefined;
|
|
182
|
+
const call = statement.expression.expression;
|
|
183
|
+
if (!ts.isCallExpression(call) ||
|
|
184
|
+
!ts.isPropertyAccessExpression(call.expression))
|
|
185
|
+
return undefined;
|
|
186
|
+
const { expression: target, name } = call.expression;
|
|
187
|
+
const args = call.arguments;
|
|
188
|
+
if (ts.isIdentifier(target) && target.text === "page") {
|
|
189
|
+
if (name.text === "goto" && args.length === 1) {
|
|
190
|
+
const url = stringOf(args[0]);
|
|
191
|
+
return url === undefined ? undefined : { kind: "goto", url };
|
|
192
|
+
}
|
|
193
|
+
return name.text === "waitForNetworkIdle" && args.length === 0
|
|
194
|
+
? { kind: "waitForNetworkIdle" }
|
|
195
|
+
: undefined;
|
|
196
|
+
}
|
|
197
|
+
// expect(locator).matcher(…) and expect(locator).not.matcher(…)
|
|
198
|
+
const negated = ts.isPropertyAccessExpression(target) && target.name.text === "not";
|
|
199
|
+
const subject = negated
|
|
200
|
+
? target.expression
|
|
201
|
+
: target;
|
|
202
|
+
if (ts.isCallExpression(subject) &&
|
|
203
|
+
ts.isIdentifier(subject.expression) &&
|
|
204
|
+
subject.expression.text === "expect" &&
|
|
205
|
+
subject.arguments.length === 1) {
|
|
206
|
+
const locator = readLocator(subject.arguments[0]);
|
|
207
|
+
const expectation = readExpectation(name.text, args);
|
|
208
|
+
return locator && expectation
|
|
209
|
+
? { kind: "expect", locator, not: negated, expectation }
|
|
210
|
+
: undefined;
|
|
211
|
+
}
|
|
212
|
+
const locator = readLocator(target);
|
|
213
|
+
if (!locator)
|
|
214
|
+
return undefined;
|
|
215
|
+
if (name.text === "click" && args.length === 0)
|
|
216
|
+
return { kind: "click", locator };
|
|
217
|
+
if (name.text === "fill" && args.length === 1) {
|
|
218
|
+
const value = readValue(args[0]);
|
|
219
|
+
return value ? { kind: "fill", locator, value } : undefined;
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
function parseStatement(source, statement, sourceFile) {
|
|
224
|
+
const start = statement.getStart(sourceFile);
|
|
225
|
+
const end = statement.end;
|
|
226
|
+
const comments = ts.getLeadingCommentRanges(source, statement.getFullStart());
|
|
227
|
+
return {
|
|
228
|
+
step: readStep(statement) ?? {
|
|
229
|
+
kind: "custom",
|
|
230
|
+
code: source.slice(start, end),
|
|
231
|
+
},
|
|
232
|
+
start,
|
|
233
|
+
end,
|
|
234
|
+
anchor: comments?.[0]?.pos ?? start,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
function callbackBody(node) {
|
|
238
|
+
return node &&
|
|
239
|
+
(ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
|
|
240
|
+
ts.isBlock(node.body)
|
|
241
|
+
? node.body
|
|
242
|
+
: undefined;
|
|
243
|
+
}
|
|
244
|
+
/** The tests in a spec file, with their steps and where each statement sits */
|
|
245
|
+
export function parseSpec(source) {
|
|
246
|
+
const sourceFile = ts.createSourceFile("spec.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
247
|
+
const tests = [];
|
|
248
|
+
const visit = (node, titles) => {
|
|
249
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
|
250
|
+
const title = stringOf(node.arguments[0]);
|
|
251
|
+
const body = callbackBody(node.arguments[1]);
|
|
252
|
+
if (title !== undefined && body && node.expression.text === "test") {
|
|
253
|
+
const titlePath = [...titles, title];
|
|
254
|
+
tests.push({
|
|
255
|
+
titlePath,
|
|
256
|
+
name: titlePath.join(" > "),
|
|
257
|
+
steps: body.statements.map((statement) => parseStatement(source, statement, sourceFile)),
|
|
258
|
+
bodyStart: body.getStart(sourceFile) + 1,
|
|
259
|
+
bodyEnd: body.end - 1,
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (title !== undefined && body && node.expression.text === "describe") {
|
|
264
|
+
ts.forEachChild(body, (child) => visit(child, [...titles, title]));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
ts.forEachChild(node, (child) => visit(child, titles));
|
|
269
|
+
};
|
|
270
|
+
visit(sourceFile, []);
|
|
271
|
+
return tests;
|
|
272
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { BiDiCommands, BiDiEvents } from "../types/bidi.js";
|
|
2
|
+
import { type EventName, type Subscription, type SubscribeOptions } from "./subscriptions.js";
|
|
3
|
+
export interface ConnectorOptions {
|
|
4
|
+
/** @default 30000 */
|
|
5
|
+
commandTimeout?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface SendOptions {
|
|
8
|
+
/** Overrides the connector's commandTimeout for this command */
|
|
9
|
+
timeout?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare class BiDiConnector {
|
|
12
|
+
private resolveMap;
|
|
13
|
+
private currentId;
|
|
14
|
+
private webSocket;
|
|
15
|
+
private eventEmitter;
|
|
16
|
+
private commandTimeout;
|
|
17
|
+
private subscriptions;
|
|
18
|
+
constructor(ws: WebSocket, options?: ConnectorOptions);
|
|
19
|
+
static connect(url: string, options?: ConnectorOptions): Promise<BiDiConnector>;
|
|
20
|
+
private onWebsocketClose;
|
|
21
|
+
private onWebsocketError;
|
|
22
|
+
/** Rejects every pending command; `reason` gets the pending method names for its message */
|
|
23
|
+
private rejectAll;
|
|
24
|
+
private getId;
|
|
25
|
+
send<M extends keyof BiDiCommands>(method: M, params?: BiDiCommands[M]["params"], options?: SendOptions): Promise<BiDiCommands[M]["result"]>;
|
|
26
|
+
/**
|
|
27
|
+
* Subscribes to events. Unscoped subscriptions are refcounted per event, so features can
|
|
28
|
+
* subscribe independently; scoped ones (`contexts`) always send their own command.
|
|
29
|
+
*/
|
|
30
|
+
subscribe(events: EventName[], options?: SubscribeOptions): Promise<Subscription>;
|
|
31
|
+
/**
|
|
32
|
+
* Resolves with the params of the first `event` for which `predicate` is true.
|
|
33
|
+
* The listener is registered before this returns, so start the triggering command afterwards.
|
|
34
|
+
* Rejects with WaitTimeoutError after `timeout` ms, or with the abort reason.
|
|
35
|
+
*/
|
|
36
|
+
waitForEvent<E extends keyof BiDiEvents>(event: E, predicate: (params: BiDiEvents[E]["params"]) => boolean, options: {
|
|
37
|
+
timeout: number;
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}): Promise<BiDiEvents[E]["params"]>;
|
|
40
|
+
/** Register an event listener */
|
|
41
|
+
onEvent<E extends keyof BiDiEvents>(event: E, listener: (params: BiDiEvents[E]["params"]) => void): void;
|
|
42
|
+
/** Remove an event listener */
|
|
43
|
+
offEvent<E extends keyof BiDiEvents>(event: E, listener: (params: BiDiEvents[E]["params"]) => void): void;
|
|
44
|
+
private messageListener;
|
|
45
|
+
kill(): void;
|
|
46
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import EventEmitter from "node:events";
|
|
2
|
+
import logger from "../logger/index.js";
|
|
3
|
+
import { WaitTimeoutError } from "../wait/wait-until.js";
|
|
4
|
+
import { BiDiError } from "./bidi-error.js";
|
|
5
|
+
import { SubscriptionManager, } from "./subscriptions.js";
|
|
6
|
+
/** Time (ms) a command may wait for its reply before it fails with code "timeout" */
|
|
7
|
+
const DEFAULT_COMMAND_TIMEOUT = 30000;
|
|
8
|
+
export class BiDiConnector {
|
|
9
|
+
constructor(ws, options = {}) {
|
|
10
|
+
this.resolveMap = new Map();
|
|
11
|
+
this.currentId = 0;
|
|
12
|
+
this.onWebsocketClose = () => {
|
|
13
|
+
this.rejectAll((pending) => new Error(`Websocket closed${pendingSuffix(pending)}`));
|
|
14
|
+
};
|
|
15
|
+
this.onWebsocketError = (ev) => {
|
|
16
|
+
this.rejectAll((pending) => Object.assign(new Error(`Websocket error${pendingSuffix(pending)}`), {
|
|
17
|
+
cause: ev,
|
|
18
|
+
}));
|
|
19
|
+
};
|
|
20
|
+
this.messageListener = (ev) => {
|
|
21
|
+
const message = JSON.parse(ev.data);
|
|
22
|
+
logger.debug("Received message from BiDi Websocket: ", message);
|
|
23
|
+
if (message.type === "success" && message.id !== undefined) {
|
|
24
|
+
const targetToResolve = this.resolveMap.get(message.id);
|
|
25
|
+
if (targetToResolve) {
|
|
26
|
+
this.resolveMap.delete(message.id);
|
|
27
|
+
targetToResolve.resolve(message.result);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else if (message.type === "error") {
|
|
31
|
+
const target = message.id === null || message.id === undefined
|
|
32
|
+
? undefined
|
|
33
|
+
: this.resolveMap.get(message.id);
|
|
34
|
+
if (!target) {
|
|
35
|
+
logger.debug("BiDi error reply without a pending command", message);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
this.resolveMap.delete(message.id);
|
|
39
|
+
target.reject(new BiDiError(target.method, message.error, message.message, message.stacktrace));
|
|
40
|
+
}
|
|
41
|
+
else if (message.type === "event") {
|
|
42
|
+
this.eventEmitter.emit(message.method, message.params);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
this.commandTimeout = options.commandTimeout ?? DEFAULT_COMMAND_TIMEOUT;
|
|
46
|
+
this.subscriptions = new SubscriptionManager({
|
|
47
|
+
subscribe: (params) => this.send("session.subscribe", params),
|
|
48
|
+
unsubscribe: (params) => this.send("session.unsubscribe", params),
|
|
49
|
+
});
|
|
50
|
+
this.webSocket = ws;
|
|
51
|
+
this.webSocket.addEventListener("message", this.messageListener);
|
|
52
|
+
this.webSocket.addEventListener("close", this.onWebsocketClose);
|
|
53
|
+
this.webSocket.addEventListener("error", this.onWebsocketError);
|
|
54
|
+
this.eventEmitter = new EventEmitter();
|
|
55
|
+
logger.debug("BiDiConnector: Websocket handshake finished");
|
|
56
|
+
}
|
|
57
|
+
static async connect(url, options) {
|
|
58
|
+
logger.debug("Connecting to WebSocket");
|
|
59
|
+
const ws = new WebSocket(url);
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
logger.debug("Waiting for WebSocket handshake");
|
|
62
|
+
ws.addEventListener("open", () => {
|
|
63
|
+
logger.debug("WebSocket handshake finished");
|
|
64
|
+
resolve(new BiDiConnector(ws, options));
|
|
65
|
+
});
|
|
66
|
+
ws.addEventListener("error", (err) => {
|
|
67
|
+
logger.debug("WebSocket error", { err });
|
|
68
|
+
reject(new Error("WebSocket connection failed"));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** Rejects every pending command; `reason` gets the pending method names for its message */
|
|
73
|
+
rejectAll(reason) {
|
|
74
|
+
const entries = [...this.resolveMap.values()];
|
|
75
|
+
const error = reason(entries.map((entry) => entry.method));
|
|
76
|
+
this.resolveMap.clear();
|
|
77
|
+
entries.forEach(({ reject }) => reject(error));
|
|
78
|
+
}
|
|
79
|
+
getId() {
|
|
80
|
+
return this.currentId++;
|
|
81
|
+
}
|
|
82
|
+
send(method, params, options = {}) {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
try {
|
|
85
|
+
const id = this.getId();
|
|
86
|
+
const timeout = options.timeout ?? this.commandTimeout;
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
this.resolveMap.delete(id);
|
|
89
|
+
reject(new BiDiError(method, "timeout", `no reply within ${timeout}ms`));
|
|
90
|
+
}, timeout);
|
|
91
|
+
this.resolveMap.set(id, {
|
|
92
|
+
method,
|
|
93
|
+
resolve: (value) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
resolve(value);
|
|
96
|
+
},
|
|
97
|
+
reject: (reason) => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
reject(reason);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
const commandBody = {
|
|
103
|
+
id,
|
|
104
|
+
method,
|
|
105
|
+
params,
|
|
106
|
+
};
|
|
107
|
+
logger.debug("Sending message to BiDi Websocket", commandBody);
|
|
108
|
+
this.webSocket.send(JSON.stringify(commandBody));
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
reject(err);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Subscribes to events. Unscoped subscriptions are refcounted per event, so features can
|
|
117
|
+
* subscribe independently; scoped ones (`contexts`) always send their own command.
|
|
118
|
+
*/
|
|
119
|
+
subscribe(events, options) {
|
|
120
|
+
return this.subscriptions.subscribe(events, options);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Resolves with the params of the first `event` for which `predicate` is true.
|
|
124
|
+
* The listener is registered before this returns, so start the triggering command afterwards.
|
|
125
|
+
* Rejects with WaitTimeoutError after `timeout` ms, or with the abort reason.
|
|
126
|
+
*/
|
|
127
|
+
waitForEvent(event, predicate, options) {
|
|
128
|
+
const { timeout, signal } = options;
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
if (signal?.aborted) {
|
|
131
|
+
reject(signal.reason);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const cleanup = () => {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
this.offEvent(event, listener);
|
|
137
|
+
signal?.removeEventListener("abort", onAbort);
|
|
138
|
+
};
|
|
139
|
+
const listener = (params) => {
|
|
140
|
+
if (!predicate(params))
|
|
141
|
+
return;
|
|
142
|
+
cleanup();
|
|
143
|
+
resolve(params);
|
|
144
|
+
};
|
|
145
|
+
const onAbort = () => {
|
|
146
|
+
cleanup();
|
|
147
|
+
reject(signal?.reason);
|
|
148
|
+
};
|
|
149
|
+
const timer = setTimeout(() => {
|
|
150
|
+
cleanup();
|
|
151
|
+
reject(new WaitTimeoutError(timeout, undefined));
|
|
152
|
+
}, timeout);
|
|
153
|
+
this.onEvent(event, listener);
|
|
154
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** Register an event listener */
|
|
158
|
+
onEvent(event, listener) {
|
|
159
|
+
this.eventEmitter.on(event, listener);
|
|
160
|
+
}
|
|
161
|
+
/** Remove an event listener */
|
|
162
|
+
offEvent(event, listener) {
|
|
163
|
+
this.eventEmitter.off(event, listener);
|
|
164
|
+
}
|
|
165
|
+
kill() {
|
|
166
|
+
this.webSocket.removeEventListener("message", this.messageListener);
|
|
167
|
+
this.webSocket.removeEventListener("close", this.onWebsocketClose);
|
|
168
|
+
this.webSocket.removeEventListener("error", this.onWebsocketError);
|
|
169
|
+
this.rejectAll((pending) => new Error(`BiDiConnector killed${pendingSuffix(pending)}`));
|
|
170
|
+
this.eventEmitter.removeAllListeners();
|
|
171
|
+
this.webSocket.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function pendingSuffix(pending) {
|
|
175
|
+
return pending.length > 0 ? ` (pending: ${pending.join(", ")})` : "";
|
|
176
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ErrorCode } from "../types/bidi-protocols/error.js";
|
|
2
|
+
/** A failed BiDi command: an error reply from the browser, or `"timeout"` when none came in time */
|
|
3
|
+
export declare class BiDiError extends Error {
|
|
4
|
+
/** BiDi error code such as "no such node", or "timeout" */
|
|
5
|
+
readonly code: ErrorCode | "timeout";
|
|
6
|
+
/** The command that failed, e.g. "script.callFunction" */
|
|
7
|
+
readonly command: string;
|
|
8
|
+
readonly remoteStack?: string | undefined;
|
|
9
|
+
constructor(command: string, code: ErrorCode | "timeout", message: string, remoteStack?: string);
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** A failed BiDi command: an error reply from the browser, or `"timeout"` when none came in time */
|
|
2
|
+
export class BiDiError extends Error {
|
|
3
|
+
constructor(command, code, message, remoteStack) {
|
|
4
|
+
super(`${command} failed (${code}): ${message}`);
|
|
5
|
+
this.name = "BiDiError";
|
|
6
|
+
this.command = command;
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.remoteStack = remoteStack;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { BiDiEvents } from "../types/bidi.js";
|
|
2
|
+
export type EventName = keyof BiDiEvents;
|
|
3
|
+
export interface Subscription {
|
|
4
|
+
/** Idempotent */
|
|
5
|
+
unsubscribe(): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export interface SubscribeOptions {
|
|
8
|
+
/** Scope to these browsing contexts. Scoped subscriptions are never shared */
|
|
9
|
+
contexts?: string[];
|
|
10
|
+
}
|
|
11
|
+
/** Sends the two session commands; the connector provides it */
|
|
12
|
+
export interface SessionCommands {
|
|
13
|
+
subscribe(params: {
|
|
14
|
+
events: string[];
|
|
15
|
+
contexts?: string[];
|
|
16
|
+
}): Promise<{
|
|
17
|
+
subscription: string;
|
|
18
|
+
}>;
|
|
19
|
+
unsubscribe(params: {
|
|
20
|
+
subscriptions: string[];
|
|
21
|
+
}): Promise<unknown>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Global (unscoped) subscriptions are refcounted per event name: the first subscriber sends
|
|
25
|
+
* `session.subscribe`, the last one leaving sends `session.unsubscribe`.
|
|
26
|
+
*/
|
|
27
|
+
export declare class SubscriptionManager {
|
|
28
|
+
private commands;
|
|
29
|
+
private shared;
|
|
30
|
+
constructor(commands: SessionCommands);
|
|
31
|
+
subscribe(events: EventName[], options?: SubscribeOptions): Promise<Subscription>;
|
|
32
|
+
private subscribeScoped;
|
|
33
|
+
private state;
|
|
34
|
+
private acquire;
|
|
35
|
+
private release;
|
|
36
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global (unscoped) subscriptions are refcounted per event name: the first subscriber sends
|
|
3
|
+
* `session.subscribe`, the last one leaving sends `session.unsubscribe`.
|
|
4
|
+
*/
|
|
5
|
+
export class SubscriptionManager {
|
|
6
|
+
constructor(commands) {
|
|
7
|
+
this.commands = commands;
|
|
8
|
+
this.shared = new Map();
|
|
9
|
+
}
|
|
10
|
+
async subscribe(events, options = {}) {
|
|
11
|
+
if (options.contexts)
|
|
12
|
+
return this.subscribeScoped(events, options.contexts);
|
|
13
|
+
const acquired = [];
|
|
14
|
+
try {
|
|
15
|
+
for (const event of events) {
|
|
16
|
+
await this.acquire(event);
|
|
17
|
+
acquired.push(event);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
await Promise.allSettled(acquired.map((event) => this.release(event)));
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
let released = false;
|
|
25
|
+
return {
|
|
26
|
+
unsubscribe: async () => {
|
|
27
|
+
if (released)
|
|
28
|
+
return;
|
|
29
|
+
released = true;
|
|
30
|
+
await Promise.all(acquired.map((event) => this.release(event)));
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
async subscribeScoped(events, contexts) {
|
|
35
|
+
const { subscription } = await this.commands.subscribe({ events, contexts });
|
|
36
|
+
let released = false;
|
|
37
|
+
return {
|
|
38
|
+
unsubscribe: async () => {
|
|
39
|
+
if (released)
|
|
40
|
+
return;
|
|
41
|
+
released = true;
|
|
42
|
+
await this.commands.unsubscribe({ subscriptions: [subscription] });
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
state(event) {
|
|
47
|
+
let state = this.shared.get(event);
|
|
48
|
+
if (!state) {
|
|
49
|
+
state = { count: 0, id: undefined, chain: Promise.resolve() };
|
|
50
|
+
this.shared.set(event, state);
|
|
51
|
+
}
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
acquire(event) {
|
|
55
|
+
const state = this.state(event);
|
|
56
|
+
state.count++;
|
|
57
|
+
const step = state.chain.then(async () => {
|
|
58
|
+
if (state.count > 0 && state.id === undefined) {
|
|
59
|
+
state.id = (await this.commands.subscribe({ events: [event] })).subscription;
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
state.chain = step.catch(() => { });
|
|
63
|
+
return step.catch((err) => {
|
|
64
|
+
state.count--;
|
|
65
|
+
throw err;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
release(event) {
|
|
69
|
+
const state = this.state(event);
|
|
70
|
+
state.count--;
|
|
71
|
+
const step = state.chain.then(async () => {
|
|
72
|
+
if (state.count === 0 && state.id !== undefined) {
|
|
73
|
+
const id = state.id;
|
|
74
|
+
state.id = undefined;
|
|
75
|
+
await this.commands.unsubscribe({ subscriptions: [id] });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
state.chain = step.catch(() => { });
|
|
79
|
+
return step;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -33,7 +33,7 @@ export interface PartialTestResult extends TestResultBase {
|
|
|
33
33
|
|
|
34
34
|
export type TestResult = TestResultSuccess | TestResultFailed | TestResultBase;
|
|
35
35
|
|
|
36
|
-
interface TestResultSuccess {
|
|
36
|
+
interface TestResultSuccess extends TestResultIdentity {
|
|
37
37
|
status: "success";
|
|
38
38
|
duration: number;
|
|
39
39
|
logs?: TestLogEntry[];
|
|
@@ -41,9 +41,9 @@ interface TestResultSuccess {
|
|
|
41
41
|
logsDropped?: number;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
/** The error sits flat on the entry, as in the report: `message`, `type`, `stack`, and for assertions `expected` and `actual` */
|
|
45
|
+
interface TestResultFailed extends TestResultIdentity, TestError {
|
|
45
46
|
status: "failed";
|
|
46
|
-
error: TestError;
|
|
47
47
|
duration: number;
|
|
48
48
|
logs?: TestLogEntry[];
|
|
49
49
|
logsDropped?: number;
|
|
@@ -59,6 +59,12 @@ export interface TestLogEntry {
|
|
|
59
59
|
timestamp: number;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
interface TestResultIdentity {
|
|
63
|
+
name: string;
|
|
64
|
+
file: string;
|
|
65
|
+
startTime: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
62
68
|
interface TestResultBase {
|
|
63
69
|
name: string;
|
|
64
70
|
file: string;
|
|
@@ -66,7 +72,7 @@ interface TestResultBase {
|
|
|
66
72
|
status: "started";
|
|
67
73
|
}
|
|
68
74
|
|
|
69
|
-
interface TestError {
|
|
75
|
+
export interface TestError {
|
|
70
76
|
message: string;
|
|
71
77
|
type: "timeout" | "error" | "assertion";
|
|
72
78
|
stack?: string | undefined;
|