Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ dist/

# Test and coverage outputs
coverage
mobilewright-report
playwright-report
test-results/

Expand Down
2 changes: 1 addition & 1 deletion docs/src/test/sharding.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,5 +122,5 @@ jobs:
- uses: actions/upload-artifact@v5
with:
name: html-report
path: playwright-report/
path: mobilewright-report/
```
36 changes: 8 additions & 28 deletions packages/mobilewright/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { Command } from 'commander';
import { execFileSync } from 'node:child_process';
import { existsSync, renameSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand All @@ -12,13 +12,12 @@ import type { DeviceInfo } from '@mobilewright/protocol';
import { MobilecliDriver, DEFAULT_URL, resolveMobilecliBinary, ensureMobilecliReachable } from '@mobilewright/driver-mobilecli';
import { loadConfig } from './config.js';
import { gatherChecks, renderTerminal, renderJSON } from './commands/doctor.js';
import { brandReport } from './reporter.js';
import { HTML_REPORT_DIR } from './constants.js';
import { scarf, telemetry } from './telemetry.js';

const _require = createRequire(import.meta.url);
const _pkg = _require('../package.json') as { version: string };

const HTML_REPORT_DIR = 'mobilewright-report';
const TEMPLATES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'templates');

const program = new Command();
Expand Down Expand Up @@ -69,7 +68,7 @@ program
const names = (opts.reporter as string).split(',');
overrides.reporter = names.map((name: string) => {
const n = name.trim();
if (n === 'html') return [n, { outputFolder: HTML_REPORT_DIR }];
if (n === 'html') return [_require.resolve('./html-reporter.js')];
return [n];
});
}
Expand Down Expand Up @@ -103,17 +102,6 @@ program

telemetry('mw_test-ended', { Status: status });

// Post-process HTML report with Mobilewright branding.
// Apply whenever the report dir exists — covers both --reporter html
// and reporter configured in the config file.
if (existsSync(resolve(process.cwd(), HTML_REPORT_DIR))) {
try {
brandReport(resolve(process.cwd(), HTML_REPORT_DIR));
} catch {
// Report branding is best-effort; don't fail the test run
}
}

const exitCode = status === 'interrupted' ? 130 : status === 'passed' ? 0 : 1;
process.exit(exitCode);
});
Expand All @@ -135,8 +123,8 @@ program
});

// ── merge-reports ──────────────────────────────────────────────────────
// Delegate to Playwright's merge-reports, then rename playwright-report
// to mobilewright-report when the html reporter is used.
// Delegate to Playwright's merge-reports, pointing an `html` merge at our
// branded reporter so a merged report looks like a regular one.
program
.command('merge-reports [dir]')
.description('merge blob reports from sharded runs into one report')
Expand All @@ -146,20 +134,12 @@ program
const { program: pwProgram } = await import('playwright/lib/program');
const args = ['node', 'playwright', 'merge-reports'];
if (dir) { args.push(dir); }
if (opts.reporter) { args.push('--reporter', opts.reporter); }
if (opts.reporter) {
args.push('--reporter', opts.reporter === 'html' ? _require.resolve('./html-reporter.js') : opts.reporter);
}
if (opts.config) { args.push('--config', opts.config); }
await pwProgram.parseAsync(args);

const reporter = opts.reporter ?? 'html';
if (reporter === 'html') {
const playwrightReport = resolve(process.cwd(), 'playwright-report');
const mobilewrightReport = resolve(process.cwd(), HTML_REPORT_DIR);
try {
renameSync(playwrightReport, mobilewrightReport);
} catch {
// Already renamed, or html reporter not used — nothing to do.
}
}
});

function printDevicesTable(devices: DeviceInfo[]): void {
Expand Down
27 changes: 23 additions & 4 deletions packages/mobilewright/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,8 @@ test('defineConfig preserves the user reporter list ahead of the injected entrie
reporter: [['html'], ['list']],
}),
);
const names = (config.reporter as ReporterEntry[]).map(([name]) => name);
expect(names[0]).toBe('html');
const names = (config.reporter as ReporterEntry[]).map(([name]) => String(name));
expect(names[0]).toMatch(/html-reporter\.(js|ts)$/);
expect(names[1]).toBe('list');
});

Expand All @@ -304,8 +304,8 @@ test('defineConfig normalizes a string reporter to array form before injecting',
reporter: 'html',
}),
);
const names = (config.reporter as ReporterEntry[]).map(([name]) => name);
expect(names).toContain('html');
const names = (config.reporter as ReporterEntry[]).map(([name]) => String(name));
expect(names.some((name) => /html-reporter\.(js|ts)$/.test(name))).toBe(true);
});

test('defineConfig sets captureGitInfo commit:true when injecting the observer reporter', () => {
Expand All @@ -320,3 +320,22 @@ test('defineConfig preserves the user explicit captureGitInfo values when inject
});
expect(config.captureGitInfo).toEqual({ commit: false, diff: true });
});

// ─── HTML reporter rebranding ──────────────────────────────────────

test('defineConfig routes an html reporter through the mobilewright html reporter', () => {
const config = defineConfig({ reporter: 'html' });

const reporters = config.reporter as ReporterEntry[];
expect(reporters).toHaveLength(1);
expect(String(reporters[0]![0])).toMatch(/html-reporter\.(js|ts)$/);
});

test('defineConfig keeps html reporter options and leaves other reporters alone', () => {
const config = defineConfig({ reporter: [['list'], ['html', { open: 'never' }]] });

const reporters = config.reporter as ReporterEntry[];
expect(reporters[0]).toEqual(['list']);
expect(String(reporters[1]![0])).toMatch(/html-reporter\.(js|ts)$/);
expect(reporters[1]![1]).toEqual({ open: 'never' });
});
20 changes: 20 additions & 0 deletions packages/mobilewright/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,25 @@ function normalizeReporters(reporter: MobilewrightConfig['reporter']): ReporterE
return reporter;
}

/**
* Routes `html` reporter entries through Mobilewright's branded HTML reporter,
* so a report configured in the config file is branded too — not just one
* requested with `mobilewright test --reporter html`.
*/
function rebrandHtmlReporters(
reporter: MobilewrightConfig['reporter'],
): MobilewrightConfig['reporter'] {
const entries = normalizeReporters(reporter);
if (!entries.some(([name]) => name === 'html')) {
return reporter;
}
return entries.map((entry) =>
entry[0] === 'html'
? ([fileURLToPath(new URL('./html-reporter.js', import.meta.url)), ...entry.slice(1)] as ReporterEntry)
: entry,
);
}

/**
* Mirrors Playwright's own resolution of the JSON reporter's output file from
* env vars, for a `json` reporter entry that has no explicit `outputFile`:
Expand Down Expand Up @@ -270,6 +289,7 @@ export function defineConfig(config: MobilewrightConfig): MobilewrightConfig {
const base: MobilewrightConfig = {
workers: 1,
...config,
...(config.reporter !== undefined && { reporter: rebrandHtmlReporters(config.reporter) }),
...(driver && { driver }),
globalSetup: userSetups.length > 0 ? [ourSetup, ...userSetups] : ourSetup,
globalTeardown: userTeardowns.length > 0 ? [...userTeardowns, ourTeardown] : ourTeardown,
Expand Down
2 changes: 2 additions & 0 deletions packages/mobilewright/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Default output folder of the HTML reporter. */
export const HTML_REPORT_DIR = 'mobilewright-report';
54 changes: 54 additions & 0 deletions packages/mobilewright/src/html-reporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Playwright's HTML reporter, rebranded for Mobilewright.
*
* - Reports default to `mobilewright-report` instead of `playwright-report`
* - The generated HTML is rebranded once the report is written
* - The "To open last HTML report run" hint points at the mobilewright CLI
*/

import { createRequire } from 'node:module';

import { resolve } from 'node:path';

import { HTML_REPORT_DIR } from './constants.js';
import { brandReport } from './reporter.js';

const _require = createRequire(import.meta.url);

interface HtmlReporterLike {
/** Absolute output folder, resolved by the base reporter in `onBegin`. */
_outputFolder?: string;
onExit(): Promise<void>;
}

const { html } = _require('playwright/lib/runner') as {
html: { default: new (options: Record<string, unknown>) => HtmlReporterLike };
};

export default class MobilewrightHtmlReporter extends html.default {
constructor(options: Record<string, unknown> = {}) {
super({ outputFolder: HTML_REPORT_DIR, ...options });
}

override async onExit(): Promise<void> {
try {
brandReport(this._outputFolder ?? resolve(process.cwd(), HTML_REPORT_DIR));
} catch {
// Branding is best-effort — never fail a run over it.
}

// The hint is printed straight to stdout from a private helper, so the
// only way to rebrand it is to rewrite the bytes while it is written.
const write = process.stdout.write.bind(process.stdout);
process.stdout.write = ((chunk: string | Uint8Array, ...rest: unknown[]) =>
(write as (...args: unknown[]) => boolean)(
typeof chunk === 'string' ? chunk.replace('playwright show-report', 'mobilewright show-report') : chunk,
...rest,
)) as typeof process.stdout.write;
try {
await super.onExit();
} finally {
process.stdout.write = write;
}
}
}
50 changes: 50 additions & 0 deletions packages/mobilewright/src/reporter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test, expect } from '@playwright/test';
import { brandReport } from './reporter.js';

/** Writes `index.html` into a throwaway report folder and brands it. */
function brand(body: string): string {
const dir = mkdtempSync(join(tmpdir(), 'mw-report-'));
try {
writeFileSync(join(dir, 'index.html'), body, 'utf-8');
brandReport(dir);
return readFileSync(join(dir, 'index.html'), 'utf-8');
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

test('brandReport replaces the title', () => {
const html = brand('<html><head><title>Playwright Test Report</title></head><body></body></html>');
expect(html).toContain('<title>Mobilewright Test Report</title>');
});

// The bundler picks the quoting of the JS-side document.title fallback, and a
// missed form lets the page rename itself back to "Playwright" once it loads.
for (const quote of ['\'', '"', '`']) {
test(`brandReport replaces the document.title fallback quoted with ${quote}`, () => {
const html = brand(
`<html><head></head><body><script>document.title=${quote}Playwright Test Report${quote}</script></body></html>`,
);
expect(html).toContain(`document.title=${quote}Mobilewright Test Report${quote}`);
expect(html).not.toContain('Playwright Test Report');
});
}

