@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.
Files changed (175) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/.env.example +3 -0
  3. package/.github/actions/setup/action.yml +16 -0
  4. package/.github/workflows/ci.yml +71 -0
  5. package/.trunk/configs/.markdownlint.yaml +2 -0
  6. package/.trunk/configs/.yamllint.yaml +7 -0
  7. package/.trunk/configs/svgo.config.mjs +14 -0
  8. package/.trunk/trunk.yaml +34 -0
  9. package/docs/superpowers/bidi-roadmap.md +60 -0
  10. package/docs/superpowers/plans/2026-09-29-auto-wait.md +1827 -0
  11. package/docs/superpowers/plans/2026-09-29-expect.md +1350 -0
  12. package/docs/superpowers/plans/2026-10-01-ci.md +350 -0
  13. package/docs/superpowers/plans/2026-10-01-test-api-alignment.md +1821 -0
  14. package/docs/superpowers/recorder-roadmap.md +52 -0
  15. package/docs/superpowers/specs/2026-09-29-auto-wait-design.md +185 -0
  16. package/docs/superpowers/specs/2026-09-29-bidi-foundation-design.md +130 -0
  17. package/docs/superpowers/specs/2026-09-29-contexts-and-storage-design.md +88 -0
  18. package/docs/superpowers/specs/2026-09-29-downloads-and-file-dialogs-design.md +94 -0
  19. package/docs/superpowers/specs/2026-09-29-emulation-design.md +88 -0
  20. package/docs/superpowers/specs/2026-09-29-expect-design.md +130 -0
  21. package/docs/superpowers/specs/2026-09-29-locate-nodes-design.md +90 -0
  22. package/docs/superpowers/specs/2026-09-29-logs-and-errors-design.md +87 -0
  23. package/docs/superpowers/specs/2026-09-29-navigation-waits-design.md +83 -0
  24. package/docs/superpowers/specs/2026-09-29-network-data-design.md +78 -0
  25. package/docs/superpowers/specs/2026-09-29-network-interception-design.md +91 -0
  26. package/docs/superpowers/specs/2026-09-29-network-tracking-design.md +86 -0
  27. package/docs/superpowers/specs/2026-09-29-page-api-design.md +90 -0
  28. package/docs/superpowers/specs/2026-09-29-preload-scripts-design.md +82 -0
  29. package/docs/superpowers/specs/2026-09-29-script-call-function-design.md +107 -0
  30. package/docs/superpowers/specs/2026-09-29-set-files-design.md +62 -0
  31. package/docs/superpowers/specs/2026-09-29-user-prompts-design.md +75 -0
  32. package/docs/superpowers/specs/2026-10-01-ci-design.md +106 -0
  33. package/docs/superpowers/specs/2026-10-01-test-api-alignment-design.md +241 -0
  34. package/package.json +27 -0
  35. package/public/logo-dark.svg +236 -0
  36. package/public/logo-light.svg +262 -0
  37. package/samurai.config.ts +7 -0
  38. package/src/api.ts +4 -0
  39. package/src/assert/assertion-error.test.ts +55 -0
  40. package/src/assert/assertion-error.ts +62 -0
  41. package/src/assert/expect-locator.test.ts +217 -0
  42. package/src/assert/expect.test.ts +73 -0
  43. package/src/assert/expect.ts +299 -0
  44. package/src/browser/browser-context.browser-test.ts +201 -0
  45. package/src/browser/browser-context.test.ts +407 -0
  46. package/src/browser/browser-context.ts +208 -0
  47. package/src/browser/browser-finder.ts +110 -0
  48. package/src/browser/browser.test.ts +88 -0
  49. package/src/browser/browser.ts +515 -0
  50. package/src/browser/context-tree.test.ts +86 -0
  51. package/src/browser/context-tree.ts +70 -0
  52. package/src/browser/dialog.browser-test.ts +144 -0
  53. package/src/browser/dialog.test.ts +322 -0
  54. package/src/browser/dialog.ts +83 -0
  55. package/src/browser/download.test.ts +161 -0
  56. package/src/browser/download.ts +172 -0
  57. package/src/browser/downloads-dir.test.ts +75 -0
  58. package/src/browser/downloads-dir.ts +39 -0
  59. package/src/browser/emulation.browser-test.ts +186 -0
  60. package/src/browser/emulation.test.ts +200 -0
  61. package/src/browser/emulation.ts +204 -0
  62. package/src/browser/file-chooser.test.ts +142 -0
  63. package/src/browser/file-chooser.ts +99 -0
  64. package/src/browser/file-paths.ts +18 -0
  65. package/src/browser/goto.browser-test.ts +44 -0
  66. package/src/browser/navigation-error.ts +26 -0
  67. package/src/browser/navigation.browser-test.ts +172 -0
  68. package/src/browser/page-api.browser-test.ts +228 -0
  69. package/src/browser/page-logs.browser-test.ts +119 -0
  70. package/src/browser/page-logs.test.ts +212 -0
  71. package/src/browser/page-logs.ts +208 -0
  72. package/src/browser/page-wait.ts +19 -0
  73. package/src/browser/page.test.ts +1567 -0
  74. package/src/browser/page.ts +1176 -0
  75. package/src/browser/pdf.ts +62 -0
  76. package/src/browser/screenshot.ts +65 -0
  77. package/src/browser/transfers.browser-test.ts +180 -0
  78. package/src/config/config.ts +33 -0
  79. package/src/config/default.ts +5 -0
  80. package/src/config/fixtures.test.ts +137 -0
  81. package/src/config/mask.test.ts +143 -0
  82. package/src/config/mask.ts +87 -0
  83. package/src/config/run-settings.test.ts +163 -0
  84. package/src/config/run-settings.ts +114 -0
  85. package/src/config/secrets.ts +70 -0
  86. package/src/config/variables.ts +31 -0
  87. package/src/index.ts +10 -0
  88. package/src/locator/action-timeout-error.test.ts +83 -0
  89. package/src/locator/action-timeout-error.ts +72 -0
  90. package/src/locator/element-state.test.ts +181 -0
  91. package/src/locator/element-state.ts +159 -0
  92. package/src/locator/locate-nodes.browser-test.ts +169 -0
  93. package/src/locator/locator-actions.test.ts +355 -0
  94. package/src/locator/locator-chain.test.ts +292 -0
  95. package/src/locator/locator.browser-test.ts +137 -0
  96. package/src/locator/locator.test.ts +179 -0
  97. package/src/locator/locator.ts +656 -0
  98. package/src/locator/selector-errors.ts +18 -0
  99. package/src/locator/selector.test.ts +124 -0
  100. package/src/locator/selector.ts +107 -0
  101. package/src/locator/set-files.browser-test.ts +141 -0
  102. package/src/locator/set-files.test.ts +191 -0
  103. package/src/locator/text-locator.test.ts +118 -0
  104. package/src/locator/text-locator.ts +38 -0
  105. package/src/logger/index.test.ts +28 -0
  106. package/src/logger/index.ts +40 -0
  107. package/src/network/data-collector.test.ts +111 -0
  108. package/src/network/data-collector.ts +92 -0
  109. package/src/network/network-data.browser-test.ts +192 -0
  110. package/src/network/network-tracker.browser-test.ts +151 -0
  111. package/src/network/network-tracker.test.ts +307 -0
  112. package/src/network/network-tracker.ts +286 -0
  113. package/src/network/network-wait.ts +28 -0
  114. package/src/network/response.test.ts +93 -0
  115. package/src/network/response.ts +58 -0
  116. package/src/network/router.browser-test.ts +260 -0
  117. package/src/network/router.test.ts +528 -0
  118. package/src/network/router.ts +420 -0
  119. package/src/network/url-match.test.ts +69 -0
  120. package/src/network/url-match.ts +41 -0
  121. package/src/runner/page-logs-report.test.ts +144 -0
  122. package/src/runner/page-logs-report.ts +82 -0
  123. package/src/runner/prepare-run.test.ts +33 -0
  124. package/src/runner/prepare-run.ts +36 -0
  125. package/src/runner/registry.test.ts +59 -0
  126. package/src/runner/registry.ts +46 -0
  127. package/src/runner/reporter.test.ts +73 -0
  128. package/src/runner/reporter.ts +109 -0
  129. package/src/runner/test-error.test.ts +39 -0
  130. package/src/runner/test-error.ts +20 -0
  131. package/src/runner/test-runner.ts +184 -0
  132. package/src/script/call-function.browser-test.ts +112 -0
  133. package/src/script/call-function.test.ts +174 -0
  134. package/src/script/call-function.ts +113 -0
  135. package/src/script/element-handle.ts +11 -0
  136. package/src/script/helpers.test.ts +116 -0
  137. package/src/script/helpers.ts +68 -0
  138. package/src/script/preload.browser-test.ts +144 -0
  139. package/src/script/preload.test.ts +180 -0
  140. package/src/script/preload.ts +102 -0
  141. package/src/script/serialize.test.ts +182 -0
  142. package/src/script/serialize.ts +203 -0
  143. package/src/testing/browser-fixture.ts +18 -0
  144. package/src/testing/fake-websocket.ts +72 -0
  145. package/src/testing/stub-connector.ts +154 -0
  146. package/src/tests/index.spec.ts +30 -0
  147. package/src/transport/bidi-connection.test.ts +155 -0
  148. package/src/transport/bidi-connection.ts +256 -0
  149. package/src/transport/bidi-error.ts +23 -0
  150. package/src/transport/foundation.browser-test.ts +73 -0
  151. package/src/transport/subscriptions.test.ts +140 -0
  152. package/src/transport/subscriptions.ts +110 -0
  153. package/src/types/bidi-commands.test-d.ts +55 -0
  154. package/src/types/bidi-modules/browser.d.ts +103 -0
  155. package/src/types/bidi-modules/browsing-context.d.ts +430 -0
  156. package/src/types/bidi-modules/emulation.d.ts +280 -0
  157. package/src/types/bidi-modules/input.d.ts +201 -0
  158. package/src/types/bidi-modules/log.d.ts +49 -0
  159. package/src/types/bidi-modules/network.d.ts +358 -0
  160. package/src/types/bidi-modules/permissions.d.ts +19 -0
  161. package/src/types/bidi-modules/script.d.ts +543 -0
  162. package/src/types/bidi-modules/session.d.ts +135 -0
  163. package/src/types/bidi-modules/storage.d.ts +92 -0
  164. package/src/types/bidi-modules/web-extensions.d.ts +9 -0
  165. package/src/types/bidi-protocols/command.d.ts +7 -0
  166. package/src/types/bidi-protocols/error.d.ts +38 -0
  167. package/src/types/bidi-protocols/event.d.ts +17 -0
  168. package/src/types/bidi.d.ts +65 -0
  169. package/src/types/browser.d.ts +1 -0
  170. package/src/types/config.d.ts +157 -0
  171. package/src/types/env.d.ts +5 -0
  172. package/src/types/test.d.ts +95 -0
  173. package/src/wait/wait-until.test.ts +117 -0
  174. package/src/wait/wait-until.ts +86 -0
  175. 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,9 @@
1
+ import type { EmptyResult } from "../bidi.js";
2
+
3
+ export type WebExtensionResult = InstallResult | UninstallResult;
4
+
5
+ export type UninstallResult = EmptyResult;
6
+ export type Extension = string;
7
+ export interface InstallResult {
8
+ extension: Extension;
9
+ }
@@ -0,0 +1,7 @@
1
+ import type { Extensible, ResultData } from "../bidi.js";
2
+
3
+ export interface CommandResponse extends Extensible {
4
+ type: "success";
5
+ id: number;
6
+ result: ResultData;
7
+ }
@@ -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,5 @@
1
+ declare namespace NodeJS {
2
+ interface ProcessEnv {
3
+ NODE_CONFIG_DIR: string;
4
+ }
5
+ }
@@ -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
+ }