Skip to content

Commit 266fb60

Browse files
Merge pull request #17 from kunai-consulting/feat/local-handling
feat: local handling
2 parents e5df7dc + 6ac0ee7 commit 266fb60

File tree

9 files changed

+779
-401
lines changed

9 files changed

+779
-401
lines changed

.github/workflows/unit-test.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Unit Test
1+
name: Integration Tests
22

33
on:
44
push:
@@ -25,6 +25,9 @@ jobs:
2525
- name: Install
2626
run: pnpm install
2727

28+
- name: Install Playwright Browsers
29+
run: pnpm exec playwright install
30+
2831
- name: Build
2932
run: pnpm run build
3033

@@ -34,5 +37,8 @@ jobs:
3437
- name: Typecheck
3538
run: pnpm run typecheck
3639

37-
# - name: Test
38-
# run: pnpm run test
40+
- name: Test
41+
run: pnpm run test
42+
43+
- name: SSR Plugin Tests
44+
run: pnpm run test:ssr-plugin

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,6 @@
7575
"oxc-resolver": "^11.2.0",
7676
"oxc-walker": "^0.3.0",
7777
"tsdown": "^0.11.9",
78-
"tsx": "^4.19.4",
7978
"typescript": "^5.8.3",
8079
"vitest": "^3.1.3"
8180
},

pnpm-lock.yaml

Lines changed: 1 addition & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
1-
import type { JSXOutput } from "@builder.io/qwik";
1+
import type { JSXNode, JSXOutput } from "@builder.io/qwik";
22
import { page } from "@vitest/browser/context";
33
import { beforeEach } from "vitest";
4-
import { cleanup, render, renderServerHTML } from "./pure";
4+
import { cleanup, type RenderResult, render, renderServerHTML } from "./pure";
55

6-
/** This is replaced with actual code by the ssr-plugin.ts transform */
7-
export declare function renderSSR(
8-
jsxNode: JSXOutput,
9-
): Promise<import("./pure").RenderResult>;
6+
export function renderSSR(jsxNode: JSXOutput): Promise<RenderResult> {
7+
const node = jsxNode as JSXNode;
8+
9+
throw new Error(
10+
`[vitest-browser-qwik]: renderSSR function should have been transformed by the SSR plugin. JSX Node type: ${node.type}
11+
12+
Make sure the testSSR plugin is first in the plugins array in your vitest.config.ts file.
13+
`,
14+
);
15+
}
1016

