diff --git a/docs/guide/migration.md b/docs/guide/migration.md index 1bc18c2497d3..ea3892bcb0a9 100644 --- a/docs/guide/migration.md +++ b/docs/guide/migration.md @@ -134,6 +134,10 @@ $ cd subdir && vitest # [!code --] $ cd subdir && vitest --config ../vitest.config.ts # [!code ++] ``` +### DOM Environment Global Assignments Now Update the Underlying Window + +Assignments to properties on `globalThis` or `window` in `jsdom` and `happy-dom` environments are now propagated to the underlying DOM implementation. Mutable properties such as `innerWidth` can affect APIs implemented by the DOM environment, for example `happy-dom`'s `matchMedia`. + ## Migrating to Vitest 4.0 {#vitest-4} ::: warning Prerequisites diff --git a/packages/ui/client/components/trace/TraceView.vue b/packages/ui/client/components/trace/TraceView.vue index a6e320f7488d..9d08851867c9 100644 --- a/packages/ui/client/components/trace/TraceView.vue +++ b/packages/ui/client/components/trace/TraceView.vue @@ -1,6 +1,5 @@ @@ -150,11 +149,17 @@ function isTraceStepInProgress(step: BrowserTraceEntry) { type="button" data-testid="trace-step" :data-test-range="step.range?.phase" - class="w-full text-left px-2 py-1 rounded text-sm" + class="relative w-full text-left px-2 py-1 rounded text-sm" :class="getStepButtonClass(step, index)" + :style="{ paddingInlineStart: `${0.5 + step.depth}rem` }" :aria-current="selection.selectedStepIndex === index ? 'step' : undefined" @click="onSelectStep(index)" > +
() function getTraceAttemptKey(trace: BrowserTraceData): string { return `${trace.repeats}:${trace.retry}` } -function mergeTraceRangeEntries(entries: BrowserTraceEntry[]): BrowserTraceEntry[] { - const merged: BrowserTraceEntry[] = [] +function normalizeTraceEntries(entries: BrowserTraceEntry[]): NormalizedBrowserTraceEntry[] { + const merged: NormalizedBrowserTraceEntry[] = [] const startMap = new Map() + const parents: string[] = [] for (const entry of entries) { const range = entry.range if (!range) { - merged.push(entry) + merged.push({ + ...entry, + depth: parents.length, + }) continue } if (range.phase === 'start') { startMap.set(range.id, merged.length) - merged.push(entry) + merged.push({ + ...entry, + depth: parents.length, + }) + parents.push(range.id) continue } + // when range.phase === 'end' const index = startMap.get(range.id) if (index == null) { // unpaired range shouldn't happen but just leave it there - merged.push(entry) + merged.push({ ...entry, depth: 0 }) continue } @@ -58,12 +75,13 @@ function mergeTraceRangeEntries(entries: BrowserTraceEntry[]): BrowserTraceEntry startTime: start.startTime, duration: entry.startTime - start.startTime, } + parents.pop() } return merged } -export function getTraceAttemptMap(artifacts: TestArtifact[]): Record { +export function getTraceAttemptMap(artifacts: TestArtifact[]): Record { const grouped: Record = {} for (const artifact of artifacts) { if (artifact.type !== 'internal:browserTrace') { @@ -75,19 +93,19 @@ export function getTraceAttemptMap(artifacts: TestArtifact[]): Record = {} + const merged: Record = {} for (const [key, traces] of Object.entries(grouped)) { const trace = traces[0] const entries = traces.flatMap(trace => trace.entries) merged[key] = { ...trace, - entries: mergeTraceRangeEntries(entries), + entries: normalizeTraceEntries(entries), } } return merged } -export function getSelectedTrace(selection: TraceSelection): BrowserTraceData | undefined { +export function getSelectedTrace(selection: TraceSelection): NormalizedBrowserTraceData | undefined { const attempts = getTraceAttemptMap(selection.test.artifacts) return selection.attemptKey ? attempts[selection.attemptKey] diff --git a/packages/vitest/src/integrations/env/utils.ts b/packages/vitest/src/integrations/env/utils.ts index bbbeee5fb67d..f8c4a75efd89 100644 --- a/packages/vitest/src/integrations/env/utils.ts +++ b/packages/vitest/src/integrations/env/utils.ts @@ -78,6 +78,10 @@ export function populateGlobal( }, set(v) { overrideObject.set(key, v) + // propagate changes to underlying window implementation, + // which can affect other window API behavior internally, e.g. + // updating `innerWidth` affects `matchMedia("(max-width: *)")` on happy-dom. + win[key] = v }, configurable: true, }) diff --git a/test/ui/README.md b/test/ui/README.md index d7f9bec6a375..d121eee18da9 100644 --- a/test/ui/README.md +++ b/test/ui/README.md @@ -8,4 +8,8 @@ pnpm test --ui # run fixture projects pnpm test-fixtures --root fixtures/main --ui pnpm test-fixtures --root fixtures/trace + +# generate html report and use it for UI dev +pnpm -C test/ui test-fixtures --root fixtures/trace --reporter=html --ui=false --run +HTML_REPORT_DIR="$PWD/test/ui/fixtures/trace/html" pnpm -C packages/ui dev:client ``` diff --git a/test/ui/fixtures/trace-stream/nested.test.ts b/test/ui/fixtures/trace-stream/nested.test.ts new file mode 100644 index 000000000000..abc771dc0b2e --- /dev/null +++ b/test/ui/fixtures/trace-stream/nested.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from 'vitest' +import { page } from 'vitest/browser' +import { waitForGate } from './helper' + +test('nested', async () => { + document.body.innerHTML = ` +
+ + + +
+` + + await page.mark('Outer group', async () => { + await page.getByRole('button', { name: 'Outer' }).mark('Outer mark') + await waitForGate('nested-inner') + + await page.mark('Inner group', async () => { + await page.getByRole('button', { name: 'Inner' }).mark('Inner mark') + await Promise.all([ + expect + .element(page.getByRole('button', { name: 'Leaf' }), { timeout: 10000 }) + .toBeVisible(), + (async () => { + await waitForGate('nested-leaf') + document.querySelector('main')!.insertAdjacentHTML('beforeend', '') + })(), + ]) + }) + + await waitForGate('nested-sibling') + await page.getByRole('button', { name: 'Sibling' }).mark('Sibling mark') + }) +}) diff --git a/test/ui/fixtures/trace/nested.test.ts b/test/ui/fixtures/trace/nested.test.ts new file mode 100644 index 000000000000..b407ab7d417f --- /dev/null +++ b/test/ui/fixtures/trace/nested.test.ts @@ -0,0 +1,22 @@ +import { test } from 'vitest' +import { page } from 'vitest/browser' + +test('nested', async () => { + document.body.innerHTML = ` +
+ + + + +
+` + + await page.mark('Outer group', async () => { + await page.getByRole('button', { name: 'Outer' }).mark('Outer mark') + await page.mark('Inner group', async () => { + await page.getByRole('button', { name: 'Inner' }).mark('Inner mark') + await page.getByRole('button', { name: 'Leaf' }).click() + }) + await page.getByRole('button', { name: 'Sibling' }).mark('Sibling mark') + }) +}) diff --git a/test/ui/test/trace-stream.spec.ts b/test/ui/test/trace-stream.spec.ts index 061ba1ecdae6..e2c54e2531bb 100644 --- a/test/ui/test/trace-stream.spec.ts +++ b/test/ui/test/trace-stream.spec.ts @@ -158,4 +158,65 @@ test.describe('trace stream', () => { 'test finished', ]) }) + + test('nested range', async ({ page }) => { + await page.goto(baseURL) + + const runPromise = vitest!.runTestSpecifications( + await vitest!.globTestSpecifications(['nested.test.ts']), + ) + + const testItem = getExplorerItem(page, 'nested') + await expect(testItem).toBeVisible() + + const traceView = page.getByTestId('trace-view') + await expect(traceView).not.toBeVisible() + await testItem.click() + await expect(traceView).toBeVisible() + + const traceSteps = traceView.getByTestId('trace-step') + const traceStepNames = traceView.getByTestId('trace-step-name') + + await expect.poll(() => traceStepNames.allInnerTexts()).toEqual([ + 'Outer group', + 'Outer mark', + ]) + await expect(traceSteps.nth(0)).toHaveAttribute('data-test-range', 'start') + + await writeFile(resolve(gatesDir, 'nested-inner.txt'), 'open') + await expect.poll(() => traceStepNames.allInnerTexts()).toEqual([ + 'Outer group', + 'Outer mark', + 'Inner group', + 'Inner mark', + 'toBeVisible', + ]) + await expect(traceSteps.nth(0)).toHaveAttribute('data-test-range', 'start') + await expect(traceSteps.nth(2)).toHaveAttribute('data-test-range', 'start') + await expect(traceSteps.nth(4)).toHaveAttribute('data-test-range', 'start') + + await writeFile(resolve(gatesDir, 'nested-leaf.txt'), 'open') + await expect.poll(() => traceStepNames.allInnerTexts()).toEqual([ + 'Outer group', + 'Outer mark', + 'Inner group', + 'Inner mark', + 'toBeVisible', + ]) + await expect(traceSteps.nth(2)).toHaveAttribute('data-test-range', 'end') + await expect(traceSteps.nth(4)).toHaveAttribute('data-test-range', 'end') + + await writeFile(resolve(gatesDir, 'nested-sibling.txt'), 'open') + await runPromise + await expect.poll(() => traceStepNames.allInnerTexts()).toEqual([ + 'Outer group', + 'Outer mark', + 'Inner group', + 'Inner mark', + 'toBeVisible', + 'Sibling mark', + 'test finished', + ]) + await expect(traceSteps.nth(0)).toHaveAttribute('data-test-range', 'end') + }) }) diff --git a/test/ui/test/trace.spec.ts b/test/ui/test/trace.spec.ts index 54f72bc8d97f..468098541722 100644 --- a/test/ui/test/trace.spec.ts +++ b/test/ui/test/trace.spec.ts @@ -26,7 +26,7 @@ test.describe('ui', () => { test.beforeEach(async ({ page }) => { await page.goto(baseURL) - await assertTestCounts(page, { pass: 11, fail: 0 }) + await assertTestCounts(page, { pass: 12, fail: 0 }) }) test('basic', async ({ page }) => { @@ -53,6 +53,10 @@ test.describe('ui', () => { await testScroll(page) }) + test('nested', async ({ page }) => { + await testNested(page) + }) + test('attempts', async ({ page }) => { await testAttempts(page) }) @@ -92,7 +96,7 @@ test.describe('html reporter', () => { test.beforeEach(async ({ page }) => { await page.goto(baseURL) - await assertTestCounts(page, { pass: 11, fail: 0 }) + await assertTestCounts(page, { pass: 12, fail: 0 }) }) test('basic', async ({ page }) => { @@ -119,6 +123,10 @@ test.describe('html reporter', () => { await testScroll(page) }) + test('nested', async ({ page }) => { + await testNested(page) + }) + test('attempts', async ({ page }) => { await testAttempts(page) }) @@ -301,3 +309,20 @@ async function testAttempts(page: Page) { await expect(traceFrame.getByText('retryCount: 2')).toBeVisible() await expect(traceFrame.getByText('repeatCount: 0')).toBeVisible() } + +async function testNested(page: Page) { + await openExplorerItem(page, 'nested') + + const traceView = page.getByTestId('trace-view') + const traceStepNames = traceView.getByTestId('trace-step-name') + await expect(traceView).toBeVisible() + await expect.poll(() => traceStepNames.allInnerTexts()).toEqual([ + 'Outer group', + 'Outer mark', + 'Inner group', + 'Inner mark', + 'click', + 'Sibling mark', + 'test finished', + ]) +} diff --git a/test/unit/test/environments/happy-dom.spec.ts b/test/unit/test/environments/happy-dom.spec.ts index 47bea78d8f21..6d99b3219940 100644 --- a/test/unit/test/environments/happy-dom.spec.ts +++ b/test/unit/test/environments/happy-dom.spec.ts @@ -39,3 +39,28 @@ test('can pass down a simple form data', async () => { await req.formData() })()).resolves.not.toThrow() }) + +test('innerWidth and matchMedia', () => { + expect(window.innerWidth).toBe(1024) + expect(window.matchMedia('(max-width: 100px)').matches).toBe(false) + window.innerWidth = 50 + expect(window.matchMedia('(max-width: 100px)').matches).toBe(true) +}) + +test('readonly window assignment throws', ({ task }) => { + // happy-dom's vmThreads setup returns Window as a Node VM context directly. + // Node contextification reports this getter-only assignment as successful, + // unlike the populateGlobal facade used by threads/forks. + if (task.file.pool === 'vmThreads') { + expect(() => { + Object.assign(window, { navigator: {} }) + }).not.toThrow() + return + } + + expect(() => { + Object.assign(window, { navigator: {} }) + }).toThrowErrorMatchingInlineSnapshot( + `[TypeError: Cannot set property navigator of # which has only a getter]`, + ) +}) diff --git a/test/unit/test/environments/jsdom.spec.ts b/test/unit/test/environments/jsdom.spec.ts index c2d8ab6c59ad..bac68eea1918 100644 --- a/test/unit/test/environments/jsdom.spec.ts +++ b/test/unit/test/environments/jsdom.spec.ts @@ -335,3 +335,16 @@ test('jsdom global is exposed', () => { test('ssr is disabled', () => { expect(import.meta.env.SSR).toBe(false) }) + +test('innerWidth and matchMedia', () => { + expect(window.innerWidth).toBe(1024) + expect(window.matchMedia).toBe(undefined) +}) + +test('readonly window assignment throws', () => { + expect(() => { + Object.assign(window, { navigator: {} }) + }).toThrowErrorMatchingInlineSnapshot( + `[TypeError: Cannot set property navigator of [object Window] which has only a getter]`, + ) +})