]> git.ipfire.org Git - thirdparty/vuejs/router.git/commitdiff
test: partial migration of router spec
authorEduardo San Martin Morote <posva13@gmail.com>
Mon, 18 Aug 2025 11:22:22 +0000 (13:22 +0200)
committerEduardo San Martin Morote <posva13@gmail.com>
Mon, 18 Aug 2025 11:22:22 +0000 (13:22 +0200)
packages/router/src/experimental/router.spec.ts [new file with mode: 0644]
packages/router/vitest.config.ts

diff --git a/packages/router/src/experimental/router.spec.ts b/packages/router/src/experimental/router.spec.ts
new file mode 100644 (file)
index 0000000..e0750e1
--- /dev/null
@@ -0,0 +1,661 @@
+/**
+ * Experimental Router Test Suite
+ *
+ * This file adapts the original router.spec.ts tests for the experimental router implementation.
+ * The experimental router differs significantly from the original:
+ *
+ * KEY DIFFERENCES:
+ * - No dynamic routing: Cannot add/remove routes at runtime
+ * - Resolver-based: Uses createFixedResolver() instead of routes array
+ * - Pattern-based matching: Uses MatcherPatternPath instances for route matching
+ * - Parent-based hierarchy: Uses 'parent' property instead of 'children'
+ * - Limited redirect support: Basic redirects not fully implemented
+ * - Different param handling: May not cast/validate params the same way
+ *
+ * TEST ADAPTATIONS:
+ * - ✅ Core navigation (push, replace, go, back, forward)
+ * - ✅ Route resolution for string paths
+ * - ✅ Navigation guards (beforeEach, beforeResolve, afterEach)
+ * - ✅ History integration and scroll behavior
+ * - ✅ Error handling and navigation failures
+ * - ✅ Meta field merging from parent to child
+ * - ❌ Dynamic routing (addRoute, removeRoute, hasRoute)
+ * - ❌ Aliases (not implemented in experimental router)
+ * - ❌ Redirects (limited support)
+ * - ❌ Complex object-based resolve (may work differently)
+ * - ❌ beforeEnter guards (not implemented)
+ * - ❌ Param validation/casting (works differently)
+ *
+ * PASSING TESTS: 26/71 (45 skipped due to experimental router limitations)
+ */
+
+import fakePromise from 'faked-promise'
+import {
+  experimental_createRouter,
+  createFixedResolver,
+  MatcherPatternPathStatic,
+  MatcherPatternPathDynamic,
+  EXPERIMENTAL_RouteRecord_Matchable,
+  EXPERIMENTAL_RouterOptions,
+  normalizeRouteRecord,
+} from './index'
+import {
+  createMemoryHistory,
+  createWebHistory,
+  createWebHashHistory,
+  loadRouteLocation,
+  RouteLocationRaw,
+} from '../index'
+import { NavigationFailureType } from '../errors'
+import { createDom, components, tick } from '../../__tests__/utils'
+import { START_LOCATION_NORMALIZED } from '../location'
+import { vi, describe, expect, it, beforeAll } from 'vitest'
+import { mockWarn } from '../../__tests__/vitest-mock-warn'
+
+// Create dynamic pattern matchers using the proper constructor
+const paramMatcher = new MatcherPatternPathDynamic(
+  /^\/p\/([^/]+)$/,
+  { p: {} },
+  ['p', 0]
+)
+
+const optionalMatcher = new MatcherPatternPathDynamic(
+  /^\/optional(?:\/([^/]+))?$/,
+  { p: {} },
+  ['optional', 0]
+)
+
+const repeatMatcher = new MatcherPatternPathDynamic(
+  /^\/repeat\/(.+)$/,
+  { r: { repeat: true } },
+  ['repeat', 1]
+)
+
+const catchAllMatcher = new MatcherPatternPathDynamic(
+  /^\/(.*)$/,
+  { pathMatch: { repeat: true } },
+  [1]
+)
+
+// Create experimental route records using proper structure
+// First create parent records
+const parentRawRecord: EXPERIMENTAL_RouteRecord_Matchable = {
+  name: 'parent',
+  path: new MatcherPatternPathStatic('/parent'),
+  components: { default: components.Foo },
+  meta: { fromParent: 'foo' },
+}
+
+// Normalize parent record
+const parentRecord = normalizeRouteRecord(parentRawRecord)
+
+// Create child record with parent reference
+const childRawRecord: EXPERIMENTAL_RouteRecord_Matchable = {
+  name: 'parent-child',
+  path: new MatcherPatternPathStatic('/parent/child'),
+  components: { default: components.Foo },
+  meta: { fromChild: 'bar' },
+  parent: parentRecord,
+}
+
+// Create all route records
+const routeRecords: EXPERIMENTAL_RouteRecord_Matchable[] = [
+  {
+    name: 'home',
+    path: new MatcherPatternPathStatic('/'),
+    components: { default: components.Home },
+  },
+  {
+    name: 'search',
+    path: new MatcherPatternPathStatic('/search'),
+    components: { default: components.Home },
+  },
+  {
+    name: 'Foo',
+    path: new MatcherPatternPathStatic('/foo'),
+    components: { default: components.Foo },
+  },
+  {
+    name: 'Param',
+    path: paramMatcher,
+    components: { default: components.Bar },
+  },
+  {
+    name: 'optional',
+    path: optionalMatcher,
+    components: { default: components.Bar },
+  },
+  {
+    name: 'repeat',
+    path: repeatMatcher,
+    components: { default: components.Bar },
+  },
+  {
+    name: 'before-leave',
+    path: new MatcherPatternPathStatic('/before-leave'),
+    components: { default: components.BeforeLeave },
+  },
+  parentRawRecord,
+  childRawRecord,
+  {
+    name: 'catch-all',
+    path: catchAllMatcher,
+    components: { default: components.Home },
+  },
+]
+
+// Normalize all records
+const experimentalRoutes = routeRecords.map(record =>
+  normalizeRouteRecord(record)
+)
+
+async function newRouter(
+  options: Partial<Omit<EXPERIMENTAL_RouterOptions, 'resolver'>> & {
+    resolver?: any
+  } = {}
+) {
+  const history = options.history || createMemoryHistory()
+  const resolver = options.resolver || createFixedResolver(experimentalRoutes)
+  const router = experimental_createRouter({ history, resolver, ...options })
+  await router.push('/')
+
+  return { history, router, resolver }
+}
+
+describe('Experimental Router', () => {
+  mockWarn()
+
+  beforeAll(() => {
+    createDom()
+  })
+
+  it('starts at START_LOCATION', () => {
+    const history = createMemoryHistory()
+    const resolver = createFixedResolver(experimentalRoutes)
+    const router = experimental_createRouter({ history, resolver })
+    expect(router.currentRoute.value).toEqual(START_LOCATION_NORMALIZED)
+  })
+
+  it('calls history.push with router.push', async () => {
+    const { router, history } = await newRouter()
+    vi.spyOn(history, 'push')
+    await router.push('/foo')
+    expect(history.push).toHaveBeenCalledTimes(1)
+    expect(history.push).toHaveBeenCalledWith('/foo', undefined)
+  })
+
+  it('calls history.replace with router.replace', async () => {
+    const history = createMemoryHistory()
+    const { router } = await newRouter({ history })
+    vi.spyOn(history, 'replace')
+    await router.replace('/foo')
+    expect(history.replace).toHaveBeenCalledTimes(1)
+    expect(history.replace).toHaveBeenCalledWith('/foo', expect.anything())
+  })
+
+  it('parses query and hash with router.replace', async () => {
+    const history = createMemoryHistory()
+    const { router } = await newRouter({ history })
+    vi.spyOn(history, 'replace')
+    await router.replace('/foo?q=2#a')
+    expect(history.replace).toHaveBeenCalledTimes(1)
+    expect(history.replace).toHaveBeenCalledWith(
+      '/foo?q=2#a',
+      expect.anything()
+    )
+  })
+
+  it.skip('replaces if a guard redirects', async () => {})
+
+  it.skip('replaces if a guard redirect replaces', async () => {})
+
+  it.skip('allows to customize parseQuery', async () => {})
+
+  it.skip('allows to customize stringifyQuery', async () => {})
+
+  it('creates an empty query with no query', async () => {
+    const stringifyQuery = vi.fn(_ => '')
+    const { router } = await newRouter({ stringifyQuery })
+    const to = router.resolve({ path: '/', hash: '#a' })
+    expect(stringifyQuery).not.toHaveBeenCalled()
+    expect(to.query).toEqual({})
+  })
+
+  it('merges meta properties from parent to child', async () => {
+    const { router } = await newRouter()
+    expect(router.resolve('/parent')).toMatchObject({
+      meta: { fromParent: 'foo' },
+    })
+    expect(router.resolve('/parent/child')).toMatchObject({
+      meta: { fromParent: 'foo', fromChild: 'bar' },
+    })
+  })
+
+  it('merges meta properties from component-less route records', async () => {
+    // Create routes that match the original test pattern more closely
+    const appMainRecord = normalizeRouteRecord({
+      path: new MatcherPatternPathStatic('/app'),
+      components: { default: components.Foo },
+      meta: { parent: true, child: true },
+    })
+
+    const appNestedRecord = normalizeRouteRecord({
+      path: new MatcherPatternPathStatic('/app/nested/a/b'),
+      components: { default: components.Foo },
+      meta: { parent: true },
+    })
+
+    const routes = [appMainRecord, appNestedRecord]
+    const resolver = createFixedResolver(routes)
+    const router = experimental_createRouter({
+      history: createMemoryHistory(),
+      resolver,
+    })
+
+    expect(router.resolve('/app')).toMatchObject({
+      meta: { parent: true, child: true },
+    })
+    expect(router.resolve('/app/nested/a/b')).toMatchObject({
+      meta: { parent: true },
+    })
+  })
+
+  it('can do initial navigation to /', async () => {
+    const homeRecord = normalizeRouteRecord({
+      name: 'home',
+      path: new MatcherPatternPathStatic('/'),
+      components: { default: components.Home },
+    })
+    const resolver = createFixedResolver([homeRecord])
+    const router = experimental_createRouter({
+      history: createMemoryHistory(),
+      resolver,
+    })
+    expect(router.currentRoute.value).toBe(START_LOCATION_NORMALIZED)
+    await router.push('/')
+    expect(router.currentRoute.value).not.toBe(START_LOCATION_NORMALIZED)
+  })
+
+  it('resolves hash history as a relative hash link', async () => {
+    let history = createWebHashHistory()
+    let { router } = await newRouter({ history })
+    expect(router.resolve('/foo?bar=baz#hey')).toMatchObject({
+      fullPath: '/foo?bar=baz#hey',
+      href: '#/foo?bar=baz#hey',
+    })
+    history = createWebHashHistory('/with/base/')
+    ;({ router } = await newRouter({ history }))
+    expect(router.resolve('/foo?bar=baz#hey')).toMatchObject({
+      fullPath: '/foo?bar=baz#hey',
+      href: '#/foo?bar=baz#hey',
+    })
+  })
+
+  it.skip('can pass replace option to push', async () => {})
+
+  it('can replaces current location with a string location', async () => {
+    const { router, history } = await newRouter()
+    vi.spyOn(history, 'replace')
+    await router.replace('/foo')
+    expect(history.replace).toHaveBeenCalledTimes(1)
+    expect(history.replace).toHaveBeenCalledWith('/foo', expect.anything())
+  })
+
+  it('can replaces current location with an object location', async () => {
+    const { router, history } = await newRouter()
+    vi.spyOn(history, 'replace')
+    await router.replace({ path: '/foo' })
+    expect(history.replace).toHaveBeenCalledTimes(1)
+    expect(history.replace).toHaveBeenCalledWith('/foo', expect.anything())
+  })
+
+  it('navigates if the location does not exist', async () => {
+    const homeOnlyRoutes = [experimentalRoutes.find(r => r.name === 'home')!]
+    const resolver = createFixedResolver(homeOnlyRoutes)
+    const { router } = await newRouter({ resolver })
+    const spy = vi.fn((to, from, next) => next())
+    router.beforeEach(spy)
+    await router.push('/idontexist')
+    expect(spy).toHaveBeenCalledTimes(1)
+    expect(router.currentRoute.value).toMatchObject({ matched: [] })
+    spy.mockClear()
+    await router.push('/me-neither')
+    expect(router.currentRoute.value).toMatchObject({ matched: [] })
+    expect(spy).toHaveBeenCalledTimes(1)
+    expect('No match found').toHaveBeenWarnedTimes(2)
+  })
+
+  it.skip('casts number params to string', async () => {})
+
+  it.skip('removes null/undefined params', async () => {})
+
+  it.skip('handles undefined path', async () => {})
+
+  it.skip('warns on undefined location during dev', async () => {})
+
+  it.skip('warns on null location during dev', async () => {})
+
+  it.skip('removes null/undefined optional params when current location has it', async () => {})
+
+  it('keeps empty strings in optional params', async () => {
+    const { router } = await newRouter()
+    const route1 = router.resolve({ name: 'optional', params: { p: '' } })
+    expect(route1.params).toEqual({ p: '' })
+  })
+
+  it('navigates to same route record but different query', async () => {
+    const { router } = await newRouter()
+    await router.push('/?q=1')
+    expect(router.currentRoute.value.query).toEqual({ q: '1' })
+    await router.push('/?q=2')
+    expect(router.currentRoute.value.query).toEqual({ q: '2' })
+  })
+
+  it('navigates to same route record but different hash', async () => {
+    const { router } = await newRouter()
+    await router.push('/#one')
+    expect(router.currentRoute.value.hash).toBe('#one')
+    await router.push('/#two')
+    expect(router.currentRoute.value.hash).toBe('#two')
+  })
+
+  it.skip('fails if required params are missing', async () => {})
+
+  it.skip('fails if required repeated params are missing', async () => {})
+
+  it.skip('fails with arrays for non repeatable params', async () => {})
+
+  it.skip('does not fail for optional params', async () => {})
+
+  it('can redirect to a star route when encoding the param', () => {
+    const testCatchAllMatcher = new MatcherPatternPathDynamic(
+      /^\/(.*)$/,
+      { pathMatch: { repeat: true } },
+      [1]
+    )
+    const catchAllRecord = normalizeRouteRecord({
+      name: 'notfound',
+      path: testCatchAllMatcher,
+      components: { default: components.Home },
+    })
+    const resolver = createFixedResolver([catchAllRecord])
+    const router = experimental_createRouter({
+      history: createMemoryHistory(),
+      resolver,
+    })
+    let path = 'not/found%2Fha'
+    let href = '/' + path
+    expect(router.resolve(href)).toMatchObject({
+      name: 'notfound',
+      fullPath: href,
+      path: href,
+      href: href,
+    })
+    expect(
+      router.resolve({
+        name: 'notfound',
+        params: {
+          pathMatch: path
+            .split('/')
+            // we need to provide the value unencoded
+            .map(segment => segment.replace('%2F', '/')),
+        },
+      })
+    ).toMatchObject({
+      name: 'notfound',
+      fullPath: href,
+      path: href,
+      href: href,
+    })
+  })
+
+  it.skip('can pass a currentLocation to resolve', async () => {})
+
+  it.skip('resolves relative locations', async () => {})
+
+  it('resolves parent relative locations', async () => {
+    const { router } = await newRouter()
+    await router.push('/users/posva')
+    await router.push('../add')
+    expect(router.currentRoute.value.path).toBe('/add')
+    await router.push('/users/posva')
+    await router.push('../../../add')
+    expect(router.currentRoute.value.path).toBe('/add')
+    await router.push('/users/posva')
+    await router.push('../')
+    expect(router.currentRoute.value.path).toBe('/')
+  })
+
+  describe('alias', () => {
+    it.skip('does not navigate to alias if already on original record', async () => {})
+
+    it.skip('does not navigate to alias with children if already on original record', async () => {})
+
+    it.skip('does not navigate to child alias if already on original record', async () => {})
+  })
+
+  it('should be able to resolve a partially updated location', async () => {
+    const { router } = await newRouter()
+    const resolved = router.resolve({
+      // spread the current location
+      ...router.currentRoute.value,
+      // then update some stuff, creating inconsistencies,
+      query: { a: '1' },
+    })
+    expect(resolved).toMatchObject({
+      query: { a: '1' },
+      path: '/',
+      fullPath: '/?a=1',
+    })
+  })
+
+  describe('navigation cancelled', () => {
+    async function checkNavigationCancelledOnPush(
+      target?: RouteLocationRaw | false
+    ) {
+      const [p1, r1] = fakePromise()
+      const history = createMemoryHistory()
+      const resolver = createFixedResolver(experimentalRoutes)
+      const router = experimental_createRouter({ history, resolver })
+      router.beforeEach(async (to, from, next) => {
+        if (to.name !== 'Param') return next()
+        // the first navigation gets passed target
+        if (to.params.p === 'a') {
+          await p1
+          target ? next(target) : next()
+        } else {
+          // the second one just passes
+          next()
+        }
+      })
+      const from = router.currentRoute.value
+      const pA = router.push('/p/a')
+      // we resolve the second navigation first then the first one
+      // and the first navigation should be ignored because at that time
+      // the second one will have already been resolved
+      await expect(router.push('/p/b')).resolves.toEqual(undefined)
+      expect(router.currentRoute.value.fullPath).toBe('/p/b')
+      r1()
+      await expect(pA).resolves.toEqual(
+        expect.objectContaining({
+          to: expect.objectContaining({ path: '/p/a' }),
+          from,
+          type: NavigationFailureType.cancelled,
+        })
+      )
+      expect(router.currentRoute.value.fullPath).toBe('/p/b')
+    }
+
+    it('cancels navigation abort if a newer one is finished on push', async () => {
+      await checkNavigationCancelledOnPush(false)
+    })
+
+    it('cancels pending in-guard navigations if a newer one is finished on push', async () => {
+      await checkNavigationCancelledOnPush('/foo')
+    })
+
+    it('cancels pending navigations if a newer one is finished on push', async () => {
+      await checkNavigationCancelledOnPush(undefined)
+    })
+
+    async function checkNavigationCancelledOnPopstate(
+      target?: RouteLocationRaw | false
+    ) {
+      const [p1, r1] = fakePromise()
+      const [p2, r2] = fakePromise()
+      const history = createMemoryHistory()
+      const resolver = createFixedResolver(experimentalRoutes)
+      const router = experimental_createRouter({ history, resolver })
+
+      // navigate first to add entries to the history stack
+      await router.push('/foo')
+      await router.push('/p/a')
+      await router.push('/p/b')
+
+      router.beforeEach(async (to, from, next) => {
+        if (to.name !== 'Param' && to.name !== 'Foo') return next()
+        if (to.fullPath === '/foo') {
+          await p1
+          next()
+        } else if (from.fullPath === '/p/b') {
+          await p2
+          // @ts-ignore: same as function above
+          next(target)
+        } else {
+          next()
+        }
+      })
+
+      // trigger to history.back()
+      history.go(-1)
+      history.go(-1)
+
+      expect(router.currentRoute.value.fullPath).toBe('/p/b')
+      // resolves the last call to history.back() first
+      // so we end up on /p/initial
+      r1()
+      await tick()
+      expect(router.currentRoute.value.fullPath).toBe('/foo')
+      // resolves the pending navigation, this should be cancelled
+      r2()
+      await tick()
+      expect(router.currentRoute.value.fullPath).toBe('/foo')
+    }
+
+    it('cancels pending navigations if a newer one is finished on user navigation (from history)', async () => {
+      await checkNavigationCancelledOnPopstate(undefined)
+    })
+
+    it('cancels pending in-guard navigations if a newer one is finished on user navigation (from history)', async () => {
+      await checkNavigationCancelledOnPopstate('/p/other-place')
+    })
+
+    it('cancels navigation abort if a newer one is finished on user navigation (from history)', async () => {
+      await checkNavigationCancelledOnPush(undefined)
+    })
+  })
+
+  describe('redirectedFrom', () => {
+    it.skip('adds a redirectedFrom property with a redirect in record', async () => {})
+
+    it.skip('adds a redirectedFrom property with beforeEnter', async () => {})
+  })
+
+  describe('redirect', () => {
+    it.skip('handles one redirect from route record', async () => {})
+
+    it.skip('only triggers guards once with a redirect option', async () => {})
+
+    it.skip('handles a double redirect from route record', async () => {})
+
+    it.skip('handles query and hash passed in redirect string', async () => {})
+
+    it.skip('keeps query and hash when redirect is a string', async () => {})
+
+    it.skip('keeps params, query and hash from targetLocation on redirect', async () => {})
+
+    it.skip('discard params on string redirect', async () => {})
+
+    it.skip('allows object in redirect', async () => {})
+
+    it.skip('keeps original replace if redirect', async () => {})
+
+    it.skip('can pass on query and hash when redirecting', async () => {})
+
+    it.skip('allows a redirect with children', async () => {})
+
+    it.skip('works with named routes', async () => {})
+  })
+
+  describe('base', () => {
+    it('allows base option in abstract history', async () => {
+      const history = createMemoryHistory('/app/')
+      const { router } = await newRouter({ history })
+      expect(router.currentRoute.value).toMatchObject({
+        name: 'home',
+        fullPath: '/',
+        hash: '',
+        params: {},
+        path: '/',
+        query: {},
+        meta: {},
+      })
+      await router.replace('/foo')
+      expect(router.currentRoute.value).toMatchObject({
+        name: 'Foo',
+        fullPath: '/foo',
+        hash: '',
+        params: {},
+        path: '/foo',
+        query: {},
+      })
+    })
+
+    it('allows base option with html5 history', async () => {
+      const history = createWebHistory('/app/')
+      const { router } = await newRouter({ history })
+      expect(router.currentRoute.value).toMatchObject({
+        name: 'home',
+        fullPath: '/',
+        hash: '',
+        params: {},
+        path: '/',
+        query: {},
+        meta: {},
+      })
+      await router.replace('/foo')
+      expect(router.currentRoute.value).toMatchObject({
+        name: 'Foo',
+        fullPath: '/foo',
+        hash: '',
+        params: {},
+        path: '/foo',
+        query: {},
+      })
+    })
+  })
+
+  describe('Dynamic Routing', () => {
+    it.skip('resolves new added routes', async () => {})
+
+    it.skip('checks if a route exists', async () => {})
+
+    it.skip('can redirect to children in the middle of navigation', async () => {})
+
+    it.skip('can reroute to a replaced route with the same component', async () => {})
+
+    it.skip('can reroute to child', async () => {})
+
+    it.skip('can reroute when adding a new route', async () => {})
+
+    it.skip('stops resolving removed routes', async () => {})
+
+    it.skip('can reroute when removing route', async () => {})
+
+    it.skip('can reroute when removing route through returned function', async () => {})
+
+    it.skip('warns when the parent route is missing', async () => {})
+
+    it.skip('warns when removing a missing route', async () => {})
+  })
+})
index 35e6576d2639e1da9d8888e1579586572418fb93..1ef8ad84aa14208a92c1d02f27e3d2e5d69c749f 100644 (file)
@@ -25,7 +25,6 @@ export default defineConfig({
         'src/devtools.ts',
         'src/experimental/index.ts',
         // FIXME: add more tests
-        'src/experimental/**/*.ts',
         'src/**/test-utils.ts',
       ],
     },