@rstest/browser 0.7.9

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.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Re-export runtime API from @rstest/core/browser-runtime for browser use.
3
+ * This file is used as an alias target for '@rstest/core' in browser mode.
4
+ *
5
+ * Uses @rstest/core/browser-runtime which only exports the test APIs
6
+ * (describe, it, expect, etc.) without any Node.js dependencies.
7
+ */
8
+
9
+ // Re-export types from @rstest/core (these are compile-time only)
10
+ export type { Assertion, Mock } from '@rstest/core';
11
+ // Re-export all public test APIs
12
+ export * from '@rstest/core/browser-runtime';
@@ -0,0 +1,177 @@
1
+ import type {
2
+ BrowserHostConfig,
3
+ SnapshotRpcRequest,
4
+ SnapshotRpcResponse,
5
+ } from '../protocol';
6
+ import { mapStackFrame } from './sourceMapSupport';
7
+
8
+ declare global {
9
+ interface Window {
10
+ __RSTEST_BROWSER_OPTIONS__?: BrowserHostConfig;
11
+ }
12
+ }
13
+
14
+ const SNAPSHOT_HEADER = '// Rstest Snapshot';
15
+
16
+ /** Default RPC timeout if not specified in config (30 seconds) */
17
+ const DEFAULT_RPC_TIMEOUT_MS = 30_000;
18
+
19
+ /**
20
+ * Get RPC timeout from browser options or use default.
21
+ */
22
+ const getRpcTimeout = (): number => {
23
+ return (
24
+ window.__RSTEST_BROWSER_OPTIONS__?.rpcTimeout ?? DEFAULT_RPC_TIMEOUT_MS
25
+ );
26
+ };
27
+
28
+ /**
29
+ * Pending RPC requests waiting for responses from the container.
30
+ */
31
+ const pendingRequests = new Map<
32
+ string,
33
+ {
34
+ resolve: (value: unknown) => void;
35
+ reject: (error: Error) => void;
36
+ }
37
+ >();
38
+
39
+ let requestIdCounter = 0;
40
+ let messageListenerInitialized = false;
41
+
42
+ /**
43
+ * Initialize the message listener for snapshot RPC responses.
44
+ * This is called once when the first RPC request is made.
45
+ */
46
+ const initMessageListener = (): void => {
47
+ if (messageListenerInitialized) {
48
+ return;
49
+ }
50
+ messageListenerInitialized = true;
51
+
52
+ window.addEventListener('message', (event: MessageEvent) => {
53
+ if (event.data?.type === '__rstest_snapshot_response__') {
54
+ const response = event.data.payload as SnapshotRpcResponse;
55
+ const pending = pendingRequests.get(response.id);
56
+ if (pending) {
57
+ pendingRequests.delete(response.id);
58
+ if (response.error) {
59
+ pending.reject(new Error(response.error));
60
+ } else {
61
+ pending.resolve(response.result);
62
+ }
63
+ }
64
+ }
65
+ });
66
+ };
67
+
68
+ /**
69
+ * Send a snapshot RPC request to the container (parent window).
70
+ * The container will forward it to the host via WebSocket RPC.
71
+ */
72
+ const sendRpcRequest = <T>(
73
+ method: SnapshotRpcRequest['method'],
74
+ args: SnapshotRpcRequest['args'],
75
+ ): Promise<T> => {
76
+ initMessageListener();
77
+
78
+ const id = `snapshot-rpc-${++requestIdCounter}`;
79
+ const rpcTimeout = getRpcTimeout();
80
+
81
+ return new Promise<T>((resolve, reject) => {
82
+ // Set a timeout for the RPC call
83
+ const timeoutId = setTimeout(() => {
84
+ pendingRequests.delete(id);
85
+ reject(
86
+ new Error(
87
+ `Snapshot RPC timeout after ${rpcTimeout / 1000}s: ${method}`,
88
+ ),
89
+ );
90
+ }, rpcTimeout);
91
+
92
+ pendingRequests.set(id, {
93
+ resolve: (value) => {
94
+ clearTimeout(timeoutId);
95
+ resolve(value as T);
96
+ },
97
+ reject: (error) => {
98
+ clearTimeout(timeoutId);
99
+ reject(error);
100
+ },
101
+ });
102
+
103
+ // Send request to parent window (container)
104
+ window.parent.postMessage(
105
+ {
106
+ type: '__rstest_dispatch__',
107
+ payload: {
108
+ type: 'snapshot-rpc-request',
109
+ payload: { id, method, args },
110
+ },
111
+ },
112
+ '*',
113
+ );
114
+ });
115
+ };
116
+
117
+ /**
118
+ * Browser snapshot environment that proxies file operations to the host
119
+ * via postMessage RPC through the container.
120
+ */
121
+ export class BrowserSnapshotEnvironment {
122
+ getVersion(): string {
123
+ return '1';
124
+ }
125
+
126
+ getHeader(): string {
127
+ return `${SNAPSHOT_HEADER} v${this.getVersion()}`;
128
+ }
129
+
130
+ async resolveRawPath(_testPath: string, rawPath: string): Promise<string> {
131
+ return rawPath;
132
+ }
133
+
134
+ async resolvePath(filepath: string): Promise<string> {
135
+ return sendRpcRequest<string>('resolveSnapshotPath', {
136
+ testPath: filepath,
137
+ });
138
+ }
139
+
140
+ async prepareDirectory(): Promise<void> {
141
+ // Directory creation is handled by saveSnapshotFile on the host
142
+ }
143
+
144
+ async saveSnapshotFile(filepath: string, snapshot: string): Promise<void> {
145
+ await sendRpcRequest<void>('saveSnapshotFile', {
146
+ filepath,
147
+ content: snapshot,
148
+ });
149
+ }
150
+
151
+ async readSnapshotFile(filepath: string): Promise<string | null> {
152
+ return sendRpcRequest<string | null>('readSnapshotFile', { filepath });
153
+ }
154
+
155
+ async removeSnapshotFile(filepath: string): Promise<void> {
156
+ await sendRpcRequest<void>('removeSnapshotFile', { filepath });
157
+ }
158
+
159
+ /**
160
+ * Process stack trace for inline snapshots.
161
+ * Maps bundled URLs back to original source file paths.
162
+ */
163
+ processStackTrace(stack: {
164
+ file: string;
165
+ line: number;
166
+ column: number;
167
+ method: string;
168
+ }): { file: string; line: number; column: number; method: string } {
169
+ const mapped = mapStackFrame(stack);
170
+ return {
171
+ file: mapped.file,
172
+ line: mapped.line,
173
+ column: mapped.column,
174
+ method: mapped.method || stack.method,
175
+ };
176
+ }
177
+ }
@@ -0,0 +1,178 @@
1
+ import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping';
2
+ import convert from 'convert-source-map';
3
+
4
+ // Source map cache: JS URL → TraceMap
5
+ const sourceMapCache = new Map<string, TraceMap | null>();
6
+
7
+ /**
8
+ * Get TraceMap for specified URL (sync cache lookup)
9
+ */
10
+ const getSourceMap = (url: string): TraceMap | null => {
11
+ return sourceMapCache.get(url) ?? null;
12
+ };
13
+
14
+ /**
15
+ * Preload source map for specified JS URL.
16
+ * First tries to extract inline source map from JS code,
17
+ * then falls back to fetching external .map file.
18
+ *
19
+ * @param jsUrl - The URL of the JS file
20
+ * @param force - If true, bypass cache and always fetch fresh source map
21
+ */
22
+ const preloadSourceMap = async (
23
+ jsUrl: string,
24
+ force = false,
25
+ ): Promise<void> => {
26
+ if (!force && sourceMapCache.has(jsUrl)) return;
27
+
28
+ try {
29
+ // First, fetch JS file and try to extract inline source map
30
+ const jsResponse = await fetch(jsUrl);
31
+ if (!jsResponse.ok) {
32
+ sourceMapCache.set(jsUrl, null);
33
+ return;
34
+ }
35
+
36
+ const code = await jsResponse.text();
37
+
38
+ // Try to extract inline source map using convert-source-map
39
+ const inlineConverter = convert.fromSource(code);
40
+ if (inlineConverter) {
41
+ const mapObject = inlineConverter.toObject();
42
+ sourceMapCache.set(jsUrl, new TraceMap(mapObject));
43
+ return;
44
+ }
45
+
46
+ // Fallback: try to fetch external .map file
47
+ const mapUrl = `${jsUrl}.map`;
48
+ const mapResponse = await fetch(mapUrl);
49
+ if (mapResponse.ok) {
50
+ const mapJson = await mapResponse.json();
51
+ sourceMapCache.set(jsUrl, new TraceMap(mapJson));
52
+ return;
53
+ }
54
+
55
+ // No source map found
56
+ sourceMapCache.set(jsUrl, null);
57
+ } catch {
58
+ sourceMapCache.set(jsUrl, null);
59
+ }
60
+ };
61
+
62
+ /**
63
+ * Get all script URLs currently on the page.
64
+ * Used to detect newly loaded chunk scripts.
65
+ */
66
+ export const getScriptUrls = (): Set<string> => {
67
+ const scripts = document.querySelectorAll('script[src]');
68
+ const urls = new Set<string>();
69
+ for (const script of scripts) {
70
+ const src = script.getAttribute('src');
71
+ if (src) {
72
+ // Normalize to full URL
73
+ const fullUrl = src.startsWith('http')
74
+ ? src
75
+ : `${window.location.origin}${src.startsWith('/') ? '' : '/'}${src}`;
76
+ urls.add(fullUrl);
77
+ }
78
+ }
79
+ return urls;
80
+ };
81
+
82
+ /**
83
+ * Find the newly added script URL by comparing script sets.
84
+ * Returns the first new script URL found, or null if none.
85
+ */
86
+ export const findNewScriptUrl = (
87
+ beforeUrls: Set<string>,
88
+ afterUrls: Set<string>,
89
+ ): string | null => {
90
+ for (const url of afterUrls) {
91
+ if (!beforeUrls.has(url)) {
92
+ return url;
93
+ }
94
+ }
95
+ return null;
96
+ };
97
+
98
+ /**
99
+ * Preload source map for a test file's chunk URL.
100
+ *
101
+ * Always fetches fresh source map to handle file changes during watch mode.
102
+ *
103
+ * @param chunkUrl - The full URL of the chunk JS file
104
+ */
105
+ export const preloadTestFileSourceMap = async (
106
+ chunkUrl: string,
107
+ ): Promise<void> => {
108
+ // Always force refresh to ensure we have the latest source map
109
+ // This handles the case where user saves file during test execution
110
+ await preloadSourceMap(chunkUrl, true);
111
+ };
112
+
113
+ /**
114
+ * Preload source map for the runner.js file.
115
+ *
116
+ * This is essential for inline snapshot support because the snapshot code
117
+ * runs in runner.js (which contains @rstest/core/browser-runtime).
118
+ * Without this, stack traces from inline snapshots cannot be mapped back
119
+ * to the original source files.
120
+ */
121
+ export const preloadRunnerSourceMap = async (): Promise<void> => {
122
+ const runnerUrl = `${window.location.origin}/static/js/runner.js`;
123
+ await preloadSourceMap(runnerUrl);
124
+ };
125
+
126
+ /**
127
+ * Clear cache (for testing purposes)
128
+ */
129
+ export const clearCache = (): void => {
130
+ sourceMapCache.clear();
131
+ };
132
+
133
+ /**
134
+ * Stack frame interface matching @vitest/snapshot's format
135
+ */
136
+ export interface StackFrame {
137
+ file: string;
138
+ line: number;
139
+ column: number;
140
+ method?: string;
141
+ }
142
+
143
+ /**
144
+ * Map a stack frame from bundled URL to original source file.
145
+ * This is used by BrowserSnapshotEnvironment.processStackTrace
146
+ */
147
+ export const mapStackFrame = (frame: StackFrame): StackFrame => {
148
+ const { file, line, column } = frame;
149
+
150
+ // Normalize file path to full URL for cache lookup
151
+ let fullUrl = file;
152
+ if (!file.startsWith('http://') && !file.startsWith('https://')) {
153
+ // Convert relative path to full URL
154
+ fullUrl = `${window.location.origin}${file.startsWith('/') ? '' : '/'}${file}`;
155
+ }
156
+
157
+ const traceMap = getSourceMap(fullUrl);
158
+ if (!traceMap) {
159
+ return frame;
160
+ }
161
+
162
+ const pos = originalPositionFor(traceMap, {
163
+ line,
164
+ column: column - 1, // source map uses 0-based column
165
+ });
166
+
167
+ if (pos.source && pos.line != null && pos.column != null) {
168
+ return {
169
+ ...frame,
170
+ file: pos.source,
171
+ line: pos.line,
172
+ column: pos.column + 1, // convert back to 1-based
173
+ method: pos.name || frame.method,
174
+ };
175
+ }
176
+
177
+ return frame;
178
+ };
package/src/env.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { BrowserClientMessage, BrowserHostConfig } from './protocol';
2
+
3
+ declare module '@rstest/browser-manifest' {
4
+ export type ManifestProjectConfig = {
5
+ name: string;
6
+ environmentName: string;
7
+ projectRoot: string;
8
+ };
9
+
10
+ export type ManifestTestContext = {
11
+ getTestKeys: () => string[];
12
+ loadTest: (key: string) => Promise<unknown>;
13
+ projectRoot: string;
14
+ };
15
+
16
+ export const projects: ManifestProjectConfig[];
17
+
18
+ export const projectSetupLoaders: Record<
19
+ string,
20
+ Array<() => Promise<unknown>>
21
+ >;
22
+
23
+ export const projectTestContexts: Record<string, ManifestTestContext>;
24
+
25
+ // Backward compatibility exports
26
+ export const projectConfig: ManifestProjectConfig | undefined;
27
+ export const setupLoaders: Array<() => Promise<unknown>>;
28
+ export const getTestKeys: () => string[];
29
+ export const loadTest: (key: string) => Promise<unknown>;
30
+ }
31
+
32
+ declare global {
33
+ const RSTEST_VERSION: string;
34
+
35
+ interface Window {
36
+ __RSTEST_BROWSER_OPTIONS__?: BrowserHostConfig;
37
+ __rstest_dispatch__?: (message: BrowserClientMessage) => void;
38
+ __rstest_container_dispatch__?: (data: unknown) => void;
39
+ __rstest_container_on__?: (data: unknown) => void;
40
+ __RSTEST_DONE__?: boolean;
41
+ __RSTEST_TEST_FILES__?: string[];
42
+ }
43
+ }