diff --git a/knip.jsonc b/knip.jsonc index ed1f48487..c5aa2ca36 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -8,7 +8,8 @@ "src/runtime/{plugins,components,composables}/**", "src/runtime/server/**", "src/runtime/kit/**", - "scripts/*" + "scripts/*", + "specs/utils/nitro-plugin.ts" ], "ignoreUnresolved": [ // virtuals diff --git a/specs/basic-usage-tests.ts b/specs/basic-usage-tests.ts index e98e6022e..efb860dd8 100644 --- a/specs/basic-usage-tests.ts +++ b/specs/basic-usage-tests.ts @@ -157,14 +157,12 @@ export function basicUsageTests() { expect(await getText(page, '#runtime-config')).toEqual('Hello from runtime config!') - const restore = await startServerWithRuntimeConfig({ + await startServerWithRuntimeConfig({ public: { runtimeValue: 'The environment variable has changed!' } }) await gotoPath(page, '/') expect(await getText(page, '#runtime-config')).toEqual('The environment variable has changed!') - - await restore() }) test('layer provides locale `nl` and translation for key `hello`', async () => { @@ -389,7 +387,7 @@ export function basicUsageTests() { test('render seo tags with baseUrl', async () => { const configDomain = 'https://runtime-config-domain.com' - const restore = await startServerWithRuntimeConfig({ + await startServerWithRuntimeConfig({ public: { i18n: { baseUrl: configDomain @@ -407,8 +405,6 @@ export function basicUsageTests() { expect(dom.querySelector('#i18n-alt-fr')?.getAttribute('href')).toEqual( 'https://runtime-config-domain.com/fr?canonical=' ) - - await restore() }) test('render seo tags with `experimental.alternateLinkCanonicalQueries`', async () => { @@ -534,15 +530,13 @@ export function basicUsageTests() { }) test('(#2000) Should be able to load large vue-i18n messages', async () => { - const restore = await startServerWithRuntimeConfig({ + await startServerWithRuntimeConfig({ public: { longTextTest: true } }) const { page } = await renderPage('/nl/long-text') expect(await getText(page, '#long-text')).toEqual('hallo,'.repeat(8 * 500)) - - await restore() }) test('(#2094) vue-i18n messages are loaded from config exported as variable', async () => { diff --git a/specs/basic_usage.spec.ts b/specs/basic_usage.spec.ts index 12947035e..e9f2c0948 100644 --- a/specs/basic_usage.spec.ts +++ b/specs/basic_usage.spec.ts @@ -32,14 +32,17 @@ describe('basic usage', async () => { describe('language switching', async () => { beforeAll(async () => { setTestContext(ctx) - await startServerWithRuntimeConfig({ - public: { - i18n: { - skipSettingLocaleOnNavigate: true, - detectBrowserLanguage: false + await startServerWithRuntimeConfig( + { + public: { + i18n: { + skipSettingLocaleOnNavigate: true, + detectBrowserLanguage: false + } } - } - }) + }, + true + ) }) languageSwitchingTests() diff --git a/specs/basic_usage_compat_4.spec.ts b/specs/basic_usage_compat_4.spec.ts index 2ccfe6ff3..4c4b052bf 100644 --- a/specs/basic_usage_compat_4.spec.ts +++ b/specs/basic_usage_compat_4.spec.ts @@ -32,14 +32,17 @@ describe('basic usage - compatibilityVersion: 4', async () => { describe('language switching', async () => { beforeAll(async () => { setTestContext(ctx) - await startServerWithRuntimeConfig({ - public: { - i18n: { - skipSettingLocaleOnNavigate: true, - detectBrowserLanguage: false + await startServerWithRuntimeConfig( + { + public: { + i18n: { + skipSettingLocaleOnNavigate: true, + detectBrowserLanguage: false + } } - } - }) + }, + true + ) }) languageSwitchingTests() diff --git a/specs/browser_language_detection/prefix_except_default.spec.ts b/specs/browser_language_detection/prefix_except_default.spec.ts index 2fc03bd04..0ae4d49bb 100644 --- a/specs/browser_language_detection/prefix_except_default.spec.ts +++ b/specs/browser_language_detection/prefix_except_default.spec.ts @@ -21,7 +21,7 @@ await setup({ describe('`detectBrowserLanguage` using strategy `prefix_except_default`', async () => { test('(#2262) redirect using browser cookie with `alwaysRedirect: true`', async () => { - const restore = await startServerWithRuntimeConfig({ + await startServerWithRuntimeConfig({ public: { i18n: { detectBrowserLanguage: { @@ -52,8 +52,6 @@ describe('`detectBrowserLanguage` using strategy `prefix_except_default`', async await page.locator('#nuxt-locale-link-en').click() expect(await getText(page, '#lang-switcher-current-locale code')).toEqual('en') expect(await ctx.cookies()).toMatchObject([{ name: 'i18n_redirected', value: 'en' }]) - - await restore() }) describe('(#2255) detect browser language and redirect on root', async () => { diff --git a/specs/helper.ts b/specs/helper.ts index 6550f7830..0c3cc1431 100644 --- a/specs/helper.ts +++ b/specs/helper.ts @@ -1,9 +1,10 @@ // @ts-ignore import createJITI from 'jiti' import { JSDOM } from 'jsdom' -import { getBrowser, startServer, url, useTestContext } from './utils' +import { getBrowser, TestContext, url, useTestContext } from './utils' import { snakeCase } from 'scule' import { resolveAlias } from '@nuxt/kit' +import { onTestFinished } from 'vitest' import { errors, type BrowserContextOptions, type Page } from 'playwright-core' @@ -149,42 +150,42 @@ export async function waitForURL(page: Page, path: string) { } } -function flattenObject(obj: Record = {}) { - const flattened: Record = {} - - for (const key of Object.keys(obj)) { - const entry = obj[key] - - if (typeof entry !== 'object' || entry == null) { - flattened[key] = obj[key] - continue +async function updateProcessRuntimeConfig(ctx: TestContext, config: unknown) { + const updated = new Promise(resolve => { + const handler = (msg: { type: string; value: unknown }) => { + if (msg.type === 'confirm:runtime-config') { + ctx.serverProcess!.process?.off('message', handler) + resolve(msg.value) + } } + ctx.serverProcess!.process?.on('message', handler) + }) - const flatObject = flattenObject(entry as Record) - for (const x of Object.keys(flatObject)) { - flattened[key + '_' + x] = flatObject[x] - } - } + ctx.serverProcess!.process?.send({ type: 'update:runtime-config', value: config }, undefined, { + keepOpen: true + }) - return flattened + return await updated } -function convertObjectToConfig(obj: Record) { - const makeEnvKey = (str: string) => `NUXT_${snakeCase(str).toUpperCase()}` +export async function startServerWithRuntimeConfig(env: Record, skipRestore = false) { + const ctx = useTestContext() + + const stored = await updateProcessRuntimeConfig(ctx, env) - const env: Record = {} - const flattened = flattenObject(obj) - for (const key in flattened) { - env[makeEnvKey(key)] = flattened[key] + let restored = false + const restoreFn = async () => { + if (restored) return + + restored = true + await await updateProcessRuntimeConfig(ctx, stored) } - return env -} + if (!skipRestore) { + onTestFinished(restoreFn) + } -export async function startServerWithRuntimeConfig(env: Record) { - const converted = convertObjectToConfig(env) - await startServer(converted) - return async () => startServer() + return restoreFn } export async function localeLoaderHelpers() { diff --git a/specs/routing_strategies/no_prefix.spec.ts b/specs/routing_strategies/no_prefix.spec.ts index 784e3c510..724aae0a9 100644 --- a/specs/routing_strategies/no_prefix.spec.ts +++ b/specs/routing_strategies/no_prefix.spec.ts @@ -30,11 +30,14 @@ await setup({ describe('strategy: no_prefix', async () => { beforeAll(async () => { - await startServerWithRuntimeConfig({ - public: { - i18n: { detectBrowserLanguage: false } - } - }) + await startServerWithRuntimeConfig( + { + public: { + i18n: { detectBrowserLanguage: false } + } + }, + true + ) }) test('cannot access with locale prefix: /ja', async () => { diff --git a/specs/routing_strategies/prefix.spec.ts b/specs/routing_strategies/prefix.spec.ts index 270140286..ab1bb7994 100644 --- a/specs/routing_strategies/prefix.spec.ts +++ b/specs/routing_strategies/prefix.spec.ts @@ -22,11 +22,14 @@ await setup({ describe('strategy: prefix', async () => { beforeEach(async () => { // use original fixture `detectBrowserLanguage` value as default for tests, overwrite here needed - await startServerWithRuntimeConfig({ - public: { - i18n: { detectBrowserLanguage: false } - } - }) + await startServerWithRuntimeConfig( + { + public: { + i18n: { detectBrowserLanguage: false } + } + }, + true + ) }) test.each([ @@ -120,7 +123,7 @@ describe('strategy: prefix', async () => { }) test('(#1889) navigation to page with `defineI18nRoute(false)`', async () => { - const restore = await startServerWithRuntimeConfig({ + await startServerWithRuntimeConfig({ public: { i18n: { detectBrowserLanguage: { @@ -153,8 +156,6 @@ describe('strategy: prefix', async () => { // does not redirect to prefixed route for routes with disabled localization await page.goto(url('/ignore-routes/disable')) await waitForURL(page, '/ignore-routes/disable') - - await restore() }) test('should not transform `defineI18nRoute()` inside template', async () => { @@ -165,7 +166,7 @@ describe('strategy: prefix', async () => { }) test("(#2132) should redirect on root url with `redirectOn: 'no prefix'`", async () => { - const restore = await startServerWithRuntimeConfig({ + await startServerWithRuntimeConfig({ public: { i18n: { detectBrowserLanguage: { @@ -183,8 +184,6 @@ describe('strategy: prefix', async () => { await gotoPath(page, '/en') expect(await getText(page, '#home-header')).toEqual('Homepage') - - await restore() }) test('(#2020) pass query parameter', async () => { diff --git a/specs/routing_strategies/root_redirect.spec.ts b/specs/routing_strategies/root_redirect.spec.ts index daccad68c..3b2242666 100644 --- a/specs/routing_strategies/root_redirect.spec.ts +++ b/specs/routing_strategies/root_redirect.spec.ts @@ -19,7 +19,7 @@ await setup({ describe('rootRedirect', async () => { test('can redirect to rootRedirect option path', async () => { - const restore = await startServerWithRuntimeConfig({ + await startServerWithRuntimeConfig({ public: { i18n: { rootRedirect: 'fr' @@ -29,12 +29,10 @@ describe('rootRedirect', async () => { const res = await fetch('/') expect(res.url).toBe(url('/fr')) - - await restore() }) test('(#2758) `statusCode` in `rootRedirect` should work with strategy "prefix"', async () => { - const restore = await startServerWithRuntimeConfig({ + await startServerWithRuntimeConfig({ public: { i18n: { rootRedirect: { statusCode: 418, path: 'test-route' } @@ -45,7 +43,5 @@ describe('rootRedirect', async () => { const res = await fetch(url('/')) expect(res.status).toEqual(418) expect(res.headers.get('location')).toEqual('/en/test-route') - - await restore() }) }) diff --git a/specs/utils/nitro-plugin.ts b/specs/utils/nitro-plugin.ts new file mode 100644 index 000000000..2e73a4f4e --- /dev/null +++ b/specs/utils/nitro-plugin.ts @@ -0,0 +1,68 @@ +import { defineNitroPlugin, useRuntimeConfig } from 'nitropack/runtime' +import { snakeCase } from 'scule' + +function flattenObject(obj: Record = {}) { + const flattened: Record = {} + + for (const key of Object.keys(obj)) { + const entry = obj[key] + + if (typeof entry !== 'object' || entry == null) { + flattened[key] = obj[key] + continue + } + + const flatObject = flattenObject(entry as Record) + for (const x of Object.keys(flatObject)) { + flattened[key + '_' + x] = flatObject[x] + } + } + + return flattened +} + +export function convertObjectToConfig(obj: Record) { + const makeEnvKey = (str: string) => `NUXT_${snakeCase(str).toUpperCase()}` + + const env: Record = {} + const flattened = flattenObject(obj) + for (const key in flattened) { + env[makeEnvKey(key)] = flattened[key] + } + + return env +} + +export default defineNitroPlugin(async nitroApp => { + const config = useRuntimeConfig() + const tempKeys = new Set() + + const handler = (msg: { type: string; value: Record }) => { + if (msg.type !== 'update:runtime-config') return + + // cleanup temporary keys + for (const k of tempKeys) { + delete process.env[k] + } + + // flatten object and use env variable keys + const envConfig = convertObjectToConfig(msg.value) + for (const [k, val] of Object.entries(envConfig)) { + // collect keys which are newly introduced to cleanup later + if (k in process.env === false) { + tempKeys.add(k) + } + + // @ts-expect-error untyped + process.env[k] = val + } + + process.send!({ type: 'confirm:runtime-config', value: config }, undefined, { + keepOpen: true + }) + } + + process.on('message', handler) + + nitroApp.hooks.hook('close', () => process.off('message', handler)) +}) diff --git a/specs/utils/nuxt.ts b/specs/utils/nuxt.ts index be186b8fc..321f37d1a 100644 --- a/specs/utils/nuxt.ts +++ b/specs/utils/nuxt.ts @@ -4,6 +4,7 @@ import * as _kit from '@nuxt/kit' import { useTestContext } from './context' import { resolve } from 'node:path' import { relative } from 'pathe' +import { fileURLToPath } from 'node:url' import type { VitestContext } from './types' @@ -64,10 +65,15 @@ export async function loadFixture(testContext: VitestContext) { ctx.options.nuxtConfig = defu(ctx.options.nuxtConfig, { buildDir, modules: [ - /** - * The `overrides` option is only used for testing, it is used to option overrides to the project layer in a fixture. - */ (_, nuxt) => { + /** + * Register nitro plugin for IPC communication to update runtime config + */ + nuxt.options.nitro.plugins ||= [] + nuxt.options.nitro.plugins.push(fileURLToPath(new URL('./nitro-plugin', import.meta.url))) + /** + * The `overrides` option is only used for testing, it is used to option overrides to the project layer in a fixture. + */ if (nuxt.options?.i18n?.overrides) { const project = nuxt.options._layers[0] const { overrides, ...mergedOptions } = nuxt.options.i18n diff --git a/specs/utils/server.ts b/specs/utils/server.ts index 8c7201da6..084f8770c 100644 --- a/specs/utils/server.ts +++ b/specs/utils/server.ts @@ -72,7 +72,7 @@ export async function startServer(env: Record = {}) { ctx.serverProcess = x('node', [resolve(ctx.nuxt!.options.nitro.output!.dir!, 'server/index.mjs')], { throwOnError: true, nodeOptions: { - stdio: 'inherit', + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env: { ...process.env, PORT: String(ports[0]),