Skip to content

feat(runner): propagate AsyncLocalStorage context from fixtures into tests and hooks - #10858

Open
VialFlorian wants to merge 10 commits into
vitest-dev:mainfrom
VialFlorian:feat/fixture-async-context
Open

feat(runner): propagate AsyncLocalStorage context from fixtures into tests and hooks#10858
VialFlorian wants to merge 10 commits into
vitest-dev:mainfrom
VialFlorian:feat/fixture-async-context

Conversation

@VialFlorian

@VialFlorian VialFlorian commented Aug 1, 2026

Copy link
Copy Markdown

Description

Fixtures created with test.extend deliver their value through a deferred use() handshake, so an AsyncLocalStorage context established around use() never reached the test body:

const test = baseTest.extend({
  store: async ({}, use) => {
    const store = { tenant: 'alpha' }
    await als.run(store, () => use(store))
  },
})

test('reads the store', () => {
  als.getStore() // undefined before this PR
})

This PR captures an async context snapshot inside each test-scoped fixture's use() and enters later fixtures, beforeEach/afterEach, onTestFinished/onTestFailed, beforeEach cleanup callbacks and the test body through the latest capture. The fixture handshake, teardown ordering and caching are untouched.

What this enables

Per-test stores become trivial to set up (the pattern that motivated this change)
An app whose code reads a request-scoped store (loggers, tenant config, DB clients) can give every test its own isolated store, with per-test overrides, no visible wrapper, and full support for .only/.skip/.each/test.concurrent:

const base = baseTest.extend<{ override: Partial<Store>, store: Store }>({
  override: {},
  store: [
    async ({ override }, use) => {
      const store = { tenant: 'main', logger: mockLogger(), ...override }
      await als.run(store, () => use(store))
    },
    { auto: true },
  ],
})

// one line of userland turns this into a per-test API
const it = Object.assign(base, {
  withStore: (override: Partial<Store>) => base.extend({ override }),
})

it('uses the default store', ({ store }) => {
  expect(als.getStore().tenant).toBe("main")
})

it.withStore({ tenant: 'acme' })('configures its own store, independently', ({ store }) => {
  expect(als.getStore().tenant).toBe("acme")
  expect(store.logger.warn).toHaveBeenCalled()  // per-test logger assertions
})

Each test keeps its own store even under test.concurrent. Today this requires a custom it-wrapper helper that loses the chainable API, or is simply impossible where hooks and dependent fixtures must see the store too.

Revisiting #5858

This revisits #5858, which was closed in 2024 as intentional ("Fixtures initialization runs separately from test execution") and redirected to #5728, since resolved by aroundEach/aroundAll in 4.1. aroundEach wraps each test of a suite in a store and aroundAll can span one store across a whole suite or file, but the fixture form remains the natural way to model per-test context with overrides and composition. The test.extend API is modeled on Playwright's fixtures; note that Playwright does not propagate the store from fixtures either (verified with a small repro), so this is new capability rather than parity. The approach here did not exist in that discussion: it does not restructure fixture resolution into call nesting, it only re-enters a frame captured at use() time, so the change is small and strictly additive.

Related: #5858, #5728

Design notes

  • Only test-scoped fixtures propagate their store. Entering a snapshot replaces the whole async frame, so a chain kept for a file/worker-scoped fixture would leak the resolving test's ambient context into other tests and erase aroundAll stores. Scoped fixtures keep propagating their value only (tested and documented).
  • Browsers are unaffected. The snapshot factory is injected from the Node-only worker entry; when it is not set, every helper degrades to a plain call.
  • No observable change without fixtures. Pipelines that never resolve a fixture never enter a snapshot, keeping error stack traces byte-identical (locked by the existing aroundEach e2e snapshots).
  • The snapshot is a named AsyncResource that detectAsyncLeaks ignores, and aroundEach/aroundAll stores compose with fixture stores (covered by the new e2e suite).

Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

Tests

  • Run the tests with pnpm test:ci.

Documentation

  • If you introduce new functionality, document it. You can run documentation with pnpm run docs command. (New "Fixtures and AsyncLocalStorage" section in the Test Context guide.)

Changesets

  • Changes in changelog are generated from PR name. Please, make sure that it explains your changes in an understandable manner. Please, prefix changeset messages with feat:, fix:, perf:, docs:, or chore:.

@netlify

netlify Bot commented Aug 1, 2026

Copy link
Copy Markdown

