Skip to content

Commit 837f67b

Browse files
Add functional tests for Next.js (#252)
1 parent b9bb453 commit 837f67b

File tree

7 files changed

+251
-4
lines changed

7 files changed

+251
-4
lines changed
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +0,0 @@
1-
/e2e
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
runs
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import * as assert from "assert";
2+
import { posix } from "path";
3+
4+
export const host = process.env.HOST;
5+
6+
if (!host) {
7+
throw new Error("HOST environment variable expected");
8+
}
9+
10+
describe("app", () => {
11+
it("/", async () => {
12+
const response = await fetch(host);
13+
assert.ok(response.ok);
14+
assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8");
15+
assert.equal(
16+
response.headers.get("cache-control"),
17+
"s-maxage=31536000, stale-while-revalidate",
18+
);
19+
});
20+
21+
it("/ssg", async () => {
22+
const response = await fetch(posix.join(host, "ssg"));
23+
assert.ok(response.ok);
24+
assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8");
25+
assert.equal(
26+
response.headers.get("cache-control"),
27+
"s-maxage=31536000, stale-while-revalidate",
28+
);
29+
const text = await response.text();
30+
assert.ok(text.includes("SSG"));
31+
assert.ok(text.includes("Generated"));
32+
assert.ok(text.includes("UUID"));
33+
});
34+
35+
it("/ssr", async () => {
36+
const response = await fetch(posix.join(host, "ssr"));
37+
assert.ok(response.ok);
38+
assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8");
39+
assert.equal(
40+
response.headers.get("cache-control"),
41+
"private, no-cache, no-store, max-age=0, must-revalidate",
42+
);
43+
const text = await response.text();
44+
assert.ok(text.includes("A server generated page!"));
45+
assert.ok(text.includes("Generated"));
46+
assert.ok(text.includes("UUID"));
47+
});
48+
49+
it("/ssr/streaming", async () => {
50+
const response = await fetch(posix.join(host, "ssr", "streaming"));
51+
assert.ok(response.ok);
52+
assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8");
53+
assert.equal(
54+
response.headers.get("cache-control"),
55+
"private, no-cache, no-store, max-age=0, must-revalidate",
56+
);
57+
const text = await response.text();
58+
assert.ok(text.includes("A server generated page!"));
59+
assert.ok(text.includes("Streaming!"));
60+
assert.ok(text.includes("Generated"));
61+
assert.ok(text.includes("UUID"));
62+
}).timeout(3000); // Increased timeout to 3000ms to allow for the 2000ms component timeout
63+
64+
it("/isr/time", async () => {
65+
const response = await fetch(posix.join(host, "isr", "time"));
66+
assert.ok(response.ok);
67+
assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8");
68+
assert.ok(
69+
response.headers.get("cache-control")?.includes("s-maxage=10"),
70+
"Cache-Control header should include s-maxage=10",
71+
);
72+
const text = await response.text();
73+
assert.ok(text.includes("A cached page"));
74+
assert.ok(text.includes("(should be regenerated every 10 seconds)"));
75+
assert.ok(text.includes("Generated"));
76+
assert.ok(text.includes("UUID"));
77+
});
78+
79+
it("/isr/demand", async () => {
80+
const response = await fetch(posix.join(host, "isr", "demand"));
81+
assert.ok(response.ok);
82+
assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8");
83+
assert.equal(
84+
response.headers.get("cache-control"),
85+
"s-maxage=31536000, stale-while-revalidate",
86+
);
87+
const text = await response.text();
88+
assert.ok(text.includes("A cached page"));
89+
assert.ok(text.includes("Generated"));
90+
assert.ok(text.includes("UUID"));
91+
assert.ok(text.includes("Regenerate page"));
92+
});
93+
94+
it("/isr/demand/revalidate", async () => {
95+
// First, fetch the initial page
96+
const initialResponse = await fetch(posix.join(host, "isr", "demand"));
97+
assert.ok(initialResponse.ok);
98+
const initialText = await initialResponse.text();
99+
const initialUUID = initialText.match(/UUID<\/p>\s*<h2>([^<]+)<\/h2>/)?.[1];
100+
101+
// Trigger revalidation
102+
const revalidateResponse = await fetch(posix.join(host, "isr", "demand", "revalidate"), {
103+
method: "POST",
104+
});
105+
assert.equal(revalidateResponse.status, 200);
106+
107+
// Fetch the page again
108+
const newResponse = await fetch(posix.join(host, "isr", "demand"));
109+
assert.ok(newResponse.ok);
110+
const newText = await newResponse.text();
111+
const newUUID = newText.match(/UUID<\/p>\s*<h2>([^<]+)<\/h2>/)?.[1];
112+
113+
// Check if the UUID has changed, indicating successful revalidation
114+
assert.notEqual(initialUUID, newUUID, "UUID should change after revalidation");
115+
});
116+
117+
it(`404`, async () => {
118+
const response = await fetch(posix.join(host, Math.random().toString()));
119+
assert.equal(response.status, 404);
120+
assert.equal(response.headers.get("content-type")?.toLowerCase(), "text/html; charset=utf-8");
121+
assert.equal(
122+
response.headers.get("cache-control"),
123+
"private, no-cache, no-store, max-age=0, must-revalidate",
124+
);
125+
});
126+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { cp } from "fs/promises";
2+
import promiseSpawn from "@npmcli/promise-spawn";
3+
import { dirname, join, relative } from "path";
4+
import { fileURLToPath } from "url";
5+
import { parse as parseYaml } from "yaml";
6+
import { spawn } from "child_process";
7+
import fsExtra from "fs-extra";
8+
9+
const { readFileSync, mkdirp, rmdir } = fsExtra;
10+
11+
const __dirname = dirname(fileURLToPath(import.meta.url));
12+
13+
const starterTemplateDir = "../../../starters/nextjs/basic";
14+
15+
const errors: any[] = [];
16+
17+
await rmdir(join(__dirname, "runs"), { recursive: true }).catch(() => undefined);
18+
19+
console.log("\nBuilding and starting test project...");
20+
21+
const runId = Math.random().toString().split(".")[1];
22+
const cwd = join(__dirname, "runs", runId);
23+
await mkdirp(cwd);
24+
25+
console.log(`[${runId}] Copying ${starterTemplateDir} to working directory`);
26+
await cp(starterTemplateDir, cwd, { recursive: true });
27+
28+
console.log(`[${runId}] > npm ci --silent --no-progress`);
29+
await promiseSpawn("npm", ["ci", "--silent", "--no-progress"], {
30+
cwd,
31+
stdio: "inherit",
32+
shell: true,
33+
});
34+
35+
const buildScript = relative(cwd, join(__dirname, "../dist/bin/build.js"));
36+
console.log(`[${runId}] > node ${buildScript}`);
37+
38+
const packageJson = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8"));
39+
const frameworkVersion = packageJson.dependencies.next.replace("^", "");
40+
await promiseSpawn("node", [buildScript], {
41+
cwd,
42+
stdio: "inherit",
43+
shell: true,
44+
env: {
45+
...process.env,
46+
FRAMEWORK_VERSION: frameworkVersion,
47+
},
48+
});
49+
50+
const bundleYaml = parseYaml(readFileSync(join(cwd, ".apphosting/bundle.yaml")).toString());
51+
52+
const runCommand = bundleYaml.runCommand;
53+
54+
if (typeof runCommand !== "string") {
55+
throw new Error("runCommand must be a string");
56+
}
57+
58+
const [runScript, ...runArgs] = runCommand.split(" ");
59+
let resolveHostname: (it: string) => void;
60+
let rejectHostname: () => void;
61+
const hostnamePromise = new Promise<string>((resolve, reject) => {
62+
resolveHostname = resolve;
63+
rejectHostname = reject;
64+
});
65+
const port = 8080 + Math.floor(Math.random() * 1000);
66+
console.log(`[${runId}] > PORT=${port} ${runCommand}`);
67+
const run = spawn(runScript, runArgs, {
68+
cwd,
69+
shell: true,
70+
env: {
71+
NODE_ENV: "production",
72+
PORT: port.toString(),
73+
},
74+
});
75+
run.stderr.on("data", (data) => console.error(data.toString()));
76+
run.stdout.on("data", (data) => {
77+
console.log(data.toString());
78+
// Check for the "Ready in" message to determine when the server is fully started
79+
if (data.toString().includes(`Ready in`)) {
80+
// We use 0.0.0.0 instead of localhost to avoid issues when ipv6 is not available (Node 18)
81+
resolveHostname(`http://0.0.0.0:${port}`);
82+
}
83+
});
84+
run.on("close", (code) => {
85+
if (code) {
86+
rejectHostname();
87+
}
88+
});
89+
const host = await hostnamePromise;
90+
91+
console.log("\n\n");
92+
93+
try {
94+
console.log(`> HOST=${host} ts-mocha -p tsconfig.json e2e/*.spec.ts`);
95+
await promiseSpawn("ts-mocha", ["-p", "tsconfig.json", "e2e/*.spec.ts"], {
96+
shell: true,
97+
stdio: "inherit",
98+
env: {
99+
...process.env,
100+
HOST: host,
101+
},
102+
}).finally(() => {
103+
run.stdin.end();
104+
run.kill("SIGKILL");
105+
});
106+
} catch (e) {
107+
errors.push(e);
108+
}
109+
110+
if (errors.length) {
111+
console.error(errors);
112+
process.exit(1);
113+
}
114+
115+
process.exit(0);

packages/@apphosting/adapter-nextjs/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
"sideEffects": false,
2323
"scripts": {
2424
"build": "rm -rf dist && tsc && chmod +x ./dist/bin/*",
25-
"test": "ts-mocha -p tsconfig.json src/**/*.spec.ts",
25+
"test": "npm run test:unit && npm run test:functional",
26+
"test:unit": "ts-mocha -p tsconfig.json src/**/*.spec.ts",
27+
"test:functional": "node --loader ts-node/esm ./e2e/run-local.ts",
2628
"localregistry:start": "npx verdaccio --config ../publish-dev/verdaccio-config.yaml",
2729
"localregistry:publish": "(npm view --registry=http://localhost:4873 @apphosting/adapter-nextjs && npm unpublish --@apphosting:registry=http://localhost:4873 --force); npm publish --@apphosting:registry=http://localhost:4873"
2830
},
@@ -55,11 +57,15 @@
5557
},
5658
"devDependencies": {
5759
"@types/fs-extra": "*",
60+
"@types/mocha": "*",
5861
"@types/tmp": "*",
62+
"mocha": "*",
5963
"next": "~14.0.0",
6064
"semver": "*",
6165
"tmp": "*",
66+
"ts-mocha": "*",
6267
"ts-node": "*",
68+
"typescript": "*",
6369
"verdaccio": "^5.30.3"
6470
}
6571
}

starters/nextjs/basic/src/app/isr/demand/RegenerateButton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function RegenerateButton() {
1010
<button
1111
type="button"
1212
className="button regenerate-button"
13-
onClick={() => revalidate()}
13+
onClick={() => void revalidate()}
1414
>
1515
Regenerate page
1616
</button>

starters/nextjs/basic/src/app/isr/demand/revalidate/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { revalidatePath } from "next/cache";
22

3-
export async function POST() {
3+
export function POST() {
44
revalidatePath("/isr/demand");
55

66
return new Response(null, { status: 200 });

0 commit comments

Comments
 (0)