Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions e2e/browser-mode/fixtures/github-actions/rstest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { defineConfig } from '@rstest/core';
import { BROWSER_PORTS } from '../ports';

export default defineConfig({
reporters: ['github-actions'],
projects: [
{
name: 'browser',
browser: {
enabled: true,
provider: 'playwright',
headless: true,
port: BROWSER_PORTS['github-actions'],
},
include: ['tests/browser/**/*.test.ts'],
},
{
name: 'node',
include: ['tests/node/**/*.test.ts'],
},
],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { describe, expect, it } from '@rstest/core';

describe('browser failing test', () => {
it('should fail in browser', () => {
expect('4').toBe('41');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { describe, expect, it } from '@rstest/core';

describe('node passing test', () => {
it('should pass in node', () => {
expect(1).toBe(1);
});
});
1 change: 1 addition & 0 deletions e2e/browser-mode/fixtures/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ export const BROWSER_PORTS = {
'viewport-preset': 5216,
reporter: 5220,
'reporter-watch': 5222,
'github-actions': 5224,
} as const;
23 changes: 23 additions & 0 deletions e2e/browser-mode/githubActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { describe, expect, it } from '@rstest/core';
import { runBrowserCli } from './utils';

describe('browser mode - github-actions reporter', () => {
it('should annotate browser failures with test source path', async () => {
const { expectExecFailed, cli } = await runBrowserCli('github-actions');

await expectExecFailed();

const logs = cli.stdout
.split('\n')
.filter(Boolean)
.filter((log) => log.startsWith('::error'));

expect(logs.length).toBeGreaterThan(0);

const browserFailure =
logs.find((log) => log.includes('browser failing test')) || logs[0]!;

expect(browserFailure).toContain('tests/browser/failing.test.ts');
expect(browserFailure).not.toContain('http://localhost');
});
});
63 changes: 26 additions & 37 deletions packages/browser/src/client/sourceMapSupport.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping';
import convert from 'convert-source-map';
import {
loadSourceMapWithCache,
normalizeJavaScriptUrl,
type SourceMapPayload,
} from '../sourceMap/sourceMapLoader';

// Source map cache: JS URL → TraceMap
const sourceMapCache = new Map<string, TraceMap | null>();
const sourceMapPayloadCache = new Map<string, SourceMapPayload | null>();

/**
* Get TraceMap for specified URL (sync cache lookup)
Expand All @@ -23,40 +28,23 @@ const preloadSourceMap = async (
jsUrl: string,
force = false,
): Promise<void> => {
if (!force && sourceMapCache.has(jsUrl)) return;

try {
// First, fetch JS file and try to extract inline source map
const jsResponse = await fetch(jsUrl);
if (!jsResponse.ok) {
sourceMapCache.set(jsUrl, null);
return;
}

const code = await jsResponse.text();
const normalizedUrl = normalizeJavaScriptUrl(jsUrl, {
origin: window.location.origin,
});
if (!normalizedUrl) {
return;
}

// Try to extract inline source map using convert-source-map
const inlineConverter = convert.fromSource(code);
if (inlineConverter) {
const mapObject = inlineConverter.toObject();
sourceMapCache.set(jsUrl, new TraceMap(mapObject));
return;
}
if (!force && sourceMapCache.has(normalizedUrl)) return;

// Fallback: try to fetch external .map file
const mapUrl = `${jsUrl}.map`;
const mapResponse = await fetch(mapUrl);
if (mapResponse.ok) {
const mapJson = await mapResponse.json();
sourceMapCache.set(jsUrl, new TraceMap(mapJson));
return;
}
const sourceMap = await loadSourceMapWithCache({
jsUrl: normalizedUrl,
cache: sourceMapPayloadCache,
force,
origin: window.location.origin,
});

// No source map found
sourceMapCache.set(jsUrl, null);
} catch {
sourceMapCache.set(jsUrl, null);
}
sourceMapCache.set(normalizedUrl, sourceMap ? new TraceMap(sourceMap) : null);
};

/**
Expand Down Expand Up @@ -128,6 +116,7 @@ export const preloadRunnerSourceMap = async (): Promise<void> => {
*/
export const clearCache = (): void => {
sourceMapCache.clear();
sourceMapPayloadCache.clear();
};

/**
Expand All @@ -147,11 +136,11 @@ export interface StackFrame {
export const mapStackFrame = (frame: StackFrame): StackFrame => {
const { file, line, column } = frame;

// Normalize file path to full URL for cache lookup
let fullUrl = file;
if (!file.startsWith('http://') && !file.startsWith('https://')) {
// Convert relative path to full URL
fullUrl = `${window.location.origin}${file.startsWith('/') ? '' : '/'}${file}`;
const fullUrl = normalizeJavaScriptUrl(file, {
origin: window.location.origin,
});
if (!fullUrl) {
return frame;
}

const traceMap = getSourceMap(fullUrl);
Expand Down
140 changes: 112 additions & 28 deletions packages/browser/src/hostController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ import {
RunSessionLifecycle,
} from './runSession';
import { RunnerSessionRegistry } from './sessionRegistry';
import {
loadSourceMapWithCache,
normalizeJavaScriptUrl,
type SourceMapPayload,
} from './sourceMap/sourceMapLoader';
import { resolveBrowserViewportPreset } from './viewportPresets';
import { collectWatchTestFiles, planWatchRerun } from './watchRerunPlanner';

Expand Down Expand Up @@ -1346,20 +1351,67 @@ export const runBrowserController = async (
(project) => project.normalizedConfig.browser.headless,
);

const browserSourceMapCache = new Map<string, SourceMapPayload | null>();

const isHttpLikeFile = (file: string): boolean => /^https?:\/\//.test(file);

const resolveBrowserSourcemap = async (sourcePath: string) => {
if (!isHttpLikeFile(sourcePath)) {
return {
handled: false,
sourcemap: null,
};
}

const normalizedUrl = normalizeJavaScriptUrl(sourcePath);
if (!normalizedUrl) {
return {
handled: true,
sourcemap: null,
};
}

if (browserSourceMapCache.has(normalizedUrl)) {
return {
handled: true,
sourcemap: browserSourceMapCache.get(normalizedUrl) ?? null,
};
}

return {
handled: true,
sourcemap: await loadSourceMapWithCache({
jsUrl: normalizedUrl,
cache: browserSourceMapCache,
}),
};
};

const getBrowserSourcemap = async (
sourcePath: string,
): Promise<SourceMapPayload | null> => {
const result = await resolveBrowserSourcemap(sourcePath);
return result.handled ? result.sourcemap : null;
};

/**
* Build an error BrowserTestRunResult and call onTestRunEnd if needed.
* Used for early-exit error paths to ensure errors reach the summary report.
*/
const buildErrorResult = async (
error: Error,
close?: () => Promise<void>,
): Promise<BrowserTestRunResult> => {
const elapsed = Math.max(0, Date.now() - buildStart);
const errorResult: BrowserTestRunResult = {
const errorResult = {
results: [],
testResults: [],
duration: { totalTime: elapsed, buildTime: elapsed, testTime: 0 },
hasFailure: true,
unhandledErrors: [error],
getSourcemap: getBrowserSourcemap,
resolveSourcemap: resolveBrowserSourcemap,
close,
};

if (!skipOnTestRunEnd) {
Expand All @@ -1369,7 +1421,7 @@ export const runBrowserController = async (
testResults: [],
duration: errorResult.duration,
snapshotSummary: context.snapshotManager.summary,
getSourcemap: async () => null,
getSourcemap: getBrowserSourcemap,
unhandledErrors: errorResult.unhandledErrors,
});
}
Expand All @@ -1387,8 +1439,18 @@ export const runBrowserController = async (
cleanup?: () => Promise<void>,
): Promise<BrowserTestRunResult> => {
ensureProcessExitCode(1);
await cleanup?.();
return buildErrorResult(toError(error));

const normalizedError = toError(error);

if (cleanup && skipOnTestRunEnd) {
return buildErrorResult(normalizedError, cleanup);
}

try {
return await buildErrorResult(normalizedError);
} finally {
await cleanup?.();
}
};

const collectDeletedTestPaths = (
Expand Down Expand Up @@ -1434,7 +1496,7 @@ export const runBrowserController = async (
testResults: context.reporterResults.testResults,
duration,
snapshotSummary: context.snapshotManager.summary,
getSourcemap: async () => null,
getSourcemap: getBrowserSourcemap,
unhandledErrors,
filterRerunTestPaths,
});
Expand Down Expand Up @@ -2179,13 +2241,15 @@ export const runBrowserController = async (
};
}

if (!isWatchMode) {
sessionRegistry.clear();
await destroyBrowserRuntime(runtime);
}
const closeHeadlessRuntime = !isWatchMode
? async () => {
sessionRegistry.clear();
await destroyBrowserRuntime(runtime);
}
: undefined;

if (fatalError) {
return failWithError(fatalError);
return failWithError(fatalError, closeHeadlessRuntime);
}

const duration = {
Expand All @@ -2203,14 +2267,23 @@ export const runBrowserController = async (
ensureProcessExitCode(1);
}

const result: BrowserTestRunResult = {
const result = {
results: reporterResults,
testResults: caseResults,
duration,
hasFailure: isFailure,
getSourcemap: getBrowserSourcemap,
resolveSourcemap: resolveBrowserSourcemap,
close: skipOnTestRunEnd ? closeHeadlessRuntime : undefined,
};

await notifyTestRunEnd({ duration });
if (!skipOnTestRunEnd) {
try {
await notifyTestRunEnd({ duration });
} finally {
await closeHeadlessRuntime?.();
}
}

if (isWatchMode && triggerRerun) {
watchContext.hooksEnabled = true;
Expand Down Expand Up @@ -2451,22 +2524,24 @@ export const runBrowserController = async (
};
}

if (!isWatchMode) {
try {
await containerPage.close();
} catch {
// ignore
}
try {
await containerContext.close();
} catch {
// ignore
}
await destroyBrowserRuntime(runtime);
}
const closeContainerRuntime = !isWatchMode
? async () => {
try {
await containerPage.close();
} catch {
// ignore
}
try {
await containerContext.close();
} catch {
// ignore
}
await destroyBrowserRuntime(runtime);
}
: undefined;

if (fatalError) {
return failWithError(fatalError);
return failWithError(fatalError, closeContainerRuntime);
}

const duration = {
Expand All @@ -2484,14 +2559,23 @@ export const runBrowserController = async (
ensureProcessExitCode(1);
}

const result: BrowserTestRunResult = {
const result = {
results: reporterResults,
testResults: caseResults,
duration,
hasFailure: isFailure,
getSourcemap: getBrowserSourcemap,
resolveSourcemap: resolveBrowserSourcemap,
close: skipOnTestRunEnd ? closeContainerRuntime : undefined,
};

await notifyTestRunEnd({ duration });
if (!skipOnTestRunEnd) {
try {
await notifyTestRunEnd({ duration });
} finally {
await closeContainerRuntime?.();
}
}

// Enable watch hooks AFTER initial test run to avoid duplicate runs
if (isWatchMode && triggerRerun) {
Expand Down
Loading