diff --git a/.gitignore b/.gitignore index cb3f887..47a326b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dist/ # Test and coverage outputs coverage +mobilewright-report playwright-report test-results/ diff --git a/docs/src/test/sharding.md b/docs/src/test/sharding.md index e077fee..3f6db26 100644 --- a/docs/src/test/sharding.md +++ b/docs/src/test/sharding.md @@ -122,5 +122,5 @@ jobs: - uses: actions/upload-artifact@v5 with: name: html-report - path: playwright-report/ + path: mobilewright-report/ ``` diff --git a/packages/mobilewright/src/cli.ts b/packages/mobilewright/src/cli.ts index 37e0190..710f4d7 100644 --- a/packages/mobilewright/src/cli.ts +++ b/packages/mobilewright/src/cli.ts @@ -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'; @@ -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(); @@ -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]; }); } @@ -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); }); @@ -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') @@ -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 { diff --git a/packages/mobilewright/src/config.test.ts b/packages/mobilewright/src/config.test.ts index 5795b97..81fe530 100644 --- a/packages/mobilewright/src/config.test.ts +++ b/packages/mobilewright/src/config.test.ts @@ -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'); }); @@ -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', () => { @@ -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' }); +}); diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index 6cac634..8eea43d 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -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`: @@ -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, diff --git a/packages/mobilewright/src/constants.ts b/packages/mobilewright/src/constants.ts new file mode 100644 index 0000000..a40fcc6 --- /dev/null +++ b/packages/mobilewright/src/constants.ts @@ -0,0 +1,2 @@ +/** Default output folder of the HTML reporter. */ +export const HTML_REPORT_DIR = 'mobilewright-report'; diff --git a/packages/mobilewright/src/html-reporter.ts b/packages/mobilewright/src/html-reporter.ts new file mode 100644 index 0000000..877f040 --- /dev/null +++ b/packages/mobilewright/src/html-reporter.ts @@ -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; +} + +const { html } = _require('playwright/lib/runner') as { + html: { default: new (options: Record) => HtmlReporterLike }; +}; + +export default class MobilewrightHtmlReporter extends html.default { + constructor(options: Record = {}) { + super({ outputFolder: HTML_REPORT_DIR, ...options }); + } + + override async onExit(): Promise { + 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; + } + } +} diff --git a/packages/mobilewright/src/reporter.test.ts b/packages/mobilewright/src/reporter.test.ts new file mode 100644 index 0000000..e137343 --- /dev/null +++ b/packages/mobilewright/src/reporter.test.ts @@ -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('Playwright Test Report'); + expect(html).toContain('Mobilewright Test Report'); +}); + +// 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( + ``, + ); + 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'), '', '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 }); + } +}); diff --git a/packages/mobilewright/src/reporter.ts b/packages/mobilewright/src/reporter.ts index 714728c..f458ee4 100644 --- a/packages/mobilewright/src/reporter.ts +++ b/packages/mobilewright/src/reporter.ts @@ -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'; @@ -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. */ @@ -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 const headInjection = ` @@ -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; } @@ -187,4 +198,12 @@ export function brandReport(reportPath: string): void { html = html.replace('', bodyScript + '\n '); 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'); + } }