@poveste/plugin-vue 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/bundled/client/app/Story.js +60 -48
- package/dist/bundled/client/app/global-components.js +5 -3
- package/dist/bundled/client/app/host.js +9 -5
- package/dist/bundled/client/app/util.js +13 -4
- package/dist/bundled/client/server/Story.js +13 -0
- package/dist/bundled/client/server/collect.js +41 -0
- package/dist/bundled/client/server/run.js +10 -39
- package/dist/bundled/testing/index.js +168 -0
- package/dist/client/app/Story.d.ts +19 -1
- package/dist/client/app/Variant.d.ts +2 -2
- package/dist/client/app/global-components.d.ts +1 -0
- package/dist/client/app/host.d.ts +8 -0
- package/dist/client/app/util.d.ts +16 -0
- package/dist/client/server/Story.d.ts +21 -3
- package/dist/client/server/collect.d.ts +14 -0
- package/dist/client-dependencies.d.ts +2 -0
- package/dist/client-dependencies.js +2 -0
- package/dist/docgen/events.d.ts +9 -0
- package/dist/docgen/events.js +45 -0
- package/dist/docgen/extractor.d.ts +0 -1
- package/dist/docgen/extractor.js +28 -9
- package/dist/docgen/generic.d.ts +5 -0
- package/dist/docgen/generic.js +19 -0
- package/dist/docgen/meta.d.ts +12 -9
- package/dist/docgen/meta.js +39 -13
- package/dist/docgen/slots.d.ts +6 -0
- package/dist/docgen/slots.js +21 -0
- package/dist/index.node.d.ts +2 -0
- package/dist/index.node.js +18 -15
- package/dist/testing/index.d.ts +28 -0
- package/dist/testing/vite.d.ts +18 -0
- package/dist/testing/vite.js +171 -0
- package/dist/util/project-vue.d.ts +1 -0
- package/dist/util/project-vue.js +12 -0
- package/package.json +26 -9
- package/src/client/app/Story.ts +15 -3
- package/src/client/app/global-components.ts +4 -0
- package/src/client/app/host.ts +21 -2
- package/src/client/app/util.ts +40 -0
- package/src/client/server/Story.ts +15 -2
- package/src/client/server/collect.ts +83 -0
- package/src/client/server/run.ts +3 -63
- package/src/client-dependencies.ts +2 -0
- package/src/docgen/events.ts +55 -0
- package/src/docgen/extractor.ts +30 -10
- package/src/docgen/generic.ts +20 -0
- package/src/docgen/meta.ts +46 -21
- package/src/docgen/slots.ts +33 -0
- package/src/index.node.ts +20 -16
- package/src/testing/index.ts +286 -0
- package/src/testing/vite.ts +194 -0
- package/src/util/project-vue.ts +13 -0
- package/testing.d.ts +2 -0
- package/dist/docgen/typescript.d.ts +0 -9
- package/dist/docgen/typescript.js +0 -36
- package/src/docgen/typescript.ts +0 -41
package/src/docgen/meta.ts
CHANGED
|
@@ -1,17 +1,13 @@
|
|
|
1
1
|
import type { ComponentDoc, DocEvent, DocProp, DocSlot, DocTag } from '@poveste/shared'
|
|
2
|
+
import type { DocgenOptions } from 'poveste/docgen'
|
|
2
3
|
import type { ComponentMeta, EventMeta, PropertyMeta, SlotMeta } from 'vue-component-meta'
|
|
4
|
+
import type { EventDoc } from './events.js'
|
|
5
|
+
import { declaredOnlyInPackages, DEFAULT_TAGS, HIDDEN_TAGS, normalizeDefault } from 'poveste/docgen'
|
|
3
6
|
|
|
4
|
-
export
|
|
5
|
-
/** Packages whose props count although they are declared under `node_modules`. */
|
|
6
|
-
allow?: string[]
|
|
7
|
-
}
|
|
7
|
+
export type VueDocgenOptions = DocgenOptions
|
|
8
8
|
|
|
9
9
|
type Tags = { name: string, text?: string }[]
|
|
10
10
|
|
|
11
|
-
const HIDDEN_TAGS = ['internal', 'private']
|
|
12
|
-
// Aliases: bits-ui mixes them about 50/50, reka-ui and nuxt/ui write `@defaultValue`.
|
|
13
|
-
const DEFAULT_TAGS = ['default', 'defaultValue']
|
|
14
|
-
|
|
15
11
|
function tagsOf(tags: Tags): DocTag[] {
|
|
16
12
|
return tags.map(({ name, text }) => text === undefined ? { name } : { name, text })
|
|
17
13
|
}
|
|
@@ -20,16 +16,8 @@ function isHidden(tags: Tags) {
|
|
|
20
16
|
return tags.some(tag => HIDDEN_TAGS.includes(tag.name))
|
|
21
17
|
}
|
|
22
18
|
|
|
23
|
-
/** Declared only in installed packages none of which is allowed: an inherited attribute. */
|
|
24
19
|
function isInherited(prop: PropertyMeta, allow: string[]) {
|
|
25
|
-
|
|
26
|
-
return files.length > 0 && files.every(file =>
|
|
27
|
-
file.includes('/node_modules/') && !allow.some(pkg => file.includes(`/node_modules/${pkg}/`)))
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** A default as written, without the quoting a tag or the printer added. */
|
|
31
|
-
export function normalizeDefault(value: string) {
|
|
32
|
-
return value.trim().replace(/^`(.*)`$/s, '$1').replace(/^'(.*)'$/s, '"$1"')
|
|
20
|
+
return declaredOnlyInPackages(prop.getDeclarations().map(declaration => declaration.file), allow)
|
|
33
21
|
}
|
|
34
22
|
|
|
35
23
|
function toProp(prop: PropertyMeta): DocProp {
|
|
@@ -49,7 +37,7 @@ function toProp(prop: PropertyMeta): DocProp {
|
|
|
49
37
|
}
|
|
50
38
|
}
|
|
51
39
|
|
|
52
|
-
function toMember(member: SlotMeta | EventMeta): DocSlot & DocEvent {
|
|
40
|
+
function toMember(member: Pick<SlotMeta | EventMeta, 'name' | 'description' | 'type' | 'tags'>): DocSlot & DocEvent {
|
|
53
41
|
return {
|
|
54
42
|
name: member.name,
|
|
55
43
|
...member.description ? { description: member.description } : {},
|
|
@@ -58,11 +46,48 @@ function toMember(member: SlotMeta | EventMeta): DocSlot & DocEvent {
|
|
|
58
46
|
}
|
|
59
47
|
}
|
|
60
48
|
|
|
61
|
-
|
|
49
|
+
/**
|
|
50
|
+
* An event's payload, which vue-component-meta prints as the tuple it is declared as,
|
|
51
|
+
* `[event: KeyboardEvent]`, written as the parameter list a handler takes (#1436). Only
|
|
52
|
+
* when the outer brackets enclose the whole of it: `[a: A] | [b: B]` is left as it is.
|
|
53
|
+
*/
|
|
54
|
+
export function asParameters(type: string): string {
|
|
55
|
+
if (!type.startsWith('[')) {
|
|
56
|
+
return type
|
|
57
|
+
}
|
|
58
|
+
let depth = 0
|
|
59
|
+
for (let i = 0; i < type.length; i++) {
|
|
60
|
+
if ('[(<{'.includes(type[i]!)) {
|
|
61
|
+
depth++
|
|
62
|
+
}
|
|
63
|
+
// The `>` of an arrow, `() => void`, closes nothing.
|
|
64
|
+
else if (']>)}'.includes(type[i]!) && !(type[i] === '>' && type[i - 1] === '=') && --depth === 0) {
|
|
65
|
+
return i === type.length - 1 ? `(${type.slice(1, -1)})` : type
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return type
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// What vue-component-meta returns for an event is empty wherever its own reading is (#1462).
|
|
72
|
+
function withDocs(event: EventMeta, doc: EventDoc | undefined): Pick<EventMeta, 'name' | 'description' | 'type' | 'tags'> {
|
|
73
|
+
return {
|
|
74
|
+
name: event.name,
|
|
75
|
+
description: event.description || doc?.description || '',
|
|
76
|
+
type: asParameters(event.type),
|
|
77
|
+
tags: event.tags.length ? event.tags : doc?.tags ?? [],
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toSlot(slot: SlotMeta, slotProps: (slot: SlotMeta) => DocSlot['props'] & {}): DocSlot {
|
|
82
|
+
const props = slotProps(slot)
|
|
83
|
+
return { ...toMember(slot), ...props.length ? { props } : {} }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function toComponentDoc(meta: ComponentMeta, options: VueDocgenOptions = {}, eventDocs = new Map<string, EventDoc>(), slotProps: (slot: SlotMeta) => DocSlot['props'] & {} = () => []): ComponentDoc {
|
|
62
87
|
const allow = options.allow ?? []
|
|
63
88
|
return {
|
|
64
89
|
props: meta.props.filter(prop => !prop.global && !isHidden(prop.tags) && !isInherited(prop, allow)).map(toProp),
|
|
65
|
-
slots: meta.slots.filter(slot => !isHidden(slot.tags)).map(
|
|
66
|
-
events: meta.events.filter(event => !isHidden(event.tags)).map(toMember),
|
|
90
|
+
slots: meta.slots.filter(slot => !isHidden(slot.tags)).map(slot => toSlot(slot, slotProps)),
|
|
91
|
+
events: meta.events.map(event => withDocs(event, eventDocs.get(event.name))).filter(event => !isHidden(event.tags)).map(toMember),
|
|
67
92
|
}
|
|
68
93
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DocSlot } from '@poveste/shared'
|
|
2
|
+
import type ts from 'typescript'
|
|
3
|
+
import type { SlotMeta } from 'vue-component-meta'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* vue-component-meta gives a slot's props as one type string, `{ open: boolean; close:
|
|
7
|
+
* () => void; }`, with the JSDoc on each of them lost inside it, and reka-ui writes its
|
|
8
|
+
* slot documentation there and nowhere else (#1436). The slot's type object resolves
|
|
9
|
+
* through the same program, one property each.
|
|
10
|
+
*/
|
|
11
|
+
type DocSlotProp = NonNullable<DocSlot['props']>[number]
|
|
12
|
+
|
|
13
|
+
export function readSlotProps(typescript: typeof ts, program: ts.Program | undefined, slot: SlotMeta): DocSlotProp[] {
|
|
14
|
+
if (!program) {
|
|
15
|
+
return []
|
|
16
|
+
}
|
|
17
|
+
const checker = program.getTypeChecker()
|
|
18
|
+
let type: ts.Type
|
|
19
|
+
try {
|
|
20
|
+
type = slot.getTypeObject()
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return []
|
|
24
|
+
}
|
|
25
|
+
return type.getProperties().map((property) => {
|
|
26
|
+
const description = typescript.displayPartsToString(property.getDocumentationComment(checker))
|
|
27
|
+
return {
|
|
28
|
+
name: property.name,
|
|
29
|
+
type: checker.typeToString(checker.getTypeOfSymbol(property)),
|
|
30
|
+
...description ? { description } : {},
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
}
|
package/src/index.node.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import type { Plugin } from 'poveste'
|
|
2
2
|
|
|
3
|
-
import { readFileSync } from 'node:fs'
|
|
4
3
|
import { createRequire } from 'node:module'
|
|
5
|
-
import { join } from 'node:path'
|
|
6
4
|
import { fileURLToPath } from 'node:url'
|
|
7
5
|
|
|
6
|
+
import { CLIENT_DEPENDENCIES } from './client-dependencies.js'
|
|
8
7
|
import generateStoryCommand from './commands/generate-story.server.js'
|
|
9
|
-
import { typescriptProblem } from './docgen/typescript.js'
|
|
10
8
|
import { VUE_SETUP_HOOK_NAMES } from './setup-hooks.js'
|
|
11
9
|
import { listComponentFiles } from './util/list-components.js'
|
|
10
|
+
import { projectVueVersion } from './util/project-vue.js'
|
|
12
11
|
import { VAPOR_INTEROP_ID, vaporInteropModule, withoutVaporPlugin } from './util/vapor.js'
|
|
13
12
|
import { requireVuePlugin } from './util/vue-plugin.js'
|
|
14
13
|
|
|
14
|
+
const SCRIPT_COMPONENT = /(?<!\.d)\.[cm]?[jt]sx?$/
|
|
15
|
+
|
|
15
16
|
export function HstVue(): Plugin {
|
|
16
17
|
return {
|
|
17
18
|
name: '@poveste/plugin-vue',
|
|
@@ -31,6 +32,14 @@ export function HstVue(): Plugin {
|
|
|
31
32
|
config() {
|
|
32
33
|
return {
|
|
33
34
|
vite: {
|
|
35
|
+
// The chrome's client code imports these, and the scan never reaches them: it
|
|
36
|
+
// loads through virtual modules, so a cold `poveste dev` found them only when the
|
|
37
|
+
// page asked, re-bundled and reloaded a Nuxt reader's first page (#1477). Named
|
|
38
|
+
// through this package, which every Vue, Nuxt and Quasar book lists, because the
|
|
39
|
+
// book itself cannot resolve them under pnpm.
|
|
40
|
+
optimizeDeps: {
|
|
41
|
+
include: CLIENT_DEPENDENCIES.map(name => `@poveste/plugin-vue > ${name}`),
|
|
42
|
+
},
|
|
34
43
|
plugins: [
|
|
35
44
|
requireVuePlugin(),
|
|
36
45
|
withoutVaporPlugin(),
|
|
@@ -63,11 +72,14 @@ export function HstVue(): Plugin {
|
|
|
63
72
|
}
|
|
64
73
|
},
|
|
65
74
|
|
|
66
|
-
// A story file documents nothing: it names the components that do.
|
|
75
|
+
// A story file documents nothing: it names the components that do. A script file is
|
|
76
|
+
// offered too, for a component written with `defineComponent` (#1463); the extractor
|
|
77
|
+
// answers nothing for one that isn't.
|
|
67
78
|
docgen: {
|
|
68
|
-
match: file => file.endsWith('.vue') && !file.
|
|
79
|
+
match: file => (file.endsWith('.vue') || SCRIPT_COMPONENT.test(file)) && !/\.story\.[^/]+$/.test(file) && !file.includes('/node_modules/'),
|
|
69
80
|
module: fileURLToPath(new URL('./docgen/extractor.js', import.meta.url)),
|
|
70
|
-
|
|
81
|
+
// `vue-component-meta` requires `typescript` from where it is installed, an optional peer npm does not add.
|
|
82
|
+
typescript: createRequire(import.meta.url).resolve('vue-component-meta'),
|
|
71
83
|
},
|
|
72
84
|
|
|
73
85
|
supportPlugin: {
|
|
@@ -93,14 +105,6 @@ export function HstVue(): Plugin {
|
|
|
93
105
|
}
|
|
94
106
|
}
|
|
95
107
|
|
|
96
|
-
function projectVueVersion(root: string): string | undefined {
|
|
97
|
-
try {
|
|
98
|
-
const manifest = createRequire(join(root, 'package.json')).resolve('vue/package.json')
|
|
99
|
-
return JSON.parse(readFileSync(manifest, 'utf8')).version
|
|
100
|
-
}
|
|
101
|
-
catch {
|
|
102
|
-
return undefined
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
108
|
export * from './helpers.js'
|
|
109
|
+
export { povesteTesting } from './testing/vite.js'
|
|
110
|
+
export type { PovesteTestingOptions } from './testing/vite.js'
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { ServerStory, ServerStoryFile, Story, StoryFile, Variant } from '@poveste/shared'
|
|
2
|
+
import type { App, Component } from 'vue'
|
|
3
|
+
import type { PreviewRenderContext } from '../client/app/render-context.js'
|
|
4
|
+
// @ts-expect-error virtual module id
|
|
5
|
+
import { environmentApp, environmentReady } from 'virtual:$poveste-testing-environment'
|
|
6
|
+
import { describe, it } from 'vitest'
|
|
7
|
+
import { markRaw, nextTick, reactive } from 'vue'
|
|
8
|
+
import { registerControlComponents } from '../client/app/global-components.js'
|
|
9
|
+
import { createPreviewHost } from '../client/app/host.js'
|
|
10
|
+
import { syncStateBundledAndExternal } from '../client/app/util.js'
|
|
11
|
+
import { collectComponent } from '../client/server/collect.js'
|
|
12
|
+
|
|
13
|
+
export interface TestStoriesOptions {
|
|
14
|
+
/** The outer `describe`'s name. The story file's name by default. */
|
|
15
|
+
name?: string
|
|
16
|
+
/**
|
|
17
|
+
* How long a variant's setup may take to settle, in milliseconds: an
|
|
18
|
+
* `initState` or an async `setup()` still pending then fails the test.
|
|
19
|
+
*/
|
|
20
|
+
timeout?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type StoryComponent = Component & { __file?: string, doc?: string }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Turns a story file into one test per variant. Each test renders the variant
|
|
27
|
+
* the way the preview does, and fails on what the preview would only log: an
|
|
28
|
+
* error from setup or render, a rejected `initState`, or a `[Vue warn]`.
|
|
29
|
+
*
|
|
30
|
+
* ```ts
|
|
31
|
+
* import { testStories } from '@poveste/plugin-vue/testing'
|
|
32
|
+
* import BaseButton from './BaseButton.story.vue'
|
|
33
|
+
*
|
|
34
|
+
* testStories(BaseButton)
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function testStories(component: StoryComponent, options: TestStoriesOptions = {}): void {
|
|
38
|
+
const name = options.name ?? fileName(component.__file) ?? 'stories'
|
|
39
|
+
const timeout = options.timeout ?? 5000
|
|
40
|
+
|
|
41
|
+
describe(name, async () => {
|
|
42
|
+
await environmentReady()
|
|
43
|
+
const { stories, problems } = await collect(component, name)
|
|
44
|
+
|
|
45
|
+
if (problems.length || !stories.length) {
|
|
46
|
+
it('collects', () => {
|
|
47
|
+
throw failure(`collecting ${name}`, problems.length ? problems : ['it has no <Story>'])
|
|
48
|
+
})
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const story of stories) {
|
|
53
|
+
describe(story.title, () => {
|
|
54
|
+
for (const variant of story.variants) {
|
|
55
|
+
it(variant.title, async () => {
|
|
56
|
+
const found = await renderVariant(story, variant, timeout)
|
|
57
|
+
if (found.length) {
|
|
58
|
+
throw failure(`${story.title} › ${variant.title}`, found)
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function collect(component: StoryComponent, name: string) {
|
|
68
|
+
const path = component.__file ?? name
|
|
69
|
+
const base = fileName(path) ?? name
|
|
70
|
+
const file = {
|
|
71
|
+
id: name.replace(/\W+/g, '-').toLowerCase(),
|
|
72
|
+
path,
|
|
73
|
+
moduleId: path,
|
|
74
|
+
// As poveste names it: what a `<Story>` with no `title` is called.
|
|
75
|
+
fileName: base.includes('.') ? base.slice(0, base.indexOf('.')) : base,
|
|
76
|
+
} as ServerStoryFile
|
|
77
|
+
const storyData: ServerStory[] = []
|
|
78
|
+
const problems: unknown[] = []
|
|
79
|
+
const el = document.createElement('div')
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await collectComponent(component, { file, storyData, el }, {
|
|
83
|
+
beforeSetup(app) {
|
|
84
|
+
app.config.errorHandler = (err) => {
|
|
85
|
+
problems.push(err)
|
|
86
|
+
}
|
|
87
|
+
app.config.warnHandler = (message, _instance, trace) => {
|
|
88
|
+
problems.push(vueWarning(message, trace))
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
afterSetup(app) {
|
|
92
|
+
copyMissing(app._context.components, controlComponents())
|
|
93
|
+
inheritEnvironmentApp(app)
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
// A setup file that throws, reported as the file's one failing test.
|
|
99
|
+
problems.push(err)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const storyFile = {
|
|
103
|
+
id: file.id,
|
|
104
|
+
supportPluginId: 'vue3',
|
|
105
|
+
component: markRaw(component),
|
|
106
|
+
path: [],
|
|
107
|
+
filePath: file.path,
|
|
108
|
+
source: async () => ({ default: '' }),
|
|
109
|
+
} as unknown as StoryFile
|
|
110
|
+
|
|
111
|
+
const stories = storyData.map((data) => {
|
|
112
|
+
const story: Story = {
|
|
113
|
+
...data,
|
|
114
|
+
file: markRaw(storyFile),
|
|
115
|
+
variants: data.variants.map(variant => toClientVariant(variant as unknown as Variant)),
|
|
116
|
+
slots: () => ({}),
|
|
117
|
+
} as Story
|
|
118
|
+
storyFile.story = story
|
|
119
|
+
return story
|
|
120
|
+
})
|
|
121
|
+
return { stories, problems }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function toClientVariant(variant: Variant): Variant {
|
|
125
|
+
return {
|
|
126
|
+
...variant,
|
|
127
|
+
state: reactive({
|
|
128
|
+
_hPropState: {},
|
|
129
|
+
_hPropDefs: {},
|
|
130
|
+
}),
|
|
131
|
+
setupApp: undefined,
|
|
132
|
+
slots: () => ({}),
|
|
133
|
+
previewReady: false,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The mount pass a sandbox runs, for the variant's `initState`, then the render
|
|
139
|
+
* pass for its default slot: what the preview shows.
|
|
140
|
+
*/
|
|
141
|
+
async function renderVariant(story: Story, variant: Variant, timeout: number): Promise<unknown[]> {
|
|
142
|
+
const found: unknown[] = []
|
|
143
|
+
const onError = (err: unknown) => {
|
|
144
|
+
found.push(err)
|
|
145
|
+
}
|
|
146
|
+
const onWarn = (message: string, trace: string) => {
|
|
147
|
+
found.push(vueWarning(message, trace))
|
|
148
|
+
}
|
|
149
|
+
let resolved = false
|
|
150
|
+
const el = document.createElement('div')
|
|
151
|
+
document.body.appendChild(el)
|
|
152
|
+
|
|
153
|
+
const mountHost = createPreviewHost({
|
|
154
|
+
name: 'StoryTestMount',
|
|
155
|
+
el,
|
|
156
|
+
getStory: () => story,
|
|
157
|
+
getVariant: () => null,
|
|
158
|
+
renderContext: reactive({
|
|
159
|
+
mode: 'mount',
|
|
160
|
+
slotName: 'default',
|
|
161
|
+
currentVariant: null,
|
|
162
|
+
targetVariantId: variant.id,
|
|
163
|
+
externalState: null,
|
|
164
|
+
nextVariantIndex: { value: 0 },
|
|
165
|
+
}) as PreviewRenderContext,
|
|
166
|
+
onError,
|
|
167
|
+
onWarn,
|
|
168
|
+
configureApp: inheritEnvironmentApp,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const externalState = reactive<Variant['state']>({})
|
|
172
|
+
const stateSync = syncStateBundledAndExternal(variant.state, externalState)
|
|
173
|
+
const renderHost = createPreviewHost({
|
|
174
|
+
name: 'StoryTestRender',
|
|
175
|
+
el,
|
|
176
|
+
getStory: () => story,
|
|
177
|
+
getVariant: () => variant,
|
|
178
|
+
renderContext: reactive({
|
|
179
|
+
mode: 'render',
|
|
180
|
+
slotName: 'default',
|
|
181
|
+
currentVariant: variant,
|
|
182
|
+
externalState,
|
|
183
|
+
nextVariantIndex: { value: 0 },
|
|
184
|
+
}) as PreviewRenderContext,
|
|
185
|
+
wrapInDiv: true,
|
|
186
|
+
onError,
|
|
187
|
+
onWarn,
|
|
188
|
+
onResolve: () => {
|
|
189
|
+
resolved = true
|
|
190
|
+
},
|
|
191
|
+
configureApp: inheritEnvironmentApp,
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
await mountHost.mount()
|
|
196
|
+
if (!await settled(() => !!variant.configReady || found.length > 0, timeout)) {
|
|
197
|
+
found.push(new Error(`its setup did not settle within ${timeout} ms`))
|
|
198
|
+
}
|
|
199
|
+
if (!found.length) {
|
|
200
|
+
await renderHost.mount()
|
|
201
|
+
// Until its async setups settle: one that throws after an `await` is caught
|
|
202
|
+
// only once it does.
|
|
203
|
+
if (!await settled(() => resolved || found.length > 0, timeout)) {
|
|
204
|
+
found.push(new Error(`its render did not settle within ${timeout} ms`))
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (err) {
|
|
209
|
+
found.push(err)
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
renderHost.unmount()
|
|
213
|
+
mountHost.unmount()
|
|
214
|
+
stateSync.stop()
|
|
215
|
+
el.remove()
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return found
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// The controls' globals, registered without the warning a second registration of
|
|
222
|
+
// a name the book's setup file already took would print.
|
|
223
|
+
function controlComponents(): Record<string, Component> {
|
|
224
|
+
const registry: Record<string, Component> = {}
|
|
225
|
+
registerControlComponents({ component: (name: string, component: Component) => {
|
|
226
|
+
registry[name] = component
|
|
227
|
+
} } as unknown as App)
|
|
228
|
+
return registry
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Gives a story's app the context of the app the test environment boots, so the
|
|
233
|
+
* story's `useNuxtApp()` and the plugins' injections resolve as in `poveste dev`.
|
|
234
|
+
* What `@nuxt/test-utils`' own mounts copy.
|
|
235
|
+
*/
|
|
236
|
+
function inheritEnvironmentApp(app: App) {
|
|
237
|
+
const host: App | undefined = environmentApp()
|
|
238
|
+
if (!host) {
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
copyMissing(app, host)
|
|
242
|
+
copyMissing(app._context.provides, host._context.provides)
|
|
243
|
+
copyMissing(app.config.globalProperties, host.config.globalProperties)
|
|
244
|
+
copyMissing(app._context.components, host._context.components)
|
|
245
|
+
copyMissing(app._context.directives, host._context.directives)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// By descriptor: Nuxt defines `$nuxt` and its injections as getters that aren't
|
|
249
|
+
// enumerable, so a spread or `Object.assign` leaves them behind.
|
|
250
|
+
function copyMissing(target: object, source: object) {
|
|
251
|
+
for (const key of Reflect.ownKeys(source)) {
|
|
252
|
+
if (!(key in target)) {
|
|
253
|
+
Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)!)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function settled(done: () => boolean, timeout: number): Promise<boolean> {
|
|
259
|
+
const deadline = Date.now() + timeout
|
|
260
|
+
do {
|
|
261
|
+
await nextTick()
|
|
262
|
+
await new Promise(resolve => setTimeout(resolve))
|
|
263
|
+
if (done()) {
|
|
264
|
+
return true
|
|
265
|
+
}
|
|
266
|
+
} while (Date.now() < deadline)
|
|
267
|
+
return done()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function failure(where: string, found: unknown[]): Error {
|
|
271
|
+
const first = found[0]
|
|
272
|
+
const lines = found.map(item => item instanceof Error ? (item.stack ?? item.message) : String(item))
|
|
273
|
+
const error = new Error(`${where}: ${found.length === 1 ? 'one problem' : `${found.length} problems`}\n\n${lines.join('\n\n')}`)
|
|
274
|
+
if (first instanceof Error) {
|
|
275
|
+
error.cause = first
|
|
276
|
+
}
|
|
277
|
+
return error
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function vueWarning(message: string, trace: string): string {
|
|
281
|
+
return `[Vue warn]: ${message}${trace ? `\n${trace}` : ''}`
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function fileName(path: string | undefined): string | undefined {
|
|
285
|
+
return path?.split(/[\\/]/).pop()
|
|
286
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { PovesteConfig } from 'poveste'
|
|
2
|
+
import type { Plugin, UserConfig } from 'vite'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
6
|
+
import { clientConfig, VIRTUAL_IDS } from '@poveste/shared'
|
|
7
|
+
import { getDefaultConfig, loadConfigFile, mergeConfig, resolveConfigFile } from 'poveste'
|
|
8
|
+
import { VUE_SETUP_HOOK_NAMES } from '../setup-hooks.js'
|
|
9
|
+
import { projectVueVersion } from '../util/project-vue.js'
|
|
10
|
+
import { VAPOR_INTEROP_ID, vaporInteropModule } from '../util/vapor.js'
|
|
11
|
+
|
|
12
|
+
const EMPTY_SETUP_ID = '\0poveste-testing:empty-setup'
|
|
13
|
+
const CONFIG_RESOLVED_ID = `\0${VIRTUAL_IDS.config}`
|
|
14
|
+
const ENVIRONMENT_ID = 'virtual:$poveste-testing-environment'
|
|
15
|
+
|
|
16
|
+
// Vitest hands what it finds in `node_modules` to Node, which can't load the
|
|
17
|
+
// `virtual:` modules these packages import, so in a book that installs them no test
|
|
18
|
+
// ran (#1503). The workspace links them, and Vitest inlines a linked package.
|
|
19
|
+
const POVESTE_PACKAGES = [/\/node_modules\/poveste\//, /\/node_modules\/@poveste\//]
|
|
20
|
+
|
|
21
|
+
const EMPTY_SETUP = VUE_SETUP_HOOK_NAMES.map(name => `export const ${name} = undefined`).join('\n')
|
|
22
|
+
|
|
23
|
+
// `logEvent` from `poveste/client` reaches `@poveste/app`, which names these. A
|
|
24
|
+
// test never runs the code that reads them, but Vite's dependency scan follows
|
|
25
|
+
// the imports, and one it can't resolve skips pre-bundling and reloads the
|
|
26
|
+
// browser run partway through.
|
|
27
|
+
const INERT: Record<string, string> = {
|
|
28
|
+
[VIRTUAL_IDS.markdownFiles]: 'export const markdownFiles = []\n',
|
|
29
|
+
[VIRTUAL_IDS.componentDocs]: 'export const componentDocs = {}\n',
|
|
30
|
+
[VIRTUAL_IDS.supportPluginsClient]: 'export const clientSupportPlugins = {}\n',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Nuxt boots in `@nuxt/test-utils`' `beforeAll`, after Vitest has collected the
|
|
34
|
+
// tests, and a story's own setup can call a Nuxt composable while it is being
|
|
35
|
+
// collected. So its own `setupNuxt` runs before collection, into the promise that
|
|
36
|
+
// `beforeAll` awaits, and Nuxt boots once and in full. Both names are internal to
|
|
37
|
+
// `@nuxt/test-utils`: if a version renames them, the story tests fail loudly.
|
|
38
|
+
function nuxtEnvironment(setupNuxt: string) {
|
|
39
|
+
return `import { tryUseNuxtApp } from '#app/nuxt'
|
|
40
|
+
import { setupNuxt } from ${JSON.stringify(setupNuxt)}
|
|
41
|
+
|
|
42
|
+
export async function environmentReady() {
|
|
43
|
+
const win = window
|
|
44
|
+
if (!win.__NUXT_VITEST_ENVIRONMENT__ || tryUseNuxtApp()) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
win.__NUXT_VITEST_ENVIRONMENT_PROMISE__ ??= setupNuxt()
|
|
48
|
+
await win.__NUXT_VITEST_ENVIRONMENT_PROMISE__
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function environmentApp() {
|
|
52
|
+
return tryUseNuxtApp()?.vueApp
|
|
53
|
+
}
|
|
54
|
+
`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const NO_ENVIRONMENT = `export async function environmentReady() {}\nexport function environmentApp() {\n return undefined\n}\n`
|
|
58
|
+
|
|
59
|
+
// `@nuxt/test-utils`' own boot, by file: its `exports` don't list it.
|
|
60
|
+
function setupNuxtFile(root: string): string | undefined {
|
|
61
|
+
try {
|
|
62
|
+
const file = join(dirname(createRequire(join(root, 'package.json')).resolve('@nuxt/test-utils/package.json')), 'dist/runtime/shared/nuxt.mjs')
|
|
63
|
+
return existsSync(file) ? file : undefined
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Root-relative, as `poveste dev` reads it: the docs write `/src/poveste.setup.ts`,
|
|
71
|
+
// which `resolve` takes as absolute, so pre-bundling scanned a file outside the
|
|
72
|
+
// book and the setup file's imports were found late, with a reload (#1507).
|
|
73
|
+
function setupFileOf(config: Partial<PovesteConfig>): string | undefined {
|
|
74
|
+
const setupFile = config.setupFile
|
|
75
|
+
const file = typeof setupFile === 'string' ? setupFile : setupFile && 'browser' in setupFile ? setupFile.browser : undefined
|
|
76
|
+
return file?.replace(/^\/+/, '')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface PovesteTestingOptions {
|
|
80
|
+
/** The book's poveste config file. Found from the Vite root by default. */
|
|
81
|
+
configFile?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What a story needs in the user's own Vitest to render as `poveste dev` renders
|
|
86
|
+
* it: the book's setup file and the modules Poveste's client imports, with
|
|
87
|
+
* Poveste's own packages inlined so Vitest can serve those, and `vue` and the
|
|
88
|
+
* setup file pre-bundled for browser mode. Nothing else: no alias and no change
|
|
89
|
+
* to how anything resolves, so the test sees the project's Vite config as the
|
|
90
|
+
* project wrote it.
|
|
91
|
+
*
|
|
92
|
+
* Setup code that a plugin generates for `poveste dev`, such as the Nuxt app
|
|
93
|
+
* `@poveste/plugin-nuxt` boots, is left to the test environment, which already
|
|
94
|
+
* provides it: `@nuxt/test-utils`'s `nuxt` environment does in a Nuxt project.
|
|
95
|
+
*/
|
|
96
|
+
export function povesteTesting(options: PovesteTestingOptions = {}): Plugin {
|
|
97
|
+
let root = process.cwd()
|
|
98
|
+
let config: Partial<PovesteConfig> = {}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
name: 'poveste:testing',
|
|
102
|
+
|
|
103
|
+
async config(user) {
|
|
104
|
+
root = resolve(user.root ?? process.cwd())
|
|
105
|
+
const file = options.configFile ? resolve(root, options.configFile) : resolveConfigFile(root)
|
|
106
|
+
const fromFile = file ? await loadConfigFile(file) : {}
|
|
107
|
+
// As `poveste dev` reads it: the config file, then the Vite config's key.
|
|
108
|
+
const fromVite = ((user as { poveste?: unknown, histoire?: unknown }).poveste
|
|
109
|
+
?? (user as { histoire?: unknown }).histoire ?? {}) as Partial<PovesteConfig>
|
|
110
|
+
config = mergeConfig(fromFile, fromVite) as Partial<PovesteConfig>
|
|
111
|
+
|
|
112
|
+
// In browser mode Vite pre-bundles what its scan finds. It never sees `vue`,
|
|
113
|
+
// which compiled templates and this package's own bundle import, nor the
|
|
114
|
+
// setup file, which arrives as a virtual module. Each was bundled later,
|
|
115
|
+
// with a reload partway through the first run, and two copies of Vue
|
|
116
|
+
// failed it (#1503).
|
|
117
|
+
const setupFile = setupFileOf(config)
|
|
118
|
+
const optimizeDeps = {
|
|
119
|
+
include: ['vue'],
|
|
120
|
+
...setupFile ? { entries: [relative(root, resolve(root, setupFile)).split(sep).join('/')] } : {},
|
|
121
|
+
}
|
|
122
|
+
// `true` already inlines everything, and an array merged into it would not.
|
|
123
|
+
if ((user as { test?: { server?: { deps?: { inline?: unknown } } } }).test?.server?.deps?.inline === true) {
|
|
124
|
+
return { optimizeDeps }
|
|
125
|
+
}
|
|
126
|
+
return { optimizeDeps, test: { server: { deps: { inline: POVESTE_PACKAGES } } } } as UserConfig
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
configResolved(resolved) {
|
|
130
|
+
root = resolved.root
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async resolveId(id, importer) {
|
|
134
|
+
if (id === VIRTUAL_IDS.setup) {
|
|
135
|
+
const file = setupFileOf(config)
|
|
136
|
+
if (file) {
|
|
137
|
+
return this.resolve(resolve(root, file), importer, { skipSelf: true })
|
|
138
|
+
}
|
|
139
|
+
return EMPTY_SETUP_ID
|
|
140
|
+
}
|
|
141
|
+
if (id === VIRTUAL_IDS.generatedGlobalSetup) {
|
|
142
|
+
return EMPTY_SETUP_ID
|
|
143
|
+
}
|
|
144
|
+
if (id === VIRTUAL_IDS.config) {
|
|
145
|
+
return CONFIG_RESOLVED_ID
|
|
146
|
+
}
|
|
147
|
+
if (id === VAPOR_INTEROP_ID) {
|
|
148
|
+
return `\0${VAPOR_INTEROP_ID}`
|
|
149
|
+
}
|
|
150
|
+
if (Object.hasOwn(INERT, id)) {
|
|
151
|
+
return `\0${id}`
|
|
152
|
+
}
|
|
153
|
+
if (id === ENVIRONMENT_ID) {
|
|
154
|
+
return `\0${ENVIRONMENT_ID}`
|
|
155
|
+
}
|
|
156
|
+
return undefined
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// A story's `<docs>` block, which `poveste dev` renders as markdown. A test
|
|
160
|
+
// renders the variants, not the docs, so it only has to import.
|
|
161
|
+
transform(_code, id) {
|
|
162
|
+
if (id.includes('?vue&type=docs')) {
|
|
163
|
+
return 'export default () => {}'
|
|
164
|
+
}
|
|
165
|
+
return undefined
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
async load(id) {
|
|
169
|
+
if (id === EMPTY_SETUP_ID) {
|
|
170
|
+
return EMPTY_SETUP
|
|
171
|
+
}
|
|
172
|
+
if (id === CONFIG_RESOLVED_ID) {
|
|
173
|
+
return `export const config = ${JSON.stringify(clientConfig({ ...getDefaultConfig(), ...config }))}\nexport const logos = {}\n`
|
|
174
|
+
}
|
|
175
|
+
if (id === `\0${VAPOR_INTEROP_ID}`) {
|
|
176
|
+
return vaporInteropModule(projectVueVersion(root))
|
|
177
|
+
}
|
|
178
|
+
if (id.startsWith('\0') && Object.hasOwn(INERT, id.slice(1))) {
|
|
179
|
+
return INERT[id.slice(1)]
|
|
180
|
+
}
|
|
181
|
+
// The app the test environment boots: Nuxt's, under `@nuxt/test-utils`' `nuxt`
|
|
182
|
+
// environment. `poveste dev` renders a story inside the Nuxt app its plugin
|
|
183
|
+
// boots, and a test does the same. Told by Nuxt's own `#app` alias and the
|
|
184
|
+
// test utils being installed, not by `#imports`, which unimport also serves.
|
|
185
|
+
if (id === `\0${ENVIRONMENT_ID}`) {
|
|
186
|
+
const setupNuxt = setupNuxtFile(root)
|
|
187
|
+
return setupNuxt && await this.resolve('#app/nuxt', resolve(root, 'index.html'))
|
|
188
|
+
? nuxtEnvironment(setupNuxt)
|
|
189
|
+
: NO_ENVIRONMENT
|
|
190
|
+
}
|
|
191
|
+
return undefined
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
}
|