@storybook-astro/framework 0.1.0-beta.8 → 1.0.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 +38 -0
- package/dist/base-IRZo3zgK.d.ts +23 -0
- package/dist/chunk-4SWPVM6R.js +96 -0
- package/dist/chunk-4SWPVM6R.js.map +1 -0
- package/dist/chunk-5EF25G5S.js +69 -0
- package/dist/chunk-5EF25G5S.js.map +1 -0
- package/dist/chunk-7GHEQUPV.js +439 -0
- package/dist/chunk-7GHEQUPV.js.map +1 -0
- package/dist/chunk-C5OH4VBR.js +492 -0
- package/dist/chunk-C5OH4VBR.js.map +1 -0
- package/dist/chunk-DNGQBPT7.js +15 -0
- package/dist/chunk-DNGQBPT7.js.map +1 -0
- package/dist/chunk-E4LB75JN.js +89 -0
- package/dist/chunk-E4LB75JN.js.map +1 -0
- package/dist/chunk-PJEDXZVN.js +240 -0
- package/dist/chunk-PJEDXZVN.js.map +1 -0
- package/dist/chunk-UK43WNEA.js +657 -0
- package/dist/chunk-UK43WNEA.js.map +1 -0
- package/dist/dist-HJOEPVRQ.js +15574 -0
- package/dist/dist-HJOEPVRQ.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +13 -64
- package/dist/index.js.map +1 -1
- package/dist/integrations/index.d.ts +138 -0
- package/dist/integrations/index.js +8 -196
- package/dist/integrations/index.js.map +1 -1
- package/dist/middleware.d.ts +26 -0
- package/dist/middleware.js +179 -0
- package/dist/middleware.js.map +1 -0
- package/dist/portable-stories-BvdaQigq.d.ts +83 -0
- package/dist/preset.d.ts +14 -0
- package/dist/preset.js +5 -1
- package/dist/testing.d.ts +27 -0
- package/dist/testing.js +324 -15539
- package/dist/testing.js.map +1 -1
- package/dist/types-CHTsRtA7.d.ts +42 -0
- package/dist/viteStorybookAstroMiddlewarePlugin-NP2E52IC.js +11 -0
- package/dist/viteStorybookAstroMiddlewarePlugin-NP2E52IC.js.map +1 -0
- package/dist/vitest/index.d.ts +19 -0
- package/dist/vitest/index.js +229 -0
- package/dist/vitest/index.js.map +1 -0
- package/package.json +31 -17
- package/src/importAstroConfig.ts +11 -0
- package/src/index.ts +20 -6
- package/src/integrations/alpine.ts +5 -2
- package/src/integrations/base.ts +2 -2
- package/src/integrations/moduleResolver.ts +43 -0
- package/src/integrations/preact.ts +5 -2
- package/src/integrations/react.ts +5 -2
- package/src/integrations/solid.ts +5 -2
- package/src/integrations/svelte.ts +5 -2
- package/src/integrations/vue.ts +5 -2
- package/src/lib/sanitization.test.ts +232 -0
- package/src/lib/sanitization.ts +338 -0
- package/src/lib/ssr-load-module-with-fs-fallback.ts +29 -0
- package/src/middleware.test.ts +48 -0
- package/src/middleware.ts +204 -96
- package/src/module-mocks.ts +16 -0
- package/src/msw-helpers.ts +1 -0
- package/src/msw.ts +58 -0
- package/src/preset.ts +38 -3
- package/src/rules-options.test.ts +71 -0
- package/src/rules-options.ts +87 -0
- package/src/rules.test.ts +183 -0
- package/src/rules.ts +314 -0
- package/src/testing/astro-runtime.ts +219 -0
- package/src/testing/component-utils.ts +32 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/integration-config.ts +121 -0
- package/src/testing/project-root.ts +185 -0
- package/src/testing/renderer-daemon.ts +269 -0
- package/src/testing/story-composition.ts +33 -0
- package/src/testing/types.ts +14 -0
- package/src/testing/working-directory.ts +28 -0
- package/src/testing.ts +1 -254
- package/src/types.ts +16 -4
- package/src/virtual.d.ts +2 -1
- package/src/vite/createVirtualModulePlugin.test.ts +80 -0
- package/src/vite/createVirtualModulePlugin.ts +25 -0
- package/src/viteAstroContainerRenderersPlugin.ts +60 -26
- package/src/vitePluginAstro.ts +12 -5
- package/src/vitePluginAstroBuildPrerender.ts +665 -204
- package/src/vitePluginAstroRoutesFallback.ts +37 -0
- package/src/vitePluginAstroVueFallback.ts +47 -0
- package/src/viteStorybookAstroMiddlewarePlugin.ts +88 -12
- package/src/viteStorybookRendererFallbackPlugin.ts +13 -23
- package/src/vitest/config.ts +95 -0
- package/src/vitest/global-setup.ts +16 -0
- package/src/vitest/index.ts +2 -0
- package/src/vitest/vite-plugins.ts +187 -0
- package/dist/chunk-SAOPE6SA.js +0 -557
- package/dist/chunk-SAOPE6SA.js.map +0 -1
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export function isStorybookAstroClientStub(component: unknown) {
|
|
2
|
+
return (
|
|
3
|
+
typeof component === 'function' &&
|
|
4
|
+
String(component).includes('Astro components are rendered server-side by Storybook')
|
|
5
|
+
);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isAstroComponentFactory(component: unknown) {
|
|
9
|
+
return typeof component === 'function' && 'isAstroComponentFactory' in component;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getComponentModuleId(component: unknown) {
|
|
13
|
+
if (typeof component !== 'function' || !('moduleId' in component)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (typeof component.moduleId !== 'string') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return component.moduleId.split('?')[0].split('#')[0];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getComponentModuleFilePath(component: unknown) {
|
|
25
|
+
const moduleId = getComponentModuleId(component);
|
|
26
|
+
|
|
27
|
+
if (!moduleId || !moduleId.startsWith('/')) {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return moduleId;
|
|
32
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { resolve as resolvePath } from 'node:path';
|
|
2
|
+
import type { Integration } from '../integrations/base.ts';
|
|
3
|
+
import {
|
|
4
|
+
alpinejs,
|
|
5
|
+
preact,
|
|
6
|
+
react,
|
|
7
|
+
solid,
|
|
8
|
+
svelte,
|
|
9
|
+
vue,
|
|
10
|
+
} from '../integrations/index.ts';
|
|
11
|
+
|
|
12
|
+
const TESTING_INTEGRATIONS_ENV = 'STORYBOOK_ASTRO_TESTING_INTEGRATIONS';
|
|
13
|
+
|
|
14
|
+
type SerializedIntegration = {
|
|
15
|
+
name: string;
|
|
16
|
+
options: unknown;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type SerializedIntegrationMap = Record<string, SerializedIntegration[]>;
|
|
20
|
+
|
|
21
|
+
const REGEXP_TAG = '__storybookAstroRegExp';
|
|
22
|
+
|
|
23
|
+
function replacer(_key: string, value: unknown): unknown {
|
|
24
|
+
if (value instanceof RegExp) {
|
|
25
|
+
return {
|
|
26
|
+
[REGEXP_TAG]: true,
|
|
27
|
+
source: value.source,
|
|
28
|
+
flags: value.flags
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function reviver(_key: string, value: unknown): unknown {
|
|
36
|
+
if (
|
|
37
|
+
value &&
|
|
38
|
+
typeof value === 'object' &&
|
|
39
|
+
REGEXP_TAG in value &&
|
|
40
|
+
(value as Record<string, unknown>)[REGEXP_TAG] === true
|
|
41
|
+
) {
|
|
42
|
+
const source = (value as Record<string, unknown>).source;
|
|
43
|
+
const flags = (value as Record<string, unknown>).flags;
|
|
44
|
+
|
|
45
|
+
if (typeof source === 'string' && typeof flags === 'string') {
|
|
46
|
+
return new RegExp(source, flags);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readIntegrationMapFromEnv(): SerializedIntegrationMap {
|
|
54
|
+
const raw = process.env[TESTING_INTEGRATIONS_ENV];
|
|
55
|
+
|
|
56
|
+
if (!raw) {
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(raw, reviver);
|
|
62
|
+
|
|
63
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return parsed as SerializedIntegrationMap;
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function writeIntegrationMapToEnv(value: SerializedIntegrationMap) {
|
|
74
|
+
process.env[TESTING_INTEGRATIONS_ENV] = JSON.stringify(value, replacer);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function serializeIntegration(integration: Integration): SerializedIntegration {
|
|
78
|
+
return {
|
|
79
|
+
name: integration.name,
|
|
80
|
+
options: integration.options
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function deserializeIntegration(integration: SerializedIntegration): Integration {
|
|
85
|
+
switch (integration.name) {
|
|
86
|
+
case 'react':
|
|
87
|
+
return react(integration.options as Parameters<typeof react>[0]);
|
|
88
|
+
case 'solid':
|
|
89
|
+
return solid(integration.options as Parameters<typeof solid>[0]);
|
|
90
|
+
case 'preact':
|
|
91
|
+
return preact(integration.options as Parameters<typeof preact>[0]);
|
|
92
|
+
case 'vue':
|
|
93
|
+
return vue(integration.options as Parameters<typeof vue>[0]);
|
|
94
|
+
case 'svelte':
|
|
95
|
+
return svelte(integration.options as Parameters<typeof svelte>[0]);
|
|
96
|
+
case 'alpine':
|
|
97
|
+
return alpinejs(integration.options as Parameters<typeof alpinejs>[0]);
|
|
98
|
+
default:
|
|
99
|
+
throw new Error(`Unknown testing integration: ${integration.name}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function registerTestingIntegrationsForRoot(root: string, integrations: Integration[]) {
|
|
104
|
+
const normalizedRoot = resolvePath(root);
|
|
105
|
+
const integrationMap = readIntegrationMapFromEnv();
|
|
106
|
+
|
|
107
|
+
integrationMap[normalizedRoot] = integrations.map(serializeIntegration);
|
|
108
|
+
writeIntegrationMapToEnv(integrationMap);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function resolveTestingIntegrationsForRoot(root: string): Integration[] {
|
|
112
|
+
const normalizedRoot = resolvePath(root);
|
|
113
|
+
const integrationMap = readIntegrationMapFromEnv();
|
|
114
|
+
const integrations = integrationMap[normalizedRoot];
|
|
115
|
+
|
|
116
|
+
if (!integrations) {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return integrations.map(deserializeIntegration);
|
|
121
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { getComponentModuleFilePath } from './component-utils.ts';
|
|
6
|
+
|
|
7
|
+
const VITEST_CONFIG_FILES = [
|
|
8
|
+
'vitest.config.ts',
|
|
9
|
+
'vitest.config.mts',
|
|
10
|
+
'vitest.config.js',
|
|
11
|
+
'vitest.config.mjs',
|
|
12
|
+
'vitest.config.cjs'
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
function extractStackFilePath(line: string) {
|
|
16
|
+
const trimmed = line.trim();
|
|
17
|
+
|
|
18
|
+
const match = trimmed.match(/\((.+):(\d+):(\d+)\)$/) ?? trimmed.match(/^at\s+(.+):(\d+):(\d+)$/);
|
|
19
|
+
|
|
20
|
+
if (!match) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const rawPath = match[1];
|
|
25
|
+
|
|
26
|
+
if (rawPath.startsWith('node:')) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (rawPath.startsWith('file://')) {
|
|
31
|
+
return fileURLToPath(rawPath);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (rawPath.startsWith('/')) {
|
|
35
|
+
return rawPath;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getCurrentTestFilePath() {
|
|
42
|
+
try {
|
|
43
|
+
const { expect } = await import('vitest');
|
|
44
|
+
const vitestState = expect.getState() as {
|
|
45
|
+
testPath?: string;
|
|
46
|
+
filepath?: string;
|
|
47
|
+
filePath?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const fromVitestState = vitestState.testPath ?? vitestState.filepath ?? vitestState.filePath;
|
|
51
|
+
|
|
52
|
+
if (typeof fromVitestState === 'string') {
|
|
53
|
+
const absolutePath = fromVitestState.startsWith('/')
|
|
54
|
+
? fromVitestState
|
|
55
|
+
: resolve(process.cwd(), fromVitestState);
|
|
56
|
+
|
|
57
|
+
if (existsSync(absolutePath)) {
|
|
58
|
+
return absolutePath;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
// Fall through to stack-based lookup when Vitest state is unavailable.
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const stack = new Error().stack;
|
|
66
|
+
|
|
67
|
+
if (!stack) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const thisFilePath = fileURLToPath(import.meta.url);
|
|
72
|
+
|
|
73
|
+
for (const line of stack.split('\n')) {
|
|
74
|
+
const filePath = extractStackFilePath(line);
|
|
75
|
+
|
|
76
|
+
if (!filePath) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (filePath === thisFilePath || filePath.includes('/node_modules/')) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (existsSync(filePath)) {
|
|
85
|
+
return filePath;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findNearestVitestConfigDir(startPath: string) {
|
|
93
|
+
let dir = dirname(startPath);
|
|
94
|
+
|
|
95
|
+
while (true) {
|
|
96
|
+
if (VITEST_CONFIG_FILES.some((name) => existsSync(join(dir, name)))) {
|
|
97
|
+
return dir;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const parent = dirname(dir);
|
|
101
|
+
|
|
102
|
+
if (parent === dir) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
dir = parent;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function packageJsonDeclaresAstro(packageJsonPath: string) {
|
|
113
|
+
if (!existsSync(packageJsonPath)) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
119
|
+
|
|
120
|
+
return ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].some(
|
|
121
|
+
(field) =>
|
|
122
|
+
packageJson[field] &&
|
|
123
|
+
typeof packageJson[field] === 'object' &&
|
|
124
|
+
Object.prototype.hasOwnProperty.call(packageJson[field], 'astro')
|
|
125
|
+
);
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function findNearestAstroPackageDir(startPath: string) {
|
|
132
|
+
let dir = dirname(startPath);
|
|
133
|
+
|
|
134
|
+
while (true) {
|
|
135
|
+
const packageJsonPath = join(dir, 'package.json');
|
|
136
|
+
|
|
137
|
+
if (packageJsonDeclaresAstro(packageJsonPath)) {
|
|
138
|
+
return dir;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const parent = dirname(dir);
|
|
142
|
+
|
|
143
|
+
if (parent === dir) {
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
dir = parent;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function canResolveAstroFrom(dir: string) {
|
|
154
|
+
try {
|
|
155
|
+
const require = createRequire(`${join(dir, '__storybook-astro-testing-resolve__.js')}`);
|
|
156
|
+
|
|
157
|
+
require.resolve('astro/package.json');
|
|
158
|
+
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function resolveTestingProjectRoot(component: unknown) {
|
|
166
|
+
const currentTestFilePath = await getCurrentTestFilePath();
|
|
167
|
+
const componentModulePath = getComponentModuleFilePath(component);
|
|
168
|
+
const candidates = [
|
|
169
|
+
currentTestFilePath ? findNearestVitestConfigDir(currentTestFilePath) : null,
|
|
170
|
+
currentTestFilePath ? findNearestAstroPackageDir(currentTestFilePath) : null,
|
|
171
|
+
componentModulePath ? findNearestAstroPackageDir(componentModulePath) : null,
|
|
172
|
+
packageJsonDeclaresAstro(join(process.cwd(), 'package.json')) ? process.cwd() : null,
|
|
173
|
+
process.env.INIT_CWD && packageJsonDeclaresAstro(join(process.env.INIT_CWD, 'package.json'))
|
|
174
|
+
? process.env.INIT_CWD
|
|
175
|
+
: null
|
|
176
|
+
].filter((value): value is string => Boolean(value));
|
|
177
|
+
|
|
178
|
+
for (const candidate of candidates) {
|
|
179
|
+
if (canResolveAstroFrom(candidate)) {
|
|
180
|
+
return candidate;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return process.cwd();
|
|
185
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
2
|
+
import type { IncomingMessage } from 'node:http';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import type { ViteDevServer } from 'vite';
|
|
5
|
+
import { createViteServer } from '../viteStorybookAstroMiddlewarePlugin.ts';
|
|
6
|
+
import { resolveTestingIntegrationsForRoot } from './integration-config.ts';
|
|
7
|
+
import { runWithWorkingDirectory } from './working-directory.ts';
|
|
8
|
+
import { ssrLoadModuleWithFsFallback } from '../lib/ssr-load-module-with-fs-fallback.ts';
|
|
9
|
+
|
|
10
|
+
const RENDER_PATH = '/render';
|
|
11
|
+
|
|
12
|
+
export const TESTING_RENDERER_DAEMON_URL_ENV = 'STORYBOOK_ASTRO_TESTING_RENDERER_DAEMON_URL';
|
|
13
|
+
|
|
14
|
+
type RenderPayload = {
|
|
15
|
+
resolveFrom: string;
|
|
16
|
+
component: string;
|
|
17
|
+
args?: Record<string, unknown>;
|
|
18
|
+
slots?: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type RenderHandler = (data: {
|
|
22
|
+
component: string;
|
|
23
|
+
args?: Record<string, unknown>;
|
|
24
|
+
slots?: Record<string, unknown>;
|
|
25
|
+
}) => Promise<string>;
|
|
26
|
+
|
|
27
|
+
type RunningDaemon = {
|
|
28
|
+
url: string;
|
|
29
|
+
close: () => Promise<void>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
async function readJsonBody(request: IncomingMessage) {
|
|
33
|
+
const chunks: Buffer[] = [];
|
|
34
|
+
|
|
35
|
+
for await (const chunk of request) {
|
|
36
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (chunks.length === 0) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf-8')) as unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function createBadRequest(message: string) {
|
|
47
|
+
const error = new Error(message);
|
|
48
|
+
|
|
49
|
+
(error as Error & { statusCode?: number }).statusCode = 400;
|
|
50
|
+
|
|
51
|
+
return error;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function assertRenderPayload(payload: unknown): asserts payload is RenderPayload {
|
|
55
|
+
if (!payload || typeof payload !== 'object') {
|
|
56
|
+
throw createBadRequest('Invalid render payload.');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const record = payload as Record<string, unknown>;
|
|
60
|
+
|
|
61
|
+
if (typeof record.resolveFrom !== 'string' || record.resolveFrom.length === 0) {
|
|
62
|
+
throw createBadRequest('Missing render payload field: resolveFrom.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof record.component !== 'string' || record.component.length === 0) {
|
|
66
|
+
throw createBadRequest('Missing render payload field: component.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (
|
|
70
|
+
'args' in record &&
|
|
71
|
+
typeof record.args !== 'undefined' &&
|
|
72
|
+
(typeof record.args !== 'object' || record.args === null || Array.isArray(record.args))
|
|
73
|
+
) {
|
|
74
|
+
throw createBadRequest('Render payload field args must be an object when provided.');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (
|
|
78
|
+
'slots' in record &&
|
|
79
|
+
typeof record.slots !== 'undefined' &&
|
|
80
|
+
(typeof record.slots !== 'object' || record.slots === null || Array.isArray(record.slots))
|
|
81
|
+
) {
|
|
82
|
+
throw createBadRequest('Render payload field slots must be an object when provided.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function getErrorStatusCode(error: unknown) {
|
|
87
|
+
if (error && typeof error === 'object' && 'statusCode' in error) {
|
|
88
|
+
const statusCode = (error as { statusCode?: unknown }).statusCode;
|
|
89
|
+
|
|
90
|
+
if (typeof statusCode === 'number' && Number.isInteger(statusCode) && statusCode >= 400) {
|
|
91
|
+
return statusCode;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return 500;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function startTestingRendererDaemon(): Promise<RunningDaemon> {
|
|
99
|
+
// One daemon process serves all Vitest workers; cache is keyed by project root.
|
|
100
|
+
const viteServerPromises = new Map<string, Promise<ViteDevServer>>();
|
|
101
|
+
const renderHandlerPromises = new Map<string, Promise<RenderHandler>>();
|
|
102
|
+
|
|
103
|
+
async function getViteServer(resolveFrom: string) {
|
|
104
|
+
if (!viteServerPromises.has(resolveFrom)) {
|
|
105
|
+
const integrations = resolveTestingIntegrationsForRoot(resolveFrom);
|
|
106
|
+
|
|
107
|
+
viteServerPromises.set(
|
|
108
|
+
resolveFrom,
|
|
109
|
+
runWithWorkingDirectory(resolveFrom, () => createViteServer(integrations, resolveFrom))
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return viteServerPromises.get(resolveFrom)!;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function getRenderHandler(resolveFrom: string) {
|
|
117
|
+
if (!renderHandlerPromises.has(resolveFrom)) {
|
|
118
|
+
renderHandlerPromises.set(resolveFrom, (async () => {
|
|
119
|
+
const integrations = resolveTestingIntegrationsForRoot(resolveFrom);
|
|
120
|
+
const viteServer = await getViteServer(resolveFrom);
|
|
121
|
+
const middlewareModulePath = fileURLToPath(new URL('../middleware', import.meta.url));
|
|
122
|
+
const middleware = await runWithWorkingDirectory(resolveFrom, () =>
|
|
123
|
+
viteServer.ssrLoadModule(middlewareModulePath, {
|
|
124
|
+
fixStacktrace: true
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
return middleware.handlerFactory(integrations, {
|
|
129
|
+
loadModule: (id: string) =>
|
|
130
|
+
ssrLoadModuleWithFsFallback(viteServer, id, {
|
|
131
|
+
fixStacktrace: true
|
|
132
|
+
})
|
|
133
|
+
}) as Promise<RenderHandler>;
|
|
134
|
+
})());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return renderHandlerPromises.get(resolveFrom)!;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const server = createHttpServer(async (request, response) => {
|
|
141
|
+
// Allow cross-origin requests from browser-like test environments (e.g. happy-dom).
|
|
142
|
+
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
143
|
+
response.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
|
144
|
+
response.setHeader('Access-Control-Allow-Headers', 'content-type');
|
|
145
|
+
|
|
146
|
+
if (request.method === 'OPTIONS') {
|
|
147
|
+
response.statusCode = 204;
|
|
148
|
+
response.end();
|
|
149
|
+
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (request.method !== 'POST' || request.url !== RENDER_PATH) {
|
|
154
|
+
response.statusCode = 404;
|
|
155
|
+
response.end();
|
|
156
|
+
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const payload = await readJsonBody(request);
|
|
162
|
+
|
|
163
|
+
assertRenderPayload(payload);
|
|
164
|
+
|
|
165
|
+
const handler = await getRenderHandler(payload.resolveFrom);
|
|
166
|
+
const html = await handler({
|
|
167
|
+
component: payload.component,
|
|
168
|
+
args: payload.args,
|
|
169
|
+
slots: payload.slots
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
response.statusCode = 200;
|
|
173
|
+
response.setHeader('content-type', 'application/json');
|
|
174
|
+
response.end(JSON.stringify({ html }));
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const statusCode = getErrorStatusCode(error);
|
|
177
|
+
|
|
178
|
+
response.statusCode = statusCode;
|
|
179
|
+
response.setHeader('content-type', 'application/json');
|
|
180
|
+
response.end(JSON.stringify({
|
|
181
|
+
error: error instanceof Error ? error.message : String(error)
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await new Promise<void>((resolve, reject) => {
|
|
187
|
+
server.once('error', reject);
|
|
188
|
+
server.listen(0, '127.0.0.1', () => {
|
|
189
|
+
server.off('error', reject);
|
|
190
|
+
resolve();
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const address = server.address();
|
|
195
|
+
|
|
196
|
+
if (!address || typeof address === 'string') {
|
|
197
|
+
throw new Error('Failed to start Storybook Astro testing renderer daemon.');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const url = `http://127.0.0.1:${address.port}${RENDER_PATH}`;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
url,
|
|
204
|
+
close: async () => {
|
|
205
|
+
await Promise.all(
|
|
206
|
+
[...viteServerPromises.values()].map(async (viteServerPromise) => {
|
|
207
|
+
const viteServer = await viteServerPromise;
|
|
208
|
+
|
|
209
|
+
await viteServer.close();
|
|
210
|
+
})
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
await new Promise<void>((resolve, reject) => {
|
|
214
|
+
server.close((error) => {
|
|
215
|
+
if (error) {
|
|
216
|
+
reject(error);
|
|
217
|
+
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
resolve();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function getTestingRendererDaemonUrl() {
|
|
229
|
+
const value = process.env[TESTING_RENDERER_DAEMON_URL_ENV];
|
|
230
|
+
|
|
231
|
+
if (!value || value.length === 0) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function renderViaTestingRendererDaemon(payload: RenderPayload) {
|
|
239
|
+
const daemonUrl = getTestingRendererDaemonUrl();
|
|
240
|
+
|
|
241
|
+
if (!daemonUrl) {
|
|
242
|
+
// Daemon is optional so local in-worker rendering can still be used as fallback.
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// eslint-disable-next-line n/no-unsupported-features/node-builtins
|
|
247
|
+
const response = await fetch(daemonUrl, {
|
|
248
|
+
method: 'POST',
|
|
249
|
+
headers: {
|
|
250
|
+
'content-type': 'application/json'
|
|
251
|
+
},
|
|
252
|
+
body: JSON.stringify(payload)
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const parsed = (await response.json()) as {
|
|
256
|
+
html?: string;
|
|
257
|
+
error?: string;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
throw new Error(parsed.error ?? `Renderer daemon returned ${response.status}.`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (typeof parsed.html !== 'string') {
|
|
265
|
+
throw new Error('Renderer daemon returned an invalid payload.');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return parsed.html;
|
|
269
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {
|
|
2
|
+
composeStories as portableComposeStories,
|
|
3
|
+
composeStory as portableComposeStory,
|
|
4
|
+
setProjectAnnotations as portableSetProjectAnnotations,
|
|
5
|
+
} from '../portable-stories.ts';
|
|
6
|
+
import type { ProjectAnnotations, Store_CSFExports as StoreCsfExports } from 'storybook/internal/types';
|
|
7
|
+
import type { AstroRenderer } from '../portable-stories.ts';
|
|
8
|
+
import type { ComposedStory, StoryMeta } from './types.ts';
|
|
9
|
+
|
|
10
|
+
export function composeStories<
|
|
11
|
+
TModule extends StoreCsfExports<AstroRenderer> & Record<string, unknown>
|
|
12
|
+
>(
|
|
13
|
+
storiesImport: TModule,
|
|
14
|
+
projectAnnotations?: ProjectAnnotations<AstroRenderer>
|
|
15
|
+
) {
|
|
16
|
+
const composed = portableComposeStories(storiesImport, projectAnnotations);
|
|
17
|
+
|
|
18
|
+
for (const [storyExportName, story] of Object.entries(composed)) {
|
|
19
|
+
if (typeof story === 'function') {
|
|
20
|
+
const composedStory = story as ComposedStory;
|
|
21
|
+
|
|
22
|
+
composedStory.__storybookAstroMeta = storiesImport.default as StoryMeta;
|
|
23
|
+
composedStory.__storybookAstroStoryExport = storiesImport[
|
|
24
|
+
storyExportName as keyof TModule
|
|
25
|
+
] as ComposedStory['__storybookAstroStoryExport'];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return composed;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const composeStory = portableComposeStory;
|
|
33
|
+
export const setProjectAnnotations = portableSetProjectAnnotations;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type StoryMeta = {
|
|
2
|
+
component: unknown;
|
|
3
|
+
args?: Record<string, unknown>;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type ComposedStory = {
|
|
7
|
+
(...args: unknown[]): unknown;
|
|
8
|
+
args?: Record<string, unknown>;
|
|
9
|
+
component?: unknown;
|
|
10
|
+
run?: () => unknown | Promise<unknown>;
|
|
11
|
+
storyName?: string;
|
|
12
|
+
__storybookAstroMeta?: StoryMeta;
|
|
13
|
+
__storybookAstroStoryExport?: { args?: Record<string, unknown> };
|
|
14
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
let workingDirectoryLock: Promise<void> = Promise.resolve();
|
|
2
|
+
|
|
3
|
+
export async function runWithWorkingDirectory<T>(dir: string, fn: () => Promise<T>) {
|
|
4
|
+
const previousLock = workingDirectoryLock;
|
|
5
|
+
let releaseLock!: () => void;
|
|
6
|
+
|
|
7
|
+
workingDirectoryLock = new Promise<void>((resolve) => {
|
|
8
|
+
releaseLock = resolve;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
await previousLock;
|
|
12
|
+
|
|
13
|
+
const previousCwd = process.cwd();
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
if (previousCwd !== dir) {
|
|
17
|
+
process.chdir(dir);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return await fn();
|
|
21
|
+
} finally {
|
|
22
|
+
if (process.cwd() !== previousCwd) {
|
|
23
|
+
process.chdir(previousCwd);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
releaseLock();
|
|
27
|
+
}
|
|
28
|
+
}
|