Skip to content
Open
79 changes: 79 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Integration Tests

on:
push:
branches: [main]
pull_request:
workflow_dispatch:
inputs:
node-version:
description: 'Node.js version'
required: true
type: choice
default: 'all'
options:
- 'all'
- '22'
- '24'
- '26'

jobs:
generate-node-version-matrix:
name: Generate Node Version Matrix
runs-on: ubuntu-latest
outputs:
node-versions: ${{ steps.set-node-versions.outputs.node-versions }}

steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Set Node versions
id: set-node-versions
env:
NODE_VER: ${{ github.event.inputs.node-version }}
run: |
if [ "$NODE_VER" == "all" ] || [ -z "$NODE_VER" ]; then
echo "node-versions=[22, 24, 26]" >> $GITHUB_OUTPUT
else
echo "node-versions=[$NODE_VER]" >> $GITHUB_OUTPUT
fi

integration-tests:
name: Integration Tests (Node ${{ matrix.node-version }})
needs: [generate-node-version-matrix]
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
node-version: ${{ fromJSON(needs.generate-node-version-matrix.outputs.node-versions) }}

steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3

- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node-version }}

- name: Install dependencies
run: npm ci

- name: Build SSR client and server bundles
run: npm run build

- name: Run integration tests
run: npm run test:integration
env:
HARPER_INTEGRATION_TEST_LOG_DIR: /tmp/harper-test-logs
FORCE_COLOR: '1'

- name: Upload Harper logs on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: harper-logs-node-${{ matrix.node-version }}
path: /tmp/harper-test-logs/
retention-days: 7
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# HarperDB Vue SSR Example
# Harper Vue SSR Example

This repo is an example of how to implement Vue SSR using HarperDB Resources to efficiently generate a _Blog_ from a database of _Posts_.
This repo is an example of how to implement Vue SSR using Harper Resources to efficiently generate a _Blog_ from a database of _Posts_.

It includes complete client side hydration as well, resulting in a fully interactive web app experience.

Expand All @@ -11,7 +11,7 @@ It includes complete client side hydration as well, resulting in a fully interac

