๐Ÿ“ฆ cloudflare / vinext

๐Ÿ“„ static-export.test.ts ยท 328 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328/**
 * Static export E2E tests โ€” verify exported files work when served via HTTP.
 *
 * Unlike the unit tests in pages-router.test.ts and app-router.test.ts which
 * only check file existence and content, these tests:
 * 1. Run static export for both Pages Router and App Router
 * 2. Serve the exported files with a real HTTP server
 * 3. Make HTTP requests to verify correct responses
 * 4. Check Content-Type, status codes, and asset references
 */
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createServer as createViteServer, type ViteDevServer } from "vite";
import { createServer, type Server } from "node:http";
import fs from "node:fs";
import path from "node:path";

const PAGES_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/pages-basic");
const APP_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/app-basic");

// โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

/** Simple static file server for testing. */
function createStaticServer(rootDir: string): Promise<{ server: Server; baseUrl: string }> {
  const MIME_TYPES: Record<string, string> = {
    ".html": "text/html",
    ".js": "application/javascript",
    ".css": "text/css",
    ".json": "application/json",
    ".png": "image/png",
    ".svg": "image/svg+xml",
  };

  return new Promise((resolve) => {
    const server = createServer((req, res) => {
      const url = req.url ?? "/";
      let pathname = url.split("?")[0];

      // Directory index
      if (pathname.endsWith("/")) pathname += "index.html";
      // Try .html extension for extensionless paths
      let filePath = path.join(rootDir, pathname);
      if (!fs.existsSync(filePath) && !path.extname(filePath)) {
        filePath += ".html";
      }

      if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
        // Serve 404.html if it exists
        const notFoundPath = path.join(rootDir, "404.html");
        if (fs.existsSync(notFoundPath)) {
          const content = fs.readFileSync(notFoundPath);
          res.writeHead(404, { "Content-Type": "text/html" });
          res.end(content);
        } else {
          res.writeHead(404);
          res.end("Not Found");
        }
        return;
      }

      const ext = path.extname(filePath);
      const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
      const content = fs.readFileSync(filePath);
      res.writeHead(200, { "Content-Type": contentType });
      res.end(content);
    });

    server.listen(0, "127.0.0.1", () => {
      const addr = server.address();
      const port = typeof addr === "object" && addr ? addr.port : 0;
      resolve({ server, baseUrl: `http://127.0.0.1:${port}` });
    });
  });
}

/** Start a Vite dev server for a fixture directory. */
async function startFixtureServer(
  fixtureDir: string,
  _opts?: { appRouter?: boolean },
): Promise<{ server: ViteDevServer; baseUrl: string }> {
  const server = await createViteServer({
    root: fixtureDir,
    configFile: path.join(fixtureDir, "vite.config.ts"),
    server: { port: 0, strictPort: false },
    logLevel: "silent",
  });
  await server.listen();
  const addr = server.httpServer?.address();
  const port = typeof addr === "object" && addr ? addr.port : 4321;
  return { server, baseUrl: `http://localhost:${port}` };
}

// โ”€โ”€โ”€ Pages Router Static Export E2E โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

describe("Static export โ€” Pages Router (served via HTTP)", () => {
  let viteServer: ViteDevServer;
  let staticServer: Server;
  let baseUrl: string;
  const exportDir = path.resolve(PAGES_FIXTURE, "out-e2e");

  beforeAll(async () => {
    // 1. Start Vite dev server for the fixture
    const vite = await startFixtureServer(PAGES_FIXTURE);
    viteServer = vite.server;

    // 2. Run static export
    const { staticExportPages } = await import(
      "../packages/vinext/src/build/static-export.js"
    );
    const { pagesRouter } = await import(
      "../packages/vinext/src/routing/pages-router.js"
    );
    const { resolveNextConfig } = await import(
      "../packages/vinext/src/config/next-config.js"
    );

    const pagesDir = path.resolve(PAGES_FIXTURE, "pages");
    const routes = await pagesRouter(pagesDir);
    const pageRoutes = routes.filter(
      (r: any) => !r.filePath.includes("/api/"),
    );
    const apiRoutes = routes.filter((r: any) =>
      r.filePath.includes("/api/"),
    );
    const config = await resolveNextConfig({ output: "export" });

    await staticExportPages({
      server: viteServer,
      routes: pageRoutes,
      apiRoutes,
      pagesDir,
      outDir: exportDir,
      config,
    });

    // 3. Start a static file server on the exported directory
    const srv = await createStaticServer(exportDir);
    staticServer = srv.server;
    baseUrl = srv.baseUrl;
  }, 30_000);

  afterAll(async () => {
    staticServer?.close();
    await viteServer?.close();
    fs.rmSync(exportDir, { recursive: true, force: true });
  });

  it("serves index.html at / with text/html content type", async () => {
    const res = await fetch(`${baseUrl}/`);
    expect(res.status).toBe(200);
    expect(res.headers.get("content-type")).toBe("text/html");
    const html = await res.text();
    expect(html).toContain("<!DOCTYPE html>");
    expect(html).toContain("Hello, vinext!");
  });

  it("serves about page", async () => {
    const res = await fetch(`${baseUrl}/about`);
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("About");
  });

  it("serves pre-rendered dynamic route pages", async () => {
    const res = await fetch(`${baseUrl}/blog/hello-world`);
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("hello-world");
  });

  it("serves 404.html for missing pages", async () => {
    const res = await fetch(`${baseUrl}/nonexistent-page`);
    expect(res.status).toBe(404);
    const html = await res.text();
    expect(html).toContain("404");
  });

  it("includes __NEXT_DATA__ in served pages", async () => {
    const res = await fetch(`${baseUrl}/`);
    const html = await res.text();
    expect(html).toContain("__NEXT_DATA__");
    // Verify it's valid JSON inside the script tag
    const match = html.match(
      /window\.__NEXT_DATA__\s*=\s*({[^<]+})/,
    );
    expect(match).toBeTruthy();
    const data = JSON.parse(match![1]);
    expect(data.props).toBeDefined();
    expect(data.page).toBeDefined();
  });

  it("includes HTML document structure", async () => {
    const res = await fetch(`${baseUrl}/`);
    const html = await res.text();
    expect(html).toContain("<html");
    expect(html).toContain("<head>");
    expect(html).toContain("</head>");
    expect(html).toContain("<body");
    expect(html).toContain("</body>");
    expect(html).toContain("</html>");
    expect(html).toContain('<div id="__next">');
  });

  it("getStaticProps pages have correct data in __NEXT_DATA__", async () => {
    const res = await fetch(`${baseUrl}/blog/hello-world`);
    const html = await res.text();
    const match = html.match(
      /window\.__NEXT_DATA__\s*=\s*({[^<]+})/,
    );
    expect(match).toBeTruthy();
    const data = JSON.parse(match![1]);
    expect(data.props.pageProps).toBeDefined();
  });
});

