From c2b6144ec5b5c0158c1427be1b58afe61d194d21 Mon Sep 17 00:00:00 2001 From: Vlad Rindevich Date: Tue, 1 Oct 2024 14:29:01 +0300 Subject: [PATCH] refactor: finalize Resolver --- src/resolver/generateUrls.ts | 40 +++---- src/resolver/matchRoute.ts | 25 +++-- src/resolver/resolveRoute.ts | 4 +- src/resolver/resolver.ts | 112 +++++++++++--------- src/resolver/types.d.ts | 62 ++++++----- src/resolver/utils.ts | 78 ++++++-------- test/resolver/generateUrls.spec.ts | 18 ++-- test/resolver/matchRoute.spec.ts | 112 ++++++++++---------- test/resolver/resolver.spec.ts | 164 ++++++++++------------------- tsconfig.json | 4 +- 10 files changed, 287 insertions(+), 332 deletions(-) diff --git a/src/resolver/generateUrls.ts b/src/resolver/generateUrls.ts index 6dac306d..e2bf5e83 100644 --- a/src/resolver/generateUrls.ts +++ b/src/resolver/generateUrls.ts @@ -10,16 +10,16 @@ import { parse, type ParseOptions, type Token, tokensToFunction, type TokensToFunctionOptions } from 'path-to-regexp'; import type { EmptyObject, Writable } from 'type-fest'; import Resolver from './resolver.js'; -import type { AnyObject, ChildrenCallback, Route, Params } from './types.js'; +import type { AnyObject, ChildrenCallback, IndexedParams, Params, Route } from './types.js'; import { getRoutePath, isString } from './utils.js'; export type UrlParams = Readonly | number | string>>; -function cacheRoutes( - routesByName: Map>>, - route: Writable>, - routes?: ChildrenCallback | ReadonlyArray>, - cacheKeyProvider?: (route: Route) => string, +function cacheRoutes( + routesByName: Map>>, + route: Writable>, + routes?: ReadonlyArray> | ChildrenCallback, + cacheKeyProvider?: (route: Route) => string, ): void { const name = route.name ?? cacheKeyProvider?.(route); if (name) { @@ -30,7 +30,7 @@ function cacheRoutes( } } - if (Array.isArray>>>(routes)) { + if (Array.isArray>>>(routes)) { for (const childRoute of routes) { childRoute.parent = route; cacheRoutes(routesByName, childRoute, childRoute.__children ?? childRoute.children, cacheKeyProvider); @@ -38,10 +38,10 @@ function cacheRoutes( } } -function getRouteByName( - routesByName: Map>>, +function getRouteByName( + routesByName: Map>>, routeName: string, -): Route | undefined { +): Route | undefined { const routes = routesByName.get(routeName); if (routes) { @@ -57,7 +57,7 @@ function getRouteByName( export type StringifyQueryParams = (params: UrlParams) => string; -export type GenerateUrlOptions = ParseOptions & +export type GenerateUrlOptions = ParseOptions & Readonly<{ /** * Add a query string to generated url based on unknown route params. @@ -67,7 +67,7 @@ export type GenerateUrlOptions = ParseOptions & * Generates a unique route name based on all parent routes with the specified separator. */ uniqueRouteNameSep?: string; - cacheKeyProvider?(route: Route): string | undefined; + cacheKeyProvider?(route: Route): string; }> & TokensToFunctionOptions; @@ -78,24 +78,24 @@ type RouteCacheRecord = Readonly<{ export type UrlGenerator = (routeName: string, params?: Params) => string; -function generateUrls( - resolver: Resolver, - options: GenerateUrlOptions = {}, +function generateUrls( + resolver: Resolver, + options: GenerateUrlOptions = {}, ): UrlGenerator { if (!(resolver instanceof Resolver)) { throw new TypeError('An instance of Resolver is expected'); } const cache = new Map(); - const routesByName = new Map>>(); + const routesByName = new Map>>(); return (routeName, params) => { let route = getRouteByName(routesByName, routeName); if (!route) { routesByName.clear(); // clear cache - cacheRoutes( + cacheRoutes( routesByName, - resolver.root as Writable>, + resolver.root as Writable>, resolver.root.__children, options.cacheKeyProvider, ); @@ -133,13 +133,13 @@ function generateUrls( let url = toPath(params) || '/'; if (options.stringifyQueryParams && params) { - const queryParams: Record = {}; + const queryParams: Writable = {}; for (const [key, value] of Object.entries(params)) { if (!(key in cached.keys) && value) { queryParams[key] = value; } } - const query = options.stringifyQueryParams(queryParams); + const query = options.stringifyQueryParams(queryParams as UrlParams); if (query) { url += query.startsWith('?') ? query : `?${query}`; } diff --git a/src/resolver/matchRoute.ts b/src/resolver/matchRoute.ts index 89be899c..b1b5d386 100644 --- a/src/resolver/matchRoute.ts +++ b/src/resolver/matchRoute.ts @@ -8,17 +8,20 @@ */ import type { Key } from 'path-to-regexp'; -import type { Writable } from 'type-fest'; import matchPath, { type Match } from './matchPath.js'; import type { AnyObject, IndexedParams, Route } from './types.js'; import { getRoutePath, unwrapChildren } from './utils.js'; -export type MatchWithRoute = Match & +export type MatchWithRoute = Match & Readonly<{ - route: Route; + route: Route; }>; -type RouteMatchIterator = Iterator, undefined, Route | undefined>; +type RouteMatchIterator = Iterator< + MatchWithRoute, + undefined, + Route | undefined +>; /** * Traverses the routes tree and matches its nodes to the given pathname from @@ -66,15 +69,15 @@ type RouteMatchIterator = Iterator, * Prefix matching can be enabled also by `children: true`. */ // eslint-disable-next-line @typescript-eslint/max-params -function matchRoute( - route: Route, +function matchRoute( + route: Route, pathname: string, ignoreLeadingSlash?: boolean, parentKeys?: readonly Key[], parentParams?: IndexedParams, -): Iterator, undefined, Route | undefined> { +): Iterator, undefined, Route | undefined> { let match: Match | null; - let childMatches: RouteMatchIterator | null; + let childMatches: RouteMatchIterator | null; let childIndex = 0; let routepath = getRoutePath(route); if (routepath.startsWith('/')) { @@ -86,12 +89,12 @@ function matchRoute( } return { - next(routeToSkip?: Route): IteratorResult, undefined> { + next(routeToSkip?: Route): IteratorResult, undefined> { if (route === routeToSkip) { return { done: true, value: undefined }; } - (route as Writable>).__children ??= unwrapChildren(route.children); + route.__children ??= unwrapChildren(route.children); const children = route.__children ?? []; const exact = !route.__children && !route.children; @@ -114,7 +117,7 @@ function matchRoute( while (childIndex < children.length) { if (!childMatches) { const childRoute = children[childIndex]; - (childRoute as Writable>).parent = route; + childRoute.parent = route; let matchedLength = match.path.length; if (matchedLength > 0 && pathname.charAt(matchedLength) === '/') { diff --git a/src/resolver/resolveRoute.ts b/src/resolver/resolveRoute.ts index 1e87e7fb..42d9f38c 100644 --- a/src/resolver/resolveRoute.ts +++ b/src/resolver/resolveRoute.ts @@ -7,13 +7,13 @@ * LICENSE.txt file in the root directory of this source tree. */ import type { EmptyObject } from 'type-fest'; -import type { ActionResult, AnyObject, RouteContext } from './types.js'; +import type { ActionResult, AnyObject, MaybePromise, RouteContext } from './types.js'; import { isFunction } from './utils.js'; // eslint-disable-next-line @typescript-eslint/no-invalid-void-type export default function resolveRoute( context: RouteContext, -): ActionResult { +): MaybePromise> { if (isFunction(context.route?.action)) { return context.route.action(context); } diff --git a/src/resolver/resolver.ts b/src/resolver/resolver.ts index 2b5ef137..16269a56 100644 --- a/src/resolver/resolver.ts +++ b/src/resolver/resolver.ts @@ -9,18 +9,21 @@ import type { EmptyObject, Writable } from 'type-fest'; import matchRoute, { type MatchWithRoute } from './matchRoute.js'; import defaultResolveRoute from './resolveRoute.js'; -import type { ActionResult, AnyObject, Match, Route, RouteContext } from './types.js'; +import type { ActionResult, AnyObject, BasicRoutePart, Match, MaybePromise, Route, RouteContext } from './types.js'; import { - ensureRoutes, getNotFoundError, getRoutePath, isString, NotFoundError, notFoundResult, toArray, + type NotFoundResult, } from './utils.js'; -function isDescendantRoute(route?: Route, maybeParent?: Route) { +function isDescendantRoute( + route?: Route, + maybeParent?: Route, +) { let _route = route; while (_route) { _route = _route.parent; @@ -31,7 +34,7 @@ function isDescendantRoute(route?: Route, maybePar return false; } -function isRouteContext(value: ActionResult | RouteContext): value is RouteContext { +function isRouteContext(value: unknown): value is RouteContext { return !!value && typeof value === 'object' && 'result' in value; } @@ -39,11 +42,11 @@ export interface ResolutionErrorOptions extends ErrorOptions { code?: number; } -export class ResolutionError extends Error { +export class ResolutionError extends Error { readonly code?: number; - readonly context: RouteContext; + readonly context: RouteContext; - constructor(context: RouteContext, options?: ResolutionErrorOptions) { + constructor(context: RouteContext, options?: ResolutionErrorOptions) { let errorMessage = `Path '${context.pathname}' is not properly resolved due to an error.`; const routePath = getRoutePath(context.route); if (routePath) { @@ -59,7 +62,10 @@ export class ResolutionError extends Error } } -function updateChainForRoute(context: RouteContext, match: Match) { +function updateChainForRoute( + context: RouteContext, + match: Match, +) { const { path, route } = match; if (route && !route.__synthetic) { @@ -79,18 +85,20 @@ function updateChainForRoute(context: RouteContext export type ErrorHandlerCallback = (error: unknown) => T; -export type ResolveContext = Readonly<{ pathname: string }>; +export type ResolveContext = Readonly<{ pathname: string }> & C; -export type ResolveRouteCallback = (context: RouteContext) => ActionResult; +export type ResolveRouteCallback = ( + context: RouteContext, +) => MaybePromise>; -export type ResolverOptions = Readonly<{ +export type ResolverOptions = Readonly<{ baseUrl?: string; - context?: RouteContext; + context?: RouteContext; errorHandler?: ErrorHandlerCallback; - resolveRoute?: ResolveRouteCallback; + resolveRoute?: ResolveRouteCallback; }>; -export default class Resolver { +export default class Resolver { /** * The base URL for all routes in the router instance. By default, * if the base element exists in the ``, vaadin-router @@ -98,15 +106,15 @@ export default class Resolver { * `document.URL`. */ readonly baseUrl: string; - #context: Writable>; + #context: Writable>; readonly errorHandler?: ErrorHandlerCallback; - readonly resolveRoute: ResolveRouteCallback; - readonly #root: Writable>; + readonly resolveRoute: ResolveRouteCallback; + readonly #root: BasicRoutePart; - constructor(routes: ReadonlyArray> | Route, options?: ResolverOptions); + constructor(routes: ReadonlyArray> | Route, options?: ResolverOptions); constructor( - routes: ReadonlyArray> | Route, - { baseUrl = '', context, errorHandler, resolveRoute = defaultResolveRoute }: ResolverOptions = {}, + routes: ReadonlyArray> | Route, + { baseUrl = '', context, errorHandler, resolveRoute = defaultResolveRoute }: ResolverOptions = {}, ) { if (Object(routes) !== routes) { throw new TypeError('Invalid routes'); @@ -115,14 +123,20 @@ export default class Resolver { this.baseUrl = baseUrl; this.errorHandler = errorHandler; this.resolveRoute = resolveRoute; - this.#root = Array.isArray(routes) - ? { - __children: routes, - __synthetic: true, - action: () => undefined, - path: '', - } - : { ...routes, parent: undefined }; + + if (Array.isArray(routes)) { + // @FIXME: We should have a route array instead of a single route object + // to avoid type clash because of a missing `R` part of a route. + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + this.#root = { + __children: routes, + __synthetic: true, + action: () => undefined, + path: '', + }; + } else { + this.#root = { ...routes, parent: undefined }; + } this.#context = { ...context!, @@ -134,17 +148,17 @@ export default class Resolver { params: {}, pathname: '', resolver: this, - route: this.#root, + route: this.#root as Route, search: '', chain: [], }; } - get root(): Route { - return this.#root; + get root(): Route { + return this.#root as Route; } - get context(): RouteContext { + get context(): RouteContext { return this.#context; } @@ -166,7 +180,7 @@ export default class Resolver { * * @public */ - getRoutes(): ReadonlyArray> { + getRoutes(): ReadonlyArray> { return [...(this.#root.__children ?? [])]; } @@ -195,29 +209,29 @@ export default class Resolver { * resolve or a context object with a `pathname` property and other * properties to pass to the route resolver functions. */ - async resolve(pathnameOrContext: ResolveContext | string): Promise> { + async resolve(pathnameOrContext: ResolveContext | string): Promise>> { const self = this; - const context: Writable> = { + const context: Writable> = { ...this.#context, ...(isString(pathnameOrContext) ? { pathname: pathnameOrContext } : pathnameOrContext), // eslint-disable-next-line @typescript-eslint/no-use-before-define next, }; const match = matchRoute( - this.#root, + this.#root as Route, this.__normalizePathname(context.pathname) ?? context.pathname, !!this.baseUrl, ); const resolve = this.resolveRoute; - let matches: IteratorResult, undefined> | null = null; - let nextMatches: IteratorResult, undefined> | null = null; + let matches: IteratorResult, undefined> | null = null; + let nextMatches: IteratorResult, undefined> | null = null; let currentContext = context; async function next( resume: boolean = false, - parent: Route | undefined = matches?.value?.route, - prevResult?: T | null, - ): Promise> { + parent: Route | undefined = matches?.value?.route, + prevResult?: T | NotFoundResult | null, + ): Promise>> { const routeToSkip = prevResult === null ? matches?.value?.route : undefined; matches = nextMatches ?? match.next(routeToSkip); nextMatches = null; @@ -244,14 +258,16 @@ export default class Resolver { const resolution = await resolve(currentContext); if (resolution !== null && resolution !== undefined && resolution !== notFoundResult) { - currentContext.result = isRouteContext(resolution) ? (resolution as RouteContext).result : resolution; + currentContext.result = isRouteContext(resolution) ? (resolution as RouteContext).result : resolution; self.#context = currentContext; return currentContext; } return await next(resume, parent, resolution); } - return await next(true, this.#root).catch((error: unknown) => { + try { + return await next(true, this.#root as Route); + } catch (error: unknown) { const _error = error instanceof NotFoundError ? error @@ -262,7 +278,7 @@ export default class Resolver { return currentContext; } throw error; - }); + } } /** @@ -271,8 +287,8 @@ export default class Resolver { * @param routes - a single route or an array of those * (the array is shallow copied) */ - setRoutes(routes: ReadonlyArray> | Route): void { - ensureRoutes(routes); + setRoutes(routes: ReadonlyArray> | Route): void { + // ensureRoutes(routes); this.#root.__children = [...toArray(routes)]; } @@ -308,8 +324,8 @@ export default class Resolver { * @param routes - a single route or an array of those * (the array is shallow copied) */ - protected addRoutes(routes: ReadonlyArray> | Route): ReadonlyArray> { - ensureRoutes(routes); + protected addRoutes(routes: ReadonlyArray> | Route): ReadonlyArray> { + // ensureRoutes(routes); this.#root.__children = [...(this.#root.__children ?? []), ...toArray(routes)]; return this.getRoutes(); } diff --git a/src/resolver/types.d.ts b/src/resolver/types.d.ts index 95bf5276..77bf6a49 100644 --- a/src/resolver/types.d.ts +++ b/src/resolver/types.d.ts @@ -1,43 +1,58 @@ +import type { EmptyObject } from 'type-fest'; +import type Resolver from './resolver.js'; +import type { NotFoundResult } from './utils.js'; + /* ======================== * Common Types * ======================== */ - -export type AnyObject = Record; +export type AnyObject = Readonly>; export type MaybePromise = Promise | T; -export type ActionResult = T | null | undefined | MaybePromise; +export type ActionResult = T | NotFoundResult | null | undefined; /* ======================== * Resolver-Specific Types * ======================== */ -export type Route = Readonly<{ - __children?: ReadonlyArray>; - __synthetic?: true; +export type ChildrenCallback = ( + context: RouteContext, +) => MaybePromise>>; + +export type BasicRoutePart = Readonly<{ + children?: ReadonlyArray> | ChildrenCallback; name?: string; path?: string | readonly string[]; + action?(this: Route, context: RouteContext): MaybePromise>; +}> & { + __children?: ReadonlyArray>; + __synthetic?: true; + parent?: Route; fullPath?: string; - parent?: Route; - children?: ReadonlyArray> | ChildrenCallback; - action?(this: Route, context: RouteContext): ActionResult; -}>; +}; + +export type Route = BasicRoutePart< + T, + R, + C +> & + R; -export type Match = Readonly<{ +export type Match = Readonly<{ path: string; - route?: Route; + route?: Route; }>; -export type ChainItem = Readonly<{ +export type ChainItem = Readonly<{ element?: Element; path: string; - route?: Route; + route?: Route; }>; export type ResolutionOptions = Readonly<{ pathname: string; }>; -export type RouteContext = ResolutionOptions & +export type RouteContext = ResolutionOptions & Readonly<{ __divergedChainIndex?: number; __redirectCount?: number; @@ -45,20 +60,19 @@ export type RouteContext = ResolutionOptio __skipAttach?: boolean; hash?: string; search?: string; - chain?: Array>; + chain?: Array>; params: IndexedParams; - resolver?: Resolver; + resolver?: Resolver; redirectFrom?: string; result?: T; - route?: Route; - search?: string; - next?(resume?: boolean, parent?: Route, prevResult?: T | null): Promise>; + route?: Route; + next?( + resume?: boolean, + parent?: Route, + prevResult?: T | null, + ): Promise>>; }>; -export type ChildrenCallback = ( - context: RouteChildrenContext, -) => MaybePromise>>; - export type PrimitiveParamValue = string | number | null; export type ParamValue = PrimitiveParamValue | readonly PrimitiveParamValue[]; diff --git a/src/resolver/utils.ts b/src/resolver/utils.ts index d27cec46..fb509684 100644 --- a/src/resolver/utils.ts +++ b/src/resolver/utils.ts @@ -1,4 +1,4 @@ -import type { AnyObject, Route, RouteContext } from './types.js'; +import type { AnyObject, ChildrenCallback, Route, RouteContext, RouteMeta } from './types.js'; export function isObject(o: unknown): o is object { // guard against null passing the typeof check @@ -34,50 +34,6 @@ export function logValue(value: unknown): string { return stringType; } -export function ensureRoute(route?: Route): void { - if (!route || !isString(route.path)) { - throw new Error( - log(`Expected route config to be an object with a "path" string property, or an array of such objects`), - ); - } - - const stringKeys = ['component', 'redirect'] as const; - if ( - !isFunction(route.action) && - !Array.isArray(route.children) && - !isFunction(route.children) && - !stringKeys.some((key) => isString(route[key])) - ) { - throw new Error( - log( - `Expected route config "${route.path}" to include either "${stringKeys.join('", "')}" ` + - `or "action" function but none found.`, - ), - ); - } - - if (route.redirect) { - ['component'].forEach((overriddenProp) => { - if (overriddenProp in route) { - console.warn( - log( - `Route config "${route.path as string}" has both "redirect" and "${overriddenProp}" properties, ` + - `and "redirect" will always override the latter. Did you mean to only use "${overriddenProp}"?`, - ), - ); - } - }); - } -} - -export function ensureRoutes(routes: Route | ReadonlyArray>): void { - toArray(routes).forEach((route) => ensureRoute(route)); -} - -export function fireRouterEvent(type: string, detail: T): boolean { - return !window.dispatchEvent(new CustomEvent(`vaadin-router-${type}`, { cancelable: type === 'go', detail })); -} - export class NotFoundError extends Error { readonly code: number; readonly context: RouteContext; @@ -89,15 +45,15 @@ export class NotFoundError extends Error { } } +export const notFoundResult = Symbol('NotFoundResult'); +export type NotFoundResult = typeof notFoundResult; + export function getNotFoundError = AnyObject>( context: RouteContext, ): NotFoundError { return new NotFoundError(context); } -// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -export const notFoundResult = {} as NotFoundResult; - export function resolvePath(path?: string | readonly string[]): string { return (Array.isArray(path) ? path[0] : path) ?? ''; } @@ -111,3 +67,29 @@ export function unwrapChildren( ): ReadonlyArray> | undefined { return Array.isArray>>(children) && children.length > 0 ? children : undefined; } + +export class RouteData extends Map, RouteMeta> { + readonly #inverted: Map, Route>; + + constructor(entries?: ReadonlyArray, meta: RouteMeta]> | null) { + super(entries); + this.#inverted = new Map(entries?.map(([route, meta]) => [meta, route])); + } + + override set(key: Route, value: RouteMeta): this { + this.#inverted.set(value, key); + return super.set(key, value); + } + + override delete(key: Route): boolean { + const value = this.get(key); + if (value) { + this.#inverted.delete(value); + } + return super.delete(key); + } + + getRoute(meta: RouteMeta): Route | undefined { + return this.#inverted.get(meta); + } +} diff --git a/test/resolver/generateUrls.spec.ts b/test/resolver/generateUrls.spec.ts index 67207b00..9772898f 100644 --- a/test/resolver/generateUrls.spec.ts +++ b/test/resolver/generateUrls.spec.ts @@ -11,12 +11,10 @@ import chaiAsPromised from 'chai-as-promised'; import chaiDom from 'chai-dom'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; -import type { EmptyObject } from 'type-fest'; -import type { InternalRoute } from '../../src/internal.js'; import generateUrls, { type StringifyQueryParams } from '../../src/resolver/generateUrls.js'; import Resolver from '../../src/resolver/resolver.js'; import '../setup.js'; -import type { AnyObject, Route } from '../../src/types.js'; +import type { Route } from '../../src/resolver/types.js'; use(chaiDom); use(sinonChai); @@ -43,7 +41,7 @@ describe('generateUrls(router, options)(routeName, params)', () => { const url = generateUrls(router); expect(() => url('hello')).to.throw(Error, /Route "hello" not found/u); - (router.root.__children as Array>) = [{ action, name: 'new', path: '/b' }]; + router.root.__children = [{ action, name: 'new', path: '/b' }]; expect(url('new')).to.be.equal('/a/b'); }); @@ -99,7 +97,6 @@ describe('generateUrls(router, options)(routeName, params)', () => { it('should generate url for nested routes', () => { const resolver = new Resolver({ - // @ts-expect-error: setting the internal route __children: [ { __children: [ @@ -126,7 +123,7 @@ describe('generateUrls(router, options)(routeName, params)', () => { // let routesByName = Array.from(router.routesByName.keys()); // expect(routesByName).to.have.all.members(['a', 'b', 'c']); - (resolver.root.__children as Array>).push({ name: 'new', path: '/new', children: [] }); + (resolver.root.__children as Array>).push({ name: 'new', path: '/new', children: [] }); expect(url('new')).to.be.equal('/new'); // TODO(platosha): Re-enable assergin `routesByName` when the API is exposed // // the .keys assertion does not work with ES6 Maps until chai 4.x @@ -148,9 +145,8 @@ describe('generateUrls(router, options)(routeName, params)', () => { const url2 = generateUrls(router2); expect(url2('post', { id: '12', x: 'y' })).to.be.equal('/post/12'); - const router3 = new Resolver( + const router3 = new Resolver( { - children: [], __children: [ { name: 'b', @@ -168,7 +164,7 @@ describe('generateUrls(router, options)(routeName, params)', () => { }, ], name: 'a', - } as Route, + }, options, ); const url3 = generateUrls(router3); @@ -177,12 +173,12 @@ describe('generateUrls(router, options)(routeName, params)', () => { expect(url3('c', { x: 'x' })).to.be.equal('/c/x'); expect(url3('d', { x: 'x', y: 'y' })).to.be.equal('/c/x/d/y'); - (router3.root.__children as Array>).push({ name: 'new', path: '/new', children: [] }); + (router3.root.__children as Array>).push({ name: 'new', path: '/new' }); expect(url3('new')).to.be.equal('/new'); }); it('should generate url with trailing slash', () => { - const routes: ReadonlyArray> = [ + const routes: readonly Route[] = [ { name: 'a', path: '/', children: [] }, { __children: [ diff --git a/test/resolver/matchRoute.spec.ts b/test/resolver/matchRoute.spec.ts index 4630761c..81968380 100644 --- a/test/resolver/matchRoute.spec.ts +++ b/test/resolver/matchRoute.spec.ts @@ -9,11 +9,9 @@ import { expect, use } from '@esm-bundle/chai'; import chaiDom from 'chai-dom'; import sinonChai from 'sinon-chai'; -import type { EmptyObject } from 'type-fest'; -import type { InternalRoute } from '../../src/internal.js'; import matchRoute from '../../src/resolver/matchRoute.js'; import '../setup.js'; -import type {ActionResult, Commands, MaybePromise, Route, RouteContext} from '../../src/types.js'; +import type { Route } from '../../src/resolver/types.js'; use(chaiDom); use(sinonChai); @@ -32,7 +30,7 @@ const dummyAction = () => undefined; describe('matchRoute(route, pathname)', () => { it('should return a valid iterator', () => { - const route: InternalRoute = { + const route: Route = { path: '/a', action: dummyAction, }; @@ -49,7 +47,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should yield well-formed match objects', () => { - const route = { + const route: Route = { path: '/a', action: dummyAction, }; @@ -68,7 +66,7 @@ describe('matchRoute(route, pathname)', () => { describe('no matches', () => { it('should not match a route if it does not match the path', () => { - const route = { + const route: Route = { path: '/a', action: dummyAction, }; @@ -97,7 +95,7 @@ describe('matchRoute(route, pathname)', () => { describe('matches the root of the routes tree', () => { it('should match a route without children if it matches the path exactly', () => { - const route = { + const route: Route = { path: '/a', action: dummyAction, }; @@ -107,7 +105,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should not match a route without children if it matches only a prefix of the path', () => { - const route = { + const route: Route = { path: '/a', action: () => {}, }; @@ -116,7 +114,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a route with children if it matches the path exactly', () => { - const route = { + const route: Route = { path: '/a', children: [ { path: '/b', action: dummyAction }, @@ -130,7 +128,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a route with children if it matches only a prefix of the path', () => { - const route = { + const route: Route = { path: '/a', children: [ { path: '/b', action: dummyAction }, @@ -154,7 +152,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a multi-segment route without children', () => { - const route = { + const route: Route = { path: '/a/b', action: dummyAction, }; @@ -166,7 +164,7 @@ describe('matchRoute(route, pathname)', () => { describe('matches child routes', () => { it('should match both the parent and one child route (parent first) - single child', () => { - const route = { + const route: Route = { path: '/a', children: [{ path: '/b', action: dummyAction }], }; @@ -177,7 +175,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match both the parent and one child route (parent first) - several children', () => { - const route = { + const route: Route = { path: '/a', children: [ { path: '/b', action: dummyAction }, @@ -194,7 +192,7 @@ describe('matchRoute(route, pathname)', () => { describe('matches sibling routes', () => { it('should match all sibling routes in their definition order', () => { - const route = { + const route: Route = { path: '/a', children: [ { path: '/b', action: dummyAction }, @@ -209,7 +207,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match both a multi-segment no-children route and a route with children', () => { - const route = { + const route: Route = { path: '/a', children: [ { path: '/b/c', action: dummyAction }, @@ -228,7 +226,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should continue matching on the parent level after siblings are checked', () => { - const route = { + const route: Route = { path: '/a', children: [ { @@ -249,7 +247,7 @@ describe('matchRoute(route, pathname)', () => { describe('leading and trailing "/" in the route path', () => { it('should match a relative route to a relative path', () => { - const route = { + const route: Route = { path: 'a', action: dummyAction, }; @@ -259,7 +257,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should not match an absolute route to a relative path', () => { - const route = { + const route: Route = { path: '/a', action: dummyAction, }; @@ -268,7 +266,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should not match a relative route to an absolute path', () => { - const route = { + const route: Route = { path: 'a', action: dummyAction, }; @@ -277,7 +275,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a route with a trailing "/" and no children to a path with a trailing "/"', () => { - const route = { + const route: Route = { path: 'a/', action: dummyAction, }; @@ -287,7 +285,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a route with a trailing "/" and some children to a path with a trailing "/"', () => { - const route = { + const route: Route = { path: 'a/', children: [ { path: '/b', action: dummyAction }, @@ -301,7 +299,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a route with a trailing "/" and some children to a path with more segments', () => { - const route = { + const route: Route = { path: 'a/', children: [ { path: '/b', action: dummyAction }, @@ -315,7 +313,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should not match a route with a trailing "/" to a path without a trailing "/"', () => { - const route = { + const route: Route = { path: '/a/', action: dummyAction, }; @@ -324,7 +322,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a route without a trailing "/" to a path with a trailing "/"', () => { - const route = { + const route: Route = { path: '/a', action: dummyAction, }; @@ -334,7 +332,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match child routes without the leading "/"', () => { - const route = { + const route: Route = { path: '/a', children: [{ path: 'b', action: dummyAction }], }; @@ -345,7 +343,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match parent routes with a trailing "/" and child routes with a leading "/"', () => { - const route = { + const route: Route = { path: '/a/', children: [{ path: '/b', action: dummyAction }], }; @@ -356,7 +354,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match parent routes with a trailing "/" and child routes without a leading "/"', () => { - const route = { + const route: Route = { path: '/a/', children: [{ path: 'b', action: dummyAction }], }; @@ -367,7 +365,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match deep child routes without a leading "/"', () => { - const route = { + const route: Route = { path: '/a', children: [ { @@ -384,7 +382,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match child routes if the path has a trailing "/"', () => { - const route = { + const route: Route = { path: '/a', children: [{ path: 'b', action: dummyAction }], }; @@ -403,7 +401,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a "" route with children to an absolute path', () => { - const route = { + const route: Route = { path: '', children: [ { path: '/b', action: dummyAction }, @@ -417,7 +415,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a "" route with children to an relative path', () => { - const route = { + const route: Route = { path: '', children: [ { path: '/b', action: dummyAction }, @@ -431,7 +429,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match absolute children of a "" route to an absolute path', () => { - const route = { + const route: Route = { path: '', children: [{ path: '/a', action: dummyAction }], }; @@ -442,7 +440,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match relative children of a "" route to a relative path', () => { - const route = { + const route: Route = { path: '', children: [{ path: 'a', action: dummyAction }], }; @@ -453,7 +451,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should not match absolute children of a "" route to an relative path', () => { - const route = { + const route: Route = { path: '', children: [{ path: '/a', action: dummyAction }], }; @@ -462,7 +460,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should not match relative children of a "" route to an absolute path', () => { - const route = { + const route: Route = { path: '', children: [{ path: 'a', action: dummyAction }], }; @@ -471,7 +469,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a child "" route if the path does not have a trailing "/"', () => { - const route = { + const route: Route = { path: '/a', children: [{ path: '', action: dummyAction }], }; @@ -482,7 +480,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a child "" route if the path does have a trailing "/"', () => { - const route = { + const route: Route = { path: '/a', children: [{ path: '', action: dummyAction }], }; @@ -493,7 +491,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match both the parent and the child "" routes', () => { - const route = { + const route: Route = { path: '', name: 'parent', children: [ @@ -512,7 +510,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match several nested "" routes', () => { - const route = { + const route: Route = { path: '', name: 'level-1', children: [ @@ -547,7 +545,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a "/" route with children to an absolute path', () => { - const route = { + const route: Route = { path: '/', children: [ { path: '/b', action: dummyAction }, @@ -561,7 +559,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should not match a "/" route with children to a relative path', () => { - const route = { + const route: Route = { path: '/', children: [{ path: 'a', action: dummyAction }], }; @@ -570,7 +568,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match (absolute) children of a "/" route', () => { - const route = { + const route: Route = { path: '/', children: [{ path: '/a', action: dummyAction }], }; @@ -581,7 +579,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match (relative) children of a "/" route', () => { - const route = { + const route: Route = { path: '/', children: [{ path: 'a', action: dummyAction }], }; @@ -592,7 +590,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a child "/" route if the path does not have a trailing "/"', () => { - const route = { + const route: Route = { path: '/a', children: [{ path: '/', action: dummyAction }], }; @@ -603,7 +601,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a child "/" route if the path does have a trailing "/"', () => { - const route = { + const route: Route = { path: '/a', children: [{ path: '/', action: dummyAction }], }; @@ -614,7 +612,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match both the parent and the child "/" routes', () => { - const route = { + const route: Route = { path: '/', name: 'parent', children: [ @@ -633,7 +631,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match several nested "/" routes', () => { - const route = { + const route: Route = { path: '/', name: 'level-1', children: [ @@ -662,7 +660,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should not match a deep child with a leading "/" if all parents are "" and the path is relative', () => { - const route = { + const route: Route = { path: '', name: 'parent', children: [ @@ -680,7 +678,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should match a deep child without a leading "/" if all parents are "" and the path is relative', () => { - const route = { + const route: Route = { path: '', name: 'parent', children: [ @@ -701,7 +699,7 @@ describe('matchRoute(route, pathname)', () => { describe('keys and params in the match object', () => { it('should contain the keys and params of the matched route', () => { - const route = { + const route: Route = { path: '/a/:b', action: dummyAction, }; @@ -712,7 +710,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should contain the keys and params of the parent route', () => { - const route = { + const route: Route = { path: '/a/:b', children: [{ path: '/:c', action: dummyAction }], }; @@ -723,7 +721,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should be empty if neither the matched route nor its parents have any params', () => { - const route = { + const route: Route = { path: '/a', children: [{ path: '/b', action: dummyAction }], }; @@ -736,7 +734,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should not contain the keys and params of the child routes', () => { - const route = { + const route: Route = { path: '/a/:b', children: [{ path: '/:c', action: dummyAction }], }; @@ -747,7 +745,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should not contain the keys and params of the sibling routes', () => { - const route = { + const route: Route = { path: '/a/:b', children: [ { path: '/:c', action: dummyAction }, @@ -761,7 +759,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should override a parent route param value with that of a child route if the param names collide', () => { - const route = { + const route: Route = { path: '/a/:b', children: [{ path: '/:b', action: dummyAction }], }; @@ -771,7 +769,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should not override a parent route param value with `undefined` (for an optional child param)', () => { - const route = { + const route: Route = { path: '/a/:b', children: [{ path: '/:b?', action: dummyAction }], }; @@ -781,7 +779,7 @@ describe('matchRoute(route, pathname)', () => { }); it('should not override a parent route param value in the parent match', () => { - const route = { + const route: Route = { path: '/a/:b', children: [{ path: '/:b', action: dummyAction }], }; diff --git a/test/resolver/resolver.spec.ts b/test/resolver/resolver.spec.ts index da2ebd6a..d95cd1a0 100644 --- a/test/resolver/resolver.spec.ts +++ b/test/resolver/resolver.spec.ts @@ -12,10 +12,10 @@ import chaiAsPromised from 'chai-as-promised'; import chaiDom from 'chai-dom'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; +import type { EmptyObject } from 'type-fest'; import Resolver, { ResolutionError } from '../../src/resolver/resolver.js'; import '../setup.js'; -import type { AnyObject, RouteContext } from '../../src/types.js'; -import type { InternalRouteContext } from '../../src/internal.js'; +import type { Route, RouteContext } from '../../src/resolver/types.js'; use(chaiDom); use(sinonChai); @@ -26,18 +26,19 @@ describe('Resolver', () => { it('should throw an error in case of invalid routes', () => { // @ts-expect-error: error-throwing test expect(() => new Resolver()).to.throw(TypeError, /Invalid routes/u); - // @ts-expect-error: error-throwing test expect(() => new Resolver(12)).to.throw(TypeError, /Invalid routes/u); // @ts-expect-error: error-throwing test expect(() => new Resolver(null)).to.throw(TypeError, /Invalid routes/u); }); it('should support custom resolve option for declarative routes', async () => { - const resolveRoute = sinon.spy( - (context: InternalRouteContext) => context.route.component, - ); + type CustomResolveOption = Readonly<{ + component?: string; + }>; + + const resolveRoute = sinon.spy((context: RouteContext) => context.route?.component); const action = sinon.stub(); - const resolver = new Resolver( + const resolver = new Resolver( { action, children: [ @@ -52,7 +53,7 @@ describe('Resolver', () => { const context = await resolver.resolve('/a/c'); expect(resolveRoute.calledThrice).to.be.true; expect(action.called).to.be.false; - expect(context.result).to.be.equal('c'); + expect((context as RouteContext).result).to.be.equal('c'); }); it('should support custom error handler option', async () => { @@ -60,7 +61,7 @@ describe('Resolver', () => { const errorHandler = sinon.spy(() => errorResult); const resolver = new Resolver([], { errorHandler }); const context = await resolver.resolve('/'); - expect(context.result).to.be.equal(errorResult); + expect(context).to.have.property('result').that.equals(errorResult); expect(errorHandler.calledOnce).to.be.true; const error = errorHandler.firstCall.firstArg; expect(error).to.be.an('error'); @@ -80,9 +81,9 @@ describe('Resolver', () => { }, path: '/', }; - const resolver = new Resolver(route, { errorHandler }); + const resolver = new Resolver(route, { errorHandler }); const context = await resolver.resolve('/'); - expect(context.result).to.be.equal(errorResult); + expect(context).to.have.property('result').that.equals(errorResult); expect(errorHandler).to.be.calledOnce; const error: ResolutionError = errorHandler.firstCall.firstArg; @@ -95,45 +96,6 @@ describe('Resolver', () => { }); }); - describe('router JS API', () => { - it('should have a getter for the routes config', () => { - const router = new Resolver([]); - const actual = router.getRoutes(); - expect(actual).to.be.an('array').that.is.empty; - }); - - it('should have a setter for the routes config', () => { - const router = new Resolver([]); - router.setRoutes([{ component: 'x-home-view', path: '/' }]); - const actual = router.getRoutes(); - expect(actual).to.be.an('array').that.has.lengthOf(1); - expect(actual[0]).to.have.property('path', '/'); - expect(actual[0]).to.have.property('component', 'x-home-view'); - }); - - it('should have a method for adding routes', () => { - const router = new Resolver([]); - - // @ts-expect-error: testing protected method - const newRoutes = router.addRoutes([{ component: 'x-home-view', path: '/' }]); - - const actual = router.getRoutes(); - expect(newRoutes).to.deep.equal(actual); - expect(actual) - .to.be.an('array') - .that.deep.equals([{ component: 'x-home-view', path: '/' }]); - }); - - it('should have a method for removing routes', () => { - const router = new Resolver([{ component: 'x-home-view', path: '/' }]); - expect(router.getRoutes()).to.be.an('array').that.has.lengthOf(1); - - router.removeRoutes(); - - expect(router.getRoutes()).to.be.an('array').that.has.lengthOf(0); - }); - }); - describe('resolver.resolve({ pathname, ...context })', () => { it('should throw an error if no route found', async () => { const resolver = new Resolver([]); @@ -157,7 +119,7 @@ describe('Resolver', () => { const context = await resolver.resolve('/a'); expect(action.calledOnce).to.be.true; expect(action.firstCall.firstArg).to.have.nested.property('route.path', '/a'); - expect(context.result).to.be.equal('b'); + expect((context as RouteContext).result).to.be.equal('b'); }); it('should find the first route whose action method !== undefined or null', async () => { @@ -172,7 +134,7 @@ describe('Resolver', () => { { action: action4, path: '/a' }, ]); const context = await resolver.resolve('/a'); - expect(context.result).to.be.equal('c'); + expect((context as RouteContext).result).to.be.equal('c'); expect(action1.calledOnce).to.be.true; expect(action2.calledOnce).to.be.true; expect(action3.calledOnce).to.be.true; @@ -181,12 +143,12 @@ describe('Resolver', () => { it('should be able to pass context variables to action methods', async () => { const action = sinon.spy(() => true); - const resolver = new Resolver([{ action, path: '/a' }]); + const resolver = new Resolver([{ action, path: '/a' }]); const context = await resolver.resolve({ pathname: '/a', test: 'b' }); expect(action.calledOnce).to.be.true; expect(action.firstCall.firstArg).to.have.nested.property('route.path', '/a'); expect(action.firstCall.firstArg).to.have.property('test', 'b'); - expect(context.result).to.be.true; + expect((context as RouteContext).result).to.be.true; }); it("should not call action methods of routes that don't match the URL path", async () => { @@ -207,9 +169,9 @@ describe('Resolver', () => { }); it('should support asynchronous route actions', async () => { - const resolver = new Resolver([{ action: async () => 'b', path: '/a' }]); + const resolver = new Resolver([{ action: async () => await Promise.resolve('b'), path: '/a' }]); const context = await resolver.resolve('/a'); - expect(context.result).to.be.equal('b'); + expect(context).to.have.property('result').that.equals('b'); }); it('URL parameters are captured and added to context.params', async () => { @@ -218,12 +180,12 @@ describe('Resolver', () => { const context = await resolver.resolve({ pathname: '/a/b' }); expect(action.calledOnce).to.be.true; expect(action.firstCall.firstArg).to.have.property('params').that.deep.equals({ one: 'a', two: 'b' }); - expect(context.result).to.be.true; + expect(context).to.have.property('result').that.is.true; }); it('context.chain contains the path to the last matched route if context.next() is called', async () => { const resolver = new Resolver([ - { action: async (context) => context.next(), name: 'first', path: '/a' }, + { action: async (context) => await context.next?.(), name: 'first', path: '/a' }, { action: () => true, name: 'second', path: '/a' }, ]); await resolver.resolve({ pathname: '/a' }); @@ -279,7 +241,7 @@ describe('Resolver', () => { }); it('the path to the route that produced the result is in the `context` (3)', async () => { - const resolver = new Resolver([ + const resolver = new Resolver([ { children: [ { @@ -322,7 +284,7 @@ describe('Resolver', () => { expect(action1.firstCall.firstArg).to.have.property('params').that.deep.equals({ one: 'a' }); expect(action2.calledOnce).to.be.true; expect(action2.firstCall.firstArg).to.have.property('params').that.deep.equals({ one: 'a', two: 'b' }); - expect(context.result).to.be.true; + expect(context).to.have.property('result').that.is.true; }); it('should override URL parameters with same name in child route', async () => { @@ -350,7 +312,7 @@ describe('Resolver', () => { expect(action1.args[1][0]).to.have.property('params').that.deep.equals({ one: 'b' }); expect(action2.calledOnce).to.be.true; expect(action2.firstCall.firstArg).to.have.property('params').that.deep.equals({ one: 'a', two: 'b' }); - expect(context.result).to.be.true; + expect(context).to.have.property('result').that.is.true; }); it('should not collect parameters from previous routes', async () => { @@ -392,7 +354,7 @@ describe('Resolver', () => { expect(action2.secondCall.firstArg).to.have.property('params').that.deep.equals({ four: 'b', three: 'a' }); expect(action3.calledOnce).to.be.true; expect(action3.firstCall.firstArg).to.have.property('params').that.deep.equals({ five: 'b', three: 'a' }); - expect(context.result).to.be.true; + expect(context).to.have.property('result').that.is.true; }); it('should support next() across multiple routes', async () => { @@ -401,7 +363,7 @@ describe('Resolver', () => { { async action({ next }) { log.push(1); - const result = await next(); + const result = await next?.(); log.push(10); return result; }, @@ -414,7 +376,7 @@ describe('Resolver', () => { { async action({ next }) { log.push(3); - return next().then(() => { + return await next?.().then(() => { log.push(6); }); }, @@ -422,7 +384,7 @@ describe('Resolver', () => { { async action({ next }) { log.push(4); - return next().then(() => { + return await next?.().then(() => { log.push(5); }); }, @@ -480,7 +442,7 @@ describe('Resolver', () => { const context = await resolver.resolve('/test'); expect(log).to.be.deep.equal([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); - expect(context.result).to.be.equal('done'); + expect(context).to.have.property('result').that.equals('done'); }); it('should support next(true) across multiple routes', async () => { @@ -488,7 +450,7 @@ describe('Resolver', () => { const resolver = new Resolver({ async action({ next }) { log.push(1); - return next().then((result) => { + return await next?.().then((result) => { log.push(9); return result; }); @@ -497,7 +459,7 @@ describe('Resolver', () => { { async action({ next }) { log.push(2); - return next(true).then((result) => { + return await next?.(true).then((result) => { log.push(8); return result; }); @@ -512,7 +474,7 @@ describe('Resolver', () => { { async action({ next }) { log.push(4); - return next().then((result) => { + return await next?.().then((result) => { log.push(6); return result; }); @@ -542,7 +504,7 @@ describe('Resolver', () => { const context = await resolver.resolve('/a/b/c'); expect(log).to.be.deep.equal([1, 2, 3, 4, 5, 6, 7, 8, 9]); - expect(context.result).to.be.equal('done'); + expect(context).to.have.property('result').that.equals('done'); }); it('should support parametrized routes 1', async () => { @@ -554,7 +516,7 @@ describe('Resolver', () => { expect(action.firstCall.firstArg).to.have.nested.property('params.b', '2'); expect(action.firstCall.firstArg).to.have.nested.property('params.a', '1'); expect(action.firstCall.firstArg).to.have.nested.property('params.b', '2'); - expect(context.result).to.be.true; + expect(context).to.have.property('result').that.is.true; }); it('should support nested routes (1)', async () => { @@ -578,7 +540,7 @@ describe('Resolver', () => { expect(action1.firstCall.firstArg).to.have.nested.property('route.path', ''); expect(action2.calledOnce).to.be.true; expect(action2.firstCall.firstArg).to.have.nested.property('route.path', '/a'); - expect(context.result).to.be.true; + expect(context).to.have.property('result').that.is.true; }); it('should support nested routes (2)', async () => { @@ -602,14 +564,14 @@ describe('Resolver', () => { expect(action1.firstCall.firstArg).to.have.nested.property('route.path', '/a'); expect(action2.calledOnce).to.be.true; expect(action2.firstCall.firstArg).to.have.nested.property('route.path', '/b'); - expect(context.result).to.be.true; + expect(context).to.have.property('result').that.is.true; }); it('should support nested routes (3)', async () => { const action1 = sinon.spy(() => undefined); const action2 = sinon.spy(() => null); const action3 = sinon.spy(() => true); - const resolver = new Resolver([ + const resolver = new Resolver([ { action: action1, children: [ @@ -633,7 +595,7 @@ describe('Resolver', () => { expect(action2.firstCall.firstArg).to.have.nested.property('route.path', '/b'); expect(action3.calledOnce).to.be.true; expect(action3.firstCall.firstArg).to.have.nested.property('route.path', '/a/b'); - expect(context.result).to.be.true; + expect(context).to.have.property('result').that.is.true; }); it('should support an empty array of children', async () => { @@ -671,23 +633,24 @@ describe('Resolver', () => { it('should respect baseUrl', async () => { const action = sinon.spy(() => 17); - const routes = { + const targetRoute = { action, path: '/c' }; + const route: Route = { children: [ { - children: [{ action, path: '/c' }], + children: [targetRoute], path: '/b', }, ], path: '/a', }; - const resolver = new Resolver(routes, { baseUrl: '/base/' }); + const resolver = new Resolver(route, { baseUrl: '/base/' }); const context = await resolver.resolve('/base/a/b/c'); expect(action.calledOnce).to.be.true; expect(action.firstCall.firstArg).to.have.property('pathname', '/base/a/b/c'); expect(action.firstCall.firstArg).to.have.nested.property('route.path', '/c'); - expect(action.firstCall.firstArg).to.have.property('route', routes.children[0].children[0]); + expect(action.firstCall.firstArg).to.have.property('route').that.equals(targetRoute); expect(action.firstCall.firstArg).to.have.property('resolver', resolver); - expect(context.result).to.be.equal(17); + expect(context).to.have.property('result').that.equals(17); let err; try { @@ -718,16 +681,16 @@ describe('Resolver', () => { path: '/child', }, ]); - expect((await resolver.resolve('/')).result).to.be.equal('a'); - expect((await resolver.resolve('/page/')).result).to.be.equal('b'); - expect((await resolver.resolve('/child/')).result).to.be.equal('c'); - expect((await resolver.resolve('/child/page/')).result).to.be.equal('d'); + await expect(resolver.resolve('/')).to.eventually.have.property('result').that.equals('a'); + await expect(resolver.resolve('/page/')).to.eventually.have.property('result').that.equals('b'); + await expect(resolver.resolve('/child/')).to.eventually.have.property('result').that.equals('c'); + await expect(resolver.resolve('/child/page/')).to.eventually.have.property('result').that.equals('d'); }); it('should skip nested routes when middleware route returns null', async () => { const middleware = sinon.spy(() => null); const action = sinon.spy(() => 'skipped'); - const resolver = new Resolver([ + const resolver = new Resolver([ { action: middleware, children: [{ action }], @@ -740,7 +703,7 @@ describe('Resolver', () => { ]); const context = await resolver.resolve('/match'); - expect(context.result).to.be.equal(404); + expect(context).to.have.property('result').that.equals(404); expect(action.called).to.be.false; expect(middleware.calledOnce).to.be.true; }); @@ -748,7 +711,7 @@ describe('Resolver', () => { it('should match nested routes when middleware route returns undefined', async () => { const middleware = sinon.spy(() => undefined); const action = sinon.spy(() => null); - const resolver = new Resolver([ + const resolver = new Resolver([ { action: middleware, children: [{ action }], @@ -761,7 +724,7 @@ describe('Resolver', () => { ]); const context = await resolver.resolve('/match'); - expect(context.result).to.be.equal(404); + expect(context).to.have.property('result').that.equals(404); expect(action.calledOnce).to.be.true; expect(middleware.calledOnce).to.be.true; }); @@ -787,15 +750,19 @@ describe('Resolver', () => { describe('resolver.__effectiveBaseUrl getter', () => { it('should return empty string by default', () => { + // @ts-expect-error: testing protected property expect(new Resolver([]).__effectiveBaseUrl).to.equal(''); }); it('should return full base when baseUrl is set', () => { + // @ts-expect-error: testing protected property expect(new Resolver([], { baseUrl: '/foo/' }).__effectiveBaseUrl).to.equal(`${location.origin}/foo/`); }); it('should ignore everything after last slash', () => { + // @ts-expect-error: testing protected property expect(new Resolver([], { baseUrl: '/foo' }).__effectiveBaseUrl).to.equal(`${location.origin}/`); + // @ts-expect-error: testing protected property expect(new Resolver([], { baseUrl: '/foo/bar' }).__effectiveBaseUrl).to.equal(`${location.origin}/foo/`); }); @@ -845,26 +812,5 @@ describe('Resolver', () => { expect(resolver.__normalizePathname('/bar/')).to.equal(''); expect(stub).to.be.called; }); - - // it('should invoke Resolver.__createUrl(url, base) hook', () => { - // const createUrlSpy = sinon.spy(Resolver, '__createUrl'); - // try { - // // Absolute pathname: prepend origin - // new Resolver([], { baseUrl: '/foo/bar' }) - // // @ts-expect-error: testing private method - // .__normalizePathname('/baz/'); - // expect(createUrlSpy).to.be.calledWith(`${location.origin}/baz/`, `${location.origin}/foo/`); - // - // createUrlSpy.resetHistory(); - // - // // Relative pathname: prepend dot path prefix - // new Resolver([], { baseUrl: '/foo/bar' }) - // // @ts-expect-error: testing private method - // .__normalizePathname('baz'); - // expect(createUrlSpy).to.be.calledWith('./baz', `${location.origin}/foo/`); - // } finally { - // createUrlSpy.restore(); - // } - // }); }); }); diff --git a/tsconfig.json b/tsconfig.json index acf3040c..4dd307c0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "allowJs": true, "allowSyntheticDefaultImports": true, "declaration": true, "declarationMap": true, @@ -8,7 +9,6 @@ "moduleResolution": "bundler", "lib": ["es2022", "dom"], "allowArbitraryExtensions": true, - "skipLibCheck": true, "strict": true, "strictBindCallApply": true, "sourceMap": true, @@ -19,6 +19,6 @@ "useDefineForClassFields": true, "useUnknownInCatchVariables": true }, - "include": ["scripts", "src", "test", "./vite.config.ts", "./*.cjs"], + "include": ["scripts", "src/**/*", "test/**/*", "./vite.config.ts", "./*.cjs"], "exclude": ["scripts/**/*.js"] }