diff --git a/docs/2.guide/2.directory-structure/1.plugins.md b/docs/2.guide/2.directory-structure/1.plugins.md index 19430b5765..4a29e5bbc8 100644 --- a/docs/2.guide/2.directory-structure/1.plugins.md +++ b/docs/2.guide/2.directory-structure/1.plugins.md @@ -101,9 +101,11 @@ In case you're new to 'alphabetical' numbering, remember that filenames are sort ## Loading Strategy +### Parallel Plugins + By default, Nuxt loads plugins sequentially. You can define a plugin as `parallel` so Nuxt won't wait the end of the plugin's execution before loading the next plugin. -```ts [plugins/hello.ts] +```ts [plugins/my-plugin.ts] export default defineNuxtPlugin({ name: 'my-plugin', parallel: true, @@ -113,6 +115,20 @@ export default defineNuxtPlugin({ }) ``` +### Plugins With Dependencies + +If a plugin needs to await a parallel plugin before it runs, you can add the plugin's name to the `dependsOn` array. + +```ts [plugins/depending-on-my-plugin.ts] +export default defineNuxtPlugin({ + name: 'depends-on-my-plugin', + dependsOn: ['my-plugin'] + async setup (nuxtApp) { + // this plugin will wait for the end of `my-plugin`'s execution before it runs + } +}) +``` + ## Using Composables You can use [composables](/docs/guide/directory-structure/composables) as well as [utils](/docs/guide/directory-structure/utils) within Nuxt plugins: diff --git a/packages/nuxt/src/app/index.ts b/packages/nuxt/src/app/index.ts index a23928e064..1acc4ad82e 100644 --- a/packages/nuxt/src/app/index.ts +++ b/packages/nuxt/src/app/index.ts @@ -7,9 +7,7 @@ export * from './composables/index' export * from './components/index' export * from './config' export * from './compat/idle-callback' - -// eslint-disable-next-line import/no-restricted-paths -export type { PageMeta } from '../pages/runtime/index' +export * from './types' export const isVue2 = false export const isVue3 = true diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index 21c8f3f744..878b1f2f9a 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -18,6 +18,8 @@ import type { NuxtError } from '../app/composables/error' import type { AsyncDataRequestStatus } from '../app/composables/asyncData' import type { NuxtAppManifestMeta } from '../app/composables/manifest' +import type { NuxtAppLiterals } from '#app' + const nuxtAppCtx = /*@__PURE__*/ getContext('nuxt-app', { asyncContext: !!__NUXT_ASYNC_CONTEXT__ && import.meta.server }) @@ -154,6 +156,10 @@ export const NuxtPluginIndicator = '__nuxt_plugin' export interface PluginMeta { name?: string enforce?: 'pre' | 'default' | 'post' + /** + * Await for other named plugins to finish before running this plugin. + */ + dependsOn?: NuxtAppLiterals['pluginName'][] /** * This allows more granular control over plugin order and should only be used by advanced users. * It overrides the value of `enforce` and is used to sort plugins. @@ -190,6 +196,10 @@ export interface ObjectPlugin = Recor * @default false */ parallel?: boolean + /** + * @internal + */ + _name?: string } /** @deprecated Use `ObjectPlugin` */ @@ -331,26 +341,61 @@ export async function applyPlugin (nuxtApp: NuxtApp, plugin: Plugin & ObjectPlug } export async function applyPlugins (nuxtApp: NuxtApp, plugins: Array>) { + const resolvedPlugins: string[] = [] + const unresolvedPlugins: [Set, Plugin & ObjectPlugin][] = [] const parallels: Promise[] = [] const errors: Error[] = [] - for (const plugin of plugins) { - if (import.meta.server && nuxtApp.ssrContext?.islandContext && plugin.env?.islands === false) { continue } - const promise = applyPlugin(nuxtApp, plugin) - if (plugin.parallel) { - parallels.push(promise.catch(e => errors.push(e))) + let promiseDepth = 0 + + async function executePlugin (plugin: Plugin & ObjectPlugin) { + if (plugin.dependsOn && !plugin.dependsOn.every(name => resolvedPlugins.includes(name))) { + unresolvedPlugins.push([new Set(plugin.dependsOn), plugin]) } else { - await promise + const promise = applyPlugin(nuxtApp, plugin).then(async () => { + if (plugin._name) { + resolvedPlugins.push(plugin._name) + await Promise.all(unresolvedPlugins.map(async ([dependsOn, unexecutedPlugin]) => { + if (dependsOn.has(plugin._name!)) { + dependsOn.delete(plugin._name!) + if (dependsOn.size === 0) { + promiseDepth++ + await executePlugin(unexecutedPlugin) + } + } + })) + } + }) + + if (plugin.parallel) { + parallels.push(promise.catch(e => errors.push(e))) + } else { + await promise + } } } + + for (const plugin of plugins) { + if (import.meta.server && nuxtApp.ssrContext?.islandContext && plugin.env?.islands === false) { continue } + await executePlugin(plugin) + } + await Promise.all(parallels) + if (promiseDepth) { + for (let i = 0; i < promiseDepth; i++) { + await Promise.all(parallels) + } + } + if (errors.length) { throw errors[0] } } /*@__NO_SIDE_EFFECTS__*/ export function defineNuxtPlugin> (plugin: Plugin | ObjectPlugin): Plugin & ObjectPlugin { if (typeof plugin === 'function') { return plugin } + + const _name = plugin._name || plugin.name delete plugin.name - return Object.assign(plugin.setup || (() => {}), plugin, { [NuxtPluginIndicator]: true } as const) + return Object.assign(plugin.setup || (() => {}), plugin, { [NuxtPluginIndicator]: true, _name } as const) } /*@__NO_SIDE_EFFECTS__*/ diff --git a/packages/nuxt/src/app/types.ts b/packages/nuxt/src/app/types.ts new file mode 100644 index 0000000000..0ec9c30f44 --- /dev/null +++ b/packages/nuxt/src/app/types.ts @@ -0,0 +1,6 @@ +// eslint-disable-next-line import/no-restricted-paths +export type { PageMeta } from '../pages/runtime/index' + +export interface NuxtAppLiterals { + [key: string]: string +} diff --git a/packages/nuxt/src/core/app.ts b/packages/nuxt/src/core/app.ts index 7c3b15f097..df70fd4934 100644 --- a/packages/nuxt/src/core/app.ts +++ b/packages/nuxt/src/core/app.ts @@ -8,6 +8,8 @@ import * as defaultTemplates from './templates' import { getNameFromPath, hasSuffix, uniqueBy } from './utils' import { extractMetadata, orderMap } from './plugins/plugin-metadata' +import type { PluginMeta } from '#app' + export function createApp (nuxt: Nuxt, options: Partial = {}): NuxtApp { return defu(options, { dir: nuxt.options.srcDir, @@ -185,7 +187,7 @@ function resolvePaths> (items: Item[], key: { [ } export async function annotatePlugins (nuxt: Nuxt, plugins: NuxtPlugin[]) { - const _plugins: NuxtPlugin[] = [] + const _plugins: Array> = [] for (const plugin of plugins) { try { const code = plugin.src in nuxt.vfs ? nuxt.vfs[plugin.src] : await fsp.readFile(plugin.src!, 'utf-8') @@ -201,3 +203,29 @@ export async function annotatePlugins (nuxt: Nuxt, plugins: NuxtPlugin[]) { return _plugins.sort((a, b) => (a.order ?? orderMap.default) - (b.order ?? orderMap.default)) } + +export function checkForCircularDependencies (_plugins: Array>) { + const deps: Record = Object.create(null) + const pluginNames = _plugins.map(plugin => plugin.name) + for (const plugin of _plugins) { + // Make sure dependency plugins are registered + if (plugin.dependsOn && plugin.dependsOn.some(name => !pluginNames.includes(name))) { + console.error(`Plugin \`${plugin.name}\` depends on \`${plugin.dependsOn.filter(name => !pluginNames.includes(name)).join(', ')}\` but they are not registered.`) + } + // Make graph to detect circular dependencies + if (plugin.name) { + deps[plugin.name] = plugin.dependsOn || [] + } + } + const checkDeps = (name: string, visited: string[] = []): string[] => { + if (visited.includes(name)) { + console.error(`Circular dependency detected in plugins: ${visited.join(' -> ')} -> ${name}`) + return [] + } + visited.push(name) + return (deps[name] || []).flatMap(dep => checkDeps(dep, [...visited])) + } + for (const name in deps) { + checkDeps(name) + } +} diff --git a/packages/nuxt/src/core/plugins/plugin-metadata.ts b/packages/nuxt/src/core/plugins/plugin-metadata.ts index fa3f3b43fc..b77678caa3 100644 --- a/packages/nuxt/src/core/plugins/plugin-metadata.ts +++ b/packages/nuxt/src/core/plugins/plugin-metadata.ts @@ -1,4 +1,4 @@ -import type { CallExpression, Property, SpreadElement } from 'estree' +import type { CallExpression, Literal, Property, SpreadElement } from 'estree' import type { Node } from 'estree-walker' import { walk } from 'estree-walker' import { transform } from 'esbuild' @@ -87,7 +87,8 @@ type PluginMetaKey = keyof PluginMeta const keys: Record = { name: 'name', order: 'order', - enforce: 'enforce' + enforce: 'enforce', + dependsOn: 'dependsOn' } function isMetadataKey (key: string): key is PluginMetaKey { return key in keys @@ -107,6 +108,12 @@ function extractMetaFromObject (properties: Array) { if (property.value.type === 'UnaryExpression' && property.value.argument.type === 'Literal') { meta[propertyKey] = JSON.parse(property.value.operator + property.value.argument.raw!) } + if (propertyKey === 'dependsOn' && property.value.type === 'ArrayExpression') { + if (property.value.elements.some(e => !e || e.type !== 'Literal' || typeof e.value !== 'string')) { + throw new Error('dependsOn must take an array of string literals') + } + meta[propertyKey] = property.value.elements.map(e => (e as Literal)!.value as string) + } } return meta } diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index c503ecfec0..e36c944987 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -8,7 +8,7 @@ import { hash } from 'ohash' import { camelCase } from 'scule' import { filename } from 'pathe/utils' import type { Nuxt, NuxtApp, NuxtTemplate } from 'nuxt/schema' -import { annotatePlugins } from './app' +import { annotatePlugins, checkForCircularDependencies } from './app' interface TemplateContext { nuxt: Nuxt @@ -62,7 +62,7 @@ export const clientPluginTemplate: NuxtTemplate = { filename: 'plugins/client.mjs', async getContents (ctx) { const clientPlugins = await annotatePlugins(ctx.nuxt, ctx.app.plugins.filter(p => !p.mode || p.mode !== 'server')) - await annotatePlugins(ctx.nuxt, clientPlugins) + checkForCircularDependencies(clientPlugins) const exports: string[] = [] const imports: string[] = [] for (const plugin of clientPlugins) { @@ -82,6 +82,7 @@ export const serverPluginTemplate: NuxtTemplate = { filename: 'plugins/server.mjs', async getContents (ctx) { const serverPlugins = await annotatePlugins(ctx.nuxt, ctx.app.plugins.filter(p => !p.mode || p.mode !== 'client')) + checkForCircularDependencies(serverPlugins) const exports: string[] = [] const imports: string[] = [] for (const plugin of serverPlugins) { @@ -99,7 +100,7 @@ export const serverPluginTemplate: NuxtTemplate = { export const pluginsDeclaration: NuxtTemplate = { filename: 'types/plugins.d.ts', - getContents: (ctx) => { + getContents: async (ctx) => { const EXTENSION_RE = new RegExp(`(?<=\\w)(${ctx.nuxt.options.extensions.map(e => escapeRE(e)).join('|')})$`, 'g') const tsImports: string[] = [] for (const p of ctx.app.plugins) { @@ -111,6 +112,8 @@ export const pluginsDeclaration: NuxtTemplate = { } } + const pluginsName = (await annotatePlugins(ctx.nuxt, ctx.app.plugins)).filter(p => p.name).map(p => `'${p.name}'`) + return `// Generated by Nuxt' import type { Plugin } from '#app' @@ -122,6 +125,10 @@ type NuxtAppInjections = \n ${tsImports.map(p => `InjectionType { it('should extract metadata from object-syntax plugins', async () => { @@ -63,3 +64,51 @@ describe('plugin-metadata', () => { `) }) }) + +describe('plugin sanity checking', () => { + it('non-existent depends are warned', () => { + vi.spyOn(console, 'error') + checkForCircularDependencies([ + { + name: 'A', + src: '' + }, + { + name: 'B', + dependsOn: ['D'], + src: '' + }, + { + name: 'C', + src: '' + } + ]) + expect(console.error).toBeCalledWith('Plugin `B` depends on `D` but they are not registered.') + vi.restoreAllMocks() + }) + + it('circular dependencies are warned', () => { + vi.spyOn(console, 'error') + checkForCircularDependencies([ + { + name: 'A', + dependsOn: ['B'], + src: '' + }, + { + name: 'B', + dependsOn: ['C'], + src: '' + }, + { + name: 'C', + dependsOn: ['A'], + src: '' + } + ]) + expect(console.error).toBeCalledWith('Circular dependency detected in plugins: A -> B -> C -> A') + expect(console.error).toBeCalledWith('Circular dependency detected in plugins: B -> C -> A -> B') + expect(console.error).toBeCalledWith('Circular dependency detected in plugins: C -> A -> B -> C') + vi.restoreAllMocks() + }) +}) diff --git a/packages/schema/src/types/nuxt.ts b/packages/schema/src/types/nuxt.ts index ac1ad593a5..38865201a2 100644 --- a/packages/schema/src/types/nuxt.ts +++ b/packages/schema/src/types/nuxt.ts @@ -16,6 +16,10 @@ export interface NuxtPlugin { * Default Nuxt priorities can be seen at [here](https://github.com/nuxt/nuxt/blob/9904849bc87c53dfbd3ea3528140a5684c63c8d8/packages/nuxt/src/core/plugins/plugin-metadata.ts#L15-L34). */ order?: number + /** + * @internal + */ + name?: string } // Internal type for simpler NuxtTemplate interface extension diff --git a/test/bundle.test.ts b/test/bundle.test.ts index 017021102d..9d85f5b605 100644 --- a/test/bundle.test.ts +++ b/test/bundle.test.ts @@ -32,7 +32,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM const serverDir = join(rootDir, '.output/server') const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) - expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"199k"') + expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"200k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"1847k"') @@ -71,7 +71,7 @@ describe.skipIf(process.env.SKIP_BUNDLE_SIZE === 'true' || process.env.ECOSYSTEM const serverDir = join(rootDir, '.output-inline/server') const serverStats = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) - expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot('"510k"') + expect.soft(roundToKilobytes(serverStats.totalBytes)).toMatchInlineSnapshot(`"511k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) expect.soft(roundToKilobytes(modules.totalBytes)).toMatchInlineSnapshot('"77.0k"') diff --git a/test/fixtures/basic-types/types.ts b/test/fixtures/basic-types/types.ts index 3efc8b1dfd..a2617138ec 100644 --- a/test/fixtures/basic-types/types.ts +++ b/test/fixtures/basic-types/types.ts @@ -242,6 +242,18 @@ describe('nuxtApp', () => { }) }) +describe('plugins', () => { + it('dependsOn is strongly typed', () => { + defineNuxtPlugin({ + // @ts-expect-error invalid plugin name + dependsOn: ['something'] + }) + defineNuxtPlugin({ + dependsOn: ['nuxt:router'] + }) + }) +}) + describe('runtimeConfig', () => { it('generated runtimeConfig types', () => { const runtimeConfig = useRuntimeConfig() diff --git a/test/fixtures/basic/plugins/async-plugin.ts b/test/fixtures/basic/plugins/async-plugin.ts index 05924f6336..e6ac65b7f9 100644 --- a/test/fixtures/basic/plugins/async-plugin.ts +++ b/test/fixtures/basic/plugins/async-plugin.ts @@ -1,13 +1,17 @@ -export default defineNuxtPlugin(async (/* nuxtApp */) => { - const config1 = useRuntimeConfig() - await new Promise(resolve => setTimeout(resolve, 100)) - const { data } = useFetch('/api/hey', { key: 'hey' }) - const config2 = useRuntimeConfig() - return { - provide: { - asyncPlugin: () => config1 && config1 === config2 - ? 'Async plugin works! ' + config1.public.testConfig + (data.value?.baz ? 'useFetch works!' : 'useFetch does not work') - : 'Async plugin failed!' +export default defineNuxtPlugin({ + name: 'async-plugin', + async setup (/* nuxtApp */) { + const config1 = useRuntimeConfig() + await new Promise(resolve => setTimeout(resolve, 100)) + const { data } = useFetch('/api/hey', { key: 'hey' }) + const config2 = useRuntimeConfig() + return { + provide: { + asyncPlugin: () => config1 && config1 === config2 + ? 'Async plugin works! ' + config1.public.testConfig + (data.value?.baz ? 'useFetch works!' : 'useFetch does not work') + : 'Async plugin failed!' + } } - } + }, + parallel: true }) diff --git a/test/fixtures/basic/plugins/dependsOnPlugin.ts b/test/fixtures/basic/plugins/dependsOnPlugin.ts new file mode 100644 index 0000000000..2f9b0ebde6 --- /dev/null +++ b/test/fixtures/basic/plugins/dependsOnPlugin.ts @@ -0,0 +1,12 @@ +export default defineNuxtPlugin({ + name: 'depends-on-plugin', + dependsOn: ['async-plugin'], + async setup () { + const nuxtApp = useNuxtApp() + if (!nuxtApp.$asyncPlugin) { + throw new Error('$asyncPlugin is not defined') + } + await new Promise(resolve => setTimeout(resolve, 100)) + }, + parallel: true +}) diff --git a/test/nuxt/plugin.test.ts b/test/nuxt/plugin.test.ts new file mode 100644 index 0000000000..2856feceb1 --- /dev/null +++ b/test/nuxt/plugin.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyPlugins } from '#app/nuxt' +import { defineNuxtPlugin } from '#app' + +vi.mock('#app', async (original) => { + return { + ...(await original()), + applyPlugin: vi.fn(async (_nuxtApp, plugin) => { + await plugin() + }) + } +}) + +function pluginFactory (name: string, dependsOn?: string[], sequence: string[], parallel = true) { + return defineNuxtPlugin({ + name, + dependsOn, + async setup () { + sequence.push(`start ${name}`) + await new Promise(resolve => setTimeout(resolve, 10)) + sequence.push(`end ${name}`) + }, + parallel + }) +} + +describe('plugin dependsOn', () => { + it('expect B to await A to finish before being run', async () => { + const nuxtApp = useNuxtApp() + const sequence: string[] = [] + const plugins = [ + pluginFactory('A', undefined, sequence), + pluginFactory('B', ['A'], sequence) + ] + + await applyPlugins(nuxtApp, plugins) + + expect(sequence).toMatchObject([ + 'start A', + 'end A', + 'start B', + 'end B' + ]) + }) + + it('expect C to await A and B to finish before being run', async () => { + const nuxtApp = useNuxtApp() + const sequence: string[] = [] + const plugins = [ + pluginFactory('A', undefined, sequence), + pluginFactory('B', ['A'], sequence), + pluginFactory('C', ['A', 'B'], sequence) + ] + + await applyPlugins(nuxtApp, plugins) + + expect(sequence).toMatchObject([ + 'start A', + 'end A', + 'start B', + 'end B', + 'start C', + 'end C' + ]) + }) + + it('expect C to not wait for A to finish before being run', async () => { + const nuxtApp = useNuxtApp() + const sequence: string[] = [] + const plugins = [ + pluginFactory('A', undefined, sequence), + pluginFactory('B', ['A'], sequence), + defineNuxtPlugin({ + name, + async setup () { + sequence.push('start C') + await new Promise(resolve => setTimeout(resolve, 5)) + sequence.push('end C') + }, + parallel: true + }) + ] + + await applyPlugins(nuxtApp, plugins) + + expect(sequence).toMatchObject([ + 'start A', + 'start C', + 'end C', + 'end A', + 'start B', + 'end B' + ]) + }) + + it('expect C to block the depends on of A-B since C is sequential', async () => { + const nuxtApp = useNuxtApp() + const sequence: string[] = [] + const plugins = [ + pluginFactory('A', undefined, sequence), + defineNuxtPlugin({ + name, + async setup () { + sequence.push('start C') + await new Promise(resolve => setTimeout(resolve, 50)) + sequence.push('end C') + } + }), + pluginFactory('B', ['A'], sequence) + ] + + await applyPlugins(nuxtApp, plugins) + + expect(sequence).toMatchObject([ + 'start A', + 'start C', + 'end A', + 'end C', + 'start B', + 'end B' + ]) + }) + + it('relying on plugin not registed yet', async () => { + const nuxtApp = useNuxtApp() + const sequence: string[] = [] + const plugins = [ + pluginFactory('C', ['A'], sequence), + pluginFactory('A', undefined, sequence, true), + pluginFactory('E', ['B', 'C'], sequence, false), + pluginFactory('B', undefined, sequence), + pluginFactory('D', ['C'], sequence, false) + ] + await applyPlugins(nuxtApp, plugins) + + expect(sequence).toMatchObject([ + 'start A', + 'start B', + 'end A', + 'start C', + 'end B', + 'end C', + 'start E', + 'start D', + 'end E', + 'end D' + ]) + }) + + it('test depending on not yet registered plugin and already resolved plugin', async () => { + const nuxtApp = useNuxtApp() + const sequence: string[] = [] + const plugins = [ + pluginFactory('A', undefined, sequence), + pluginFactory('B', ['A', 'C'], sequence), + pluginFactory('C', undefined, sequence, false), + pluginFactory('D', undefined, sequence, false), + pluginFactory('E', ['C'], sequence, false) + ] + await applyPlugins(nuxtApp, plugins) + + expect(sequence).toMatchObject([ + 'start A', + 'start C', + 'end A', + 'end C', + 'start B', + 'start D', + 'end B', + 'end D', + 'start E', + 'end E' + ]) + }) + + it('multiple depth of plugin dependency', async () => { + const nuxtApp = useNuxtApp() + const sequence: string[] = [] + const plugins = [ + pluginFactory('A', undefined, sequence), + pluginFactory('C', ['B', 'A'], sequence), + pluginFactory('B', undefined, sequence, false), + pluginFactory('E', ['D'], sequence, false), + pluginFactory('D', ['C'], sequence, false) + ] + await applyPlugins(nuxtApp, plugins) + + expect(sequence).toMatchObject([ + 'start A', + 'start B', + 'end A', + 'end B', + 'start C', + 'end C', + 'start D', + 'end D', + 'start E', + 'end E' + ]) + }) + + it('does not throw when circular dependency is not a problem', async () => { + const nuxtApp = useNuxtApp() + const sequence: string[] = [] + const plugins = [ + pluginFactory('A', ['B'], sequence), + pluginFactory('B', ['C'], sequence), + pluginFactory('C', ['D'], sequence), + pluginFactory('D', [], sequence), + ] + + await applyPlugins(nuxtApp, plugins) + expect(sequence).toMatchObject([ + 'start D', + 'end D', + 'start C', + 'end C', + 'start B', + 'end B', + 'start A', + 'end A' + ]) + }) + + it('function plugin', async () => { + const nuxtApp = useNuxtApp() + const sequence: string[] = [] + const plugins = [ + pluginFactory('A', undefined, sequence), + defineNuxtPlugin(() => { + sequence.push('start C') + sequence.push('end C') + }), + pluginFactory('B', undefined, sequence, false) + ] + await applyPlugins(nuxtApp, plugins) + + expect(sequence).toMatchObject([ + 'start A', + 'start C', + 'end C', + 'start B', + 'end A', + 'end B' + ]) + }) +})