diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 000000000..8efaada48 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,29 @@ +name: Playwright Tests +on: + push: + branches: [main] + pull_request: + branches: [main] +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + cache: 'pnpm' + - name: Install dependencies + run: pnpm install + - name: Install Playwright Browsers + run: pnpm exec playwright install --with-deps + - name: Run Playwright tests + run: pnpm exec playwright test + - uses: actions/upload-artifact@v5 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index 9ae8b61ef..ce1d4ff2f 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,10 @@ tsconfig.vitest-temp.json docs/.vitepress/cache vite.config.ts.timestamp-* .DS_Store + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/e2e/__snapshots__/routes.spec.ts.snap b/e2e/__snapshots__/routes.spec.ts.snap index cf854b9d1..300fe33f8 100644 --- a/e2e/__snapshots__/routes.spec.ts.snap +++ b/e2e/__snapshots__/routes.spec.ts.snap @@ -119,9 +119,10 @@ exports[`e2e routes > generates the routes 1`] = ` } ] -export function handleHotUpdate(_router) { +export function handleHotUpdate(_router, _hotUpdateCallback) { if (import.meta.hot) { import.meta.hot.data.router = _router + import.meta.hot.data.router_hotUpdateCallback = _hotUpdateCallback } } @@ -136,7 +137,18 @@ if (import.meta.hot) { for (const route of mod.routes) { router.addRoute(route) } - router.replace('') + // call the hotUpdateCallback for custom updates + import.meta.hot.data.router_hotUpdateCallback?.(mod.routes) + const route = router.currentRoute.value + router.replace({ + ...route, + // NOTE: we should be able to just do ...route but the router + // currently skips resolving and can give errors with renamed routes + // so we explicitly set remove matched and name + name: undefined, + matched: undefined, + force: true + }) }) } diff --git a/e2e/hmr/.gitignore b/e2e/hmr/.gitignore new file mode 100644 index 000000000..ef56a853a --- /dev/null +++ b/e2e/hmr/.gitignore @@ -0,0 +1 @@ +playground-tmp diff --git a/e2e/hmr/fixtures/vite-server.ts b/e2e/hmr/fixtures/vite-server.ts new file mode 100644 index 000000000..3f1649450 --- /dev/null +++ b/e2e/hmr/fixtures/vite-server.ts @@ -0,0 +1,78 @@ +import { test as base, expect } from '@playwright/test' +import { createServer, type ViteDevServer } from 'vite' +import { type AddressInfo } from 'node:net' +import path from 'node:path' +import fs from 'node:fs' +import { fileURLToPath } from 'node:url' + +type ViteFixtures = { + devServer: ViteDevServer + baseURL: string + projectRoot: string +} + +export function applyEditFile( + sourceFilePath: string, + newContentFilePath: string +) { + fs.writeFileSync( + path.join(projectRoot, sourceFilePath), + fs.readFileSync(path.join(projectRoot, newContentFilePath), 'utf8'), + 'utf8' + ) +} + +const sourceDir = fileURLToPath(new URL('../playground', import.meta.url)) +const fixtureDir = fileURLToPath(new URL('../playground-tmp', import.meta.url)) +const projectRoot = path.resolve(fixtureDir) + +export const test = base.extend({ + projectRoot, + + // @ts-expect-error: type matched what is passed to use(server) + devServer: [ + async ({}, use) => { + fs.rmSync(fixtureDir, { force: true, recursive: true }) + fs.cpSync(sourceDir, fixtureDir, { + recursive: true, + filter: (src) => { + return ( + !src.includes('.cache') && + !src.endsWith('.sock') && + !src.includes('.output') && + !src.includes('.vite') + ) + }, + }) + // Start a real Vite dev server with your plugin(s) & config. + // If you already have vite.config.ts, omit configFile:false and rely on it. + const server = await createServer({ + configFile: path.join(fixtureDir, 'vite.config.ts'), + // If you need to inline the plugin directly, you could do: + // configFile: false, + // plugins: [myPlugin()], + server: { host: '127.0.0.1', port: 0, strictPort: false }, // random open port + logLevel: 'error', + }) + + await server.listen() + + const http = server.httpServer + if (!http) throw new Error('No httpServer from Vite') + + // Expose the running server & URL to tests + await use(server) + + await server.close() + }, + { scope: 'worker' }, + ], + + baseURL: async ({ devServer }, use) => { + const http = devServer.httpServer! + const addr = http.address() as AddressInfo + await use(`http://127.0.0.1:${addr.port}`) + }, +}) + +export { expect } diff --git a/e2e/hmr/hmr.spec.ts b/e2e/hmr/hmr.spec.ts new file mode 100644 index 000000000..62f9f9d5e --- /dev/null +++ b/e2e/hmr/hmr.spec.ts @@ -0,0 +1,37 @@ +import { Page } from '@playwright/test' +import { test, expect, applyEditFile } from './fixtures/vite-server' + +test.describe('Pages HMR', () => { + let hmrToken: number = -1 + // reset hmr token before each test + test.beforeEach(() => { + hmrToken = -1 + }) + + async function ensureHmrToken(page: Page) { + hmrToken = await page.evaluate( + () => ((window as any).__hmrToken ??= Math.random()) + ) + } + + // ensure hmr token is stable across tests + test.afterEach(async ({ page }) => { + if (hmrToken === -1) { + throw new Error('hmrToken was not set in the test') + } + await expect + .poll(async () => page.evaluate(() => (window as any).__hmrToken)) + .toBe(hmrToken) + }) + + test('applies meta changes in block', async ({ page, baseURL }) => { + await page.goto(baseURL + '/') + + await expect(page.locator('[data-testid="meta-hello"]')).toHaveText('') + + await ensureHmrToken(page) + applyEditFile('src/pages/(home).vue', 'edits/(home)-with-route-block.vue') + + await expect(page.locator('[data-testid="meta-hello"]')).toHaveText('world') + }) +}) diff --git a/e2e/hmr/playground/edits/(home)-with-route-block.vue b/e2e/hmr/playground/edits/(home)-with-route-block.vue new file mode 100644 index 000000000..2a7628da1 --- /dev/null +++ b/e2e/hmr/playground/edits/(home)-with-route-block.vue @@ -0,0 +1,15 @@ + + + +{ + "meta": { + "hello": "world" + } +} + diff --git a/e2e/hmr/playground/index.html b/e2e/hmr/playground/index.html new file mode 100644 index 000000000..73307b678 --- /dev/null +++ b/e2e/hmr/playground/index.html @@ -0,0 +1,12 @@ + + + + + + HMR E2E Tests + + +
+ + + diff --git a/e2e/hmr/playground/package.json b/e2e/hmr/playground/package.json new file mode 100644 index 000000000..a2bd8b87d --- /dev/null +++ b/e2e/hmr/playground/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "name": "fixture-hmr", + "scripts": { + "build": "vite build" + } +} diff --git a/e2e/hmr/playground/src/App.vue b/e2e/hmr/playground/src/App.vue new file mode 100644 index 000000000..7c2aa3f3e --- /dev/null +++ b/e2e/hmr/playground/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/e2e/hmr/playground/src/main.ts b/e2e/hmr/playground/src/main.ts new file mode 100644 index 000000000..78d766681 --- /dev/null +++ b/e2e/hmr/playground/src/main.ts @@ -0,0 +1,14 @@ +import { createApp } from 'vue' +import App from './App.vue' +import { router } from './router' + +const app = createApp(App) +app.use(router) +app.mount('#app') + +// small logger for navigations, useful to check HMR +router.isReady().then(() => { + router.beforeEach((to, from) => { + console.log('🧭', from.fullPath, '->', to.fullPath) + }) +}) diff --git a/e2e/hmr/playground/src/pages/(home).vue b/e2e/hmr/playground/src/pages/(home).vue new file mode 100644 index 000000000..6cb9222eb --- /dev/null +++ b/e2e/hmr/playground/src/pages/(home).vue @@ -0,0 +1,7 @@ + diff --git a/e2e/hmr/playground/src/router.ts b/e2e/hmr/playground/src/router.ts new file mode 100644 index 000000000..bd09374ec --- /dev/null +++ b/e2e/hmr/playground/src/router.ts @@ -0,0 +1,13 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { routes, handleHotUpdate } from 'vue-router/auto-routes' + +export const router = createRouter({ + history: createWebHistory(), + routes, +}) + +if (import.meta.hot) { + handleHotUpdate(router, (routes) => { + console.log('🔥 HMR with', routes) + }) +} diff --git a/e2e/hmr/playground/vite.config.ts b/e2e/hmr/playground/vite.config.ts new file mode 100644 index 000000000..886355ebe --- /dev/null +++ b/e2e/hmr/playground/vite.config.ts @@ -0,0 +1,53 @@ +import { defineConfig } from 'vite' +import { fileURLToPath, URL } from 'node:url' +import VueRouter from '../../../src/vite' +import Vue from '@vitejs/plugin-vue' + +const root = fileURLToPath(new URL('./', import.meta.url)) + +export default defineConfig({ + root, + clearScreen: false, + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '~': fileURLToPath(new URL('./src', import.meta.url)), + 'unplugin-vue-router/runtime': fileURLToPath( + new URL('../../../src/runtime.ts', import.meta.url) + ), + 'unplugin-vue-router/types': fileURLToPath( + new URL('../../../src/types.ts', import.meta.url) + ), + 'unplugin-vue-router/data-loaders/basic': fileURLToPath( + new URL('../../../src/data-loaders/entries/basic.ts', import.meta.url) + ), + 'unplugin-vue-router/data-loaders/pinia-colada': fileURLToPath( + new URL( + '../../../src/data-loaders/entries/pinia-colada.ts', + import.meta.url + ) + ), + 'unplugin-vue-router/data-loaders': fileURLToPath( + new URL('../../../src/data-loaders/entries/index.ts', import.meta.url) + ), + }, + }, + build: { + sourcemap: true, + }, + + plugins: [ + VueRouter({ + root, + logs: true, + // defaults to false on CI + watch: true, + // getRouteName: getPascalCaseRouteName, + experimental: { + autoExportsDataLoaders: ['src/loaders/**/*', '@/loaders/**/*'], + paramParsers: false, + }, + }), + Vue(), + ], +}) diff --git a/package.json b/package.json index 25ead5986..2f88e7310 100644 --- a/package.json +++ b/package.json @@ -182,6 +182,7 @@ "devDependencies": { "@babel/types": "^7.28.5", "@pinia/colada": "^0.17.8", + "@playwright/test": "^1.56.1", "@posva/prompts": "^2.4.4", "@shikijs/vitepress-twoslash": "3.15.0", "@tanstack/vue-query": "^5.90.7", diff --git a/playground/src/pages/[name].vue b/playground/src/pages/[name].vue index b97b87a80..04ad124c3 100644 --- a/playground/src/pages/[name].vue +++ b/playground/src/pages/[name].vue @@ -134,7 +134,8 @@ definePage({ + + +{ + "meta": { + "number": 14 + } +} + diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..1b7e95234 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './e2e/hmr', + + fullyParallel: true, + forbidOnly: !!process.env.CI, + // no retries because we have a setup + retries: 0, + workers: process.env.CI ? 1 : undefined, + reporter: [ + // for console logs + ['list'], + // to debug + ['html'], + ], + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + // baseURL: 'http://localhost:3000', + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + // { + // name: 'chromium', + // use: { ...devices['Desktop Chrome'] }, + // }, + // + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + // + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd442d73f..c808b360b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ importers: '@pinia/colada': specifier: ^0.17.8 version: 0.17.8(pinia@3.0.4(typescript@5.9.3)(vue@3.5.24(typescript@5.9.3)))(vue@3.5.24(typescript@5.9.3)) + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 '@posva/prompts': specifier: ^2.4.4 version: 2.4.4 @@ -1644,6 +1647,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.56.1': + resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} @@ -3668,6 +3676,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4954,6 +4967,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.56.1: + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.56.1: + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} + engines: {node: '>=18'} + hasBin: true + please-upgrade-node@3.2.0: resolution: {integrity: sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==} @@ -8066,6 +8089,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.56.1': + dependencies: + playwright: 1.56.1 + '@polka/url@1.0.0-next.28': {} '@poppinss/colors@4.1.5': @@ -10219,6 +10246,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -11890,6 +11920,14 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + playwright-core@1.56.1: {} + + playwright@1.56.1: + dependencies: + playwright-core: 1.56.1 + optionalDependencies: + fsevents: 2.3.2 + please-upgrade-node@3.2.0: dependencies: semver-compare: 1.0.0 diff --git a/src/core/RoutesFolderWatcher.spec.ts b/src/core/RoutesFolderWatcher.spec.ts index 1290fdf35..f47f6d52a 100644 --- a/src/core/RoutesFolderWatcher.spec.ts +++ b/src/core/RoutesFolderWatcher.spec.ts @@ -36,7 +36,7 @@ describe('RoutesFolderWatcher', () => { const rootDir = pathe.join(FIXTURES_ROOT, `test-${testId++}`) const srcDir = pathe.join(rootDir, routesFolderOptions.src) const options = resolveFolderOptions( - resolveOptions({ root: rootDir }), + resolveOptions({ root: rootDir, watch: false }), routesFolderOptions ) diff --git a/src/core/RoutesFolderWatcher.ts b/src/core/RoutesFolderWatcher.ts index dd3568421..239dc5eee 100644 --- a/src/core/RoutesFolderWatcher.ts +++ b/src/core/RoutesFolderWatcher.ts @@ -40,6 +40,9 @@ export class RoutesFolderWatcher { cwd: this.src, ignoreInitial: true, ignorePermissionErrors: true, + // usePolling: !!process.env.CI, + // interval: process.env.CI ? 100 : undefined, + awaitWriteFinish: !!process.env.CI, ignored: (filePath, stats) => { // let folders pass, they are ignored by the glob pattern if (!stats || stats.isDirectory()) { diff --git a/src/core/context.ts b/src/core/context.ts index a3fb8bf2b..2eeb51868 100644 --- a/src/core/context.ts +++ b/src/core/context.ts @@ -189,6 +189,9 @@ export function createRoutesContext(options: ResolvedOptions) { if (triggerExtendRoute) { await options.extendRoute?.(new EditableTreeNode(node)) } + + // TODO: trigger HMR vue-router/auto-routes + server?.updateRoutes() } async function updatePage({ filePath, routePath }: HandlerContext) { @@ -202,11 +205,15 @@ export function createRoutesContext(options: ResolvedOptions) { await options.extendRoute?.(new EditableTreeNode(node)) // no need to manually trigger the update of vue-router/auto-routes because // the change of the vue file will trigger HMR + // server?.invalidate(filePath) + server?.updateRoutes() } function removePage({ filePath, routePath }: HandlerContext) { logger.log(`remove "${routePath}" for "${filePath}"`) routeTree.removeChild(filePath) + // TODO: trigger HMR vue-router/auto-routes + server?.updateRoutes() } function setupParamParserWatcher(watcher: FSWatcher, cwd: string) { diff --git a/vitest.config.ts b/vitest.config.ts index 37ace1e22..5fa0be5df 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,8 +24,12 @@ export default defineConfig({ test: { setupFiles: ['./tests/router-mock.ts'], - include: ['src/**/*.spec.ts'], - exclude: ['src/**/*.test-d.ts'], + include: ['{src,e2e}/**/*.spec.ts'], + exclude: [ + 'src/**/*.test-d.ts', + // exclude playwright e2e tests + 'e2e/hmr', + ], // open: false, coverage: { include: ['src/**/*.ts'],