From 21e58bd890c4b770d8b015b25e598ae8455dbea8 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 3 May 2024 16:00:59 +0200 Subject: [PATCH] feat(browser): allow injecting scripts (#5656) --- docs/config/index.md | 49 ++++++++++++++++++++++ packages/browser/src/client/index.html | 1 + packages/browser/src/client/tester.html | 1 + packages/browser/src/node/index.ts | 40 ++++++++++++++---- packages/vitest/src/node/cli/cli-config.ts | 2 + packages/vitest/src/node/index.ts | 2 +- packages/vitest/src/types/browser.ts | 39 +++++++++++++++++ packages/vitest/src/types/config.ts | 1 + pnpm-lock.yaml | 22 ++++------ test/browser/injected-lib/index.js | 1 + test/browser/injected-lib/package.json | 7 ++++ test/browser/injected.ts | 2 + test/browser/package.json | 1 + test/browser/specs/runner.test.ts | 10 +++-- test/browser/test/injected.test.ts | 10 +++++ test/browser/vitest.config.mts | 31 ++++++++++++++ 16 files changed, 193 insertions(+), 26 deletions(-) create mode 100644 test/browser/injected-lib/index.js create mode 100644 test/browser/injected-lib/package.json create mode 100644 test/browser/injected.ts create mode 100644 test/browser/test/injected.test.ts diff --git a/docs/config/index.md b/docs/config/index.md index 3a778920b3cd..0d4411014141 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -1612,6 +1612,55 @@ This option has no effect on tests running inside Node.js. If you rely on spying on ES modules with `vi.spyOn`, you can enable this experimental feature to allow spying on module exports. +#### browser.indexScripts 1.6.0 {#browser-indexscripts} + +- **Type:** `BrowserScript[]` +- **Default:** `[]` + +Custom scripts that should be injected into the index HTML before test iframes are initiated. This HTML document only sets up iframes and doesn't actually import your code. + +The script `src` and `content` will be processed by Vite plugins. Script should be provided in the following shape: + +```ts +export interface BrowserScript { + /** + * If "content" is provided and type is "module", this will be its identifier. + * + * If you are using TypeScript, you can add `.ts` extension here for example. + * @default `injected-${index}.js` + */ + id?: string + /** + * JavaScript content to be injected. This string is processed by Vite plugins if type is "module". + * + * You can use `id` to give Vite a hint about the file extension. + */ + content?: string + /** + * Path to the script. This value is resolved by Vite so it can be a node module or a file path. + */ + src?: string + /** + * If the script should be loaded asynchronously. + */ + async?: boolean + /** + * Script type. + * @default 'module' + */ + type?: string +} +``` + +#### browser.testerScripts 1.6.0 {#browser-testerscripts} + +- **Type:** `BrowserScript[]` +- **Default:** `[]` + +Custom scripts that should be injected into the tester HTML before the tests environment is initiated. This is useful to inject polyfills required for Vitest browser implementation. It is recommended to use [`setupFiles`](#setupfiles) in almost all cases instead of this. + +The script `src` and `content` will be processed by Vite plugins. + ### clearMocks - **Type:** `boolean` diff --git a/packages/browser/src/client/index.html b/packages/browser/src/client/index.html index c8dee89a68d5..22c54abf02b5 100644 --- a/packages/browser/src/client/index.html +++ b/packages/browser/src/client/index.html @@ -22,6 +22,7 @@ } + {__VITEST_SCRIPTS__} diff --git a/packages/browser/src/client/tester.html b/packages/browser/src/client/tester.html index de8ea361c58b..a9ad5756290d 100644 --- a/packages/browser/src/client/tester.html +++ b/packages/browser/src/client/tester.html @@ -16,6 +16,7 @@ } + {__VITEST_SCRIPTS__} diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index 0da28819870c..c4465f438326 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -1,17 +1,14 @@ import { fileURLToPath } from 'node:url' import { readFile } from 'node:fs/promises' -import { basename, resolve } from 'pathe' +import { basename, join, resolve } from 'pathe' import sirv from 'sirv' -import type { Plugin } from 'vite' +import type { Plugin, ViteDevServer } from 'vite' import type { ResolvedConfig } from 'vitest' -import type { WorkspaceProject } from 'vitest/node' +import type { BrowserScript, WorkspaceProject } from 'vitest/node' import { coverageConfigDefaults } from 'vitest/config' +import { slash } from '@vitest/utils' import { injectVitestModule } from './esmInjector' -function replacer(code: string, values: Record) { - return code.replace(/{\s*(\w+)\s*}/g, (_, key) => values[key] ?? '') -} - export default (project: WorkspaceProject, base = '/'): Plugin[] => { const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') const distRoot = resolve(pkgRoot, 'dist') @@ -41,6 +38,8 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { } next() }) + let indexScripts: string | undefined + let testerScripts: string | undefined server.middlewares.use(async (req, res, next) => { if (!req.url) return next() @@ -63,9 +62,13 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { }) if (url.pathname === base) { + if (!indexScripts) + indexScripts = await formatScripts(project.config.browser.indexScripts, server) + const html = replacer(await runnerHtml, { __VITEST_FAVICON__: favicon, __VITEST_TITLE__: 'Vitest Browser Runner', + __VITEST_SCRIPTS__: indexScripts, __VITEST_INJECTOR__: injector, }) res.write(html, 'utf-8') @@ -77,9 +80,13 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => { // if decoded test file is "__vitest_all__" or not in the list of known files, run all tests const tests = decodedTestFile === '__vitest_all__' || !files.includes(decodedTestFile) ? '__vitest_browser_runner__.files' : JSON.stringify([decodedTestFile]) + if (!testerScripts) + testerScripts = await formatScripts(project.config.browser.testerScripts, server) + const html = replacer(await testerHtml, { __VITEST_FAVICON__: favicon, __VITEST_TITLE__: 'Vitest Browser Tester', + __VITEST_SCRIPTS__: testerScripts, __VITEST_INJECTOR__: injector, __VITEST_APPEND__: // TODO: have only a single global variable to not pollute the global scope @@ -233,3 +240,22 @@ function wrapConfig(config: ResolvedConfig): ResolvedConfig { : undefined, } } + +function replacer(code: string, values: Record) { + return code.replace(/{\s*(\w+)\s*}/g, (_, key) => values[key] ?? '') +} + +async function formatScripts(scripts: BrowserScript[] | undefined, server: ViteDevServer) { + if (!scripts?.length) + return '' + const promises = scripts.map(async ({ content, src, async, id, type = 'module' }, index) => { + const srcLink = (src ? (await server.pluginContainer.resolveId(src))?.id : undefined) || src + const transformId = srcLink || join(server.config.root, `virtual__${id || `injected-${index}.js`}`) + await server.moduleGraph.ensureEntryFromUrl(transformId) + const contentProcessed = content && type === 'module' + ? (await server.pluginContainer.transform(content, transformId)).code + : content + return `` + }) + return (await Promise.all(promises)).join('\n') +} diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index 0ae8b529b75d..868c0ad1583f 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -350,6 +350,8 @@ export const cliOptionsConfig: VitestCLIOptions = { fileParallelism: { description: 'Should all test files run in parallel. Use `--browser.file-parallelism=false` to disable (default: same as `--file-parallelism`)', }, + indexScripts: null, + testerScripts: null, }, }, pool: { diff --git a/packages/vitest/src/node/index.ts b/packages/vitest/src/node/index.ts index ef5b65737633..9178c52ae8c8 100644 --- a/packages/vitest/src/node/index.ts +++ b/packages/vitest/src/node/index.ts @@ -13,4 +13,4 @@ export { VitestPackageInstaller } from './packageInstaller' export type { TestSequencer, TestSequencerConstructor } from './sequencers/types' export { BaseSequencer } from './sequencers/BaseSequencer' -export type { BrowserProviderInitializationOptions, BrowserProvider, BrowserProviderOptions } from '../types/browser' +export type { BrowserProviderInitializationOptions, BrowserProvider, BrowserProviderOptions, BrowserScript } from '../types/browser' diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 55c6d89b2d4b..5beb46cd6879 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -93,6 +93,45 @@ export interface BrowserConfigOptions { * @default test.fileParallelism */ fileParallelism?: boolean + + /** + * Scripts injected into the tester iframe. + */ + testerScripts?: BrowserScript[] + + /** + * Scripts injected into the main window. + */ + indexScripts?: BrowserScript[] +} + +export interface BrowserScript { + /** + * If "content" is provided and type is "module", this will be its identifier. + * + * If you are using TypeScript, you can add `.ts` extension here for example. + * @default `injected-${index}.js` + */ + id?: string + /** + * JavaScript content to be injected. This string is processed by Vite plugins if type is "module". + * + * You can use `id` to give Vite a hint about the file extension. + */ + content?: string + /** + * Path to the script. This value is resolved by Vite so it can be a node module or a file path. + */ + src?: string + /** + * If the script should be loaded asynchronously. + */ + async?: boolean + /** + * Script type. + * @default 'module' + */ + type?: string } export interface ResolvedBrowserOptions extends BrowserConfigOptions { diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index a45546afa43c..0e507668b2a3 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -16,6 +16,7 @@ import type { BenchmarkUserOptions } from './benchmark' import type { BrowserConfigOptions, ResolvedBrowserOptions } from './browser' import type { Pool, PoolOptions } from './pool-options' +export type { BrowserScript, BrowserConfigOptions } from './browser' export type { SequenceHooks, SequenceSetupFiles } from '@vitest/runner' export type BuiltinEnvironment = 'node' | 'jsdom' | 'happy-dom' | 'edge-runtime' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa1bd6cf9a41..9734f54d6463 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -879,7 +879,7 @@ importers: version: 4.3.10 debug: specifier: ^4.3.4 - version: 4.3.4 + version: 4.3.4(supports-color@8.1.1) execa: specifier: ^8.0.1 version: 8.0.1 @@ -1060,6 +1060,9 @@ importers: '@vitest/cjs-lib': specifier: link:./cjs-lib version: link:cjs-lib + '@vitest/injected-lib': + specifier: link:./injected-lib + version: link:injected-lib execa: specifier: ^7.1.1 version: 7.1.1 @@ -7064,7 +7067,7 @@ packages: resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} engines: {node: '>= 14'} dependencies: - debug: 4.3.4 + debug: 4.3.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color dev: true @@ -8570,17 +8573,6 @@ packages: supports-color: 8.1.1 dev: true - /debug@4.3.4: - resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - dependencies: - ms: 2.1.2 - /debug@4.3.4(supports-color@8.1.1): resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} engines: {node: '>=6.0'} @@ -10866,7 +10858,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.3.4 + debug: 4.3.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color dev: true @@ -10917,7 +10909,7 @@ packages: engines: {node: '>= 14'} dependencies: agent-base: 7.1.0 - debug: 4.3.4 + debug: 4.3.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color dev: true diff --git a/test/browser/injected-lib/index.js b/test/browser/injected-lib/index.js new file mode 100644 index 000000000000..c358ba3d8bf8 --- /dev/null +++ b/test/browser/injected-lib/index.js @@ -0,0 +1 @@ +__injected.push(4) diff --git a/test/browser/injected-lib/package.json b/test/browser/injected-lib/package.json new file mode 100644 index 000000000000..6f50a87dbed0 --- /dev/null +++ b/test/browser/injected-lib/package.json @@ -0,0 +1,7 @@ +{ + "name": "@vitest/injected-lib", + "type": "module", + "exports": { + "default": "./index.js" + } +} diff --git a/test/browser/injected.ts b/test/browser/injected.ts new file mode 100644 index 000000000000..ac256565e115 --- /dev/null +++ b/test/browser/injected.ts @@ -0,0 +1,2 @@ +// @ts-expect-error not typed global +;(__injected as string[]).push(3) diff --git a/test/browser/package.json b/test/browser/package.json index 081979e2c6dc..60d89d261455 100644 --- a/test/browser/package.json +++ b/test/browser/package.json @@ -15,6 +15,7 @@ "@vitejs/plugin-basic-ssl": "^1.0.2", "@vitest/browser": "workspace:*", "@vitest/cjs-lib": "link:./cjs-lib", + "@vitest/injected-lib": "link:./injected-lib", "execa": "^7.1.1", "playwright": "^1.41.0", "url": "^0.11.3", diff --git a/test/browser/specs/runner.test.ts b/test/browser/specs/runner.test.ts index 213e673db5ad..680cd1f0d7e3 100644 --- a/test/browser/specs/runner.test.ts +++ b/test/browser/specs/runner.test.ts @@ -1,4 +1,4 @@ -import { beforeAll, describe, expect, test } from 'vitest' +import { beforeAll, describe, expect, onTestFailed, test } from 'vitest' import { runBrowserTests } from './utils' describe.each([ @@ -26,8 +26,12 @@ describe.each([ }) test(`[${description}] tests are actually running`, () => { - expect(browserResultJson.testResults).toHaveLength(14) - expect(passedTests).toHaveLength(12) + onTestFailed(() => { + console.error(stderr) + }) + + expect(browserResultJson.testResults).toHaveLength(15) + expect(passedTests).toHaveLength(13) expect(failedTests).toHaveLength(2) expect(stderr).not.toContain('has been externalized for browser compatibility') diff --git a/test/browser/test/injected.test.ts b/test/browser/test/injected.test.ts new file mode 100644 index 000000000000..364e2d89ce0b --- /dev/null +++ b/test/browser/test/injected.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from 'vitest' + +test('injected values are correct', () => { + expect((globalThis as any).__injected).toEqual([ + 1, + 2, + 3, + 4, + ]) +}) diff --git a/test/browser/vitest.config.mts b/test/browser/vitest.config.mts index 97bd6e9180e2..75f3435ad6c2 100644 --- a/test/browser/vitest.config.mts +++ b/test/browser/vitest.config.mts @@ -29,6 +29,37 @@ export default defineConfig({ provider, isolate: false, slowHijackESM: true, + testerScripts: [ + { + content: 'globalThis.__injected = []', + type: 'text/javascript', + }, + { + content: '__injected.push(1)', + }, + { + id: 'ts.ts', + content: '(__injected as string[]).push(2)', + }, + { + src: './injected.ts', + }, + { + src: '@vitest/injected-lib', + }, + ], + indexScripts: [ + { + content: 'console.log("Hello, World");globalThis.__injected = []', + type: 'text/javascript', + }, + { + content: 'import "./injected.ts"', + }, + { + content: 'if(__injected[0] !== 3) throw new Error("injected not working")', + }, + ], }, alias: { '#src': resolve(dir, './src'),