diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
new file mode 100644
index 0000000..12fece4
--- /dev/null
+++ b/.github/workflows/test.yaml
@@ -0,0 +1,36 @@
+name: Test
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install Playwright Browsers
+ run: npx playwright install --with-deps
+
+ - name: Run tests
+ run: npm run test
+
+ - uses: actions/upload-artifact@v4
+ if: ${{ !cancelled() }}
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 30
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 40b878d..289e347 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
-node_modules/
\ No newline at end of file
+node_modules/
+.next/
+test-results/
\ No newline at end of file
diff --git a/.node-version b/.node-version
new file mode 100644
index 0000000..fdb2eaa
--- /dev/null
+++ b/.node-version
@@ -0,0 +1 @@
+22.11.0
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..94c0398
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,38 @@
+# Contributing
+
+## Code Organization
+
+All code should abide by the following organization rules:
+
+- `fixtures/` is for directories that will be utilized in testing.
+ - They should be fully executable locally for debugging purposes
+ - They should be as minimal as possible
+- `test/` is for Playwright based test files that are executed by `npm run test`
+ - Test files should use `import { test, expect } from '../util/test-fixture.js';` so that the correct **Fixture** is managed for the test script
+ - Test files should execute serially, and relevant to the given Next.js versions
+- `util/` is for any non-module source code. This includes scripts (`util/scripts/`), docker configurations (`util/docker/`), or any other utility based code.
+ - Prime examples include the source code responsible for [_building_](./util/scripts/pretest.js) the **Fixtures**, or the custom Playwright [test fixture](./util/test-fixture.js)
+
+The key source files for the repo are:
+
+- `cli.js`
+- `extension.js`
+- `config.yaml`
+- `schema.graphql`
+
+## Testing
+
+Testing for this repo uses containers in order to generate stable, isolated environments containing:
+
+- HarperDB
+- Node.js
+- A HarperDB Base Component (responsible for seeding the database)
+- A Next.js application Component (which uses this `@harperdb/nextjs` extension)
+
+To execute tests, run `npm run test`
+
+The first run may take some time as the pretest script is building 12 separate images (3 Node.js ones, 9 Next.js ones). Note, at the moment this operation is parallelized as building is very expensive and can result in the system running out of resources (and crashing the build processes). Subsequent runs utilize the Docker build step cache and are very fast.
+
+After the images are built, [Playwright](https://playwright.dev/) will run the tests. These tests each utilize an image, and will manage a container instance relevant to the given Next.js and Node.js pair.
+
+The tests are configured with generous timeouts and limited to 3 workers at a time to not cause the system to run out of resources.
diff --git a/fixtures/harperdb-base-component/config.yaml b/fixtures/harperdb-base-component/config.yaml
new file mode 100644
index 0000000..118c3f8
--- /dev/null
+++ b/fixtures/harperdb-base-component/config.yaml
@@ -0,0 +1,5 @@
+rest: true
+graphqlSchema:
+ files: './schema.graphql'
+jsResource:
+ files: './resources.js'
diff --git a/fixtures/harperdb-base-component/package.json b/fixtures/harperdb-base-component/package.json
new file mode 100644
index 0000000..de0cb1b
--- /dev/null
+++ b/fixtures/harperdb-base-component/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "harperdb-base-component",
+ "private": true
+}
diff --git a/fixtures/harperdb-base-component/resources.js b/fixtures/harperdb-base-component/resources.js
new file mode 100644
index 0000000..90862e7
--- /dev/null
+++ b/fixtures/harperdb-base-component/resources.js
@@ -0,0 +1,18 @@
+const dogs = [
+ { id: '0', name: 'Lincoln', breed: 'Shepherd' },
+ { id: '1', name: 'Max', breed: 'Cocker Spaniel' },
+ { id: '2', name: 'Bella', breed: 'Lab' },
+ { id: '3', name: 'Charlie', breed: 'Great Dane' },
+ { id: '4', name: 'Lucy', breed: 'Newfoundland' },
+ { id: '5', name: 'Cooper', breed: 'Pug' },
+ { id: '6', name: 'Daisy', breed: 'Bull Dog' },
+ { id: '7', name: 'Rocky', breed: 'Akita' },
+ { id: '8', name: 'Luna', breed: 'Wolf' },
+ { id: '9', name: 'Buddy', breed: 'Border Collie' },
+ { id: '10', name: 'Bailey', breed: 'Golden Retriever' },
+ { id: '11', name: 'Sadie', breed: 'Belgian Malinois' },
+];
+
+for (const dog of dogs) {
+ tables.Dog.put(dog);
+}
diff --git a/fixtures/harperdb-base-component/schema.graphql b/fixtures/harperdb-base-component/schema.graphql
new file mode 100644
index 0000000..d92a5be
--- /dev/null
+++ b/fixtures/harperdb-base-component/schema.graphql
@@ -0,0 +1,5 @@
+type Dog @table @export {
+ id: ID @primaryKey
+ name: String
+ breed: String
+}
diff --git a/fixtures/next-13/.npmrc b/fixtures/next-13/.npmrc
new file mode 100644
index 0000000..9cf9495
--- /dev/null
+++ b/fixtures/next-13/.npmrc
@@ -0,0 +1 @@
+package-lock=false
\ No newline at end of file
diff --git a/fixtures/next-13/app/layout.js b/fixtures/next-13/app/layout.js
new file mode 100644
index 0000000..4ace4d7
--- /dev/null
+++ b/fixtures/next-13/app/layout.js
@@ -0,0 +1,11 @@
+export const metadata = {
+ title: 'HarperDB - Next.js v13 App',
+};
+
+export default function RootLayout({ children }) {
+ return (
+
+
{children}
+
+ );
+}
diff --git a/fixtures/next-13/app/page.js b/fixtures/next-13/app/page.js
new file mode 100644
index 0000000..b3b670a
--- /dev/null
+++ b/fixtures/next-13/app/page.js
@@ -0,0 +1,7 @@
+export default async function Page() {
+ return (
+
+
Next.js v13
+
+ );
+}
diff --git a/fixtures/next-13/config.yaml b/fixtures/next-13/config.yaml
new file mode 100644
index 0000000..9cc50d3
--- /dev/null
+++ b/fixtures/next-13/config.yaml
@@ -0,0 +1,4 @@
+'@harperdb/nextjs':
+ package: '@harperdb/nextjs'
+ files: '/*'
+ port: 9926
diff --git a/fixtures/next-13/next.config.js b/fixtures/next-13/next.config.js
new file mode 100644
index 0000000..f053ebf
--- /dev/null
+++ b/fixtures/next-13/next.config.js
@@ -0,0 +1 @@
+module.exports = {};
diff --git a/fixtures/next-13/package.json b/fixtures/next-13/package.json
new file mode 100644
index 0000000..83fa08e
--- /dev/null
+++ b/fixtures/next-13/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "next-13",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "postinstall": "npm link @harperdb/nextjs"
+ },
+ "dependencies": {
+ "@harperdb/nextjs": "*",
+ "react": "^18",
+ "react-dom": "^18",
+ "next": "^13"
+ }
+}
diff --git a/fixtures/next-14/.npmrc b/fixtures/next-14/.npmrc
new file mode 100644
index 0000000..9cf9495
--- /dev/null
+++ b/fixtures/next-14/.npmrc
@@ -0,0 +1 @@
+package-lock=false
\ No newline at end of file
diff --git a/fixtures/next-14/app/layout.js b/fixtures/next-14/app/layout.js
new file mode 100644
index 0000000..7fd8992
--- /dev/null
+++ b/fixtures/next-14/app/layout.js
@@ -0,0 +1,11 @@
+export const metadata = {
+ title: 'HarperDB - Next.js v14 App',
+};
+
+export default function RootLayout({ children }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/fixtures/next-14/app/page.js b/fixtures/next-14/app/page.js
new file mode 100644
index 0000000..9d17389
--- /dev/null
+++ b/fixtures/next-14/app/page.js
@@ -0,0 +1,7 @@
+export default async function Page() {
+ return (
+
+
Next.js v14
+
+ );
+}
diff --git a/fixtures/next-14/config.yaml b/fixtures/next-14/config.yaml
new file mode 100644
index 0000000..9cc50d3
--- /dev/null
+++ b/fixtures/next-14/config.yaml
@@ -0,0 +1,4 @@
+'@harperdb/nextjs':
+ package: '@harperdb/nextjs'
+ files: '/*'
+ port: 9926
diff --git a/fixtures/next-14/next.config.js b/fixtures/next-14/next.config.js
new file mode 100644
index 0000000..f053ebf
--- /dev/null
+++ b/fixtures/next-14/next.config.js
@@ -0,0 +1 @@
+module.exports = {};
diff --git a/fixtures/next-14/package.json b/fixtures/next-14/package.json
new file mode 100644
index 0000000..63ac233
--- /dev/null
+++ b/fixtures/next-14/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "next-14",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "postinstall": "npm link @harperdb/nextjs"
+ },
+ "dependencies": {
+ "@harperdb/nextjs": "*",
+ "react": "^18",
+ "react-dom": "^18",
+ "next": "^14"
+ }
+}
diff --git a/fixtures/next-15/.npmrc b/fixtures/next-15/.npmrc
new file mode 100644
index 0000000..9cf9495
--- /dev/null
+++ b/fixtures/next-15/.npmrc
@@ -0,0 +1 @@
+package-lock=false
\ No newline at end of file
diff --git a/fixtures/next-15/app/layout.js b/fixtures/next-15/app/layout.js
new file mode 100644
index 0000000..ee00d88
--- /dev/null
+++ b/fixtures/next-15/app/layout.js
@@ -0,0 +1,11 @@
+export const metadata = {
+ title: 'HarperDB - Next.js v15 App',
+};
+
+export default function RootLayout({ children }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/fixtures/next-15/app/page.js b/fixtures/next-15/app/page.js
new file mode 100644
index 0000000..4a48cf9
--- /dev/null
+++ b/fixtures/next-15/app/page.js
@@ -0,0 +1,7 @@
+export default async function Page() {
+ return (
+
+
Next.js v15
+
+ );
+}
diff --git a/fixtures/next-15/config.yaml b/fixtures/next-15/config.yaml
new file mode 100644
index 0000000..9cc50d3
--- /dev/null
+++ b/fixtures/next-15/config.yaml
@@ -0,0 +1,4 @@
+'@harperdb/nextjs':
+ package: '@harperdb/nextjs'
+ files: '/*'
+ port: 9926
diff --git a/fixtures/next-15/next.config.js b/fixtures/next-15/next.config.js
new file mode 100644
index 0000000..f053ebf
--- /dev/null
+++ b/fixtures/next-15/next.config.js
@@ -0,0 +1 @@
+module.exports = {};
diff --git a/fixtures/next-15/package.json b/fixtures/next-15/package.json
new file mode 100644
index 0000000..b5e33e1
--- /dev/null
+++ b/fixtures/next-15/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "next-15",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "build": "next build",
+ "start": "next start",
+ "lint": "next lint",
+ "postinstall": "npm link @harperdb/nextjs"
+ },
+ "dependencies": {
+ "@harperdb/nextjs": "*",
+ "react": "19.0.0-rc-66855b96-20241106",
+ "react-dom": "19.0.0-rc-66855b96-20241106",
+ "next": "^15"
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index a749e34..4fbf814 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
},
"devDependencies": {
"@harperdb/code-guidelines": "^0.0.2",
+ "@playwright/test": "^1.49.0",
"prettier": "^3.3.3"
}
},
@@ -29,6 +30,69 @@
"prettier": "3.3.3"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.49.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.0.tgz",
+ "integrity": "sha512-DMulbwQURa8rNIQrf94+jPJQ4FmOVdpE5ZppRNvWVjvhC+6sOeo28r8MgIpQRYouXRtt/FCCXU7zn20jnHR4Qw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.49.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.49.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0.tgz",
+ "integrity": "sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.49.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.49.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0.tgz",
+ "integrity": "sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/prettier": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
diff --git a/package.json b/package.json
index 9df55ad..ef40864 100644
--- a/package.json
+++ b/package.json
@@ -33,13 +33,18 @@
"schema.graphql"
],
"scripts": {
- "format": "prettier --write ."
+ "format": "prettier .",
+ "format:check": "npm run format -- --check",
+ "format:fix": "npm run format -- --write",
+ "pretest": "node util/scripts/pretest.js",
+ "test": "playwright test --workers 3"
},
"dependencies": {
"shell-quote": "^1.8.1"
},
"devDependencies": {
"@harperdb/code-guidelines": "^0.0.2",
+ "@playwright/test": "^1.49.0",
"prettier": "^3.3.3"
},
"prettier": "@harperdb/code-guidelines/prettier"
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..8a34c7c
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,22 @@
+import { defineConfig, devices } from '@playwright/test';
+
+const NEXT_MAJORS = ['13', '14', '15'];
+const NODE_MAJORS = ['18', '20', '22'];
+
+export default defineConfig({
+ testDir: 'test',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ workers: 3,
+ retries: 2,
+ expect: {
+ timeout: 30000,
+ },
+ projects: NEXT_MAJORS.flatMap((nextMajor) =>
+ NODE_MAJORS.map((nodeMajor) => ({
+ name: `Next.js v${nextMajor} - Node.js v${nodeMajor}`,
+ use: { versions: { nextMajor, nodeMajor }, ...devices['Desktop Chrome'] },
+ testMatch: [`test/next-${nextMajor}.test.js`],
+ }))
+ ),
+});
diff --git a/schema.graphql b/schema.graphql
index a04f44e..e74b5c4 100644
--- a/schema.graphql
+++ b/schema.graphql
@@ -1,4 +1,4 @@
-type NextCache @table(database: "cache" expiration: 3600) @export {
+type NextCache @table(database: "cache", expiration: 3600) @export {
id: ID @primaryKey
headers: Any
content: Bytes
diff --git a/test/next-13.test.js b/test/next-13.test.js
new file mode 100644
index 0000000..ba46df8
--- /dev/null
+++ b/test/next-13.test.js
@@ -0,0 +1,13 @@
+import { test, expect } from '../util/test-fixture.js';
+
+test.describe.configure({ mode: 'serial' });
+
+test('home page', async ({ nextApp, page }) => {
+ await page.goto(nextApp.rest.toString());
+ await expect(page.locator('h1')).toHaveText('Next.js v13');
+});
+
+test('title', async ({ nextApp, page }) => {
+ await page.goto(nextApp.rest.toString());
+ await expect(page).toHaveTitle('HarperDB - Next.js v13 App');
+});
diff --git a/test/next-14.test.js b/test/next-14.test.js
new file mode 100644
index 0000000..25e590e
--- /dev/null
+++ b/test/next-14.test.js
@@ -0,0 +1,13 @@
+import { test, expect } from '../util/test-fixture.js';
+
+test.describe.configure({ mode: 'serial' });
+
+test('home page', async ({ nextApp, page }) => {
+ await page.goto(nextApp.rest.toString());
+ await expect(page.locator('h1')).toHaveText('Next.js v14');
+});
+
+test('title', async ({ nextApp, page }) => {
+ await page.goto(nextApp.rest.toString());
+ await expect(page).toHaveTitle('HarperDB - Next.js v14 App');
+});
diff --git a/test/next-15.test.js b/test/next-15.test.js
new file mode 100644
index 0000000..bc985e3
--- /dev/null
+++ b/test/next-15.test.js
@@ -0,0 +1,13 @@
+import { test, expect } from '../util/test-fixture.js';
+
+test.describe.configure({ mode: 'serial' });
+
+test('home page', async ({ nextApp, page }) => {
+ await page.goto(nextApp.rest.toString());
+ await expect(page.locator('h1')).toHaveText('Next.js v15');
+});
+
+test('title', async ({ nextApp, page }) => {
+ await page.goto(nextApp.rest.toString());
+ await expect(page).toHaveTitle('HarperDB - Next.js v15 App');
+});
diff --git a/util/cache-bust.js b/util/cache-bust.js
new file mode 100644
index 0000000..75c5edd
--- /dev/null
+++ b/util/cache-bust.js
@@ -0,0 +1,33 @@
+import { spawn } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { join } from 'node:path';
+import { pipeline } from 'node:stream/promises';
+import { readdirSync } from 'node:fs';
+
+import { ROOT } from './constants-and-names.js';
+
+export function getCacheBustValue(files) {
+ return new Promise((resolve, reject) => {
+ const proc = spawn('git', ['status', '--porcelain', ...files], { cwd: ROOT });
+
+ proc.on('error', reject);
+
+ const hash = createHash('sha1');
+
+ pipeline(proc.stdout, hash)
+ .then(() => resolve(hash.digest('hex')))
+ .catch(reject);
+ });
+}
+
+export const MODULE_CACHE_BUST = getCacheBustValue([
+ 'config.yaml',
+ 'cli.js',
+ 'extension.js',
+ 'schema.graphql',
+ 'package.json',
+]);
+
+export function getNextFixtureCacheBustValue(nextMajor) {
+ return getCacheBustValue(readdirSync(join(ROOT, 'fixtures', `next-${nextMajor}`)));
+}
diff --git a/util/collected-transform.js b/util/collected-transform.js
new file mode 100644
index 0000000..8027cea
--- /dev/null
+++ b/util/collected-transform.js
@@ -0,0 +1,14 @@
+import { Transform } from 'node:stream';
+
+export class CollectedTransform extends Transform {
+ #chunks = [];
+
+ _transform(chunk, _, callback) {
+ this.#chunks.push(chunk);
+ callback(null, chunk);
+ }
+
+ get output() {
+ return Buffer.concat(this.#chunks).toString();
+ }
+}
diff --git a/util/constants-and-names.js b/util/constants-and-names.js
new file mode 100644
index 0000000..5d4d00b
--- /dev/null
+++ b/util/constants-and-names.js
@@ -0,0 +1,23 @@
+import { join } from 'node:path';
+
+export const ROOT = join(import.meta.dirname, '..');
+
+export const NEXT_MAJORS = ['13', '14', '15'];
+
+export const NODE_MAJORS = ['18', '20', '22'];
+
+export const PORTS = ['9925', '9926'];
+
+export const CONTAINER_ENGINE_LIST = ['podman', 'docker'];
+
+export function getNodeBaseImageName(nodeMajor) {
+ return `harperdb-nextjs/node-base-${nodeMajor}`;
+}
+
+export function getNextImageName(nextMajor, nodeMajor) {
+ return `harperdb-nextjs/test-image-next-${nextMajor}-node-${nodeMajor}`;
+}
+
+export function getNextContainerName(nextMajor, nodeMajor) {
+ return `harperdb-nextjs-test-container-next-${nextMajor}-node-${nodeMajor}`;
+}
diff --git a/util/container-engine.js b/util/container-engine.js
new file mode 100644
index 0000000..185f5ec
--- /dev/null
+++ b/util/container-engine.js
@@ -0,0 +1,16 @@
+import { spawnSync } from 'node:child_process';
+
+import { CONTAINER_ENGINE_LIST } from './constants-and-names.js';
+
+export function getContainerEngine() {
+ for (const engine of CONTAINER_ENGINE_LIST) {
+ const { status } = spawnSync(engine, ['--version'], { stdio: 'ignore' });
+ if (status === 0) {
+ return engine;
+ }
+ }
+
+ throw new Error(`No container engine found in ${CONTAINER_ENGINE_LIST.join(', ')}`);
+}
+
+export const CONTAINER_ENGINE = getContainerEngine();
diff --git a/util/docker/base.dockerfile b/util/docker/base.dockerfile
new file mode 100644
index 0000000..a8153e1
--- /dev/null
+++ b/util/docker/base.dockerfile
@@ -0,0 +1,44 @@
+# Base Dockerfile for HarperDB Next.js Integration Tests fixtures
+# Must be run from the root of the repository
+
+ARG NODE_MAJOR
+
+FROM node:${NODE_MAJOR}
+
+RUN apt-get update && apt-get install -y \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install HarperDB Globally
+RUN npm install -g harperdb
+
+# Set HarperDB Environment Variables
+ENV TC_AGREEMENT=yes
+ENV HDB_ADMIN_USERNAME=hdb_admin
+ENV HDB_ADMIN_PASSWORD=password
+ENV ROOTPATH=/hdb
+ENV OPERATIONSAPI_NETWORK_PORT=9925
+ENV HTTP_PORT=9926
+ENV THREADS_COUNT=1
+ENV LOG_TO_STDSTREAMS=true
+ENV LOG_TO_FILE=true
+
+# Create components directory
+RUN mkdir -p /hdb/components
+
+# Add base component
+COPY /fixtures/harperdb-base-component /hdb/components/harperdb-base-component
+
+# Create the @harperdb/nextjs module directory so it can be linked locally
+RUN mkdir -p /@harperdb/nextjs
+
+# Cache Bust copying project files
+ARG CACHE_BUST
+RUN echo "${CACHE_BUST}"
+COPY config.yaml extension.js cli.js schema.graphql package.json /@harperdb/nextjs/
+
+# Install dependencies for the @harperdb/nextjs module
+RUN npm install -C /@harperdb/nextjs
+
+# Create link to the @harperdb/nextjs module
+RUN npm link -C /@harperdb/nextjs
\ No newline at end of file
diff --git a/util/docker/next.dockerfile b/util/docker/next.dockerfile
new file mode 100644
index 0000000..a5845d7
--- /dev/null
+++ b/util/docker/next.dockerfile
@@ -0,0 +1,18 @@
+# Next.js Specific Dockerfile for HarperDB Next.js Integration Tests fixtures
+# Must be run from the root of the repository
+
+ARG BASE_IMAGE
+
+FROM ${BASE_IMAGE}
+
+ARG NEXT_MAJOR
+
+ARG CACHE_BUST
+RUN echo "${CACHE_BUST}"
+COPY fixtures/next-${NEXT_MAJOR} /hdb/components/next-${NEXT_MAJOR}
+
+RUN cd hdb/components/next-${NEXT_MAJOR} && npm install
+
+EXPOSE 9925 9926
+
+CMD ["harperdb", "run"]
\ No newline at end of file
diff --git a/util/fixture.js b/util/fixture.js
new file mode 100644
index 0000000..87ecf5a
--- /dev/null
+++ b/util/fixture.js
@@ -0,0 +1,112 @@
+import { spawn, spawnSync } from 'node:child_process';
+import { Transform } from 'node:stream';
+
+import { getNextImageName, getNextContainerName, NEXT_MAJORS, NODE_MAJORS, PORTS } from './constants-and-names.js';
+import { CONTAINER_ENGINE } from './container-engine.js';
+import { CollectedTransform } from './collected-transform.js';
+
+export class Fixture {
+ constructor({ autoSetup = true, debug = false, nextMajor, nodeMajor }) {
+ if (!NEXT_MAJORS.includes(nextMajor)) {
+ throw new Error(`nextMajor must be one of ${NEXT_MAJORS.join(', ')}`);
+ }
+ this.nextMajor = nextMajor;
+
+ if (!NODE_MAJORS.includes(nodeMajor)) {
+ throw new Error(`nodeMajor must be one of ${NODE_MAJORS.join(', ')}`);
+ }
+ this.nodeMajor = nodeMajor;
+
+ this.debug = debug || process.env.DEBUG === '1';
+
+ this.imageName = getNextImageName(nextMajor, nodeMajor);
+ this.containerName = getNextContainerName(nextMajor, nodeMajor);
+
+ if (autoSetup) {
+ this.ready = this.clear().then(() => this.run());
+ }
+ }
+
+ get #stdio() {
+ return ['ignore', this.debug ? 'inherit' : 'ignore', this.debug ? 'inherit' : 'ignore'];
+ }
+
+ #runCommand(args = [], options = {}) {
+ return new Promise((resolve, reject) => {
+ const childProcess = spawn(CONTAINER_ENGINE, args, {
+ stdio: this.#stdio,
+ ...options,
+ });
+
+ childProcess.on('error', reject);
+ childProcess.on('exit', resolve);
+ });
+ }
+
+ stop() {
+ return this.#runCommand(['stop', this.containerName]);
+ }
+
+ rm() {
+ return this.#runCommand(['rm', this.containerName]);
+ }
+
+ clear() {
+ return new Promise((resolve, reject) => {
+ const psProcess = spawn(CONTAINER_ENGINE, ['ps', '-aq', '-f', `name=${this.containerName}`]);
+
+ psProcess.on('error', reject);
+
+ const collectedStdout = psProcess.stdout.pipe(new CollectedTransform());
+
+ if (this.debug) {
+ collectedStdout.pipe(process.stdout);
+ psProcess.stderr.pipe(process.stderr);
+ }
+
+ psProcess.on('exit', (code) => {
+ if (code === 0 && collectedStdout.output !== '') {
+ return this.stop()
+ .then(() => this.rm())
+ .then(resolve, reject);
+ }
+ return resolve(code);
+ });
+ });
+ }
+
+ run() {
+ return new Promise((resolve, reject) => {
+ const runProcess = spawn(CONTAINER_ENGINE, ['run', '-P', '--name', this.containerName, this.imageName], {
+ stdio: ['ignore', 'pipe', this.debug ? 'inherit' : 'ignore'],
+ });
+
+ runProcess.on('error', reject);
+ runProcess.on('exit', resolve);
+
+ const stdout = runProcess.stdout.pipe(
+ new Transform({
+ transform(chunk, encoding, callback) {
+ if (/HarperDB \d+.\d+.\d+ successfully started/.test(chunk.toString())) {
+ resolve();
+ }
+ callback(null, chunk);
+ },
+ })
+ );
+
+ if (this.debug) {
+ stdout.pipe(process.stdout);
+ }
+ });
+ }
+
+ get portMap() {
+ const portMap = new Map();
+ for (const port of PORTS) {
+ const { stdout } = spawnSync(CONTAINER_ENGINE, ['port', this.containerName, port]);
+ portMap.set(port, stdout.toString().trim());
+ }
+ return portMap;
+ }
+}
diff --git a/util/scripts/pretest.js b/util/scripts/pretest.js
new file mode 100644
index 0000000..07d0139
--- /dev/null
+++ b/util/scripts/pretest.js
@@ -0,0 +1,83 @@
+import { spawn } from 'node:child_process';
+import { join } from 'node:path';
+
+import { CONTAINER_ENGINE } from '../container-engine.js';
+import { CollectedTransform } from '../collected-transform.js';
+import { MODULE_CACHE_BUST, getNextFixtureCacheBustValue } from '../cache-bust.js';
+import { NODE_MAJORS, NEXT_MAJORS, ROOT, getNodeBaseImageName, getNextImageName } from '../constants-and-names.js';
+
+const DEBUG = process.env.DEBUG === '1';
+
+function validateResult(result) {
+ const success = result.code === 0;
+
+ if (DEBUG || !success) {
+ console.log(`Image \x1b[94m${result.name}\x1b[0m build process exited with: \x1b[35m${result.code}\x1b[0m\n`);
+ result.stdout !== '' && console.log('\x1b[32mstdout\x1b[0m:\n' + result.stdout + '\n');
+ result.stderr !== '' && console.log('\x1b[31mstderr\x1b[0m:\n' + result.stderr + '\n');
+ }
+
+ if (!success) {
+ process.exit(1);
+ }
+}
+
+function build(name, args, options = {}) {
+ return new Promise((resolve, reject) => {
+ const buildProcess = spawn(CONTAINER_ENGINE, args, { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'], ...options });
+
+ const collectedStdout = buildProcess.stdout.pipe(new CollectedTransform());
+ const collectedStderr = buildProcess.stderr.pipe(new CollectedTransform());
+
+ buildProcess.on('error', reject);
+ buildProcess.on('close', (code) =>
+ resolve({
+ name,
+ code,
+ stdout: collectedStdout.output,
+ stderr: collectedStderr.output,
+ })
+ );
+ });
+}
+
+// Build Node.js Base Images
+for (const nodeMajor of NODE_MAJORS) {
+ const buildResult = await build(getNodeBaseImageName(nodeMajor), [
+ 'build',
+ '--build-arg',
+ `NODE_MAJOR=${nodeMajor}`,
+ '--build-arg',
+ `CACHE_BUST=${MODULE_CACHE_BUST}`,
+ '-t',
+ getNodeBaseImageName(nodeMajor),
+ '-f',
+ join(ROOT, 'util', 'docker', 'base.dockerfile'),
+ ROOT,
+ ]);
+
+ validateResult(buildResult);
+}
+
+// Build Next.js Images
+
+for (const nextMajor of NEXT_MAJORS) {
+ for (const nodeMajor of NODE_MAJORS) {
+ const buildResult = await build(getNextImageName(nextMajor, nodeMajor), [
+ 'build',
+ '--build-arg',
+ `BASE_IMAGE=${getNodeBaseImageName(nodeMajor)}`,
+ '--build-arg',
+ `NEXT_MAJOR=${nextMajor}`,
+ '--build-arg',
+ `CACHE_BUST=${getNextFixtureCacheBustValue(nextMajor)}`,
+ '-t',
+ getNextImageName(nextMajor, nodeMajor),
+ '-f',
+ join(ROOT, 'util', 'docker', 'next.dockerfile'),
+ ROOT,
+ ]);
+
+ validateResult(buildResult);
+ }
+}
diff --git a/util/test-fixture.js b/util/test-fixture.js
new file mode 100644
index 0000000..e95765b
--- /dev/null
+++ b/util/test-fixture.js
@@ -0,0 +1,29 @@
+import { test as base } from '@playwright/test';
+
+import { Fixture } from './fixture';
+
+export const test = base.extend({
+ versions: [
+ { nextMajor: '', nodeMajor: '' },
+ { option: true, scope: 'worker' },
+ ],
+ nextApp: [
+ async ({ versions: { nextMajor, nodeMajor } }, use) => {
+ const fixture = new Fixture({ nextMajor, nodeMajor });
+ await fixture.ready;
+
+ const rest = fixture.portMap.get('9926');
+
+ if (!rest) {
+ throw new Error('Rest port not found');
+ }
+
+ await use({ rest: `http://${rest}` });
+
+ await fixture.clear();
+ },
+ { scope: 'worker', timeout: 60000 },
+ ],
+});
+
+export { expect } from '@playwright/test';