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]`,
+ )
+})