// With `doNotInlineAssets` the bundle is written to report.js instead of being
// inlined, so the title fallback has to be rewritten there too.
test('brandReport replaces the document.title fallback in a non-inlined report.js', () => {
const dir = mkdtempSync(join(tmpdir(), 'mw-report-'));
try {
writeFileSync(join(dir, 'index.html'), '<html><head></head><body></body></html>', 'utf-8');
writeFileSync(join(dir, 'report.js'), 'x?0:document.title=`Playwright Test Report`;', 'utf-8');
brandReport(dir);
expect(readFileSync(join(dir, 'report.js'), 'utf-8')).toBe(
'x?0:document.title=`Mobilewright Test Report`;',
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
29 changes: 24 additions & 5 deletions packages/mobilewright/src/reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* - Adds click-to-fullscreen for screenshot thumbnails
*/

import { readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

Expand All @@ -23,6 +23,17 @@ function getFaviconDataUrl(): string {
return `data:image/png;base64,${iconData.toString('base64')}`;
}

/**
* Rewrites the JS-side `document.title` fallback. The bundler picks the
* quoting, so match any of the three string forms.
*/
function replaceTitleFallback(source: string): string {
return source.replace(
/document\.title=(["'`])Playwright Test Report\1/g,
(_match, quote: string) => `document.title=${quote}Mobilewright Test Report${quote}`,
);
}

/**
* Post-processes a Playwright HTML report to apply Mobilewright branding.
*/
Expand All @@ -39,10 +50,7 @@ export function brandReport(reportPath: string): void {
);

// 2. Replace the JS-side document.title fallback
html = html.replace(
/document\.title="Playwright Test Report"/g,
'document.title="Mobilewright Test Report"',
);
html = replaceTitleFallback(html);

// 3. Add favicon link and custom styles/scripts in <head>
const headInjection = `
Expand All @@ -53,6 +61,9 @@ export function brandReport(reportPath: string): void {
display: flex;
align-items: center;
gap: 8px;
/* Playwright's superheader has no gap of its own — keep the brand off
the "Project:" label it is prepended to. */
margin-right: 16px;
text-decoration: none;
color: inherit;
}
Expand Down Expand Up @@ -187,4 +198,12 @@ export function brandReport(reportPath: string): void {
html = html.replace('</body>', bodyScript + '\n </body>');

writeFileSync(indexPath, html, 'utf-8');

// With `doNotInlineAssets`, the bundle lives in report.js instead of being
// inlined — the title fallback has to be rewritten there too, or the page
// renames itself back to Playwright once it loads.
const scriptPath = resolve(reportPath, 'report.js');
if (existsSync(scriptPath)) {
writeFileSync(scriptPath, replaceTitleFallback(readFileSync(scriptPath, 'utf-8')), 'utf-8');
}
}