Deploy Preview for vitest-dev ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 5700932
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/6a7e4afd6f03ad00090be9cc
😎 Deploy Preview https://deploy-preview-10858--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@VialFlorian
VialFlorian force-pushed the feat/fixture-async-context branch 6 times, most recently from bd71b5b to 323fd01 Compare August 1, 2026 22:18
@VialFlorian VialFlorian changed the title feat(runner): add async context chain helpers WIP feat(runner): add async context chain helpers Aug 1, 2026
@VialFlorian
VialFlorian marked this pull request as draft August 1, 2026 22:19
@VialFlorian
VialFlorian force-pushed the feat/fixture-async-context branch 8 times, most recently from 5b60230 to eebbab4 Compare August 1, 2026 22:40
@VialFlorian VialFlorian changed the title WIP feat(runner): add async context chain helpers feat(runner): propagate AsyncLocalStorage context from fixtures into tests and hooks Aug 1, 2026
@VialFlorian
VialFlorian marked this pull request as ready for review August 1, 2026 23:06
@VialFlorian
VialFlorian force-pushed the feat/fixture-async-context branch 3 times, most recently from dd9b368 to d098c33 Compare August 1, 2026 23:20
@VialFlorian

VialFlorian commented Aug 1, 2026

Copy link
Copy Markdown
Author

Confirming I'm the author and maintaining this PR (built with AI assistance)

I'm already using this in a private codebase, where the test suite runs every test concurrently, each inside its own AsyncLocalStorage store. Happy to open an issue first or gate this behind an option if preferred 🙂

@sheremet-va
sheremet-va requested a review from hi-ogawa August 4, 2026 14:13
Comment thread docs/guide/test-context.md Outdated

