summaryrefslogtreecommitdiff
path: root/packages/integrations/node/test
diff options
context:
space:
mode:
authorGravatar Alexander Niebuhr <alexander@nbhr.io> 2024-08-29 19:58:06 +0200
committerGravatar GitHub <noreply@github.com> 2024-08-29 19:58:06 +0200
commitb2d097b51e1d8845d955cee4d1e8838f96975638 (patch)
tree1593bbc71f60058579ed35219adf53b68ee3a24b /packages/integrations/node/test
parent93a1db68cd9cf3bb2a4d9f7a8af13cbd881eb701 (diff)
parent7897044c1d95ef905a4835dafe75d5b5b323b5bf (diff)
downloadastro-b2d097b51e1d8845d955cee4d1e8838f96975638.tar.gz
astro-b2d097b51e1d8845d955cee4d1e8838f96975638.tar.zst
astro-b2d097b51e1d8845d955cee4d1e8838f96975638.zip
Merge `vercel` and `node` into main #366
Diffstat (limited to 'packages/integrations/node/test')
-rw-r--r--packages/integrations/node/test/api-route.test.js153
-rw-r--r--packages/integrations/node/test/assets.test.js44
-rw-r--r--packages/integrations/node/test/bad-urls.test.js49
-rw-r--r--packages/integrations/node/test/encoded.test.js45
-rw-r--r--packages/integrations/node/test/errors.test.js92
-rw-r--r--packages/integrations/node/test/fixtures/api-route/package.json9
-rw-r--r--packages/integrations/node/test/fixtures/api-route/src/pages/astro-redirect.astro3
-rw-r--r--packages/integrations/node/test/fixtures/api-route/src/pages/binary.ts11
-rw-r--r--packages/integrations/node/test/fixtures/api-route/src/pages/hash.ts16
-rw-r--r--packages/integrations/node/test/fixtures/api-route/src/pages/recipes.js24
-rw-r--r--packages/integrations/node/test/fixtures/api-route/src/pages/redirect.ts5
-rw-r--r--packages/integrations/node/test/fixtures/api-route/src/pages/response-redirect.ts5
-rw-r--r--packages/integrations/node/test/fixtures/api-route/src/pages/streaming.ts22
-rw-r--r--packages/integrations/node/test/fixtures/bad-urls/package.json9
-rw-r--r--packages/integrations/node/test/fixtures/bad-urls/src/pages/index.astro1
-rw-r--r--packages/integrations/node/test/fixtures/encoded/package.json9
-rw-r--r--packages/integrations/node/test/fixtures/encoded/src/pages/blog/什么.md1
-rw-r--r--packages/integrations/node/test/fixtures/encoded/src/pages/什么.astro1
-rw-r--r--packages/integrations/node/test/fixtures/errors/package.json9
-rw-r--r--packages/integrations/node/test/fixtures/errors/src/pages/generator.astro11
-rw-r--r--packages/integrations/node/test/fixtures/errors/src/pages/in-stream.astro13
-rw-r--r--packages/integrations/node/test/fixtures/errors/src/pages/offshoot-promise-rejection.astro2
-rw-r--r--packages/integrations/node/test/fixtures/headers/package.json9
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-multi.astro5
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-single.astro4
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-multi.astro7
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-single.astro5
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-multi.astro5
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-single.astro4
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-multi.ts9
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-single.ts8
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-multi.ts11
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-single.ts9
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/endpoints/kitchen-sink.ts11
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-multi.ts7
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-single.ts6
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-empty-headers-object.ts4
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-undefined-headers-object.ts3
-rw-r--r--packages/integrations/node/test/fixtures/headers/src/pages/endpoints/simple.ts6
-rw-r--r--packages/integrations/node/test/fixtures/image/package.json13
-rw-r--r--packages/integrations/node/test/fixtures/image/src/assets/file.txt1
-rw-r--r--packages/integrations/node/test/fixtures/image/src/assets/some_penguin.pngbin0 -> 285628 bytes
-rw-r--r--packages/integrations/node/test/fixtures/image/src/pages/index.astro6
-rw-r--r--packages/integrations/node/test/fixtures/image/src/pages/text-file.astro14
-rw-r--r--packages/integrations/node/test/fixtures/locals/package.json9
-rw-r--r--packages/integrations/node/test/fixtures/locals/src/middleware.ts6
-rw-r--r--packages/integrations/node/test/fixtures/locals/src/pages/api.js10
-rw-r--r--packages/integrations/node/test/fixtures/locals/src/pages/from-astro-middleware.astro4
-rw-r--r--packages/integrations/node/test/fixtures/locals/src/pages/from-node-middleware.astro4
-rw-r--r--packages/integrations/node/test/fixtures/node-middleware/package.json9
-rw-r--r--packages/integrations/node/test/fixtures/node-middleware/src/pages/404.astro13
-rw-r--r--packages/integrations/node/test/fixtures/node-middleware/src/pages/index.astro11
-rw-r--r--packages/integrations/node/test/fixtures/node-middleware/src/pages/ssr.ts7
-rw-r--r--packages/integrations/node/test/fixtures/prerender-404-500/package.json10
-rw-r--r--packages/integrations/node/test/fixtures/prerender-404-500/src/external-stylesheet.css3
-rw-r--r--packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-404.ts17
-rw-r--r--packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-500.ts17
-rw-r--r--packages/integrations/node/test/fixtures/prerender-404-500/src/pages/404.astro5
-rw-r--r--packages/integrations/node/test/fixtures/prerender-404-500/src/pages/500.astro6
-rw-r--r--packages/integrations/node/test/fixtures/prerender-404-500/src/pages/fivehundred.astro4
-rw-r--r--packages/integrations/node/test/fixtures/prerender-404-500/src/pages/static.astro12
-rw-r--r--packages/integrations/node/test/fixtures/prerender/package.json9
-rw-r--r--packages/integrations/node/test/fixtures/prerender/src/middleware.ts7
-rw-r--r--packages/integrations/node/test/fixtures/prerender/src/pages/one.astro10
-rw-r--r--packages/integrations/node/test/fixtures/prerender/src/pages/third.astro15
-rw-r--r--packages/integrations/node/test/fixtures/prerender/src/pages/two.astro11
-rw-r--r--packages/integrations/node/test/fixtures/prerender/src/shared.ts1
-rw-r--r--packages/integrations/node/test/fixtures/preview-headers/package.json9
-rw-r--r--packages/integrations/node/test/fixtures/preview-headers/src/pages/index.astro1
-rw-r--r--packages/integrations/node/test/fixtures/trailing-slash/astro.config.mjs8
-rw-r--r--packages/integrations/node/test/fixtures/trailing-slash/package.json9
-rw-r--r--packages/integrations/node/test/fixtures/trailing-slash/public/one.css1
-rw-r--r--packages/integrations/node/test/fixtures/trailing-slash/src/pages/index.astro8
-rw-r--r--packages/integrations/node/test/fixtures/trailing-slash/src/pages/one.astro11
-rw-r--r--packages/integrations/node/test/fixtures/url/package.json9
-rw-r--r--packages/integrations/node/test/fixtures/url/src/pages/index.astro9
-rw-r--r--packages/integrations/node/test/fixtures/well-known-locations/package.json9
-rw-r--r--packages/integrations/node/test/fixtures/well-known-locations/public/.hidden/file.json1
-rw-r--r--packages/integrations/node/test/fixtures/well-known-locations/public/.well-known/apple-app-site-association3
-rw-r--r--packages/integrations/node/test/headers.test.js148
-rw-r--r--packages/integrations/node/test/image.test.js36
-rw-r--r--packages/integrations/node/test/locals.test.js81
-rw-r--r--packages/integrations/node/test/node-middleware.test.js91
-rw-r--r--packages/integrations/node/test/prerender-404-500.test.js304
-rw-r--r--packages/integrations/node/test/prerender.test.js447
-rw-r--r--packages/integrations/node/test/preview-headers.test.js38
-rw-r--r--packages/integrations/node/test/server-host.test.js21
-rw-r--r--packages/integrations/node/test/test-utils.js82
-rw-r--r--packages/integrations/node/test/trailing-slash.test.js458
-rw-r--r--packages/integrations/node/test/url.test.js115
-rw-r--r--packages/integrations/node/test/well-known-locations.test.js46
91 files changed, 2826 insertions, 0 deletions
diff --git a/packages/integrations/node/test/api-route.test.js b/packages/integrations/node/test/api-route.test.js
new file mode 100644
index 000000000..5eca5c530
--- /dev/null
+++ b/packages/integrations/node/test/api-route.test.js
@@ -0,0 +1,153 @@
+import * as assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import { after, before, describe, it } from 'node:test';
+import nodejs from '../dist/index.js';
+import { createRequestAndResponse, loadFixture } from './test-utils.js';
+
+describe('API routes', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ /** @type {import('astro/src/@types/astro.js').PreviewServer} */
+ let previewServer;
+ /** @type {URL} */
+ let baseUri;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/api-route/',
+ output: 'server',
+ adapter: nodejs({ mode: 'middleware' }),
+ });
+ await fixture.build();
+ previewServer = await fixture.preview();
+ baseUri = new URL(`http://${previewServer.host ?? 'localhost'}:${previewServer.port}/`);
+ });
+
+ after(() => previewServer.stop());
+
+ it('Can get the request body', async () => {
+ const { handler } = await import('./fixtures/api-route/dist/server/entry.mjs');
+ const { req, res, done } = createRequestAndResponse({
+ method: 'POST',
+ url: '/recipes',
+ });
+
+ req.once('async_iterator', () => {
+ req.send(JSON.stringify({ id: 2 }));
+ });
+
+ handler(req, res);
+
+ const [buffer] = await done;
+
+ const json = JSON.parse(buffer.toString('utf-8'));
+
+ assert.equal(json.length, 1);
+
+ assert.equal(json[0].name, 'Broccoli Soup');
+ });
+
+ it('Can get binary data', async () => {
+ const { handler } = await import('./fixtures/api-route/dist/server/entry.mjs');
+
+ const { req, res, done } = createRequestAndResponse({
+ method: 'POST',
+ url: '/binary',
+ });
+
+ req.once('async_iterator', () => {
+ req.send(Buffer.from(new Uint8Array([1, 2, 3, 4, 5])));
+ });
+
+ handler(req, res);
+
+ const [out] = await done;
+ const arr = Array.from(new Uint8Array(out.buffer));
+ assert.deepEqual(arr, [5, 4, 3, 2, 1]);
+ });
+
+ it('Can post large binary data', async () => {
+ const { handler } = await import('./fixtures/api-route/dist/server/entry.mjs');
+
+ const { req, res, done } = createRequestAndResponse({
+ method: 'POST',
+ url: '/hash',
+ });
+
+ handler(req, res);
+
+ let expectedDigest = null;
+ req.once('async_iterator', () => {
+ // Send 256MB of garbage data in 256KB chunks. This should be fast (< 1sec).
+ let remainingBytes = 256 * 1024 * 1024;
+ const chunkSize = 256 * 1024;
+
+ const hash = crypto.createHash('sha256');
+ while (remainingBytes > 0) {
+ const size = Math.min(remainingBytes, chunkSize);
+ const chunk = Buffer.alloc(size, Math.floor(Math.random() * 256));
+ hash.update(chunk);
+ req.emit('data', chunk);
+ remainingBytes -= size;
+ }
+
+ req.emit('end');
+ expectedDigest = hash.digest();
+ });
+
+ const [out] = await done;
+ assert.deepEqual(new Uint8Array(out.buffer), new Uint8Array(expectedDigest));
+ });
+
+ it('Can bail on streaming', async () => {
+ const { handler } = await import('./fixtures/api-route/dist/server/entry.mjs');
+ const { req, res, done } = createRequestAndResponse({
+ url: '/streaming',
+ });
+
+ const locals = { cancelledByTheServer: false };
+
+ handler(req, res, () => {}, locals);
+ req.send();
+
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ res.emit('close');
+
+ await done;
+
+ assert.deepEqual(locals, { cancelledByTheServer: true });
+ });
+
+ it('Can respond with SSR redirect', async () => {
+ const controller = new AbortController();
+ setTimeout(() => controller.abort(), 1000);
+ const response = await fetch(new URL('/redirect', baseUri), {
+ redirect: 'manual',
+ signal: controller.signal,
+ });
+ assert.equal(response.status, 302);
+ assert.equal(response.headers.get('location'), '/destination');
+ });
+
+ it('Can respond with Astro.redirect', async () => {
+ const controller = new AbortController();
+ setTimeout(() => controller.abort(), 1000);
+ const response = await fetch(new URL('/astro-redirect', baseUri), {
+ redirect: 'manual',
+ signal: controller.signal,
+ });
+ assert.equal(response.status, 303);
+ assert.equal(response.headers.get('location'), '/destination');
+ });
+
+ it('Can respond with Response.redirect', async () => {
+ const controller = new AbortController();
+ setTimeout(() => controller.abort(), 1000);
+ const response = await fetch(new URL('/response-redirect', baseUri), {
+ redirect: 'manual',
+ signal: controller.signal,
+ });
+ assert.equal(response.status, 307);
+ assert.equal(response.headers.get('location'), String(new URL('/destination', baseUri)));
+ });
+});
diff --git a/packages/integrations/node/test/assets.test.js b/packages/integrations/node/test/assets.test.js
new file mode 100644
index 000000000..0b71f94cd
--- /dev/null
+++ b/packages/integrations/node/test/assets.test.js
@@ -0,0 +1,44 @@
+import * as assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import * as cheerio from 'cheerio';
+import nodejs from '../dist/index.js';
+import { loadFixture } from './test-utils.js';
+
+describe('Assets', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let devPreview;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/image/',
+ output: 'server',
+ adapter: nodejs({ mode: 'standalone' }),
+ vite: {
+ build: {
+ assetsInlineLimit: 0,
+ },
+ },
+ });
+ await fixture.build();
+ devPreview = await fixture.preview();
+ });
+
+ after(async () => {
+ await devPreview.stop();
+ });
+
+ it('Assets within the _astro folder should be given immutable headers', async () => {
+ let response = await fixture.fetch('/text-file');
+ let cacheControl = response.headers.get('cache-control');
+ assert.equal(cacheControl, null);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ // Fetch the asset
+ const fileURL = $('a').attr('href');
+ response = await fixture.fetch(fileURL);
+ cacheControl = response.headers.get('cache-control');
+ assert.equal(cacheControl, 'public, max-age=31536000, immutable');
+ });
+});
diff --git a/packages/integrations/node/test/bad-urls.test.js b/packages/integrations/node/test/bad-urls.test.js
new file mode 100644
index 000000000..cdc0158ff
--- /dev/null
+++ b/packages/integrations/node/test/bad-urls.test.js
@@ -0,0 +1,49 @@
+import * as assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import nodejs from '../dist/index.js';
+import { loadFixture } from './test-utils.js';
+
+describe('Bad URLs', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let devPreview;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/bad-urls/',
+ output: 'server',
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ devPreview = await fixture.preview();
+ });
+
+ after(async () => {
+ await devPreview.stop();
+ });
+
+ it('Does not crash on bad urls', async () => {
+ const weirdURLs = [
+ '/\\xfs.bxss.me%3Fastrojs.com/hello-world',
+ '/asdasdasd@ax_zX=.zxczas🐥%/úadasd000%/',
+ '%',
+ '%80',
+ '%c',
+ '%c0%80',
+ '%20foobar%',
+ ];
+
+ const statusCodes = [400, 404, 500];
+ for (const weirdUrl of weirdURLs) {
+ const fetchResult = await fixture.fetch(weirdUrl);
+ assert.equal(
+ statusCodes.includes(fetchResult.status),
+ true,
+ `${weirdUrl} returned something else than 400, 404, or 500`
+ );
+ }
+ const stillWork = await fixture.fetch('/');
+ const text = await stillWork.text();
+ assert.equal(text, '<!DOCTYPE html>Hello!');
+ });
+});
diff --git a/packages/integrations/node/test/encoded.test.js b/packages/integrations/node/test/encoded.test.js
new file mode 100644
index 000000000..4fc97cf7f
--- /dev/null
+++ b/packages/integrations/node/test/encoded.test.js
@@ -0,0 +1,45 @@
+import * as assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import nodejs from '../dist/index.js';
+import { createRequestAndResponse, loadFixture } from './test-utils.js';
+
+describe('Encoded Pathname', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/encoded/',
+ output: 'server',
+ adapter: nodejs({ mode: 'middleware' }),
+ });
+ await fixture.build();
+ });
+
+ it('Can get an Astro file', async () => {
+ const { handler } = await import('./fixtures/encoded/dist/server/entry.mjs');
+ const { req, res, text } = createRequestAndResponse({
+ url: '/什么',
+ });
+
+ handler(req, res);
+ req.send();
+
+ const html = await text();
+ assert.equal(html.includes('什么</h1>'), true);
+ });
+
+ it('Can get a Markdown file', async () => {
+ const { handler } = await import('./fixtures/encoded/dist/server/entry.mjs');
+
+ const { req, res, text } = createRequestAndResponse({
+ url: '/blog/什么',
+ });
+
+ handler(req, res);
+ req.send();
+
+ const html = await text();
+ assert.equal(html.includes('什么</h1>'), true);
+ });
+});
diff --git a/packages/integrations/node/test/errors.test.js b/packages/integrations/node/test/errors.test.js
new file mode 100644
index 000000000..9bf4aa29b
--- /dev/null
+++ b/packages/integrations/node/test/errors.test.js
@@ -0,0 +1,92 @@
+import assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import { fileURLToPath } from 'node:url';
+import { Worker } from 'node:worker_threads';
+import * as cheerio from 'cheerio';
+import nodejs from '../dist/index.js';
+import { loadFixture } from './test-utils.js';
+
+describe('Errors', () => {
+ /** @type {import('./test-utils.js').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/errors/',
+ output: 'server',
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ });
+ let devPreview;
+
+ // biome-ignore lint/suspicious/noDuplicateTestHooks: <explanation>
+ before(async () => {
+ // The two tests that need the server to run are skipped
+ // devPreview = await fixture.preview();
+ });
+ after(async () => {
+ await devPreview?.stop();
+ });
+
+ it('stays alive after offshoot promise rejections', async () => {
+ // this test needs to happen in a worker because node test runner adds a listener for unhandled rejections in the main thread
+ const url = new URL('./fixtures/errors/dist/server/entry.mjs', import.meta.url);
+ const worker = new Worker(fileURLToPath(url), {
+ type: 'module',
+ env: { ASTRO_NODE_LOGGING: 'enabled' },
+ });
+
+ await new Promise((resolve, reject) => {
+ worker.stdout.on('data', (data) => {
+ setTimeout(() => reject('Server took too long to start'), 1000);
+ if (data.toString().includes('Server listening on http://localhost:4321')) resolve();
+ });
+ });
+
+ await fetch('http://localhost:4321/offshoot-promise-rejection');
+
+ // if there was a crash, it becomes an error here
+ await worker.terminate();
+ });
+
+ it(
+ 'rejected promise in template',
+ { skip: true, todo: 'Review the response from the in-stream' },
+ async () => {
+ const res = await fixture.fetch('/in-stream');
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal($('p').text().trim(), 'Internal server error');
+ }
+ );
+
+ it(
+ 'generator that throws called in template',
+ { skip: true, todo: 'Review the response from the generator' },
+ async () => {
+ const result = ['<!DOCTYPE html><h1>Astro</h1> 1', 'Internal server error'];
+
+ /** @type {Response} */
+ const res = await fixture.fetch('/generator');
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ const chunk1 = await reader.read();
+ const chunk2 = await reader.read();
+ const chunk3 = await reader.read();
+ assert.equal(chunk1.done, false);
+ console.log(chunk1);
+ console.log(chunk2);
+ console.log(chunk3);
+ if (chunk2.done) {
+ assert.equal(decoder.decode(chunk1.value), result.join(''));
+ } else if (chunk3.done) {
+ assert.equal(decoder.decode(chunk1.value), result[0]);
+ assert.equal(decoder.decode(chunk2.value), result[1]);
+ } else {
+ throw new Error('The response should take at most 2 chunks.');
+ }
+ }
+ );
+});
diff --git a/packages/integrations/node/test/fixtures/api-route/package.json b/packages/integrations/node/test/fixtures/api-route/package.json
new file mode 100644
index 000000000..23f6ae84e
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/api-route/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/nodejs-api-route",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/api-route/src/pages/astro-redirect.astro b/packages/integrations/node/test/fixtures/api-route/src/pages/astro-redirect.astro
new file mode 100644
index 000000000..65a8765e8
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/api-route/src/pages/astro-redirect.astro
@@ -0,0 +1,3 @@
+---
+return Astro.redirect('/destination', 303);
+---
diff --git a/packages/integrations/node/test/fixtures/api-route/src/pages/binary.ts b/packages/integrations/node/test/fixtures/api-route/src/pages/binary.ts
new file mode 100644
index 000000000..b1c7ce263
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/api-route/src/pages/binary.ts
@@ -0,0 +1,11 @@
+
+export async function POST({ request }: { request: Request }) {
+ let body = await request.arrayBuffer();
+ let data = new Uint8Array(body);
+ let r = data.reverse();
+ return new Response(r, {
+ headers: {
+ 'Content-Type': 'application/octet-stream'
+ }
+ });
+}
diff --git a/packages/integrations/node/test/fixtures/api-route/src/pages/hash.ts b/packages/integrations/node/test/fixtures/api-route/src/pages/hash.ts
new file mode 100644
index 000000000..3f1b236de
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/api-route/src/pages/hash.ts
@@ -0,0 +1,16 @@
+import crypto from 'node:crypto';
+
+export async function POST({ request }: { request: Request }) {
+ const hash = crypto.createHash('sha256');
+
+ const iterable = request.body as unknown as AsyncIterable<Uint8Array>;
+ for await (const chunk of iterable) {
+ hash.update(chunk);
+ }
+
+ return new Response(hash.digest(), {
+ headers: {
+ 'Content-Type': 'application/octet-stream'
+ }
+ });
+}
diff --git a/packages/integrations/node/test/fixtures/api-route/src/pages/recipes.js b/packages/integrations/node/test/fixtures/api-route/src/pages/recipes.js
new file mode 100644
index 000000000..7297b9643
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/api-route/src/pages/recipes.js
@@ -0,0 +1,24 @@
+
+export async function POST({ request }) {
+ let body = await request.json();
+ const recipes = [
+ {
+ id: 1,
+ name: 'Potato Soup'
+ },
+ {
+ id: 2,
+ name: 'Broccoli Soup'
+ }
+ ];
+
+ let out = recipes.filter(r => {
+ return r.id === body.id;
+ });
+
+ return new Response(JSON.stringify(out), {
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+}
diff --git a/packages/integrations/node/test/fixtures/api-route/src/pages/redirect.ts b/packages/integrations/node/test/fixtures/api-route/src/pages/redirect.ts
new file mode 100644
index 000000000..baf22c93e
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/api-route/src/pages/redirect.ts
@@ -0,0 +1,5 @@
+import { APIContext } from 'astro';
+
+export async function GET({ redirect }: APIContext) {
+ return redirect('/destination');
+}
diff --git a/packages/integrations/node/test/fixtures/api-route/src/pages/response-redirect.ts b/packages/integrations/node/test/fixtures/api-route/src/pages/response-redirect.ts
new file mode 100644
index 000000000..1dfa8bb3c
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/api-route/src/pages/response-redirect.ts
@@ -0,0 +1,5 @@
+import { APIContext } from 'astro';
+
+export async function GET({ url: requestUrl }: APIContext) {
+ return Response.redirect(new URL('/destination', requestUrl), 307);
+}
diff --git a/packages/integrations/node/test/fixtures/api-route/src/pages/streaming.ts b/packages/integrations/node/test/fixtures/api-route/src/pages/streaming.ts
new file mode 100644
index 000000000..9ecb884bf
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/api-route/src/pages/streaming.ts
@@ -0,0 +1,22 @@
+export const GET = ({ locals }) => {
+ let sentChunks = 0;
+
+ const readableStream = new ReadableStream({
+ async pull(controller) {
+ if (sentChunks === 3) return controller.close();
+ else sentChunks++;
+
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ controller.enqueue(new TextEncoder().encode('hello\n'));
+ },
+ cancel() {
+ locals.cancelledByTheServer = true;
+ }
+ });
+
+ return new Response(readableStream, {
+ headers: {
+ "Content-Type": "text/event-stream"
+ }
+ });
+}
diff --git a/packages/integrations/node/test/fixtures/bad-urls/package.json b/packages/integrations/node/test/fixtures/bad-urls/package.json
new file mode 100644
index 000000000..69196d77f
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/bad-urls/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/nodejs-badurls",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/bad-urls/src/pages/index.astro b/packages/integrations/node/test/fixtures/bad-urls/src/pages/index.astro
new file mode 100644
index 000000000..10ddd6d25
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/bad-urls/src/pages/index.astro
@@ -0,0 +1 @@
+Hello!
diff --git a/packages/integrations/node/test/fixtures/encoded/package.json b/packages/integrations/node/test/fixtures/encoded/package.json
new file mode 100644
index 000000000..8e2dc22da
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/encoded/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/nodejs-encoded",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/encoded/src/pages/blog/什么.md b/packages/integrations/node/test/fixtures/encoded/src/pages/blog/什么.md
new file mode 100644
index 000000000..2820cf17e
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/encoded/src/pages/blog/什么.md
@@ -0,0 +1 @@
+# 什么
diff --git a/packages/integrations/node/test/fixtures/encoded/src/pages/什么.astro b/packages/integrations/node/test/fixtures/encoded/src/pages/什么.astro
new file mode 100644
index 000000000..c8473f594
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/encoded/src/pages/什么.astro
@@ -0,0 +1 @@
+<h1>什么</h1>
diff --git a/packages/integrations/node/test/fixtures/errors/package.json b/packages/integrations/node/test/fixtures/errors/package.json
new file mode 100644
index 000000000..bcbeb22a3
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/errors/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/nodejs-errors",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/errors/src/pages/generator.astro b/packages/integrations/node/test/fixtures/errors/src/pages/generator.astro
new file mode 100644
index 000000000..65b8ae62c
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/errors/src/pages/generator.astro
@@ -0,0 +1,11 @@
+---
+function * generator () {
+ yield 1
+ throw Error('ohnoes')
+}
+---
+<h1>Astro</h1>
+{generator()}
+<footer>
+ Footer
+</footer> \ No newline at end of file
diff --git a/packages/integrations/node/test/fixtures/errors/src/pages/in-stream.astro b/packages/integrations/node/test/fixtures/errors/src/pages/in-stream.astro
new file mode 100644
index 000000000..b7ee6b4ef
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/errors/src/pages/in-stream.astro
@@ -0,0 +1,13 @@
+---
+---
+<html>
+ <head>
+ <title>One</title>
+ </head>
+ <body>
+ <h1>One</h1>
+ <p>
+ {Promise.reject('Error in the stream')}
+ </p>
+ </body>
+</html>
diff --git a/packages/integrations/node/test/fixtures/errors/src/pages/offshoot-promise-rejection.astro b/packages/integrations/node/test/fixtures/errors/src/pages/offshoot-promise-rejection.astro
new file mode 100644
index 000000000..be702d5ef
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/errors/src/pages/offshoot-promise-rejection.astro
@@ -0,0 +1,2 @@
+{new Promise(async _ => (await {}, Astro.props.undefined.alsoAPropertyOfUndefined))}
+{Astro.props.undefined.propertyOfUndefined} \ No newline at end of file
diff --git a/packages/integrations/node/test/fixtures/headers/package.json b/packages/integrations/node/test/fixtures/headers/package.json
new file mode 100644
index 000000000..7d4461c74
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/nodejs-headers",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-multi.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-multi.astro
new file mode 100644
index 000000000..a9ff193df
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-multi.astro
@@ -0,0 +1,5 @@
+---
+Astro.cookies.set('from1', 'astro1');
+Astro.cookies.set('from2', 'astro2');
+---
+<p>hello world</p> \ No newline at end of file
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-single.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-single.astro
new file mode 100644
index 000000000..c469fd66f
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-cookies-single.astro
@@ -0,0 +1,4 @@
+---
+Astro.cookies.set('from1', 'astro1');
+---
+<p>hello world</p> \ No newline at end of file
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-multi.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-multi.astro
new file mode 100644
index 000000000..91244e838
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-multi.astro
@@ -0,0 +1,7 @@
+---
+Astro.response.headers.append('set-cookie', 'from1=response1');
+Astro.response.headers.append('set-cookie', 'from2=response2');
+Astro.cookies.set('from3', 'astro1');
+Astro.cookies.set('from4', 'astro2');
+---
+<p>hello world</p> \ No newline at end of file
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-single.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-single.astro
new file mode 100644
index 000000000..97719dfa9
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-astro-response-cookie-single.astro
@@ -0,0 +1,5 @@
+---
+Astro.response.headers.append('set-cookie', 'from1=response1');
+Astro.cookies.set('from1', 'astro1');
+---
+<p>hello world</p> \ No newline at end of file
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-multi.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-multi.astro
new file mode 100644
index 000000000..133cbd423
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-multi.astro
@@ -0,0 +1,5 @@
+---
+Astro.response.headers.append('set-cookie', 'from1=value1');
+Astro.response.headers.append('set-cookie', 'from2=value2');
+---
+<p>hello world</p> \ No newline at end of file
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-single.astro b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-single.astro
new file mode 100644
index 000000000..dc76082db
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/astro/component-response-cookies-single.astro
@@ -0,0 +1,4 @@
+---
+Astro.response.headers.append('set-cookie', 'from1=value1');
+---
+<p>hello world</p> \ No newline at end of file
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-multi.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-multi.ts
new file mode 100644
index 000000000..aaae88e59
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-multi.ts
@@ -0,0 +1,9 @@
+import type { APIContext } from 'astro';
+
+export async function GET({ request, cookies }: APIContext) {
+ const headers = new Headers();
+ headers.append('content-type', 'text/plain;charset=utf-8');
+ cookies.set('from1', 'astro1');
+ cookies.set('from2', 'astro2');
+ return new Response('hello world', { headers });
+}
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-single.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-single.ts
new file mode 100644
index 000000000..03e74c604
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-cookies-single.ts
@@ -0,0 +1,8 @@
+import type { APIContext } from 'astro';
+
+export async function GET({ request, cookies }: APIContext) {
+ const headers = new Headers();
+ headers.append('content-type', 'text/plain;charset=utf-8');
+ cookies.set('from1', 'astro1');
+ return new Response('hello world', { headers });
+}
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-multi.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-multi.ts
new file mode 100644
index 000000000..36906da3a
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-multi.ts
@@ -0,0 +1,11 @@
+import type { APIContext } from 'astro';
+
+export async function GET({ request, cookies }: APIContext) {
+ const headers = new Headers();
+ headers.append('content-type', 'text/plain;charset=utf-8');
+ headers.append('set-cookie', 'from1=response1');
+ headers.append('set-cookie', 'from2=response2');
+ cookies.set('from3', 'astro1');
+ cookies.set('from4', 'astro2');
+ return new Response('hello world', { headers });
+}
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-single.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-single.ts
new file mode 100644
index 000000000..3c1fc4775
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/astro-response-cookie-single.ts
@@ -0,0 +1,9 @@
+import type { APIContext } from 'astro';
+
+export async function GET({ request, cookies }: APIContext) {
+ const headers = new Headers();
+ headers.append('content-type', 'text/plain;charset=utf-8');
+ headers.append('set-cookie', 'from1=response1');
+ cookies.set('from1', 'astro1');
+ return new Response('hello world', { headers });
+}
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/kitchen-sink.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/kitchen-sink.ts
new file mode 100644
index 000000000..fb7c30cbc
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/kitchen-sink.ts
@@ -0,0 +1,11 @@
+export async function GET({ request }: { request: Request }) {
+ const headers = new Headers();
+ headers.append('content-type', 'text/plain;charset=utf-8');
+ headers.append('x-SINGLE', 'single');
+ headers.append('X-triple', 'one');
+ headers.append('x-Triple', 'two');
+ headers.append('x-TRIPLE', 'three');
+ headers.append('SET-cookie', 'hello1=world1');
+ headers.append('Set-Cookie', 'hello2=world2');
+ return new Response('hello world', { headers });
+}
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-multi.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-multi.ts
new file mode 100644
index 000000000..d974737ee
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-multi.ts
@@ -0,0 +1,7 @@
+export async function GET({ request }: { request: Request }) {
+ const headers = new Headers();
+ headers.append('content-type', 'text/plain;charset=utf-8');
+ headers.append('Set-Cookie', 'hello1=world1');
+ headers.append('SET-COOKIE', 'hello2=world2');
+ return new Response('hello world', { headers });
+}
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-single.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-single.ts
new file mode 100644
index 000000000..f543ae062
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-cookies-single.ts
@@ -0,0 +1,6 @@
+export async function GET({ request }: { request: Request }) {
+ const headers = new Headers();
+ headers.append('content-type', 'text/plain;charset=utf-8');
+ headers.append('Set-Cookie', 'hello1=world1');
+ return new Response('hello world', { headers });
+}
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-empty-headers-object.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-empty-headers-object.ts
new file mode 100644
index 000000000..b8a9e122e
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-empty-headers-object.ts
@@ -0,0 +1,4 @@
+export async function GET({ request }: { request: Request }) {
+ const headers = new Headers();
+ return new Response('hello world', { headers });
+}
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-undefined-headers-object.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-undefined-headers-object.ts
new file mode 100644
index 000000000..72f7af071
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/response-undefined-headers-object.ts
@@ -0,0 +1,3 @@
+export async function GET({ request }: { request: Request }) {
+ return new Response('hello world', { headers: undefined });
+}
diff --git a/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/simple.ts b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/simple.ts
new file mode 100644
index 000000000..9c6bcacaa
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/headers/src/pages/endpoints/simple.ts
@@ -0,0 +1,6 @@
+export async function GET({ request }: { request: Request }) {
+ const headers = new Headers();
+ headers.append('content-type', 'text/plain;charset=utf-8');
+ headers.append('X-HELLO', 'world');
+ return new Response('hello world', { headers });
+}
diff --git a/packages/integrations/node/test/fixtures/image/package.json b/packages/integrations/node/test/fixtures/image/package.json
new file mode 100644
index 000000000..81f8757e0
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/image/package.json
@@ -0,0 +1,13 @@
+{
+ "name": "@test/nodejs-image",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ },
+ "scripts": {
+ "build": "astro build",
+ "preview": "astro preview"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/image/src/assets/file.txt b/packages/integrations/node/test/fixtures/image/src/assets/file.txt
new file mode 100644
index 000000000..e9ea42a12
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/image/src/assets/file.txt
@@ -0,0 +1 @@
+this is a text file
diff --git a/packages/integrations/node/test/fixtures/image/src/assets/some_penguin.png b/packages/integrations/node/test/fixtures/image/src/assets/some_penguin.png
new file mode 100644
index 000000000..a09d7f894
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/image/src/assets/some_penguin.png
Binary files differ
diff --git a/packages/integrations/node/test/fixtures/image/src/pages/index.astro b/packages/integrations/node/test/fixtures/image/src/pages/index.astro
new file mode 100644
index 000000000..474a2f0c9
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/image/src/pages/index.astro
@@ -0,0 +1,6 @@
+---
+import { Image } from "astro:assets";
+import penguin from "../assets/some_penguin.png";
+---
+
+<Image src={penguin} alt="Penguins" width={50} />
diff --git a/packages/integrations/node/test/fixtures/image/src/pages/text-file.astro b/packages/integrations/node/test/fixtures/image/src/pages/text-file.astro
new file mode 100644
index 000000000..893250360
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/image/src/pages/text-file.astro
@@ -0,0 +1,14 @@
+---
+import txt from '../assets/file.txt?url';
+---
+<html>
+ <head>
+ <title>Testing</title>
+ </head>
+ <body>
+ <h1>Testing</h1>
+ <main>
+ <a href={txt} download>Download text file</a>
+ </main>
+ </body>
+</html>
diff --git a/packages/integrations/node/test/fixtures/locals/package.json b/packages/integrations/node/test/fixtures/locals/package.json
new file mode 100644
index 000000000..346197f34
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/locals/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/locals",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/locals/src/middleware.ts b/packages/integrations/node/test/fixtures/locals/src/middleware.ts
new file mode 100644
index 000000000..e349ca41d
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/locals/src/middleware.ts
@@ -0,0 +1,6 @@
+import { defineMiddleware } from 'astro:middleware';
+
+export const onRequest = defineMiddleware(({ url, locals }, next) => {
+ if (url.pathname === "/from-astro-middleware") locals.foo = "baz";
+ return next();
+})
diff --git a/packages/integrations/node/test/fixtures/locals/src/pages/api.js b/packages/integrations/node/test/fixtures/locals/src/pages/api.js
new file mode 100644
index 000000000..3c279e37b
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/locals/src/pages/api.js
@@ -0,0 +1,10 @@
+
+export async function POST({ locals }) {
+ const out = { ...locals };
+
+ return new Response(JSON.stringify(out), {
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+}
diff --git a/packages/integrations/node/test/fixtures/locals/src/pages/from-astro-middleware.astro b/packages/integrations/node/test/fixtures/locals/src/pages/from-astro-middleware.astro
new file mode 100644
index 000000000..224a875ec
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/locals/src/pages/from-astro-middleware.astro
@@ -0,0 +1,4 @@
+---
+const { foo } = Astro.locals;
+---
+<h1>{foo}</h1>
diff --git a/packages/integrations/node/test/fixtures/locals/src/pages/from-node-middleware.astro b/packages/integrations/node/test/fixtures/locals/src/pages/from-node-middleware.astro
new file mode 100644
index 000000000..224a875ec
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/locals/src/pages/from-node-middleware.astro
@@ -0,0 +1,4 @@
+---
+const { foo } = Astro.locals;
+---
+<h1>{foo}</h1>
diff --git a/packages/integrations/node/test/fixtures/node-middleware/package.json b/packages/integrations/node/test/fixtures/node-middleware/package.json
new file mode 100644
index 000000000..f1a96bded
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/node-middleware/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/node-middleware",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/node-middleware/src/pages/404.astro b/packages/integrations/node/test/fixtures/node-middleware/src/pages/404.astro
new file mode 100644
index 000000000..79f4944bc
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/node-middleware/src/pages/404.astro
@@ -0,0 +1,13 @@
+---
+---
+
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>404</title>
+</head>
+<body>Page does not exist</body>
+</html>
diff --git a/packages/integrations/node/test/fixtures/node-middleware/src/pages/index.astro b/packages/integrations/node/test/fixtures/node-middleware/src/pages/index.astro
new file mode 100644
index 000000000..28ff7d223
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/node-middleware/src/pages/index.astro
@@ -0,0 +1,11 @@
+---
+---
+
+<html lang="en">
+<head><title>node-middleware</title></head>
+<style>
+</style>
+<body>
+<div>1</div>
+</body>
+</html>
diff --git a/packages/integrations/node/test/fixtures/node-middleware/src/pages/ssr.ts b/packages/integrations/node/test/fixtures/node-middleware/src/pages/ssr.ts
new file mode 100644
index 000000000..423db341a
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/node-middleware/src/pages/ssr.ts
@@ -0,0 +1,7 @@
+export async function GET() {
+ let number = Math.random();
+ return Response.json({
+ number,
+ message: `Here's a random number: ${number}`,
+ });
+}
diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/package.json b/packages/integrations/node/test/fixtures/prerender-404-500/package.json
new file mode 100644
index 000000000..85ec9a334
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender-404-500/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@test/nodejs-prerender-404-500",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/external-stylesheet.css b/packages/integrations/node/test/fixtures/prerender-404-500/src/external-stylesheet.css
new file mode 100644
index 000000000..5f331948a
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/external-stylesheet.css
@@ -0,0 +1,3 @@
+body {
+ background-color: ivory;
+}
diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-404.ts b/packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-404.ts
new file mode 100644
index 000000000..1795c26b0
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-404.ts
@@ -0,0 +1,17 @@
+// This module is only used by the prerendered 404.astro.
+// It exhibits different behavior if it's called more than once,
+// which is detected by a test and interpreted as a failure.
+
+let usedOnce = false
+let dynamicMessage = "Page was not prerendered"
+
+export default function () {
+ if (usedOnce === false) {
+ usedOnce = true
+ return "Page does not exist"
+ }
+
+ dynamicMessage += "+"
+
+ return dynamicMessage
+}
diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-500.ts b/packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-500.ts
new file mode 100644
index 000000000..8f8024a60
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/nondeterminism-500.ts
@@ -0,0 +1,17 @@
+// This module is only used by the prerendered 500.astro.
+// It exhibits different behavior if it's called more than once,
+// which is detected by a test and interpreted as a failure.
+
+let usedOnce = false
+let dynamicMessage = "Page was not prerendered"
+
+export default function () {
+ if (usedOnce === false) {
+ usedOnce = true
+ return "Something went wrong"
+ }
+
+ dynamicMessage += "+"
+
+ return dynamicMessage
+}
diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/404.astro b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/404.astro
new file mode 100644
index 000000000..37fd1c1d3
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/404.astro
@@ -0,0 +1,5 @@
+---
+import message from "../nondeterminism-404"
+export const prerender = true;
+---
+{message()}
diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/500.astro b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/500.astro
new file mode 100644
index 000000000..ef91ad0ff
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/500.astro
@@ -0,0 +1,6 @@
+---
+import "../external-stylesheet.css"
+import message from "../nondeterminism-500"
+export const prerender = true
+---
+<h1>{message()}</h1>
diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/fivehundred.astro b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/fivehundred.astro
new file mode 100644
index 000000000..99d103567
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/fivehundred.astro
@@ -0,0 +1,4 @@
+---
+return new Response(null, { status: 500 })
+---
+<p>This html will not be served</p>
diff --git a/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/static.astro b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/static.astro
new file mode 100644
index 000000000..af6bad2fb
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender-404-500/src/pages/static.astro
@@ -0,0 +1,12 @@
+---
+export const prerender = true;
+---
+
+<html>
+<head>
+ <title>Static Page</title>
+</head>
+ <body>
+ <h1>Hello world!</h1>
+ </body>
+</html>
diff --git a/packages/integrations/node/test/fixtures/prerender/package.json b/packages/integrations/node/test/fixtures/prerender/package.json
new file mode 100644
index 000000000..0dd9eb44c
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/nodejs-prerender",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/prerender/src/middleware.ts b/packages/integrations/node/test/fixtures/prerender/src/middleware.ts
new file mode 100644
index 000000000..13d619d78
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender/src/middleware.ts
@@ -0,0 +1,7 @@
+import { shared } from './shared';
+export const onRequest = (ctx, next) => {
+ ctx.locals = {
+ name: shared,
+ };
+ return next();
+};
diff --git a/packages/integrations/node/test/fixtures/prerender/src/pages/one.astro b/packages/integrations/node/test/fixtures/prerender/src/pages/one.astro
new file mode 100644
index 000000000..f3a26721d
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender/src/pages/one.astro
@@ -0,0 +1,10 @@
+---
+---
+<html>
+ <head>
+ <title>One</title>
+ </head>
+ <body>
+ <h1>One</h1>
+ </body>
+</html>
diff --git a/packages/integrations/node/test/fixtures/prerender/src/pages/third.astro b/packages/integrations/node/test/fixtures/prerender/src/pages/third.astro
new file mode 100644
index 000000000..e29377d88
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender/src/pages/third.astro
@@ -0,0 +1,15 @@
+---
+import { shared} from "../shared";
+export const prerender = false;
+
+const shared = Astro.locals.name;
+---
+
+<html>
+<head>
+ <title>One</title>
+</head>
+<body>
+<h1>{shared}</h1>
+</body>
+</html>
diff --git a/packages/integrations/node/test/fixtures/prerender/src/pages/two.astro b/packages/integrations/node/test/fixtures/prerender/src/pages/two.astro
new file mode 100644
index 000000000..c0e5d07aa
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender/src/pages/two.astro
@@ -0,0 +1,11 @@
+---
+export const prerender = import.meta.env.PRERENDER;
+---
+<html>
+ <head>
+ <title>Two</title>
+ </head>
+ <body>
+ <h1>Two</h1>
+ </body>
+</html>
diff --git a/packages/integrations/node/test/fixtures/prerender/src/shared.ts b/packages/integrations/node/test/fixtures/prerender/src/shared.ts
new file mode 100644
index 000000000..cd35843de
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/prerender/src/shared.ts
@@ -0,0 +1 @@
+export const shared = 'shared';
diff --git a/packages/integrations/node/test/fixtures/preview-headers/package.json b/packages/integrations/node/test/fixtures/preview-headers/package.json
new file mode 100644
index 000000000..e19118612
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/preview-headers/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/nodejs-preview-headers",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/preview-headers/src/pages/index.astro b/packages/integrations/node/test/fixtures/preview-headers/src/pages/index.astro
new file mode 100644
index 000000000..10ddd6d25
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/preview-headers/src/pages/index.astro
@@ -0,0 +1 @@
+Hello!
diff --git a/packages/integrations/node/test/fixtures/trailing-slash/astro.config.mjs b/packages/integrations/node/test/fixtures/trailing-slash/astro.config.mjs
new file mode 100644
index 000000000..7ee28f213
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/trailing-slash/astro.config.mjs
@@ -0,0 +1,8 @@
+import node from '@astrojs/node'
+
+export default {
+ base: '/some-base',
+ output: 'hybrid',
+ trailingSlash: 'never',
+ adapter: node({ mode: 'standalone' })
+};
diff --git a/packages/integrations/node/test/fixtures/trailing-slash/package.json b/packages/integrations/node/test/fixtures/trailing-slash/package.json
new file mode 100644
index 000000000..8a2080109
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/trailing-slash/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/node-trailingslash",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/trailing-slash/public/one.css b/packages/integrations/node/test/fixtures/trailing-slash/public/one.css
new file mode 100644
index 000000000..5ce768ca5
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/trailing-slash/public/one.css
@@ -0,0 +1 @@
+h1 { color: red; }
diff --git a/packages/integrations/node/test/fixtures/trailing-slash/src/pages/index.astro b/packages/integrations/node/test/fixtures/trailing-slash/src/pages/index.astro
new file mode 100644
index 000000000..a4c415519
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/trailing-slash/src/pages/index.astro
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>Index</title>
+ </head>
+ <body>
+ <h1>Index</h1>
+ </body>
+</html>
diff --git a/packages/integrations/node/test/fixtures/trailing-slash/src/pages/one.astro b/packages/integrations/node/test/fixtures/trailing-slash/src/pages/one.astro
new file mode 100644
index 000000000..aa370d18d
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/trailing-slash/src/pages/one.astro
@@ -0,0 +1,11 @@
+---
+export const prerender = true;
+---
+<html>
+ <head>
+ <title>One</title>
+ </head>
+ <body>
+ <h1>One</h1>
+ </body>
+</html>
diff --git a/packages/integrations/node/test/fixtures/url/package.json b/packages/integrations/node/test/fixtures/url/package.json
new file mode 100644
index 000000000..f4e28ceac
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/url/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/url",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/url/src/pages/index.astro b/packages/integrations/node/test/fixtures/url/src/pages/index.astro
new file mode 100644
index 000000000..003429f52
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/url/src/pages/index.astro
@@ -0,0 +1,9 @@
+---
+---
+
+<html lang="en">
+ <head>
+ <title>URL</title>
+ </head>
+ <body>{Astro.url.href}</body>
+</html>
diff --git a/packages/integrations/node/test/fixtures/well-known-locations/package.json b/packages/integrations/node/test/fixtures/well-known-locations/package.json
new file mode 100644
index 000000000..adcbb1597
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/well-known-locations/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/well-known-locations",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "^4.14.6",
+ "@astrojs/node": "workspace:*"
+ }
+}
diff --git a/packages/integrations/node/test/fixtures/well-known-locations/public/.hidden/file.json b/packages/integrations/node/test/fixtures/well-known-locations/public/.hidden/file.json
new file mode 100644
index 000000000..0967ef424
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/well-known-locations/public/.hidden/file.json
@@ -0,0 +1 @@
+{}
diff --git a/packages/integrations/node/test/fixtures/well-known-locations/public/.well-known/apple-app-site-association b/packages/integrations/node/test/fixtures/well-known-locations/public/.well-known/apple-app-site-association
new file mode 100644
index 000000000..daae260f1
--- /dev/null
+++ b/packages/integrations/node/test/fixtures/well-known-locations/public/.well-known/apple-app-site-association
@@ -0,0 +1,3 @@
+{
+ "applinks": {}
+}
diff --git a/packages/integrations/node/test/headers.test.js b/packages/integrations/node/test/headers.test.js
new file mode 100644
index 000000000..f2753517e
--- /dev/null
+++ b/packages/integrations/node/test/headers.test.js
@@ -0,0 +1,148 @@
+import * as assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import nodejs from '../dist/index.js';
+import { createRequestAndResponse, loadFixture } from './test-utils.js';
+
+describe('Node Adapter Headers', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/headers/',
+ output: 'server',
+ adapter: nodejs({ mode: 'middleware' }),
+ });
+ await fixture.build();
+ });
+
+ it('Endpoint Simple Headers', async () => {
+ await runTest('/endpoints/simple', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'x-hello': 'world',
+ });
+ });
+
+ it('Endpoint Astro Single Cookie Header', async () => {
+ await runTest('/endpoints/astro-cookies-single', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'set-cookie': 'from1=astro1',
+ });
+ });
+
+ it('Endpoint Astro Multi Cookie Header', async () => {
+ await runTest('/endpoints/astro-cookies-multi', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'set-cookie': ['from1=astro1', 'from2=astro2'],
+ });
+ });
+
+ it('Endpoint Response Single Cookie Header', async () => {
+ await runTest('/endpoints/response-cookies-single', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'set-cookie': 'hello1=world1',
+ });
+ });
+
+ it('Endpoint Response Multi Cookie Header', async () => {
+ await runTest('/endpoints/response-cookies-multi', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'set-cookie': ['hello1=world1', 'hello2=world2'],
+ });
+ });
+
+ it('Endpoint Complex Headers Kitchen Sink', async () => {
+ await runTest('/endpoints/kitchen-sink', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'x-single': 'single',
+ 'x-triple': 'one, two, three',
+ 'set-cookie': ['hello1=world1', 'hello2=world2'],
+ });
+ });
+
+ it('Endpoint Astro and Response Single Cookie Header', async () => {
+ await runTest('/endpoints/astro-response-cookie-single', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'set-cookie': ['from1=response1', 'from1=astro1'],
+ });
+ });
+
+ it('Endpoint Astro and Response Multi Cookie Header', async () => {
+ await runTest('/endpoints/astro-response-cookie-multi', {
+ 'content-type': 'text/plain;charset=utf-8',
+ 'set-cookie': ['from1=response1', 'from2=response2', 'from3=astro1', 'from4=astro2'],
+ });
+ });
+
+ it('Endpoint Response Empty Headers Object', async () => {
+ await runTest('/endpoints/response-empty-headers-object', {
+ 'content-type': 'text/plain;charset=UTF-8',
+ });
+ });
+
+ it('Endpoint Response undefined Headers Object', async () => {
+ await runTest('/endpoints/response-undefined-headers-object', {
+ 'content-type': 'text/plain;charset=UTF-8',
+ });
+ });
+
+ it('Component Astro Single Cookie Header', async () => {
+ await runTest('/astro/component-astro-cookies-single', {
+ 'content-type': 'text/html',
+ 'set-cookie': 'from1=astro1',
+ });
+ });
+
+ it('Component Astro Multi Cookie Header', async () => {
+ await runTest('/astro/component-astro-cookies-multi', {
+ 'content-type': 'text/html',
+ 'set-cookie': ['from1=astro1', 'from2=astro2'],
+ });
+ });
+
+ it('Component Response Single Cookie Header', async () => {
+ await runTest('/astro/component-response-cookies-single', {
+ 'content-type': 'text/html',
+ 'set-cookie': 'from1=value1',
+ });
+ });
+
+ it('Component Response Multi Cookie Header', async () => {
+ await runTest('/astro/component-response-cookies-multi', {
+ 'content-type': 'text/html',
+ 'set-cookie': ['from1=value1', 'from2=value2'],
+ });
+ });
+
+ it('Component Astro and Response Single Cookie Header', async () => {
+ await runTest('/astro/component-astro-response-cookie-single', {
+ 'content-type': 'text/html',
+ 'set-cookie': ['from1=response1', 'from1=astro1'],
+ });
+ });
+
+ it('Component Astro and Response Multi Cookie Header', async () => {
+ await runTest('/astro/component-astro-response-cookie-multi', {
+ 'content-type': 'text/html',
+ 'set-cookie': ['from1=response1', 'from2=response2', 'from3=astro1', 'from4=astro2'],
+ });
+ });
+});
+
+async function runTest(url, expectedHeaders) {
+ const { handler } = await import('./fixtures/headers/dist/server/entry.mjs');
+
+ const { req, res, done } = createRequestAndResponse({
+ method: 'GET',
+ url,
+ });
+
+ handler(req, res);
+
+ req.send();
+
+ await done;
+ const headers = res.getHeaders();
+
+ assert.deepEqual(headers, expectedHeaders);
+}
diff --git a/packages/integrations/node/test/image.test.js b/packages/integrations/node/test/image.test.js
new file mode 100644
index 000000000..c4758f96b
--- /dev/null
+++ b/packages/integrations/node/test/image.test.js
@@ -0,0 +1,36 @@
+import * as assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import nodejs from '../dist/index.js';
+import { loadFixture } from './test-utils.js';
+
+// Temporary skip until we figure out the "Could not find Sharp" issue as `sharp` is bundled
+describe.skip('Image endpoint', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let devPreview;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/image/',
+ output: 'server',
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ devPreview = await fixture.preview();
+ });
+
+ after(async () => {
+ await devPreview.stop();
+ });
+
+ it('it returns images', async () => {
+ const res = await fixture.fetch('/');
+ assert.equal(res.status, 200);
+
+ const resImage = await fixture.fetch(
+ '/_image?href=/_astro/some_penguin.97ef5f92.png&w=50&f=webp'
+ );
+
+ assert.equal(resImage.status, 200);
+ });
+});
diff --git a/packages/integrations/node/test/locals.test.js b/packages/integrations/node/test/locals.test.js
new file mode 100644
index 000000000..b8e3ed40f
--- /dev/null
+++ b/packages/integrations/node/test/locals.test.js
@@ -0,0 +1,81 @@
+import * as assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import nodejs from '../dist/index.js';
+import { createRequestAndResponse, loadFixture } from './test-utils.js';
+
+describe('API routes', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/locals/',
+ output: 'server',
+ adapter: nodejs({ mode: 'middleware' }),
+ });
+ await fixture.build();
+ });
+
+ it('Can use locals added by node middleware', async () => {
+ const { handler } = await import('./fixtures/locals/dist/server/entry.mjs');
+ const { req, res, text } = createRequestAndResponse({
+ url: '/from-node-middleware',
+ });
+
+ const locals = { foo: 'bar' };
+
+ handler(req, res, () => {}, locals);
+ req.send();
+
+ const html = await text();
+
+ assert.equal(html.includes('<h1>bar</h1>'), true);
+ });
+
+ it('Throws an error when provided non-objects as locals', async () => {
+ const { handler } = await import('./fixtures/locals/dist/server/entry.mjs');
+ const { req, res, done } = createRequestAndResponse({
+ url: '/from-node-middleware',
+ });
+
+ handler(req, res, undefined, 'locals');
+ req.send();
+
+ await done;
+ assert.equal(res.statusCode, 500);
+ });
+
+ it('Can use locals added by astro middleware', async () => {
+ const { handler } = await import('./fixtures/locals/dist/server/entry.mjs');
+
+ const { req, res, text } = createRequestAndResponse({
+ url: '/from-astro-middleware',
+ });
+
+ handler(req, res, () => {});
+ req.send();
+
+ const html = await text();
+
+ assert.equal(html.includes('<h1>baz</h1>'), true);
+ });
+
+ it('Can access locals in API', async () => {
+ const { handler } = await import('./fixtures/locals/dist/server/entry.mjs');
+ const { req, res, done } = createRequestAndResponse({
+ method: 'POST',
+ url: '/api',
+ });
+
+ const locals = { foo: 'bar' };
+
+ handler(req, res, () => {}, locals);
+ req.send();
+
+ const [buffer] = await done;
+
+ const json = JSON.parse(buffer.toString('utf-8'));
+
+ assert.equal(json.foo, 'bar');
+ });
+});
diff --git a/packages/integrations/node/test/node-middleware.test.js b/packages/integrations/node/test/node-middleware.test.js
new file mode 100644
index 000000000..eeb193c73
--- /dev/null
+++ b/packages/integrations/node/test/node-middleware.test.js
@@ -0,0 +1,91 @@
+import * as assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import * as cheerio from 'cheerio';
+import express from 'express';
+import nodejs from '../dist/index.js';
+import { loadFixture, waitServerListen } from './test-utils.js';
+
+/**
+ * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture
+ */
+
+describe('behavior from middleware, standalone', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let server;
+
+ before(async () => {
+ process.env.PRERENDER = false;
+ fixture = await loadFixture({
+ root: './fixtures/node-middleware/',
+ output: 'server',
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ describe('404', async () => {
+ it('when mode is standalone', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/error-page`);
+
+ assert.equal(res.status, 404);
+
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ const body = $('body');
+ assert.equal(body.text().includes('Page does not exist'), true);
+ });
+ });
+});
+
+describe('behavior from middleware, middleware', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let server;
+
+ before(async () => {
+ process.env.PRERENDER = false;
+ fixture = await loadFixture({
+ root: './fixtures/node-middleware/',
+ output: 'server',
+ adapter: nodejs({ mode: 'middleware' }),
+ });
+ await fixture.build();
+ const { handler } = await fixture.loadAdapterEntryModule();
+ const app = express();
+ app.use(handler);
+ server = app.listen(8888);
+ });
+
+ after(async () => {
+ server.close();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('when mode is standalone', async () => {
+ // biome-ignore lint/style/noUnusedTemplateLiteral: <explanation>
+ const res = await fetch(`http://localhost:8888/ssr`);
+
+ assert.equal(res.status, 200);
+
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ const body = $('body');
+ assert.equal(body.text().includes("Here's a random number"), true);
+ });
+});
diff --git a/packages/integrations/node/test/prerender-404-500.test.js b/packages/integrations/node/test/prerender-404-500.test.js
new file mode 100644
index 000000000..86226c500
--- /dev/null
+++ b/packages/integrations/node/test/prerender-404-500.test.js
@@ -0,0 +1,304 @@
+import * as assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import * as cheerio from 'cheerio';
+import nodejs from '../dist/index.js';
+import { loadFixture, waitServerListen } from './test-utils.js';
+
+/**
+ * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture
+ */
+
+describe('Prerender 404', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let server;
+
+ describe('With base', async () => {
+ before(async () => {
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ // inconsequential config that differs between tests
+ // to bust cache and prevent modules and their state
+ // from being reused
+ site: 'https://test.dev/',
+ base: '/some-base',
+ root: './fixtures/prerender-404-500/',
+ output: 'server',
+ outDir: './dist/server-with-base',
+ build: {
+ client: './dist/server-with-base/client',
+ server: './dist/server-with-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render SSR route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/static`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Hello world!');
+ });
+
+ it('Can handle prerendered 404', async () => {
+ const url = `http://${server.host}:${server.port}/some-base/missing`;
+ const res1 = await fetch(url);
+ const res2 = await fetch(url);
+ const res3 = await fetch(url);
+
+ assert.equal(res1.status, 404);
+ assert.equal(res2.status, 404);
+ assert.equal(res3.status, 404);
+
+ const html1 = await res1.text();
+ const html2 = await res2.text();
+ const html3 = await res3.text();
+
+ assert.equal(html1, html2);
+ assert.equal(html2, html3);
+
+ const $ = cheerio.load(html1);
+
+ assert.equal($('body').text(), 'Page does not exist');
+ });
+
+ it(' Can handle prerendered 500 called indirectly', async () => {
+ const url = `http://${server.host}:${server.port}/some-base/fivehundred`;
+ const response1 = await fetch(url);
+ const response2 = await fetch(url);
+ const response3 = await fetch(url);
+
+ assert.equal(response1.status, 500);
+
+ const html1 = await response1.text();
+ const html2 = await response2.text();
+ const html3 = await response3.text();
+
+ assert.equal(html1.includes('Something went wrong'), true);
+
+ assert.equal(html1, html2);
+ assert.equal(html2, html3);
+ });
+
+ it('prerendered 500 page includes expected styles', async () => {
+ const response = await fetch(`http://${server.host}:${server.port}/some-base/fivehundred`);
+ const html = await response.text();
+ const $ = cheerio.load(html);
+
+ // length will be 0 if the stylesheet does not get included
+ assert.equal($('style').length, 1);
+ });
+ });
+
+ describe('Without base', async () => {
+ before(async () => {
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ // inconsequential config that differs between tests
+ // to bust cache and prevent modules and their state
+ // from being reused
+ site: 'https://test.info/',
+ root: './fixtures/prerender-404-500/',
+ output: 'server',
+ outDir: './dist/server-without-base',
+ build: {
+ client: './dist/server-without-base/client',
+ server: './dist/server-without-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render SSR route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/static`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Hello world!');
+ });
+
+ it('Can handle prerendered 404', async () => {
+ const url = `http://${server.host}:${server.port}/some-base/missing`;
+ const res1 = await fetch(url);
+ const res2 = await fetch(url);
+ const res3 = await fetch(url);
+
+ assert.equal(res1.status, 404);
+ assert.equal(res2.status, 404);
+ assert.equal(res3.status, 404);
+
+ const html1 = await res1.text();
+ const html2 = await res2.text();
+ const html3 = await res3.text();
+
+ assert.equal(html1, html2);
+ assert.equal(html2, html3);
+
+ const $ = cheerio.load(html1);
+
+ assert.equal($('body').text(), 'Page does not exist');
+ });
+ });
+});
+
+describe('Hybrid 404', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let server;
+
+ describe('With base', async () => {
+ before(async () => {
+ process.env.PRERENDER = false;
+ fixture = await loadFixture({
+ // inconsequential config that differs between tests
+ // to bust cache and prevent modules and their state
+ // from being reused
+ site: 'https://test.com/',
+ base: '/some-base',
+ root: './fixtures/prerender-404-500/',
+ output: 'hybrid',
+ outDir: './dist/hybrid-with-base',
+ build: {
+ client: './dist/hybrid-with-base/client',
+ server: './dist/hybrid-with-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render SSR route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/static`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Hello world!');
+ });
+
+ it('Can handle prerendered 404', async () => {
+ const url = `http://${server.host}:${server.port}/some-base/missing`;
+ const res1 = await fetch(url);
+ const res2 = await fetch(url);
+ const res3 = await fetch(url);
+
+ assert.equal(res1.status, 404);
+ assert.equal(res2.status, 404);
+ assert.equal(res3.status, 404);
+
+ const html1 = await res1.text();
+ const html2 = await res2.text();
+ const html3 = await res3.text();
+
+ assert.equal(html1, html2);
+ assert.equal(html2, html3);
+
+ const $ = cheerio.load(html1);
+
+ assert.equal($('body').text(), 'Page does not exist');
+ });
+ });
+
+ describe('Without base', async () => {
+ before(async () => {
+ process.env.PRERENDER = false;
+ fixture = await loadFixture({
+ // inconsequential config that differs between tests
+ // to bust cache and prevent modules and their state
+ // from being reused
+ site: 'https://test.net/',
+ root: './fixtures/prerender-404-500/',
+ output: 'hybrid',
+ outDir: './dist/hybrid-without-base',
+ build: {
+ client: './dist/hybrid-without-base/client',
+ server: './dist/hybrid-without-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render SSR route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/static`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Hello world!');
+ });
+
+ it('Can handle prerendered 404', async () => {
+ const url = `http://${server.host}:${server.port}/missing`;
+ const res1 = await fetch(url);
+ const res2 = await fetch(url);
+ const res3 = await fetch(url);
+
+ assert.equal(res1.status, 404);
+ assert.equal(res2.status, 404);
+ assert.equal(res3.status, 404);
+
+ const html1 = await res1.text();
+ const html2 = await res2.text();
+ const html3 = await res3.text();
+
+ assert.equal(html1, html2);
+ assert.equal(html2, html3);
+
+ const $ = cheerio.load(html1);
+
+ assert.equal($('body').text(), 'Page does not exist');
+ });
+ });
+});
diff --git a/packages/integrations/node/test/prerender.test.js b/packages/integrations/node/test/prerender.test.js
new file mode 100644
index 000000000..0684ff63a
--- /dev/null
+++ b/packages/integrations/node/test/prerender.test.js
@@ -0,0 +1,447 @@
+import * as assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import * as cheerio from 'cheerio';
+import nodejs from '../dist/index.js';
+import { loadFixture, waitServerListen } from './test-utils.js';
+
+/**
+ * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture
+ */
+
+describe('Prerendering', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let server;
+
+ describe('With base', async () => {
+ before(async () => {
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ base: '/some-base',
+ root: './fixtures/prerender/',
+ output: 'server',
+ outDir: './dist/with-base',
+ build: {
+ client: './dist/with-base/client',
+ server: './dist/with-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render SSR route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/two`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Two');
+ assert.ok(fixture.pathExists('/client/two/index.html'));
+ });
+
+ it('Can render prerendered route with redirect and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/two?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Two');
+ });
+
+ it('Can render prerendered route with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/two/?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Two');
+ });
+
+ it('Can render prerendered route without trailing slash', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/two`, {
+ redirect: 'manual',
+ });
+ const html = await res.text();
+ const $ = cheerio.load(html);
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Two');
+ });
+ });
+
+ describe('Without base', async () => {
+ before(async () => {
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/prerender/',
+ output: 'server',
+ outDir: './dist/without-base',
+ build: {
+ client: './dist/without-base/client',
+ server: './dist/without-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render SSR route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/two`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Two');
+ assert.ok(fixture.pathExists('/client/two/index.html'));
+ });
+
+ it('Can render prerendered route with redirect and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/two?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Two');
+ });
+
+ it('Can render prerendered route with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/two/?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Two');
+ });
+ });
+
+ describe('Via integration', () => {
+ before(async () => {
+ process.env.PRERENDER = false;
+ fixture = await loadFixture({
+ root: './fixtures/prerender/',
+ output: 'server',
+ outDir: './dist/via-integration',
+ build: {
+ client: './dist/via-integration/client',
+ server: './dist/via-integration/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ integrations: [
+ {
+ name: 'test',
+ hooks: {
+ 'astro:route:setup': ({ route }) => {
+ if (route.component.endsWith('two.astro')) {
+ route.prerender = true;
+ }
+ },
+ },
+ },
+ ],
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render SSR route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/two`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Two');
+ assert.ok(fixture.pathExists('/client/two/index.html'));
+ });
+ });
+
+ describe('Dev', () => {
+ let devServer;
+
+ before(async () => {
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/prerender/',
+ output: 'server',
+ outDir: './dist/dev',
+ build: {
+ client: './dist/dev/client',
+ server: './dist/dev/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ devServer = await fixture.startDevServer();
+ });
+
+ after(async () => {
+ await devServer.stop();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render SSR route', async () => {
+ // biome-ignore lint/style/noUnusedTemplateLiteral: <explanation>
+ const res = await fixture.fetch(`/one`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route', async () => {
+ // biome-ignore lint/style/noUnusedTemplateLiteral: <explanation>
+ const res = await fixture.fetch(`/two`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Two');
+ });
+ });
+});
+
+describe('Hybrid rendering', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let server;
+
+ describe('With base', () => {
+ before(async () => {
+ process.env.PRERENDER = false;
+ fixture = await loadFixture({
+ base: '/some-base',
+ root: './fixtures/prerender/',
+ output: 'hybrid',
+ outDir: './dist/hybrid-with-base',
+ build: {
+ client: './dist/hybrid-with-base/client',
+ server: './dist/hybrid-with-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render SSR route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/two`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Two');
+ });
+
+ it('Can render prerendered route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ assert.ok(fixture.pathExists('/client/one/index.html'));
+ });
+
+ it('Can render prerendered route with redirect and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route without trailing slash', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, {
+ redirect: 'manual',
+ });
+ const html = await res.text();
+ const $ = cheerio.load(html);
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+ });
+
+ describe('Without base', () => {
+ before(async () => {
+ process.env.PRERENDER = false;
+ fixture = await loadFixture({
+ root: './fixtures/prerender/',
+ output: 'hybrid',
+ outDir: './dist/hybrid-without-base',
+ build: {
+ client: './dist/hybrid-without-base/client',
+ server: './dist/hybrid-without-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render SSR route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/two`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Two');
+ });
+
+ it('Can render prerendered route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ assert.ok(fixture.pathExists('/client/one/index.html'));
+ });
+
+ it('Can render prerendered route with redirect and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+ });
+
+ describe('Shared modules', () => {
+ before(async () => {
+ process.env.PRERENDER = false;
+
+ fixture = await loadFixture({
+ root: './fixtures/prerender/',
+ output: 'hybrid',
+ outDir: './dist/hybrid-shared-modules',
+ build: {
+ client: './dist/hybrid-shared-modules/client',
+ server: './dist/hybrid-shared-modules/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render SSR route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/third`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'shared');
+ });
+ });
+});
diff --git a/packages/integrations/node/test/preview-headers.test.js b/packages/integrations/node/test/preview-headers.test.js
new file mode 100644
index 000000000..3fd9d0508
--- /dev/null
+++ b/packages/integrations/node/test/preview-headers.test.js
@@ -0,0 +1,38 @@
+import * as assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import nodejs from '../dist/index.js';
+import { loadFixture } from './test-utils.js';
+
+describe('Astro preview headers', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let devPreview;
+ const headers = {
+ astro: 'test',
+ };
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/preview-headers/',
+ output: 'server',
+ adapter: nodejs({ mode: 'standalone' }),
+ server: {
+ headers,
+ },
+ });
+ await fixture.build();
+ devPreview = await fixture.preview();
+ });
+
+ after(async () => {
+ await devPreview.stop();
+ });
+
+ describe('Preview Headers', () => {
+ it('returns custom headers for valid URLs', async () => {
+ const result = await fixture.fetch('/');
+ assert.equal(result.status, 200);
+ assert.equal(Object.fromEntries(result.headers).astro, headers.astro);
+ });
+ });
+});
diff --git a/packages/integrations/node/test/server-host.test.js b/packages/integrations/node/test/server-host.test.js
new file mode 100644
index 000000000..facd32d47
--- /dev/null
+++ b/packages/integrations/node/test/server-host.test.js
@@ -0,0 +1,21 @@
+import * as assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { hostOptions } from '../dist/standalone.js';
+
+describe('host', () => {
+ it('returns "0.0.0.0" when host is true', () => {
+ const options = { host: true };
+ assert.equal(hostOptions(options.host), '0.0.0.0');
+ });
+
+ it('returns "localhost" when host is false', () => {
+ const options = { host: false };
+ assert.equal(hostOptions(options.host), 'localhost');
+ });
+
+ it('returns the value of host when host is a string', () => {
+ const host = '1.1.1.1';
+ const options = { host };
+ assert.equal(hostOptions(options.host), host);
+ });
+});
diff --git a/packages/integrations/node/test/test-utils.js b/packages/integrations/node/test/test-utils.js
new file mode 100644
index 000000000..37389d6d7
--- /dev/null
+++ b/packages/integrations/node/test/test-utils.js
@@ -0,0 +1,82 @@
+import { EventEmitter } from 'node:events';
+import { loadFixture as baseLoadFixture } from '@astrojs/test-utils';
+import httpMocks from 'node-mocks-http';
+
+process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+process.env.ASTRO_NODE_LOGGING = 'disabled';
+/**
+ * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture
+ */
+
+export function loadFixture(inlineConfig) {
+ if (!inlineConfig?.root) throw new Error("Must provide { root: './fixtures/...' }");
+
+ // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath
+ // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test`
+ return baseLoadFixture({
+ ...inlineConfig,
+ root: new URL(inlineConfig.root, import.meta.url).toString(),
+ });
+}
+
+export function createRequestAndResponse(reqOptions) {
+ const req = httpMocks.createRequest(reqOptions);
+
+ const res = httpMocks.createResponse({
+ eventEmitter: EventEmitter,
+ req,
+ });
+
+ const done = toPromise(res);
+
+ // Get the response as text
+ const text = async () => {
+ const chunks = await done;
+ return buffersToString(chunks);
+ };
+
+ return { req, res, done, text };
+}
+
+export function toPromise(res) {
+ return new Promise((resolve) => {
+ // node-mocks-http doesn't correctly handle non-Buffer typed arrays,
+ // so override the write method to fix it.
+ const write = res.write;
+ res.write = function (data, encoding) {
+ if (ArrayBuffer.isView(data) && !Buffer.isBuffer(data)) {
+ // biome-ignore lint/style/noParameterAssign: <explanation>
+ data = Buffer.from(data.buffer);
+ }
+ return write.call(this, data, encoding);
+ };
+ res.on('end', () => {
+ const chunks = res._getChunks();
+ resolve(chunks);
+ });
+ });
+}
+
+export function buffersToString(buffers) {
+ const decoder = new TextDecoder();
+ let str = '';
+ for (const buffer of buffers) {
+ str += decoder.decode(buffer);
+ }
+ return str;
+}
+
+export function waitServerListen(server) {
+ return new Promise((resolve, reject) => {
+ function onListen() {
+ server.off('error', onError);
+ resolve();
+ }
+ function onError(error) {
+ server.off('listening', onListen);
+ reject(error);
+ }
+ server.once('listening', onListen);
+ server.once('error', onError);
+ });
+}
diff --git a/packages/integrations/node/test/trailing-slash.test.js b/packages/integrations/node/test/trailing-slash.test.js
new file mode 100644
index 000000000..6f6a2a3ba
--- /dev/null
+++ b/packages/integrations/node/test/trailing-slash.test.js
@@ -0,0 +1,458 @@
+import * as assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import * as cheerio from 'cheerio';
+import nodejs from '../dist/index.js';
+import { loadFixture, waitServerListen } from './test-utils.js';
+
+/**
+ * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture
+ */
+
+describe('Trailing slash', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+ let server;
+ describe('Always', async () => {
+ describe('With base', async () => {
+ before(async () => {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ base: '/some-base',
+ output: 'hybrid',
+ trailingSlash: 'always',
+ outDir: './dist/always-with-base',
+ build: {
+ client: './dist/always-with-base/client',
+ server: './dist/always-with-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render prerendered base route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Index');
+ });
+
+ it('Can render prerendered route with redirect', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, {
+ redirect: 'manual',
+ });
+ assert.equal(res.status, 301);
+ assert.equal(res.headers.get('location'), '/some-base/one/');
+ });
+
+ it('Can render prerendered route with redirect and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`, {
+ redirect: 'manual',
+ });
+ assert.equal(res.status, 301);
+ assert.equal(res.headers.get('location'), '/some-base/one/?foo=bar');
+ });
+
+ it('Can render prerendered route with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Does not add trailing slash to subresource urls', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one.css`);
+ const css = await res.text();
+
+ assert.equal(res.status, 200);
+ assert.equal(css, 'h1 { color: red; }\n');
+ });
+ });
+ describe('Without base', async () => {
+ before(async () => {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ output: 'hybrid',
+ trailingSlash: 'always',
+ outDir: './dist/always-without-base',
+ build: {
+ client: './dist/always-without-base/client',
+ server: './dist/always-without-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render prerendered base route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Index');
+ });
+
+ it('Can render prerendered route with redirect', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one`, {
+ redirect: 'manual',
+ });
+ assert.equal(res.status, 301);
+ assert.equal(res.headers.get('location'), '/one/');
+ });
+
+ it('Can render prerendered route with redirect and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`, {
+ redirect: 'manual',
+ });
+ assert.equal(res.status, 301);
+ assert.equal(res.headers.get('location'), '/one/?foo=bar');
+ });
+
+ it('Can render prerendered route with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Does not add trailing slash to subresource urls', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one.css`);
+ const css = await res.text();
+
+ assert.equal(res.status, 200);
+ assert.equal(css, 'h1 { color: red; }\n');
+ });
+ });
+ });
+ describe('Never', async () => {
+ describe('With base', async () => {
+ before(async () => {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ base: '/some-base',
+ output: 'hybrid',
+ trailingSlash: 'never',
+ outDir: './dist/never-with-base',
+ build: {
+ client: './dist/never-with-base/client',
+ server: './dist/never-with-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render prerendered base route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Index');
+ });
+
+ it('Can render prerendered route with redirect', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one/`, {
+ redirect: 'manual',
+ });
+ assert.equal(res.status, 301);
+ assert.equal(res.headers.get('location'), '/some-base/one');
+ });
+
+ it('Can render prerendered route with redirect and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`, {
+ redirect: 'manual',
+ });
+
+ assert.equal(res.status, 301);
+ assert.equal(res.headers.get('location'), '/some-base/one?foo=bar');
+ });
+
+ it('Can render prerendered route with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+ });
+ describe('Without base', async () => {
+ before(async () => {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ output: 'hybrid',
+ trailingSlash: 'never',
+ outDir: './dist/never-without-base',
+ build: {
+ client: './dist/never-without-base/client',
+ server: './dist/never-without-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render prerendered base route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Index');
+ });
+
+ it('Can render prerendered route with redirect', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one/`, {
+ redirect: 'manual',
+ });
+ assert.equal(res.status, 301);
+ assert.equal(res.headers.get('location'), '/one');
+ });
+
+ it('Can render prerendered route with redirect and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`, {
+ redirect: 'manual',
+ });
+
+ assert.equal(res.status, 301);
+ assert.equal(res.headers.get('location'), '/one?foo=bar');
+ });
+
+ it('Can render prerendered route and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+ });
+ });
+ describe('Ignore', async () => {
+ describe('With base', async () => {
+ before(async () => {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ base: '/some-base',
+ output: 'hybrid',
+ trailingSlash: 'ignore',
+ outDir: './dist/ignore-with-base',
+ build: {
+ client: './dist/ignore-with-base/client',
+ server: './dist/ignore-with-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render prerendered base route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Index');
+ });
+
+ it('Can render prerendered route with slash', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one/`, {
+ redirect: 'manual',
+ });
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route without slash', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one`, {
+ redirect: 'manual',
+ });
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route with slash and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one/?foo=bar`, {
+ redirect: 'manual',
+ });
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route without slash and with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/some-base/one?foo=bar`, {
+ redirect: 'manual',
+ });
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+ });
+ describe('Without base', async () => {
+ before(async () => {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ process.env.PRERENDER = true;
+
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ output: 'hybrid',
+ trailingSlash: 'ignore',
+ outDir: './dist/ignore-without-base',
+ build: {
+ client: './dist/ignore-without-base/client',
+ server: './dist/ignore-without-base/server',
+ },
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ const { startServer } = await fixture.loadAdapterEntryModule();
+ const res = startServer();
+ server = res.server;
+ await waitServerListen(server.server);
+ });
+
+ after(async () => {
+ await server.stop();
+ await fixture.clean();
+ // biome-ignore lint/performance/noDelete: <explanation>
+ delete process.env.PRERENDER;
+ });
+
+ it('Can render prerendered base route', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'Index');
+ });
+
+ it('Can render prerendered route with slash', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one/`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route without slash', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route with slash and query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one/?foo=bar`, {
+ redirect: 'manual',
+ });
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+
+ it('Can render prerendered route without slash and with query params', async () => {
+ const res = await fetch(`http://${server.host}:${server.port}/one?foo=bar`);
+ const html = await res.text();
+ const $ = cheerio.load(html);
+
+ assert.equal(res.status, 200);
+ assert.equal($('h1').text(), 'One');
+ });
+ });
+ });
+});
diff --git a/packages/integrations/node/test/url.test.js b/packages/integrations/node/test/url.test.js
new file mode 100644
index 000000000..81b357b71
--- /dev/null
+++ b/packages/integrations/node/test/url.test.js
@@ -0,0 +1,115 @@
+import * as assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import { TLSSocket } from 'node:tls';
+import * as cheerio from 'cheerio';
+import nodejs from '../dist/index.js';
+import { createRequestAndResponse, loadFixture } from './test-utils.js';
+
+describe('URL', () => {
+ /** @type {import('./test-utils.js').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/url/',
+ output: 'server',
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ });
+
+ it('return http when non-secure', async () => {
+ const { handler } = await import('./fixtures/url/dist/server/entry.mjs');
+ const { req, res, text } = createRequestAndResponse({
+ url: '/',
+ });
+
+ handler(req, res);
+ req.send();
+
+ const html = await text();
+ assert.equal(html.includes('http:'), true);
+ });
+
+ it('return https when secure', async () => {
+ const { handler } = await import('./fixtures/url/dist/server/entry.mjs');
+ const { req, res, text } = createRequestAndResponse({
+ socket: new TLSSocket(),
+ url: '/',
+ });
+
+ handler(req, res);
+ req.send();
+
+ const html = await text();
+ assert.equal(html.includes('https:'), true);
+ });
+
+ it('return http when the X-Forwarded-Proto header is set to http', async () => {
+ const { handler } = await import('./fixtures/url/dist/server/entry.mjs');
+ const { req, res, text } = createRequestAndResponse({
+ headers: { 'X-Forwarded-Proto': 'http' },
+ url: '/',
+ });
+
+ handler(req, res);
+ req.send();
+
+ const html = await text();
+ assert.equal(html.includes('http:'), true);
+ });
+
+ it('return https when the X-Forwarded-Proto header is set to https', async () => {
+ const { handler } = await import('./fixtures/url/dist/server/entry.mjs');
+ const { req, res, text } = createRequestAndResponse({
+ headers: { 'X-Forwarded-Proto': 'https' },
+ url: '/',
+ });
+
+ handler(req, res);
+ req.send();
+
+ const html = await text();
+ assert.equal(html.includes('https:'), true);
+ });
+
+ it('includes forwarded host and port in the url', async () => {
+ const { handler } = await import('./fixtures/url/dist/server/entry.mjs');
+ const { req, res, text } = createRequestAndResponse({
+ headers: {
+ 'X-Forwarded-Proto': 'https',
+ 'X-Forwarded-Host': 'abc.xyz',
+ 'X-Forwarded-Port': '444',
+ },
+ url: '/',
+ });
+
+ handler(req, res);
+ req.send();
+
+ const html = await text();
+ const $ = cheerio.load(html);
+
+ assert.equal($('body').text(), 'https://abc.xyz:444/');
+ });
+
+ it('accepts port in forwarded host and forwarded port', async () => {
+ const { handler } = await import('./fixtures/url/dist/server/entry.mjs');
+ const { req, res, text } = createRequestAndResponse({
+ headers: {
+ 'X-Forwarded-Proto': 'https',
+ 'X-Forwarded-Host': 'abc.xyz:444',
+ 'X-Forwarded-Port': '444',
+ },
+ url: '/',
+ });
+
+ handler(req, res);
+ req.send();
+
+ const html = await text();
+ const $ = cheerio.load(html);
+
+ assert.equal($('body').text(), 'https://abc.xyz:444/');
+ });
+});
diff --git a/packages/integrations/node/test/well-known-locations.test.js b/packages/integrations/node/test/well-known-locations.test.js
new file mode 100644
index 000000000..0951d6c27
--- /dev/null
+++ b/packages/integrations/node/test/well-known-locations.test.js
@@ -0,0 +1,46 @@
+import * as assert from 'node:assert/strict';
+import { after, before, describe, it } from 'node:test';
+import nodejs from '../dist/index.js';
+import { loadFixture } from './test-utils.js';
+
+describe('test URIs beginning with a dot', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/well-known-locations/',
+ output: 'server',
+ adapter: nodejs({ mode: 'standalone' }),
+ });
+ await fixture.build();
+ });
+
+ describe('can load well-known URIs', async () => {
+ let devPreview;
+
+ before(async () => {
+ devPreview = await fixture.preview();
+ });
+
+ after(async () => {
+ await devPreview.stop();
+ });
+
+ it('can load a valid well-known URI', async () => {
+ const res = await fixture.fetch('/.well-known/apple-app-site-association');
+
+ assert.equal(res.status, 200);
+
+ const json = await res.json();
+
+ assert.notEqual(json.applinks, {});
+ });
+
+ it('cannot load a dot folder that is not a well-known URI', async () => {
+ const res = await fixture.fetch('/.hidden/file.json');
+
+ assert.equal(res.status, 404);
+ });
+ });
+});