@itmetsam/samurai-framework 0.1.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/.claude/settings.local.json +7 -0
- package/.env.example +3 -0
- package/.github/actions/setup/action.yml +16 -0
- package/.github/workflows/ci.yml +71 -0
- package/.trunk/configs/.markdownlint.yaml +2 -0
- package/.trunk/configs/.yamllint.yaml +7 -0
- package/.trunk/configs/svgo.config.mjs +14 -0
- package/.trunk/trunk.yaml +34 -0
- package/docs/superpowers/bidi-roadmap.md +60 -0
- package/docs/superpowers/plans/2026-09-29-auto-wait.md +1827 -0
- package/docs/superpowers/plans/2026-09-29-expect.md +1350 -0
- package/docs/superpowers/plans/2026-10-01-ci.md +350 -0
- package/docs/superpowers/plans/2026-10-01-test-api-alignment.md +1821 -0
- package/docs/superpowers/recorder-roadmap.md +52 -0
- package/docs/superpowers/specs/2026-09-29-auto-wait-design.md +185 -0
- package/docs/superpowers/specs/2026-09-29-bidi-foundation-design.md +130 -0
- package/docs/superpowers/specs/2026-09-29-contexts-and-storage-design.md +88 -0
- package/docs/superpowers/specs/2026-09-29-downloads-and-file-dialogs-design.md +94 -0
- package/docs/superpowers/specs/2026-09-29-emulation-design.md +88 -0
- package/docs/superpowers/specs/2026-09-29-expect-design.md +130 -0
- package/docs/superpowers/specs/2026-09-29-locate-nodes-design.md +90 -0
- package/docs/superpowers/specs/2026-09-29-logs-and-errors-design.md +87 -0
- package/docs/superpowers/specs/2026-09-29-navigation-waits-design.md +83 -0
- package/docs/superpowers/specs/2026-09-29-network-data-design.md +78 -0
- package/docs/superpowers/specs/2026-09-29-network-interception-design.md +91 -0
- package/docs/superpowers/specs/2026-09-29-network-tracking-design.md +86 -0
- package/docs/superpowers/specs/2026-09-29-page-api-design.md +90 -0
- package/docs/superpowers/specs/2026-09-29-preload-scripts-design.md +82 -0
- package/docs/superpowers/specs/2026-09-29-script-call-function-design.md +107 -0
- package/docs/superpowers/specs/2026-09-29-set-files-design.md +62 -0
- package/docs/superpowers/specs/2026-09-29-user-prompts-design.md +75 -0
- package/docs/superpowers/specs/2026-10-01-ci-design.md +106 -0
- package/docs/superpowers/specs/2026-10-01-test-api-alignment-design.md +241 -0
- package/package.json +27 -0
- package/public/logo-dark.svg +236 -0
- package/public/logo-light.svg +262 -0
- package/samurai.config.ts +7 -0
- package/src/api.ts +4 -0
- package/src/assert/assertion-error.test.ts +55 -0
- package/src/assert/assertion-error.ts +62 -0
- package/src/assert/expect-locator.test.ts +217 -0
- package/src/assert/expect.test.ts +73 -0
- package/src/assert/expect.ts +299 -0
- package/src/browser/browser-context.browser-test.ts +201 -0
- package/src/browser/browser-context.test.ts +407 -0
- package/src/browser/browser-context.ts +208 -0
- package/src/browser/browser-finder.ts +110 -0
- package/src/browser/browser.test.ts +88 -0
- package/src/browser/browser.ts +515 -0
- package/src/browser/context-tree.test.ts +86 -0
- package/src/browser/context-tree.ts +70 -0
- package/src/browser/dialog.browser-test.ts +144 -0
- package/src/browser/dialog.test.ts +322 -0
- package/src/browser/dialog.ts +83 -0
- package/src/browser/download.test.ts +161 -0
- package/src/browser/download.ts +172 -0
- package/src/browser/downloads-dir.test.ts +75 -0
- package/src/browser/downloads-dir.ts +39 -0
- package/src/browser/emulation.browser-test.ts +186 -0
- package/src/browser/emulation.test.ts +200 -0
- package/src/browser/emulation.ts +204 -0
- package/src/browser/file-chooser.test.ts +142 -0
- package/src/browser/file-chooser.ts +99 -0
- package/src/browser/file-paths.ts +18 -0
- package/src/browser/goto.browser-test.ts +44 -0
- package/src/browser/navigation-error.ts +26 -0
- package/src/browser/navigation.browser-test.ts +172 -0
- package/src/browser/page-api.browser-test.ts +228 -0
- package/src/browser/page-logs.browser-test.ts +119 -0
- package/src/browser/page-logs.test.ts +212 -0
- package/src/browser/page-logs.ts +208 -0
- package/src/browser/page-wait.ts +19 -0
- package/src/browser/page.test.ts +1567 -0
- package/src/browser/page.ts +1176 -0
- package/src/browser/pdf.ts +62 -0
- package/src/browser/screenshot.ts +65 -0
- package/src/browser/transfers.browser-test.ts +180 -0
- package/src/config/config.ts +33 -0
- package/src/config/default.ts +5 -0
- package/src/config/fixtures.test.ts +137 -0
- package/src/config/mask.test.ts +143 -0
- package/src/config/mask.ts +87 -0
- package/src/config/run-settings.test.ts +163 -0
- package/src/config/run-settings.ts +114 -0
- package/src/config/secrets.ts +70 -0
- package/src/config/variables.ts +31 -0
- package/src/index.ts +10 -0
- package/src/locator/action-timeout-error.test.ts +83 -0
- package/src/locator/action-timeout-error.ts +72 -0
- package/src/locator/element-state.test.ts +181 -0
- package/src/locator/element-state.ts +159 -0
- package/src/locator/locate-nodes.browser-test.ts +169 -0
- package/src/locator/locator-actions.test.ts +355 -0
- package/src/locator/locator-chain.test.ts +292 -0
- package/src/locator/locator.browser-test.ts +137 -0
- package/src/locator/locator.test.ts +179 -0
- package/src/locator/locator.ts +656 -0
- package/src/locator/selector-errors.ts +18 -0
- package/src/locator/selector.test.ts +124 -0
- package/src/locator/selector.ts +107 -0
- package/src/locator/set-files.browser-test.ts +141 -0
- package/src/locator/set-files.test.ts +191 -0
- package/src/locator/text-locator.test.ts +118 -0
- package/src/locator/text-locator.ts +38 -0
- package/src/logger/index.test.ts +28 -0
- package/src/logger/index.ts +40 -0
- package/src/network/data-collector.test.ts +111 -0
- package/src/network/data-collector.ts +92 -0
- package/src/network/network-data.browser-test.ts +192 -0
- package/src/network/network-tracker.browser-test.ts +151 -0
- package/src/network/network-tracker.test.ts +307 -0
- package/src/network/network-tracker.ts +286 -0
- package/src/network/network-wait.ts +28 -0
- package/src/network/response.test.ts +93 -0
- package/src/network/response.ts +58 -0
- package/src/network/router.browser-test.ts +260 -0
- package/src/network/router.test.ts +528 -0
- package/src/network/router.ts +420 -0
- package/src/network/url-match.test.ts +69 -0
- package/src/network/url-match.ts +41 -0
- package/src/runner/page-logs-report.test.ts +144 -0
- package/src/runner/page-logs-report.ts +82 -0
- package/src/runner/prepare-run.test.ts +33 -0
- package/src/runner/prepare-run.ts +36 -0
- package/src/runner/registry.test.ts +59 -0
- package/src/runner/registry.ts +46 -0
- package/src/runner/reporter.test.ts +73 -0
- package/src/runner/reporter.ts +109 -0
- package/src/runner/test-error.test.ts +39 -0
- package/src/runner/test-error.ts +20 -0
- package/src/runner/test-runner.ts +184 -0
- package/src/script/call-function.browser-test.ts +112 -0
- package/src/script/call-function.test.ts +174 -0
- package/src/script/call-function.ts +113 -0
- package/src/script/element-handle.ts +11 -0
- package/src/script/helpers.test.ts +116 -0
- package/src/script/helpers.ts +68 -0
- package/src/script/preload.browser-test.ts +144 -0
- package/src/script/preload.test.ts +180 -0
- package/src/script/preload.ts +102 -0
- package/src/script/serialize.test.ts +182 -0
- package/src/script/serialize.ts +203 -0
- package/src/testing/browser-fixture.ts +18 -0
- package/src/testing/fake-websocket.ts +72 -0
- package/src/testing/stub-connector.ts +154 -0
- package/src/tests/index.spec.ts +30 -0
- package/src/transport/bidi-connection.test.ts +155 -0
- package/src/transport/bidi-connection.ts +256 -0
- package/src/transport/bidi-error.ts +23 -0
- package/src/transport/foundation.browser-test.ts +73 -0
- package/src/transport/subscriptions.test.ts +140 -0
- package/src/transport/subscriptions.ts +110 -0
- package/src/types/bidi-commands.test-d.ts +55 -0
- package/src/types/bidi-modules/browser.d.ts +103 -0
- package/src/types/bidi-modules/browsing-context.d.ts +430 -0
- package/src/types/bidi-modules/emulation.d.ts +280 -0
- package/src/types/bidi-modules/input.d.ts +201 -0
- package/src/types/bidi-modules/log.d.ts +49 -0
- package/src/types/bidi-modules/network.d.ts +358 -0
- package/src/types/bidi-modules/permissions.d.ts +19 -0
- package/src/types/bidi-modules/script.d.ts +543 -0
- package/src/types/bidi-modules/session.d.ts +135 -0
- package/src/types/bidi-modules/storage.d.ts +92 -0
- package/src/types/bidi-modules/web-extensions.d.ts +9 -0
- package/src/types/bidi-protocols/command.d.ts +7 -0
- package/src/types/bidi-protocols/error.d.ts +38 -0
- package/src/types/bidi-protocols/event.d.ts +17 -0
- package/src/types/bidi.d.ts +65 -0
- package/src/types/browser.d.ts +1 -0
- package/src/types/config.d.ts +157 -0
- package/src/types/env.d.ts +5 -0
- package/src/types/test.d.ts +95 -0
- package/src/wait/wait-until.test.ts +117 -0
- package/src/wait/wait-until.ts +86 -0
- package/tsconfig.json +44 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { Extensible } from "../bidi.js";
|
|
2
|
+
import type { UserContext } from "./browser.js";
|
|
3
|
+
import type { BrowsingContext } from "./browsing-context.js";
|
|
4
|
+
import type { BytesValue, Cookie, SameSite } from "./network.js";
|
|
5
|
+
|
|
6
|
+
export type StorageCommands = {
|
|
7
|
+
"storage.getCookies": {
|
|
8
|
+
params: {
|
|
9
|
+
filter?: CookieFilter;
|
|
10
|
+
partition?: PartitionDescriptor;
|
|
11
|
+
};
|
|
12
|
+
result: {
|
|
13
|
+
cookies: Cookie[];
|
|
14
|
+
partitionKey: PartitionKey;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
"storage.setCookie": {
|
|
18
|
+
params: {
|
|
19
|
+
cookie: PartialCookie;
|
|
20
|
+
partition?: PartitionDescriptor;
|
|
21
|
+
};
|
|
22
|
+
result: {
|
|
23
|
+
partitionKey: PartitionKey;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
"storage.deleteCookies": {
|
|
27
|
+
params: {
|
|
28
|
+
filter?: CookieFilter;
|
|
29
|
+
partition?: PartitionDescriptor;
|
|
30
|
+
};
|
|
31
|
+
result: {
|
|
32
|
+
partitionKey: PartitionKey;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export interface PartialCookie extends Partial<Cookie> {
|
|
38
|
+
name: string;
|
|
39
|
+
value: BytesValue;
|
|
40
|
+
domain: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CookieFilter extends Extensible {
|
|
44
|
+
name?: string;
|
|
45
|
+
value?: BytesValue;
|
|
46
|
+
domain?: string;
|
|
47
|
+
path?: string;
|
|
48
|
+
size?: number;
|
|
49
|
+
httpOnly?: boolean;
|
|
50
|
+
secure?: boolean;
|
|
51
|
+
sameSite?: SameSite;
|
|
52
|
+
expiry?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type PartitionDescriptor =
|
|
56
|
+
| BrowsingContextPartitionDescriptor
|
|
57
|
+
| StorageKeyPartitionDescriptor
|
|
58
|
+
| undefined;
|
|
59
|
+
|
|
60
|
+
export interface BrowsingContextPartitionDescriptor {
|
|
61
|
+
type: "context";
|
|
62
|
+
context: BrowsingContext;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface StorageKeyPartitionDescriptor extends Extensible {
|
|
66
|
+
type: "storageKey";
|
|
67
|
+
userContext?: UserContext;
|
|
68
|
+
sourceOrigin?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type StorageResult =
|
|
72
|
+
| DeleteCookiesResult
|
|
73
|
+
| GetCookiesResult
|
|
74
|
+
| SetCookieResult;
|
|
75
|
+
|
|
76
|
+
export interface PartitionKey extends Extensible {
|
|
77
|
+
userContext?: UserContext;
|
|
78
|
+
sourceOrigin?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface DeleteCookiesResult {
|
|
82
|
+
partitionKey: PartitionKey;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface GetCookiesResult {
|
|
86
|
+
cookies: Cookie[];
|
|
87
|
+
partitionKey: PartitionKey;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface SetCookieResult {
|
|
91
|
+
partitionKey: PartitionKey;
|
|
92
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type ErrorCode =
|
|
2
|
+
| "invalid argument"
|
|
3
|
+
| "invalid selector"
|
|
4
|
+
| "invalid session id"
|
|
5
|
+
| "invalid web extension"
|
|
6
|
+
| "move target out of bounds"
|
|
7
|
+
| "no such alert"
|
|
8
|
+
| "no such network collector"
|
|
9
|
+
| "no such element"
|
|
10
|
+
| "no such frame"
|
|
11
|
+
| "no such handle"
|
|
12
|
+
| "no such history entry"
|
|
13
|
+
| "no such intercept"
|
|
14
|
+
| "no such network data"
|
|
15
|
+
| "no such node"
|
|
16
|
+
| "no such request"
|
|
17
|
+
| "no such script"
|
|
18
|
+
| "no such storage partition"
|
|
19
|
+
| "no such user context"
|
|
20
|
+
| "no such web extension"
|
|
21
|
+
| "session not created"
|
|
22
|
+
| "unable to capture screen"
|
|
23
|
+
| "unable to close browser"
|
|
24
|
+
| "unable to set cookie"
|
|
25
|
+
| "unable to set file input"
|
|
26
|
+
| "unavailable network data"
|
|
27
|
+
| "underspecified storage partition"
|
|
28
|
+
| "unknown command"
|
|
29
|
+
| "unknown error"
|
|
30
|
+
| "unsupported operation";
|
|
31
|
+
|
|
32
|
+
export interface ErrorResponse {
|
|
33
|
+
type: "error";
|
|
34
|
+
id: number | null;
|
|
35
|
+
error: ErrorCode;
|
|
36
|
+
message: string;
|
|
37
|
+
stacktrace?: string;
|
|
38
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BrowsingContextEvent } from "../bidi-modules/browsing-context.js";
|
|
2
|
+
import type { LogEvent } from "../bidi-modules/log.js";
|
|
3
|
+
import type { NetworkEvent } from "../bidi-modules/network.js";
|
|
4
|
+
import type { ScriptEvent } from "../bidi-modules/script.js";
|
|
5
|
+
import type { Extensible } from "../bidi.js";
|
|
6
|
+
|
|
7
|
+
export type BiDiEvent = Extensible &
|
|
8
|
+
EventData & {
|
|
9
|
+
type: "event";
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type EventData =
|
|
13
|
+
| BrowsingContextEvent
|
|
14
|
+
| InputEvent
|
|
15
|
+
| LogEvent
|
|
16
|
+
| NetworkEvent
|
|
17
|
+
| ScriptEvent;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { BrowserCommands, BrowserResult } from "./bidi-modules/browser.js";
|
|
2
|
+
import type {
|
|
3
|
+
BrowsingContextEvents,
|
|
4
|
+
BrowsingContextModule as BrowsingContextCommands,
|
|
5
|
+
BrowsingContextResult,
|
|
6
|
+
} from "./bidi-modules/browsing-context.js";
|
|
7
|
+
import type { EmulationCommands, EmulationResult } from "./bidi-modules/emulation.js";
|
|
8
|
+
import type {
|
|
9
|
+
InputCommands,
|
|
10
|
+
InputEvents,
|
|
11
|
+
InputResult,
|
|
12
|
+
} from "./bidi-modules/input.js";
|
|
13
|
+
import type { LogEvents } from "./bidi-modules/log.js";
|
|
14
|
+
import type { PermissionsCommands } from "./bidi-modules/permissions.js";
|
|
15
|
+
import type {
|
|
16
|
+
NetworkCommands,
|
|
17
|
+
NetworkEvents,
|
|
18
|
+
NetworkResult,
|
|
19
|
+
} from "./bidi-modules/network.js";
|
|
20
|
+
import type {
|
|
21
|
+
ScriptCommands,
|
|
22
|
+
ScriptEvents,
|
|
23
|
+
ScriptResult,
|
|
24
|
+
} from "./bidi-modules/script.js";
|
|
25
|
+
import type { SessionModule, SessionResult } from "./bidi-modules/session.js";
|
|
26
|
+
import type { StorageCommands, StorageResult } from "./bidi-modules/storage.js";
|
|
27
|
+
import type { WebExtensionResult } from "./bidi-modules/web-extensions.js";
|
|
28
|
+
import type { CommandResponse } from "./bidi-protocols/command.js";
|
|
29
|
+
import type { ErrorResponse } from "./bidi-protocols/error.js";
|
|
30
|
+
import type { BiDiEvent } from "./bidi-protocols/event.js";
|
|
31
|
+
|
|
32
|
+
export type BiDiCommands = BrowserCommands &
|
|
33
|
+
SessionModule &
|
|
34
|
+
BrowsingContextCommands &
|
|
35
|
+
ScriptCommands &
|
|
36
|
+
InputCommands &
|
|
37
|
+
StorageCommands &
|
|
38
|
+
NetworkCommands &
|
|
39
|
+
EmulationCommands &
|
|
40
|
+
PermissionsCommands;
|
|
41
|
+
|
|
42
|
+
export type BiDiEvents = BrowsingContextEvents &
|
|
43
|
+
InputEvents &
|
|
44
|
+
LogEvents &
|
|
45
|
+
NetworkEvents &
|
|
46
|
+
ScriptEvents;
|
|
47
|
+
|
|
48
|
+
export type BiDiMessages = CommandResponse | ErrorResponse | BiDiEvent;
|
|
49
|
+
|
|
50
|
+
export type ResultData =
|
|
51
|
+
| BrowserResult
|
|
52
|
+
| BrowsingContextResult
|
|
53
|
+
| EmulationResult
|
|
54
|
+
| InputResult
|
|
55
|
+
| NetworkResult
|
|
56
|
+
| ScriptResult
|
|
57
|
+
| SessionResult
|
|
58
|
+
| StorageResult
|
|
59
|
+
| WebExtensionResult;
|
|
60
|
+
|
|
61
|
+
export type EmptyResult = Extensible;
|
|
62
|
+
|
|
63
|
+
export interface Extensible {
|
|
64
|
+
[key: string]: any;
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type SupportedBrowser = "firefox";
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { SupportedBrowser } from "./browser.js";
|
|
2
|
+
|
|
3
|
+
interface SamuraiTest {
|
|
4
|
+
/** Path relative to srcDir, e.g. 'checkout/coupons.spec.ts' */
|
|
5
|
+
file: string;
|
|
6
|
+
/** Full title, including describe blocks: 'Guest checkout > applies coupon' */
|
|
7
|
+
title: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SamuraiGroup {
|
|
11
|
+
name: string;
|
|
12
|
+
/**
|
|
13
|
+
* Directory to search for tests for this group.
|
|
14
|
+
* If empty, it uses the 'srcDir'
|
|
15
|
+
*/
|
|
16
|
+
src?: string;
|
|
17
|
+
/** Glob pattern to use when searching for tests.
|
|
18
|
+
* @default "**\/*.spec.ts"
|
|
19
|
+
**/
|
|
20
|
+
testMatch?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Explicitly define the tests inside the group.
|
|
23
|
+
* Overrides 'src' and 'testMatch' options.
|
|
24
|
+
*/
|
|
25
|
+
tests?: SamuraiTest[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SamuraiEnvironment {
|
|
29
|
+
/** Where page.goto('/…') resolves to in this environment, and where recordings start */
|
|
30
|
+
baseURL?: string;
|
|
31
|
+
/** Overrides the project timeout (ms) for this environment */
|
|
32
|
+
timeout?: number;
|
|
33
|
+
expect?: { timeout?: number };
|
|
34
|
+
/** Test data, used in steps as ${name}. Never put secrets here. */
|
|
35
|
+
variables?: Record<string, string | number | boolean>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface SamuraiTestConfig {
|
|
39
|
+
/**
|
|
40
|
+
* directory containing all the test files
|
|
41
|
+
* @default "./src"
|
|
42
|
+
*/
|
|
43
|
+
srcDir?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The browser to use for all tests.
|
|
46
|
+
* Is overwritten by the 'browser' in a group's config
|
|
47
|
+
*/
|
|
48
|
+
browser?: SupportedBrowser;
|
|
49
|
+
/**
|
|
50
|
+
* The timeout (in ms) to use for all tests
|
|
51
|
+
* @default 30000
|
|
52
|
+
*/
|
|
53
|
+
timeout?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Define a group of test's you want to have executed with a certain configuration
|
|
56
|
+
**/
|
|
57
|
+
groups?: SamuraiGroup[];
|
|
58
|
+
/**
|
|
59
|
+
* The base URL to use when navigating the browser.
|
|
60
|
+
* When recording starts, the browser will start at this URL.
|
|
61
|
+
*/
|
|
62
|
+
baseURL?: string;
|
|
63
|
+
/** Options for `expect` assertions */
|
|
64
|
+
expect?: {
|
|
65
|
+
/**
|
|
66
|
+
* Time (ms) locator assertions, actions (click, fill, focus) and waitFor keep retrying before failing
|
|
67
|
+
* @default 5000
|
|
68
|
+
*/
|
|
69
|
+
timeout?: number;
|
|
70
|
+
};
|
|
71
|
+
/** Options for the WebDriver BiDi connection */
|
|
72
|
+
bidi?: {
|
|
73
|
+
/**
|
|
74
|
+
* Time (ms) a BiDi command may wait for its reply before failing with code "timeout"
|
|
75
|
+
* @default 30000
|
|
76
|
+
*/
|
|
77
|
+
commandTimeout?: number;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Folder below which downloads are saved: every browser gets a folder of its own in it, and removes it again
|
|
81
|
+
* when nothing was downloaded
|
|
82
|
+
* @default "result/downloads"
|
|
83
|
+
*/
|
|
84
|
+
downloadsDir?: string;
|
|
85
|
+
/** How pages are opened */
|
|
86
|
+
use?: {
|
|
87
|
+
/**
|
|
88
|
+
* Size of the page's viewport, so screenshots and layout are the same on every machine.
|
|
89
|
+
* `null` keeps the browser's own size
|
|
90
|
+
* @default { width: 1280, height: 720 }
|
|
91
|
+
*/
|
|
92
|
+
viewport?: { width: number; height: number } | null;
|
|
93
|
+
};
|
|
94
|
+
/** What to do with the browser's console output and uncaught page exceptions */
|
|
95
|
+
logs?: {
|
|
96
|
+
/**
|
|
97
|
+
* Which tests get the page's log entries in the report: only failed ones, all, or none
|
|
98
|
+
* @default "failures"
|
|
99
|
+
*/
|
|
100
|
+
capture?: "off" | "failures" | "all";
|
|
101
|
+
/**
|
|
102
|
+
* Fail a test that passed when the page threw an exception nobody caught.
|
|
103
|
+
* A test can opt out with `page.allowPageErrors()`
|
|
104
|
+
* @default false
|
|
105
|
+
*/
|
|
106
|
+
failOnPageError?: boolean;
|
|
107
|
+
/** Uncaught exceptions whose message contains a string, or matches a RegExp, never fail a test */
|
|
108
|
+
ignoreErrors?: (string | RegExp)[];
|
|
109
|
+
};
|
|
110
|
+
/** Options for `page.navigateTo()` and `page.waitForLoadState()` */
|
|
111
|
+
navigation?: {
|
|
112
|
+
/**
|
|
113
|
+
* Time (ms) a navigation, or a wait for a load state, may take. Separate from `expect.timeout`:
|
|
114
|
+
* loading a page legitimately takes longer than an element becoming clickable
|
|
115
|
+
* @default 30000
|
|
116
|
+
*/
|
|
117
|
+
timeout?: number;
|
|
118
|
+
/**
|
|
119
|
+
* When `navigateTo()` resolves: "none" right away, "interactive" at DOMContentLoaded, "complete" at load
|
|
120
|
+
* @default "complete"
|
|
121
|
+
*/
|
|
122
|
+
waitUntil?: "none" | "interactive" | "complete";
|
|
123
|
+
};
|
|
124
|
+
/** Options for network tracking (`page.waitForNetworkIdle()` and the request/response events) */
|
|
125
|
+
network?: {
|
|
126
|
+
/**
|
|
127
|
+
* Follow the page's requests from the moment the browser starts. When false, `waitForNetworkIdle()` throws
|
|
128
|
+
* @default true
|
|
129
|
+
*/
|
|
130
|
+
track?: boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Time (ms) without requests in flight that counts as idle
|
|
133
|
+
* @default 500
|
|
134
|
+
*/
|
|
135
|
+
idleTime?: number;
|
|
136
|
+
/**
|
|
137
|
+
* Time (ms) a `page.route` handler may take to answer a request before the request is let through
|
|
138
|
+
* @default 30000
|
|
139
|
+
*/
|
|
140
|
+
routeTimeout?: number;
|
|
141
|
+
/**
|
|
142
|
+
* Keep response bodies from the moment the browser starts, so `response.body()` works for every response.
|
|
143
|
+
* Bodies are otherwise kept once `page.on("response")` or `page.waitForResponse()` is used, and cost browser memory
|
|
144
|
+
* @default false
|
|
145
|
+
*/
|
|
146
|
+
collectBodies?: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* Largest response body (bytes) that is kept for `response.body()`
|
|
149
|
+
* @default 10485760
|
|
150
|
+
*/
|
|
151
|
+
maxBodySize?: number;
|
|
152
|
+
};
|
|
153
|
+
/** Keyed by name: 'dev', 'staging', 'production' */
|
|
154
|
+
environments?: Record<string, SamuraiEnvironment>;
|
|
155
|
+
/** Used when no environment is chosen with --env or SAMURAI_ENV */
|
|
156
|
+
defaultEnvironment?: string;
|
|
157
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Browser } from "../browser/browser.ts";
|
|
2
|
+
import type Page from "../browser/page.ts";
|
|
3
|
+
|
|
4
|
+
/** What a test function receives */
|
|
5
|
+
export interface TestFixtures {
|
|
6
|
+
page: Page;
|
|
7
|
+
browser: Browser;
|
|
8
|
+
/** The chosen environment's variables; reading an unknown name throws */
|
|
9
|
+
env: Readonly<Record<string, string | number | boolean>>;
|
|
10
|
+
/** Secrets by name (SAMURAI_SECRET_<NAME>); reading an unset name throws */
|
|
11
|
+
secrets: Readonly<Record<string, string>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type TestCase = TestCaseBase;
|
|
15
|
+
|
|
16
|
+
export interface RegisteredTestCase extends TestCaseBase {
|
|
17
|
+
/** Path of the spec file that registered the test */
|
|
18
|
+
file: string;
|
|
19
|
+
/** describe titles, then the test title */
|
|
20
|
+
titlePath: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface TestCaseBase {
|
|
24
|
+
/** Full title: describe titles and the test title joined with " > " */
|
|
25
|
+
name: string;
|
|
26
|
+
function: (fixtures: TestFixtures) => Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PartialTestResult extends TestResultBase {
|
|
30
|
+
name: string;
|
|
31
|
+
file: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type TestResult = TestResultSuccess | TestResultFailed | TestResultBase;
|
|
35
|
+
|
|
36
|
+
interface TestResultSuccess {
|
|
37
|
+
status: "success";
|
|
38
|
+
duration: number;
|
|
39
|
+
logs?: TestLogEntry[];
|
|
40
|
+
/** Entries that fell out of the page's log buffer */
|
|
41
|
+
logsDropped?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface TestResultFailed {
|
|
45
|
+
status: "failed";
|
|
46
|
+
error: TestError;
|
|
47
|
+
duration: number;
|
|
48
|
+
logs?: TestLogEntry[];
|
|
49
|
+
logsDropped?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One line of the browser's log, as stored in the report */
|
|
53
|
+
export interface TestLogEntry {
|
|
54
|
+
level: "debug" | "info" | "warn" | "error";
|
|
55
|
+
/** "console", "javascript" (an uncaught exception), or another type the browser reports */
|
|
56
|
+
type: string;
|
|
57
|
+
method?: string;
|
|
58
|
+
text: string;
|
|
59
|
+
timestamp: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface TestResultBase {
|
|
63
|
+
name: string;
|
|
64
|
+
file: string;
|
|
65
|
+
startTime: number;
|
|
66
|
+
status: "started";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface TestError {
|
|
70
|
+
message: string;
|
|
71
|
+
type: "timeout" | "error" | "assertion";
|
|
72
|
+
stack?: string | undefined;
|
|
73
|
+
/** Only for `type: "assertion"` */
|
|
74
|
+
expected?: unknown;
|
|
75
|
+
/** Only for `type: "assertion"` */
|
|
76
|
+
actual?: unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type TestSummary = TestSummarySuccess | TestSummaryFailed;
|
|
80
|
+
|
|
81
|
+
interface TestSummarySuccess extends TestSummaryBase {
|
|
82
|
+
status: "success";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface TestSummaryFailed extends TestSummaryBase {
|
|
86
|
+
status: "failed";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface TestSummaryBase {
|
|
90
|
+
duration: number;
|
|
91
|
+
startTime: number;
|
|
92
|
+
tests: TestResult[];
|
|
93
|
+
/** The environment the run used */
|
|
94
|
+
environment: string;
|
|
95
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { resolveTimeout, waitUntil, WaitTimeoutError } from "./wait-until.js";
|
|
4
|
+
import { resolveRunSettings, setRunSettings } from "../config/run-settings.js";
|
|
5
|
+
|
|
6
|
+
/** Probe returning the given values in order; the last one repeats */
|
|
7
|
+
function sequence<T>(...values: T[]) {
|
|
8
|
+
let calls = 0;
|
|
9
|
+
const probe = async () => values[Math.min(calls++, values.length - 1)]!;
|
|
10
|
+
return { probe, calls: () => calls };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("probes until isDone and resolves with that value", async () => {
|
|
14
|
+
const { probe, calls } = sequence(1, 2, 3);
|
|
15
|
+
assert.equal(await waitUntil(probe, (value) => value === 3, { timeout: 2000 }), 3);
|
|
16
|
+
assert.equal(calls(), 3);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("passes the previous value to isDone", async () => {
|
|
20
|
+
const { probe } = sequence(1, 2, 2);
|
|
21
|
+
const seen: Array<[number, number | undefined]> = [];
|
|
22
|
+
await waitUntil(
|
|
23
|
+
probe,
|
|
24
|
+
(current, previous) => {
|
|
25
|
+
seen.push([current, previous]);
|
|
26
|
+
return current === previous;
|
|
27
|
+
},
|
|
28
|
+
{ timeout: 2000 },
|
|
29
|
+
);
|
|
30
|
+
assert.deepEqual(seen, [
|
|
31
|
+
[1, undefined],
|
|
32
|
+
[2, 1],
|
|
33
|
+
[2, 2],
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("times out with the last value, ending at the deadline", async () => {
|
|
38
|
+
const { probe, calls } = sequence("a", "b");
|
|
39
|
+
const start = Date.now();
|
|
40
|
+
await assert.rejects(waitUntil(probe, () => false, { timeout: 250 }), (err) => {
|
|
41
|
+
assert.ok(err instanceof WaitTimeoutError);
|
|
42
|
+
assert.equal(err.name, "WaitTimeoutError");
|
|
43
|
+
assert.equal(err.timeout, 250);
|
|
44
|
+
assert.equal(err.last, "b");
|
|
45
|
+
return true;
|
|
46
|
+
});
|
|
47
|
+
const elapsed = Date.now() - start;
|
|
48
|
+
// probes at ~0, ~100, ~200; the final sleep is clamped to the ~50ms left, and no probe starts after the deadline
|
|
49
|
+
assert.ok(elapsed >= 245 && elapsed < 295, `elapsed ${elapsed}ms`);
|
|
50
|
+
assert.equal(calls(), 3);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("timeout 0 probes exactly once", async () => {
|
|
54
|
+
const { probe, calls } = sequence(false);
|
|
55
|
+
await assert.rejects(waitUntil(probe, (value) => value, { timeout: 0 }), WaitTimeoutError);
|
|
56
|
+
assert.equal(calls(), 1);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("timeout 0 resolves when the single probe is done", async () => {
|
|
60
|
+
const { probe } = sequence(true);
|
|
61
|
+
assert.equal(await waitUntil(probe, (value) => value, { timeout: 0 }), true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("probe errors are rethrown without retrying", async () => {
|
|
65
|
+
let calls = 0;
|
|
66
|
+
const probe = async () => {
|
|
67
|
+
calls++;
|
|
68
|
+
throw new Error("boom");
|
|
69
|
+
};
|
|
70
|
+
await assert.rejects(waitUntil(probe, () => true, { timeout: 2000 }), /boom/);
|
|
71
|
+
assert.equal(calls, 1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("a probe that never settles stops at the deadline", async () => {
|
|
75
|
+
const start = Date.now();
|
|
76
|
+
await assert.rejects(
|
|
77
|
+
waitUntil(() => new Promise<never>(() => {}), () => true, { timeout: 200 }),
|
|
78
|
+
(err) => {
|
|
79
|
+
assert.ok(err instanceof WaitTimeoutError);
|
|
80
|
+
assert.equal(err.last, undefined);
|
|
81
|
+
return true;
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
assert.ok(Date.now() - start < 400);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a probe that hangs after earlier results reports the last completed value", async () => {
|
|
88
|
+
let calls = 0;
|
|
89
|
+
const probe = () => (calls++ === 0 ? Promise.resolve("first") : new Promise<string>(() => {}));
|
|
90
|
+
await assert.rejects(waitUntil(probe, () => false, { timeout: 200 }), (err) => {
|
|
91
|
+
assert.ok(err instanceof WaitTimeoutError);
|
|
92
|
+
assert.equal(err.last, "first");
|
|
93
|
+
return true;
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("no timer outlives a resolved wait", async () => {
|
|
98
|
+
const timers = () => process.getActiveResourcesInfo().filter((name) => name === "Timeout").length;
|
|
99
|
+
const before = timers();
|
|
100
|
+
await waitUntil(async () => true, (value) => value, { timeout: 10000 });
|
|
101
|
+
assert.equal(timers(), before);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("resolveTimeout prefers the per-call value", async () => {
|
|
105
|
+
assert.equal(await resolveTimeout(1234), 1234);
|
|
106
|
+
assert.equal(await resolveTimeout(0), 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("resolveTimeout uses the active run's expect timeout", async () => {
|
|
110
|
+
setRunSettings(resolveRunSettings({}, { expectTimeout: 1234 }));
|
|
111
|
+
try {
|
|
112
|
+
assert.equal(await resolveTimeout(), 1234);
|
|
113
|
+
assert.equal(await resolveTimeout(10), 10);
|
|
114
|
+
} finally {
|
|
115
|
+
setRunSettings(undefined);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { readConfig } from "../config/config.js";
|
|
3
|
+
import { peekRunSettings } from "../config/run-settings.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TIMEOUT = 5000;
|
|
6
|
+
const DEFAULT_INTERVAL = 100;
|
|
7
|
+
|
|
8
|
+
export interface WaitOptions {
|
|
9
|
+
/** Total time (ms) to keep probing; 0 probes exactly once */
|
|
10
|
+
timeout: number;
|
|
11
|
+
/** Time (ms) between probes. @default 100 */
|
|
12
|
+
interval?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class WaitTimeoutError<T> extends Error {
|
|
16
|
+
public readonly timeout: number;
|
|
17
|
+
/** Last completed probe result; undefined when no probe completed */
|
|
18
|
+
public readonly last: T | undefined;
|
|
19
|
+
|
|
20
|
+
constructor(timeout: number, last: T | undefined) {
|
|
21
|
+
super(`Condition not met within ${timeout}ms`);
|
|
22
|
+
this.name = "WaitTimeoutError";
|
|
23
|
+
this.timeout = timeout;
|
|
24
|
+
this.last = last;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Per-call timeout, else the active run's expect timeout, else config `expect.timeout`, else 5000 */
|
|
29
|
+
export async function resolveTimeout(perCall?: number): Promise<number> {
|
|
30
|
+
if (perCall !== undefined) return perCall;
|
|
31
|
+
const run = peekRunSettings();
|
|
32
|
+
if (run) return run.expectTimeout;
|
|
33
|
+
return (await readConfig("expect"))?.timeout ?? DEFAULT_TIMEOUT;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEADLINE = Symbol("deadline");
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Races `probe()` against the time left. Resolves `{ value }` when the probe wins,
|
|
40
|
+
* DEADLINE when the time runs out first; probe errors reject as-is.
|
|
41
|
+
*/
|
|
42
|
+
function probeBefore<T>(
|
|
43
|
+
probe: () => Promise<T>,
|
|
44
|
+
remaining: number,
|
|
45
|
+
): Promise<{ value: T } | typeof DEADLINE> {
|
|
46
|
+
const probing = probe();
|
|
47
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
48
|
+
const expired = new Promise<typeof DEADLINE>((resolve) => {
|
|
49
|
+
timer = setTimeout(() => resolve(DEADLINE), remaining);
|
|
50
|
+
});
|
|
51
|
+
// A probe that loses the race may still fail later; nobody is waiting for it any more
|
|
52
|
+
probing.catch(() => {});
|
|
53
|
+
return Promise.race([probing.then((value) => ({ value })), expired]).finally(() =>
|
|
54
|
+
clearTimeout(timer),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Probes until `isDone(current, previous)` is true and resolves with that result.
|
|
60
|
+
* Always probes at least once; with `timeout > 0` every probe races the time left.
|
|
61
|
+
* Probe errors are rethrown immediately. Throws WaitTimeoutError at the deadline.
|
|
62
|
+
*/
|
|
63
|
+
export async function waitUntil<T>(
|
|
64
|
+
probe: () => Promise<T>,
|
|
65
|
+
isDone: (current: T, previous: T | undefined) => boolean,
|
|
66
|
+
{ timeout, interval = DEFAULT_INTERVAL }: WaitOptions,
|
|
67
|
+
): Promise<T> {
|
|
68
|
+
const deadline = Date.now() + timeout;
|
|
69
|
+
let previous: T | undefined;
|
|
70
|
+
|
|
71
|
+
for (let first = true; ; first = false) {
|
|
72
|
+
const remaining = deadline - Date.now();
|
|
73
|
+
// Only start a probe while time remains; a later one could only lose the race
|
|
74
|
+
if (!first && remaining <= 0) throw new WaitTimeoutError(timeout, previous);
|
|
75
|
+
|
|
76
|
+
// timeout 0 keeps the single probe un-raced so it always completes
|
|
77
|
+
const result = timeout > 0 ? await probeBefore(probe, remaining) : { value: await probe() };
|
|
78
|
+
if (result === DEADLINE) throw new WaitTimeoutError(timeout, previous);
|
|
79
|
+
if (isDone(result.value, previous)) return result.value;
|
|
80
|
+
previous = result.value;
|
|
81
|
+
|
|
82
|
+
const left = deadline - Date.now();
|
|
83
|
+
if (left <= 0) throw new WaitTimeoutError(timeout, previous);
|
|
84
|
+
await sleep(Math.min(interval, left));
|
|
85
|
+
}
|
|
86
|
+
}
|