::: warning
Only test-scoped fixtures propagate their store. Fixtures with `scope: 'file'` or `scope: 'worker'` still provide their **value**, but tests do not run inside their store.
To establish one store for a whole suite or file, use [`aroundEach`](/api/hooks#aroundeach) or [`aroundAll`](/api/hooks#aroundall) instead.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am confused how aroundEach establishes a store for a whole suite or a file. It runs once per test, not suite or a file

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes indeed, I have updated to make it more clear

To set up a store around each test of a suite, register an [`aroundEach`](/api/hooks#aroundeach) hook; to open a single store spanning every test in a suite or file, use [`aroundAll`](/api/hooks#aroundall).

@hi-ogawa

hi-ogawa commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

but the fixture form remains the natural way to model per-test context with overrides and composition (it is how Playwright fixtures behave, which this API is modeled on).

Do you mean playwright fixture also does something similar now with AsyncLocalStorage?

@VialFlorian
VialFlorian force-pushed the feat/fixture-async-context branch from d098c33 to 5cafca5 Compare August 5, 2026 15:16
@VialFlorian

Copy link
Copy Markdown
Author

but the fixture form remains the natural way to model per-test context with overrides and composition (it is how Playwright fixtures behave, which this API is modeled on).

Do you mean playwright fixture also does something similar now with AsyncLocalStorage?

@hi-ogawa No, I assumed it did and that was wrong. I checked with a small repro: a Playwright fixture doing await als.run(store, () => use(store)) delivers the value to the test, but als.getStore() is undefined in the test body (the same deferred use() behavior Vitest has before this PR)

import { AsyncLocalStorage } from 'node:async_hooks'
import { test as base } from '@playwright/test'

const als = new AsyncLocalStorage<{ id: string }>()

const test = base.extend<{ store: { id: string } }>({
  store: async ({}, use) => {
    const store = { id: 'from-fixture' }
    await als.run(store, () => use(store))
  },
})

test('als propagation', async ({ store }) => {
  console.log(store.id, als.getStore()) // "from-fixture" undefined
})

I've corrected the description: the test.extend API is modeled on Playwright's fixtures, but neither runner propagates the store today, so this would be new capability rather than parity.

@VialFlorian
VialFlorian force-pushed the feat/fixture-async-context branch 2 times, most recently from 14bec30 to 68fe72a Compare August 9, 2026 20:08
@VialFlorian
VialFlorian requested a review from sheremet-va August 9, 2026 22:08

@hi-ogawa hi-ogawa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea looks sound to me. Still digesting but I have thought of some tests cases that looks worth recording:


  1. For non auto fixture, the async context becomes available only after fixture execution, so something like this happens:
const test = base.extend({ fixture: () => ...als.run... })
beforeEach(() => als.getStore())       // undefined
test(({ fixture }) => als.getStore()) // fixture store
afterEach(() => als.getStore())       // fixture store
  1. without explicit inter-fixture dependency, each fixture runs still construct each "chained" async context:
const test = base.extend({
  fixture1: () =>  ...als1.run...,
  fixture2: () => ...als2.run..., // since fixture1 runs before fixture2, so als1 store is available
})
test({ fixture1, fixture2 }) => als1.getStore(), als2.getStore()) // both stores available

@hi-ogawa hi-ogawa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still in favor of this feature but found another test case to illustrate the "non composable" nature of runInAsyncScope itself. So making it look like composable does require Vitest internal to coordinate snapshot/restore properly (like especially for aroundEach). It may look too surgical, but I think it's worth tracking as test case.

Custom runTask example
test('fixture snapshots preserve contexts established by a custom runTask', async () => {
  const { errorTree } = await runInlineTests({
    'vitest.config.ts': `
      export default {
        test: {
          runner: './runner.ts',
        },
      }
    `,
    'storage.ts': `
      import { AsyncLocalStorage } from 'node:async_hooks'

      const key = Symbol.for('vitest.custom-runner-storage')
      export const runnerStorage: AsyncLocalStorage<string>
        = (globalThis as any)[key] ??= new AsyncLocalStorage<string>()
    `,
    'runner.ts': `
      import type { RunnerTask } from 'vitest'
      import { TestRunner } from 'vitest'
      import { runnerStorage } from './storage'

      export default class CustomRunner extends TestRunner {
        async runTask(test: RunnerTask): Promise<void> {
          const fn = CustomRunner.getTestFn(test)
          await runnerStorage.run('runner', () => fn())
        }
      }
    `,
    'custom-runner.test.ts': `
      import { AsyncLocalStorage } from 'node:async_hooks'
      import { beforeEach, expect, test as base } from 'vitest'
      import { runnerStorage } from './storage'

      const fixtureStorage = new AsyncLocalStorage<string>()
      const test = base.extend({
        fixture: [
          async ({}, use) => fixtureStorage.run('fixture', () => use('fixture')),
          { auto: true },
        ],
      })

      // This causes the auto fixture snapshot to be captured before runTask.
      beforeEach(() => {})

      base('preserves the runner context without fixtures', () => {
        expect(runnerStorage.getStore()).toBe('runner')
      })

      test('loses the runner context with fixtures', () => {
        expect(fixtureStorage.getStore()).toBe('fixture')
        expect(runnerStorage.getStore()).toBe('runner')
      })
    `,
  })

  expect(errorTree()).toMatchInlineSnapshot(`
    {
      "custom-runner.test.ts": {
        "loses the runner context with fixtures": [
          "expected undefined to be 'runner' // Object.is equality",
        ],
        "preserves the runner context without fixtures": "passed",
      },
    }
  `)
})

Comment on lines +13 to +17
// equivalent to `AsyncLocalStorage.snapshot()` but with an explicit resource type
setAsyncContextSnapshotFactory(() => {
const resource = new AsyncResource('VITEST_ASYNC_CONTEXT_CHAIN')
return fn => resource.runInAsyncScope(fn)
})

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is top level code only way? I think it may be more natural to coordinate some runner related init phase somehow.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, I can move it into resolveTestRunner, or do you have something else in mind?

callbackName: 'runTest()',
onTimeout: error => abortContextSignal(test.context, error),
invokeHook: (hook, use) => hook(use, test.context, suite),
advanceAsyncContext: () => refreshAsyncContextChain(test.context),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this rewriting name advanceAsyncContext/refreshAsyncContextChain is called for. Can be just refreshAsyncContextChain all the way.

@hi-ogawa hi-ogawa added the p2-to-be-discussed Enhancement under consideration (priority) label Aug 10, 2026
@hi-ogawa hi-ogawa moved this to P2 - 3 in Team Board Aug 10, 2026
`)
})

test('stacked aroundEach stores and a fixture store all accumulate', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want also another variant where aroundEach overrides same ALS as fixture. I initially had an mislead impression that refreshAsyncContextChain would make it non overridable but it would actually work fine.

Introduce a small module that stores, per test context, an async context
snapshot captured at fixture `use()` time, so later user callbacks can
be entered inside that captured frame
An AsyncLocalStorage context established around a fixture's `use()` call
was lost before the test ran (upstream vitest-dev#5858).

Capture a snapshot inside each test-scoped fixture's `use()` and enter
later fixtures, hooks and the test body through it. File/worker-scoped
fixtures propagate only their value: a shared snapshot would leak
context between tests and erase aroundAll stores.

The snapshot is a named AsyncResource ignored by detectAsyncLeaks;
browsers never register the factory and are unaffected.
A store set by an aroundEach hook's own `als.run()` was erased when a
fixture snapshot captured before the hook body was entered later.
Re-capture the chain when the hook calls `runTest()` only if a chain
exists, so pipelines without fixtures never enter snapshots and keep
byte-identical stack traces.
…chain

The callbacks are not fixture-wrapped, so they were invoked outside the
chain and could not see the stores fixtures had established.
Cleanup functions returned from beforeEach ran outside the chain while
afterEach, being fixture-wrapped, ran inside it.
@VialFlorian
VialFlorian force-pushed the feat/fixture-async-context branch from 7896326 to 5700932 Compare August 13, 2026 22:53
@VialFlorian
VialFlorian requested a review from hi-ogawa August 13, 2026 23:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

p2-to-be-discussed Enhancement under consideration (priority)

Projects

Status: P2 - 3

Development

Successfully merging this pull request may close these issues.

3 participants