From 4390c4a4afdaf098c82f259488d54f058d8cf7d6 Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Mon, 31 Aug 2026 12:48:52 +0200 Subject: [PATCH 1/2] feat(vitest): add automatic suite enforcement --- package.json | 9 ++- src/integrations/vitest-setup.ts | 38 +++++++++++++ src/integrations/vitest.ts | 5 ++ .../vitest-auto-setup/concurrency.fixture.ts | 17 ++++++ .../vitest-auto-setup/detection.fixture.ts | 23 ++++++++ .../vitest-auto-setup/hooks.fixture.ts | 16 ++++++ .../vitest-auto-setup/vitest.config.ts | 9 +++ test/unit/vitest-setup.test.ts | 57 +++++++++++++++++++ 8 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/integrations/vitest-setup.ts create mode 100644 test/fixtures/vitest-auto-setup/concurrency.fixture.ts create mode 100644 test/fixtures/vitest-auto-setup/detection.fixture.ts create mode 100644 test/fixtures/vitest-auto-setup/hooks.fixture.ts create mode 100644 test/fixtures/vitest-auto-setup/vitest.config.ts create mode 100644 test/unit/vitest-setup.test.ts diff --git a/package.json b/package.json index f4dd12c..e787055 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "typescript" ], "types": "./dist/index.d.ts", - "sideEffects": false, + "sideEffects": [ + "./dist/integrations/vitest-setup.js" + ], "exports": { ".": { "import": "./dist/index.js", @@ -41,6 +43,11 @@ "require": "./dist/integrations/vitest.js", "types": "./dist/integrations/vitest.d.ts" }, + "./vitest/setup": { + "import": "./dist/integrations/vitest-setup.js", + "require": "./dist/integrations/vitest-setup.js", + "types": "./dist/integrations/vitest-setup.d.ts" + }, "./jest": { "import": "./dist/integrations/jest.js", "require": "./dist/integrations/jest.js", diff --git a/src/integrations/vitest-setup.ts b/src/integrations/vitest-setup.ts new file mode 100644 index 0000000..5f68906 --- /dev/null +++ b/src/integrations/vitest-setup.ts @@ -0,0 +1,38 @@ +import { afterEach, beforeEach } from 'vitest' +import { detect } from '../core/detector.js' +import { dispatchNotifications } from '../core/notify.js' +import { createContext, trackingAls } from '../core/tracker.js' +import type { TrackingContext } from '../core/tracker.js' +import { install } from '../drivers/install.js' +import { QueryGuardError } from './shared.js' + +const contexts = new WeakMap() + +await install() + +// Keep this hook synchronous: entering ALS after an await does not propagate the store back to +// Vitest's runner, so queries from the test would escape the context. +beforeEach((testContext) => { + const context = createContext() + contexts.set(testContext, context) + trackingAls.enterWith(context) +}) + +afterEach(async (testContext) => { + const context = contexts.get(testContext) + contexts.delete(testContext) + if (!context) return + + const report = detect(context) + if (report.detections.length === 0) return + + await dispatchNotifications({ + report, + environment: 'test', + test: { + name: testContext.task.name, + file: testContext.task.file.filepath, + }, + }) + throw new QueryGuardError(report) +}) diff --git a/src/integrations/vitest.ts b/src/integrations/vitest.ts index 090791e..63cfdc3 100644 --- a/src/integrations/vitest.ts +++ b/src/integrations/vitest.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from 'node:url' + export type { AssertOptions, AssertScalingOptions, @@ -8,3 +10,6 @@ export { QueryGuardError, ScalingError } from './shared.js' export { runAssertNoNPlusOne as assertNoNPlusOne } from './shared.js' export { runQueryBudget as queryBudget } from './shared.js' export { runAssertScaling as assertScaling } from './shared.js' + +/** Absolute path for Vitest's `test.setupFiles` configuration. */ +export const qguardSetup = fileURLToPath(new URL('./vitest-setup.js', import.meta.url)) diff --git a/test/fixtures/vitest-auto-setup/concurrency.fixture.ts b/test/fixtures/vitest-auto-setup/concurrency.fixture.ts new file mode 100644 index 0000000..2c64206 --- /dev/null +++ b/test/fixtures/vitest-auto-setup/concurrency.fixture.ts @@ -0,0 +1,17 @@ +import { expect, test } from 'vitest' +import { recordQuery } from '../../../src/core/tracker.js' + +async function recordOneQuery(): Promise { + await new Promise((resolve) => setTimeout(resolve, 5)) + recordQuery('SELECT * FROM users WHERE id = 1', 1) +} + +test.concurrent('isolates the first concurrent test', async () => { + await recordOneQuery() + expect(true).toBe(true) +}) + +test.concurrent('isolates the second concurrent test', async () => { + await recordOneQuery() + expect(true).toBe(true) +}) diff --git a/test/fixtures/vitest-auto-setup/detection.fixture.ts b/test/fixtures/vitest-auto-setup/detection.fixture.ts new file mode 100644 index 0000000..5f92582 --- /dev/null +++ b/test/fixtures/vitest-auto-setup/detection.fixture.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'vitest' +import { configure } from '../../../src/core/config.js' +import { recordQuery } from '../../../src/core/tracker.js' + +configure({ + onDetection: [ + ({ test: metadata }) => { + console.log(`[qguard notification] ${metadata?.name} @ ${metadata?.file}`) + }, + ], +}) + +test('passes a clean test without an explicit qguard assertion', () => { + recordQuery('SELECT * FROM users WHERE id = 1', 1) + expect(true).toBe(true) +}) + +test('fails an N+1 without an explicit qguard assertion', async () => { + recordQuery('SELECT * FROM users WHERE id = 1', 1) + await new Promise((resolve) => queueMicrotask(resolve)) + recordQuery('SELECT * FROM users WHERE id = 2', 1) + expect(true).toBe(true) +}) diff --git a/test/fixtures/vitest-auto-setup/hooks.fixture.ts b/test/fixtures/vitest-auto-setup/hooks.fixture.ts new file mode 100644 index 0000000..61e51a4 --- /dev/null +++ b/test/fixtures/vitest-auto-setup/hooks.fixture.ts @@ -0,0 +1,16 @@ +import { afterEach, beforeEach, expect, test } from 'vitest' +import { recordQuery } from '../../../src/core/tracker.js' + +beforeEach(() => { + recordQuery('SELECT * FROM users WHERE id = 1', 1) +}) + +afterEach(() => { + recordQuery('SELECT * FROM users WHERE id = 3', 1) +}) + +test('tracks queries across the complete per-test lifecycle', async () => { + await new Promise((resolve) => queueMicrotask(resolve)) + recordQuery('SELECT * FROM users WHERE id = 2', 1) + expect(true).toBe(true) +}) diff --git a/test/fixtures/vitest-auto-setup/vitest.config.ts b/test/fixtures/vitest-auto-setup/vitest.config.ts new file mode 100644 index 0000000..62faae0 --- /dev/null +++ b/test/fixtures/vitest-auto-setup/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' +import { qguardSetup } from '../../../src/integrations/vitest.js' + +export default defineConfig({ + test: { + include: ['test/fixtures/vitest-auto-setup/*.fixture.ts'], + setupFiles: [qguardSetup], + }, +}) diff --git a/test/unit/vitest-setup.test.ts b/test/unit/vitest-setup.test.ts new file mode 100644 index 0000000..ca1f046 --- /dev/null +++ b/test/unit/vitest-setup.test.ts @@ -0,0 +1,57 @@ +import { spawnSync } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { qguardSetup } from '../../src/integrations/vitest.js' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') +const vitestCli = path.join(root, 'node_modules/vitest/vitest.mjs') +const config = path.join(root, 'test/fixtures/vitest-auto-setup/vitest.config.ts') + +function runFixture(filename: string) { + const result = spawnSync(process.execPath, [vitestCli, 'run', '--config', config, filename], { + cwd: root, + encoding: 'utf8', + env: { ...process.env, FORCE_COLOR: '0' }, + }) + + return { + status: result.status, + output: `${result.stdout}${result.stderr}`, + } +} + +describe('qguard/vitest/setup', () => { + it('exports the built setup-file path for Vitest configuration', () => { + expect(path.isAbsolute(qguardSetup)).toBe(true) + expect(qguardSetup).toMatch(/[/\\]integrations[/\\]vitest-setup\.js$/) + }) + + it('fails only the test that produces an N+1 query pattern', () => { + const result = runFixture('detection.fixture.ts') + + expect(result.status).toBe(1) + expect(result.output).toContain('1 failed | 1 passed') + expect(result.output).toContain('QueryGuardError: N+1 query detected') + expect(result.output).toContain('fails an N+1 without an explicit qguard assertion') + expect(result.output).toContain( + '[qguard notification] fails an N+1 without an explicit qguard assertion', + ) + expect(result.output).toContain('detection.fixture.ts') + }) + + it('isolates concurrent tests from one another', () => { + const result = runFixture('concurrency.fixture.ts') + + expect(result.status).toBe(0) + expect(result.output).toContain('2 passed') + }) + + it('tracks queries from user beforeEach and afterEach hooks', () => { + const result = runFixture('hooks.fixture.ts') + + expect(result.status).toBe(1) + expect(result.output).toContain('QueryGuardError: N+1 query detected') + expect(result.output).toContain('Repeated query executed 3 times') + }) +}) From 6b6907886e9f86162038943a4275b1553f81b1df Mon Sep 17 00:00:00 2001 From: Wiktor Plaga Date: Mon, 31 Aug 2026 12:48:58 +0200 Subject: [PATCH 2/2] docs(vitest): document automatic setup --- CHANGELOG.md | 8 ++++ README.md | 46 +++++++++++++++++++--- docs/index.html | 10 ++--- docs/plans/2026-08-31-vitest-auto-setup.md | 37 +++++++++++++++++ site/index.html | 10 ++--- 5 files changed, 96 insertions(+), 15 deletions(-) create mode 100644 docs/plans/2026-08-31-vitest-auto-setup.md diff --git a/CHANGELOG.md b/CHANGELOG.md index a85a9a1..f57c1b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Features + +- Add automatic Vitest enforcement through the `qguard/vitest/setup` export and the + `qguardSetup` path for `test.setupFiles`. Each test receives an isolated query context and fails + with `QueryGuardError` when it produces an N+1 pattern. + ## 0.3.1 ### Fixes diff --git a/README.md b/README.md index 40b6d65..34e1017 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,19 @@ npm install qguard ``` ```ts -import { assertNoNPlusOne } from 'qguard/vitest' - -test('listing users does not N+1', async () => { - await assertNoNPlusOne(() => handler(req, res)) +// vitest.config.ts +import { defineConfig } from 'vitest/config' +import { qguardSetup } from 'qguard/vitest' + +export default defineConfig({ + test: { + setupFiles: [qguardSetup], + }, }) ``` +Every test now fails when it produces an N+1 query pattern. No per-test wrapper is required. + ## Why Every ORM makes it easy to write a loop that fires one query per row. Load 100 users, each with a profile: that's 101 queries instead of 2. The database barely notices in development, then the page takes 4 seconds in production with real data. @@ -58,9 +64,39 @@ queryguard monkey-patches `pg.Client.prototype.query`, `pg.Pool.prototype.query` ## API +### Automatic Vitest enforcement + +Add `qguardSetup` to Vitest's setup files to guard the complete suite: + +```ts +// vitest.config.ts +import { defineConfig } from 'vitest/config' +import { qguardSetup } from 'qguard/vitest' + +export default defineConfig({ + test: { + setupFiles: [qguardSetup], + }, +}) +``` + +`qguardSetup` is the absolute path to the published `qguard/vitest/setup` entry point. Vitest +resolves string values in `setupFiles` relative to the project root, so use the exported path +instead of the bare string `'qguard/vitest/setup'`. + +The setup module installs the database hooks once and creates a separate `AsyncLocalStorage` +context for every test. It includes queries from the test's `beforeEach` hooks, test body, and +`afterEach` hooks, and safely isolates concurrent tests. Any detection is reported as a +`QueryGuardError` on the test that produced it. + +Queries executed while test files are being imported or in `beforeAll`/`afterAll` cannot be +attributed to an individual test and are not tracked by this integration. Remove `qguardSetup` +from the config to disable suite-wide enforcement. The explicit APIs below remain useful for +narrower scopes, query budgets, and scaling checks. + ### assertNoNPlusOne -Runs a function and throws if any N+1 pattern is detected. Available from `queryguard/vitest` and `queryguard/jest`. +Runs a function and throws if any N+1 pattern is detected. Available from `qguard/vitest` and `qguard/jest`. ```ts import { assertNoNPlusOne } from 'qguard/vitest' diff --git a/docs/index.html b/docs/index.html index ee61c91..e12fa02 100644 --- a/docs/index.html +++ b/docs/index.html @@ -444,7 +444,7 @@

Catch N+1 queries before they hit production

-
Three lines of code. No config files.
+
One config entry. Every test guarded.
QueryGuard intercepts pg.Client and pg.Pool queries at the driver level. Every query is fingerprinted and tracked per async context. If the same query repeats above a threshold, your test fails.
@@ -452,13 +452,13 @@

Catch N+1 queries before they hit production

01

Install

npm install qguard

-

No plugins to register. No setup files to create. No environment variables to set.

+

No plugin package, generated setup file, or environment variable required.

02
-

Wrap your test

-

await assertNoNPlusOne(() => handler(req, res))

-

Import from queryguard/vitest or queryguard/jest. One function call wraps your handler.

+

Enable the suite

+

setupFiles: [qguardSetup]

+

Import qguardSetup from qguard/vitest. Every test is guarded automatically.

03
diff --git a/docs/plans/2026-08-31-vitest-auto-setup.md b/docs/plans/2026-08-31-vitest-auto-setup.md new file mode 100644 index 0000000..0fa8c22 --- /dev/null +++ b/docs/plans/2026-08-31-vitest-auto-setup.md @@ -0,0 +1,37 @@ +# Vitest automatic setup + +Date: 2026-08-31 +Issue: [#9](https://github.com/oniani1/queryguard/issues/9) + +## Goal + +Allow a Vitest suite to enforce qguard for every test from one configuration entry: + +```ts +import { qguardSetup } from 'qguard/vitest' + +export default defineConfig({ + test: { + setupFiles: [qguardSetup], + }, +}) +``` + +The existing `qguard/vitest` assertion helpers remain explicit and side-effect free. Importing +`qguardSetup` loads the `qguard/vitest/setup` entry point, which installs the database hooks once, +opens an isolated tracking context for each test, dispatches configured notifications, and fails +the affected test with `QueryGuardError` when the context contains an N+1 detection. + +## Plan + +- [x] Add a runner-level failing test proving setup-file enforcement and clean-test behavior. +- [x] Implement the Vitest lifecycle adapter with per-test `AsyncLocalStorage` isolation. +- [x] Publish the `qguard/vitest/setup` package export and verify the packed artifact. +- [x] Document automatic setup, configuration, opt-out, and interaction with explicit assertions. +- [x] Run formatting, type checking, linting, unit tests, build, and relevant integration tests. + +## Non-goals + +- Baseline or acknowledgement files. +- Jest, Mocha, or other runner adapters. +- Changes to the N+1 detection algorithm. diff --git a/site/index.html b/site/index.html index ee61c91..e12fa02 100644 --- a/site/index.html +++ b/site/index.html @@ -444,7 +444,7 @@

Catch N+1 queries before they hit production

-
Three lines of code. No config files.
+
One config entry. Every test guarded.
QueryGuard intercepts pg.Client and pg.Pool queries at the driver level. Every query is fingerprinted and tracked per async context. If the same query repeats above a threshold, your test fails.
@@ -452,13 +452,13 @@

Catch N+1 queries before they hit production

01

Install

npm install qguard

-

No plugins to register. No setup files to create. No environment variables to set.

+

No plugin package, generated setup file, or environment variable required.

02
-

Wrap your test

-

await assertNoNPlusOne(() => handler(req, res))

-

Import from queryguard/vitest or queryguard/jest. One function call wraps your handler.

+

Enable the suite

+

setupFiles: [qguardSetup]

+

Import qguardSetup from qguard/vitest. Every test is guarded automatically.

03