// โ”€โ”€โ”€ App Router Static Export E2E โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

describe("Static export โ€” App Router (served via HTTP)", () => {
  let viteServer: ViteDevServer;
  let viteBaseUrl: string;
  let staticServer: Server;
  let baseUrl: string;
  const exportDir = path.resolve(APP_FIXTURE, "out-e2e");

  beforeAll(async () => {
    // 1. Start Vite dev server for the fixture
    const vite = await startFixtureServer(APP_FIXTURE, { appRouter: true });
    viteServer = vite.server;
    viteBaseUrl = vite.baseUrl;

    // 2. Run static export
    const { staticExportApp } = await import(
      "../packages/vinext/src/build/static-export.js"
    );
    const { appRouter } = await import(
      "../packages/vinext/src/routing/app-router.js"
    );
    const { resolveNextConfig } = await import(
      "../packages/vinext/src/config/next-config.js"
    );

    const appDir = path.resolve(APP_FIXTURE, "app");
    const routes = await appRouter(appDir);
    const config = await resolveNextConfig({ output: "export" });

    await staticExportApp({
      baseUrl: viteBaseUrl,
      routes,
      appDir,
      server: viteServer,
      outDir: exportDir,
      config,
    });

    // 3. Start a static file server on the exported directory
    const srv = await createStaticServer(exportDir);
    staticServer = srv.server;
    baseUrl = srv.baseUrl;
  }, 30_000);

  afterAll(async () => {
    staticServer?.close();
    await viteServer?.close();
    fs.rmSync(exportDir, { recursive: true, force: true });
  });

  it("serves index.html at / with text/html content type", async () => {
    const res = await fetch(`${baseUrl}/`);
    expect(res.status).toBe(200);
    expect(res.headers.get("content-type")).toBe("text/html");
    const html = await res.text();
    expect(html).toContain("Welcome to App Router");
  });

  it("serves about page", async () => {
    const res = await fetch(`${baseUrl}/about`);
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("About");
  });

  it("serves pre-rendered dynamic route pages", async () => {
    const res = await fetch(`${baseUrl}/blog/hello-world`);
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("hello-world");
  });

  it("serves 404.html for missing pages", async () => {
    const res = await fetch(`${baseUrl}/nonexistent-page`);
    expect(res.status).toBe(404);
    const html = await res.text();
    // App Router 404 page
    expect(html.toLowerCase()).toMatch(/not found|404/);
  });

  it("includes complete HTML document structure", async () => {
    const res = await fetch(`${baseUrl}/`);
    const html = await res.text();
    expect(html).toContain("<!DOCTYPE html>");
    expect(html).toContain("<html");
    expect(html).toContain("<head>");
    expect(html).toContain("</head>");
    expect(html).toContain("<body");
    expect(html).toContain("</body>");
  });

  it("HTML contains charset and viewport meta tags", async () => {
    const res = await fetch(`${baseUrl}/`);
    const html = await res.text();
    // React renders charset as charSet in JSX
    expect(html.toLowerCase()).toMatch(/charset/);
    expect(html).toContain("viewport");
  });

  it("multiple exported pages return distinct content", async () => {
    const [indexRes, aboutRes] = await Promise.all([
      fetch(`${baseUrl}/`),
      fetch(`${baseUrl}/about`),
    ]);
    const indexHtml = await indexRes.text();
    const aboutHtml = await aboutRes.text();
    // Pages should have different content
    expect(indexHtml).not.toBe(aboutHtml);
    expect(indexHtml).toContain("Welcome to App Router");
    expect(aboutHtml).toContain("About");
  });
});