1. `npm i`
2. `npm build`
3. `harperdb run .`
3. `harper run .`
4. Navigate to [/UncachedBlog/0](http://localhost:9926/UncachedBlog/0) or [/CachedBlog/0](http://localhost:9926/CachedBlog/0)
5. Add or remove comments!

Expand All @@ -35,4 +35,4 @@ curl -X PATCH http://localhost:9926/Post/0 \
-d '{ "comments": [] }'
```

- This repo includes a `caching-test.js` script for quickly demonstrating and validating the caching behavior. Give it a try with `node caching-test.js` (component must be running with HarperDB).
- This repo includes a `caching-test.js` script for quickly demonstrating and validating the caching behavior. Give it a try with `node caching-test.js` (component must be running with Harper).
144 changes: 144 additions & 0 deletions integrationTests/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { suite, test, before, after } from 'node:test';
import { strictEqual, ok } from 'node:assert/strict';
import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';

const __dirname = dirname(fileURLToPath(import.meta.url));
const FIXTURE_PATH = resolve(__dirname, '..');

// The `harper` package's `exports` map only exposes ".", so the harness's
// auto-resolution of 'harper/dist/bin/harper.js' fails with ERR_PACKAGE_PATH_NOT_EXPORTED.
// Resolve the CLI from the (exported) main entry and pass it explicitly.
const require = createRequire(import.meta.url);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup: build guard checks only entry-server.js, misses dist/client/index.html

resources.js reads both dist/server/entry-server.js (imported dynamically) and dist/client/index.html (read synchronously at startup). The guard here only checks for the server bundle; if the client build is absent or partial (e.g. a leftover server build from a previous run where the client build failed), Harper will start but crash immediately when resources.js tries to readFileSync the missing HTML template.

Failure scenario: developer runs npm run build:server without build:client, or a prior CI run failed mid-build leaving only the server bundle — test proceeds past the guard, Harper starts, resources.js throws ENOENT: no such file or directory, open '.../dist/client/index.html', all tests fail with an unhelpful startup error.

Fix: check both outputs:

if (
  !existsSync(resolve(FIXTURE_PATH, 'dist/server/entry-server.js')) ||
  !existsSync(resolve(FIXTURE_PATH, 'dist/client/index.html'))
) {
  execFileSync('npm', ['run', 'build'], { cwd: FIXTURE_PATH, stdio: 'inherit' });
}

const harperBinPath = resolve(dirname(require.resolve('harper')), 'bin/harper.js');

function authFetch(
ctx: ContextWithHarper,
path: string,
init: RequestInit & { headers?: Record<string, string> } = {}
) {
const { headers = {}, ...rest } = init;
const creds = Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64');
return fetch(`${ctx.harper.httpURL}${path}`, { ...rest, headers: { Authorization: `Basic ${creds}`, ...headers } });
}

void suite('Vue SSR example', (ctx: ContextWithHarper) => {
before(async () => {
// The SSR component imports ./dist/server/entry-server.js and serves
// ./dist/client/index.html, so the Vite build must exist before the
// fixture (the whole repo dir) is copied into the Harper install.
if (!existsSync(resolve(FIXTURE_PATH, 'dist/server/entry-server.js'))) {
execFileSync('npm', ['run', 'build'], { cwd: FIXTURE_PATH, stdio: 'inherit' });
}
await setupHarperWithFixture(ctx, FIXTURE_PATH, { harperBinPath });
});

after(async () => {
await teardownHarper(ctx);
});

void test('Harper starts and the Post REST table is seeded', async () => {
// resources.js seeds Post/0 on startup.
const res = await authFetch(ctx, '/Post/0');
strictEqual(res.status, 200);
const body = (await res.json()) as { id: string; title: string; comments: string[] };
strictEqual(body.id, '0');
ok(body.title, 'expected a seeded title');
ok(Array.isArray(body.comments), 'expected a comments array');
});

void test('GET /UncachedBlog/0 server-side renders the blog as HTML', async () => {
const res = await authFetch(ctx, '/UncachedBlog/0');
strictEqual(res.status, 200);
ok(res.headers.get('Content-Type')?.startsWith('text/html'), 'expected text/html content type');
const html = await res.text();
// SSR output should contain the rendered post content + the hydration data script.
ok(html.includes('<!DOCTYPE html>') || html.includes('<html'), 'expected a full HTML document');
ok(html.includes('__INITIAL_POST_DATA__'), 'expected SSR hydration data to be injected');
});

void test('GET /CachedBlog/0 serves a non-empty cached SSR HTML body', async () => {
// The seeded Post/0 title; the cached render must include it so an empty/raw
// cache record (the static-get bug, where cached.content was undefined and the
// body came back empty) fails this assertion.
const post = (await (await authFetch(ctx, '/Post/0')).json()) as { title: string };

const res = await authFetch(ctx, '/CachedBlog/0');
strictEqual(res.status, 200);
ok(res.headers.get('Content-Type')?.startsWith('text/html'), 'expected text/html content type');
const html = await res.text();

ok(html.length > 0, 'expected a non-empty cached HTML body');
ok(html.includes('<!DOCTYPE html>') || html.includes('<html'), 'expected a full HTML document');
ok(html.includes('__INITIAL_POST_DATA__'), 'expected SSR hydration data in cached render');
ok(html.includes(post.title), `expected the rendered cached HTML to contain the post title "${post.title}"`);
});

void test('CachedBlog returns 304 on a conditional re-request (cache hit)', async () => {
const first = await authFetch(ctx, '/CachedBlog/0');
strictEqual(first.status, 200);
const etag = first.headers.get('ETag');
const lastModified = first.headers.get('Last-Modified');
ok(etag || lastModified, 'expected a cache validator header (ETag or Last-Modified)');

const conditionalHeaders: Record<string, string> = {};
if (etag) conditionalHeaders['If-None-Match'] = etag;
if (lastModified) conditionalHeaders['If-Modified-Since'] = lastModified;

const second = await authFetch(ctx, '/CachedBlog/0', { headers: conditionalHeaders });
strictEqual(second.status, 304);
});

void test('Updating the Post invalidates the cache, then re-caches', async () => {
// Prime the cache and grab validators.
const primed = await authFetch(ctx, '/CachedBlog/0');
strictEqual(primed.status, 200);
const etag = primed.headers.get('ETag');
const lastModified = primed.headers.get('Last-Modified');

const conditionalHeaders: Record<string, string> = {};
if (etag) conditionalHeaders['If-None-Match'] = etag;
if (lastModified) conditionalHeaders['If-Modified-Since'] = lastModified;

// Confirm it's currently a cache hit.
const hit = await authFetch(ctx, '/CachedBlog/0', { headers: conditionalHeaders });
strictEqual(hit.status, 304);

// Mutate the source Post via REST PATCH.
const current = (await (await authFetch(ctx, '/Post/0')).json()) as { comments: string[] };
const patch = await authFetch(ctx, '/Post/0', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comments: current.comments.concat(`Test comment ${Math.random()}`) }),
});
ok(patch.ok, `expected PATCH to succeed, got ${patch.status}`);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup: strictEqual(fresh.status, 200) inside the retry loop produces a misleading failure

The unconditional GET /CachedBlog/0 on every loop iteration is expected to always return 200 — if it doesn't (e.g. a transient 500 after cache re-population), the strictEqual inside the loop fires and the test fails with Expected: 200, Actual: 500 at an iteration-scoped line, obscuring the actual problem (which is that the cache didn't settle to a stable 304).

Cost: when this assertion fires it masks the real failure — the outer strictEqual(reCachedStatus, 304, '...') with its descriptive message is never reached, making CI failures harder to diagnose.

Suggestion: move the guard outside the loop or use ok(fresh.status === 200, ...) with a message that names the attempt number, so a real cache re-population error is distinguishable from a 304-settle timeout.

// The same conditional request should now miss the cache (source changed) -> 200.
const afterUpdate = await authFetch(ctx, '/CachedBlog/0', { headers: conditionalHeaders });
strictEqual(afterUpdate.status, 200);

// The cache re-populates from the source; its Last-Modified can keep advancing
// for a moment while it settles. Grab the current validators immediately before
// the conditional re-request and retry briefly until we get a stable 304 cache hit.
let reCachedStatus = 0;
for (let attempt = 0; attempt < 20; attempt++) {
const fresh = await authFetch(ctx, '/CachedBlog/0');
strictEqual(fresh.status, 200);
const freshConditional: Record<string, string> = {};
const freshEtag = fresh.headers.get('ETag');
const freshLastModified = fresh.headers.get('Last-Modified');
if (freshEtag) freshConditional['If-None-Match'] = freshEtag;
if (freshLastModified) freshConditional['If-Modified-Since'] = freshLastModified;

const reCached = await authFetch(ctx, '/CachedBlog/0', { headers: freshConditional });
reCachedStatus = reCached.status;
if (reCachedStatus === 304) break;
await new Promise((r) => setTimeout(r, 100));
}
strictEqual(reCachedStatus, 304, 'expected the re-populated cache to yield a 304 cache hit');
});
});
Loading