1117
export type { RenderResult, SSRRenderOptions } from "./pure";
1218
export {

src/ssr-plugin-utils.ts

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import { dirname, relative, resolve } from "node:path";
2+
import type { Component } from "@builder.io/qwik";
3+
import type {
4+
BindingIdentifier,
5+
CallExpression,
6+
ExpressionStatement,
7+
FunctionType,
8+
ImportDeclaration,
9+
ImportDefaultSpecifier,
10+
ImportSpecifier,
11+
JSXAttribute,
12+
JSXAttributeItem,
13+
JSXElement,
14+
JSXExpressionContainer,
15+
Node,
16+
Function as OxcFunction,
17+
Span,
18+
VariableDeclarator,
19+
} from "@oxc-project/types";
20+
import { ResolverFactory } from "oxc-resolver";
21+
import type { BrowserCommandContext } from "vitest/node";
22+
23+
const resolver = new ResolverFactory({
24+
extensions: [".tsx", ".ts", ".jsx", ".js"],
25+
});
26+
27+
export function isFunction(node: Node): node is OxcFunction {
28+
const functionTypes: FunctionType[] = [
29+
"FunctionDeclaration",
30+
"FunctionExpression",
31+
"TSDeclareFunction",
32+
"TSEmptyBodyFunctionExpression",
33+
];
34+
return functionTypes.includes(node.type as FunctionType);
35+
}
36+
37+
export function isCallExpression(node: Node): node is CallExpression {
38+
return node.type === "CallExpression";
39+
}
40+
41+
export function isImportDeclaration(node: Node): node is ImportDeclaration {
42+
return node.type === "ImportDeclaration";
43+
}
44+
45+
export function isVariableDeclarator(node: Node): node is VariableDeclarator {
46+
return node.type === "VariableDeclarator";
47+
}
48+
49+
export function isExpressionStatement(node: Node): node is ExpressionStatement {
50+
return node.type === "ExpressionStatement";
51+
}
52+
53+
export function isJSXElement(node: Node): node is JSXElement {
54+
return node.type === "JSXElement";
55+
}
56+
57+
export function isJSXExpressionContainer(
58+
node: Node,
59+
): node is JSXExpressionContainer {
60+
return node.type === "JSXExpressionContainer";
61+
}
62+
63+
export function traverseChildren(
64+
node: Node,
65+
callback: (child: Node) => boolean | undefined,
66+
): boolean {
67+
for (const key in node) {
68+
const child = (node as unknown as Record<string, unknown>)[key];
69+
if (Array.isArray(child)) {
70+
for (const item of child) {
71+
if (item && typeof item === "object" && callback(item as Node)) {
72+
return true;
73+
}
74+
}
75+
} else if (child && typeof child === "object" && callback(child as Node)) {
76+
return true;
77+
}
78+
}
79+
return false;
80+
}
81+
82+
export function hasRenderSSRCallInAST(ast: Node, code: string): boolean {
83+
const renderSSRIdentifiers = new Set<string>(["renderSSR"]);
84+
let hasRenderSSRCallInCode = false;
85+
86+
function walkForDetection(node: Node): boolean {
87+
if (!node || typeof node !== "object") return false;
88+
89+
if (isImportDeclaration(node) && node.source?.value && node.specifiers) {
90+
for (const spec of node.specifiers) {
91+
if (spec.type === "ImportSpecifier") {
92+
const importSpec = spec as ImportSpecifier;
93+
if (
94+
importSpec.imported.type === "Identifier" &&
95+
importSpec.imported.name === "renderSSR"
96+
) {
97+
renderSSRIdentifiers.add(importSpec.local.name);
98+
}
99+
} else if (spec.type === "ImportDefaultSpecifier") {
100+
const defaultSpec = spec as ImportDefaultSpecifier;
101+
if (defaultSpec.local.name.toLowerCase().includes("renderssr")) {
102+
renderSSRIdentifiers.add(defaultSpec.local.name);
103+
}
104+
}
105+
}
106+
}
107+
108+
if (isFunction(node) && node.id?.name === "renderSSR") {
109+
renderSSRIdentifiers.add("renderSSR");
110+
}
111+
112+
if (isVariableDeclarator(node)) {
113+
if (
114+
node.id.type === "Identifier" &&
115+
node.init?.type === "Identifier" &&
116+
renderSSRIdentifiers.has(node.init.name)
117+
) {
118+
const bindingId = node.id as BindingIdentifier;
119+
renderSSRIdentifiers.add(bindingId.name);
120+
}
121+
}
122+
123+
if (
124+
isCallExpression(node) &&
125+
node.callee.type === "Identifier" &&
126+
renderSSRIdentifiers.has(node.callee.name)
127+
) {
128+
hasRenderSSRCallInCode = true;
129+
return true;
130+
}
131+
132+
return traverseChildren(node, walkForDetection);
133+
}
134+
135+
walkForDetection(ast);
136+
137+
return hasRenderSSRCallInCode || code.includes("renderSSR(");
138+
}
139+
140+
export function extractPropsFromJSX(
141+
attributes: JSXAttributeItem[],
142+
sourceCode: string,
143+
): Record<string, string> {
144+
const props: Record<string, string> = {};
145+
146+
for (const attr of attributes) {
147+
if (attr.type !== "JSXAttribute") continue;
148+
149+
const jsxAttr = attr as JSXAttribute;
150+
if (jsxAttr.name.type !== "JSXIdentifier" || !jsxAttr.value) continue;
151+
152+
const propName = jsxAttr.name.name;
153+
154+
if (
155+
isJSXExpressionContainer(jsxAttr.value) &&
156+
jsxAttr.value.expression.type !== "JSXEmptyExpression"
157+
) {
158+
const exprSpan = jsxAttr.value.expression as Node & Span;
159+
const expressionCode = sourceCode.slice(exprSpan.start, exprSpan.end);
160+
props[propName] = expressionCode;
161+
} else if (jsxAttr.value.type === "Literal") {
162+
const literal = jsxAttr.value as { value: unknown };
163+
props[propName] = JSON.stringify(literal.value);
164+
}
165+
}
166+
167+
return props;
168+
}
169+
170+
export function isTestFile(id: string): boolean {
171+
return id.includes(".test.") || id.includes(".spec.");
172+
}
173+
174+
function fallbackResolveComponentPath(
175+
importPath: string,
176+
testFileId: string,
177+
): string {
178+
if (!importPath.startsWith(".")) {
179+
return importPath.endsWith(".tsx") || importPath.endsWith(".ts")
180+
? importPath
181+
: `${importPath}.tsx`;
182+
}
183+
184+
const testFileDir = dirname(testFileId);
185+
const resolvedPath = resolve(testFileDir, importPath);
186+
const projectRoot = process.cwd();
187+
let componentPath = `./${relative(projectRoot, resolvedPath)}`;
188+
189+
if (!componentPath.endsWith(".tsx") && !componentPath.endsWith(".ts")) {
190+
componentPath += ".tsx";
191+
}
192+
193+
return componentPath;
194+
}
195+
196+
export function resolveComponentPath(
197+
importPath: string,
198+
testFileId: string,
199+
): string {
200+
const testFileDir = dirname(testFileId);
201+
const result = resolver.sync(testFileDir, importPath);
202+
203+
if (result.error || !result.path) {
204+
console.warn(
205+
`[oxc-resolver] Could not resolve "${importPath}" from "${testFileId}": ${result.error || "No path resolved"}. Using fallback resolution.`,
206+
);
207+
return fallbackResolveComponentPath(importPath, testFileId);
208+
}
209+
210+
const projectRoot = process.cwd();
211+
const relativePath = relative(projectRoot, result.path);
212+
213+
return relativePath.startsWith(".") ? relativePath : `./${relativePath}`;
214+
}
215+
216+
export function hasCommandsImport(node: Node): boolean {
217+
if (
218+
!isImportDeclaration(node) ||
219+
node.source?.value !== "@vitest/browser/context" ||
220+
!node.specifiers
221+
) {
222+
return false;
223+
}
224+
225+
return node.specifiers.some(
226+
(spec) =>
227+
spec.type === "ImportSpecifier" &&
228+
spec.imported.type === "Identifier" &&
229+
spec.imported.name === "commands",
230+
);
231+
}
232+
233+
export async function renderComponentToSSR(
234+
ctx: BrowserCommandContext,
235+
Component: Component,
236+
props: Record<string, unknown> = {},
237+
): Promise<{ html: string }> {
238+
const viteServer = ctx.project.vite;
239+
240+
const qwikModule = await viteServer.ssrLoadModule("@builder.io/qwik");
241+
const { jsx } = qwikModule;
242+
const jsxElement = jsx(Component, props);
243+
244+
const serverModule = await viteServer.ssrLoadModule(
245+
"@builder.io/qwik/server",
246+
);
247+
const { renderToStream } = serverModule;
248+
249+
let html = "";
250+
251+
await renderToStream(jsxElement, {
252+
containerTagName: "div",
253+
base: "/",
254+
qwikLoader: { include: "always" },
255+
symbolMapper: globalThis.qwikSymbolMapper,
256+
stream: {
257+
write(chunk: string) {
258+
html += chunk;
259+
},
260+
},
261+
});
262+
263+
return { html };
264+
}

0 commit comments

Comments
 (0)