-
Notifications
You must be signed in to change notification settings - Fork 0
Upgrading Repo to Harper v5 #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
af9fc09
3f6638c
ac99c00
f0ea479
8e4ef9e
511d0a5
388661f
5ff580c
92fc682
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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); | ||
| 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}`); | ||
|
|
||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cleanup: The unconditional Cost: when this assertion fires it masks the real failure — the outer Suggestion: move the guard outside the loop or use |
||
| // 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'); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
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, missesdist/client/index.htmlresources.jsreads bothdist/server/entry-server.js(imported dynamically) anddist/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 whenresources.jstries toreadFileSyncthe missing HTML template.Failure scenario: developer runs
npm run build:serverwithoutbuild:client, or a prior CI run failed mid-build leaving only the server bundle — test proceeds past the guard, Harper starts, resources.js throwsENOENT: no such file or directory, open '.../dist/client/index.html', all tests fail with an unhelpful startup error.Fix: check both outputs: