Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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) }}

defaults:
run:
working-directory: harper

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 }}
cache: npm
cache-dependency-path: harper/package-lock.json

- name: Install dependencies
run: npm ci

- 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
# The Harper app's lockfile must be committed so CI `npm ci` is reproducible.
!harper/package-lock.json

# Python
__pycache__/
Expand Down
4 changes: 2 additions & 2 deletions harper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ A Harper application demonstrating MQTT messaging capabilities.
Make sure you have [installed Harper](https://docs.harperdb.io/docs/deployments/install-harper):

```sh
npm install -g harperdb
npm install -g harper
```

Then install project dependencies:
Expand Down Expand Up @@ -65,7 +65,7 @@ npm run deploy

## Documentation

- [HarperDB Documentation](https://docs.harperdb.io/docs)
- [Harper Documentation](https://docs.harperdb.io/docs)
- [Components Reference](https://docs.harperdb.io/docs/reference/components)
- [Getting Started Guide](https://docs.harperdb.io/docs)

Expand Down
20 changes: 20 additions & 0 deletions harper/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@ import { defineConfig } from "eslint/config";

export default defineConfig([
...harperConfig,
// The shared config resolves `project: './tsconfig.json'` relative to its own
// package directory, which doesn't exist there. Point the typed-linting parser
// at this project's tsconfig instead (resolved from the config file's dir).
{
files: ["**/*.ts", "**/*.tsx", "**/*.mts"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
// Integration tests use the node:test API, whose `suite`/`test`/`before`/
// `after` calls intentionally return unawaited promises managed by the runner.
{
files: ["integrationTests/**/*.ts"],
rules: {
"@typescript-eslint/no-floating-promises": "off",
},
},
// Your custom configuration here
{
rules: {
Expand Down
4 changes: 4 additions & 0 deletions harper/integrationTests/fixture/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
graphqlSchema:
files: 'schema.graphql'

rest: true
5 changes: 5 additions & 0 deletions harper/integrationTests/fixture/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "harper-mqtt-getting-started",
"version": "1.0.0",
"type": "module"
}
10 changes: 10 additions & 0 deletions harper/integrationTests/fixture/schema.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
type Topics @table @export(name: "") {
id: ID @primaryKey
data: Object
}

type Sensors @table @export {
id: ID @primaryKey
location: String
temp: Float
}
109 changes: 109 additions & 0 deletions harper/integrationTests/mqtt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* Verifies the MQTT pub/sub messaging path — the core of this starter.
*
* The `Sensors` table is exported, so Harper maps the MQTT topic `Sensors/{id}`
* to that record: publishing to `Sensors/102` upserts the record, and a
* subscriber to the same topic receives the published payload. This test
* connects two MQTT clients (one subscriber, one publisher) to the running
* Harper instance and asserts the message is delivered end to end.
*/
import { suite, test, before, after } from 'node:test';
import { ok, deepStrictEqual } from 'node:assert/strict';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { connectAsync, type IClientOptions, type MqttClient } from 'mqtt';
import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';

const require = createRequire(import.meta.url);
// harper's `exports` map only exposes ".", so 'harper/dist/bin/harper.js' is
// not resolvable; resolve the CLI from the package main entry instead.
const harperBinPath = resolve(dirname(require.resolve('harper')), 'bin/harper.js');

const FIXTURE_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'fixture');
const MQTT_PORT = 1883;

function mqttOptions(ctx: ContextWithHarper): IClientOptions {
return {
username: ctx.harper.admin.username,
password: ctx.harper.admin.password,
protocolVersion: 5 as const,
reconnectPeriod: 0,
};
}

suite('MQTT pub/sub', (ctx: ContextWithHarper) => {
before(async () => {
await setupHarperWithFixture(ctx, FIXTURE_PATH, { harperBinPath });
});

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

test('subscriber receives a message published to Sensors/102', async () => {
const brokerUrl = `mqtt://${ctx.harper.hostname}:${MQTT_PORT}`;
const topic = 'Sensors/102';
const payload = { id: '102', temp: 68.25, location: 'cold-storage' };

let subscriber: MqttClient | undefined;
let publisher: MqttClient | undefined;
try {
subscriber = await connectAsync(brokerUrl, mqttOptions(ctx));

const received = new Promise<Buffer>((resolvePromise, reject) => {
const timer = setTimeout(() => reject(new Error('timed out waiting for MQTT message')), 15000);
subscriber!.on('message', (_topic, message) => {
clearTimeout(timer);
resolvePromise(message);
});
});

await subscriber.subscribeAsync(topic, { qos: 1 });

publisher = await connectAsync(brokerUrl, mqttOptions(ctx));
await publisher.publishAsync(topic, JSON.stringify(payload), { qos: 1 });

const message = await received;
const decoded = JSON.parse(message.toString()) as Record<string, unknown>;
deepStrictEqual(decoded.temp, payload.temp);
deepStrictEqual(decoded.location, payload.location);
} finally {
await publisher?.endAsync();
await subscriber?.endAsync();
}
});

test('published MQTT message is persisted and readable over REST', async () => {
const brokerUrl = `mqtt://${ctx.harper.hostname}:${MQTT_PORT}`;
const topic = 'Sensors/103';
const payload = { id: '103', temp: 70.0, location: 'lab' };

let publisher: MqttClient | undefined;
try {
publisher = await connectAsync(brokerUrl, mqttOptions(ctx));
await publisher.publishAsync(topic, JSON.stringify(payload), { qos: 1, retain: true });
} finally {
await publisher?.endAsync();
}

const token = Buffer.from(`${ctx.harper.admin.username}:${ctx.harper.admin.password}`).toString('base64');

// Poll the REST surface until the upsert from the MQTT publish lands.
let body: Record<string, unknown> | undefined;
for (let attempt = 0; attempt < 20; attempt++) {

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.

Flakiness risk: 5-second REST poll window may be too tight under CI load

The loop polls 20 times at 250 ms each (5 s total) for the MQTT-published record to appear over REST. Under a loaded CI runner, the Harper process may take longer than 5 s to flush the QoS-1 retained message to the database, causing a spurious failure.

Increasing to 40 attempts keeps the same 250 ms cadence but doubles the budget to 10 s, which is in line with the 15 s timeout used in the pub/sub delivery test above and is still well within a reasonable test time limit.

Suggested change
for (let attempt = 0; attempt < 20; attempt++) {
for (let attempt = 0; attempt < 40; attempt++) {

const res = await fetch(`${ctx.harper.httpURL}/Sensors/103`, {
headers: { Authorization: `Basic ${token}` },
});
if (res.status === 200) {
body = (await res.json()) as Record<string, unknown>;
break;
}
await new Promise((r) => setTimeout(r, 250));
}

ok(body, 'sensor record was not persisted from the MQTT publish');
deepStrictEqual(body.temp, payload.temp);
deepStrictEqual(body.location, payload.location);
});
});
76 changes: 76 additions & 0 deletions harper/integrationTests/rest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* Verifies the REST publish/retrieve path that the pub/sub app is built on.
*
* The app exports the `Sensors` and `Topics` tables (schema.graphql) over REST.
* Publishing a sensor reading is a PUT to /Sensors/{id}; subscribers read it
* back over the same REST surface. This exercises the table-export backbone that
* the MQTT/WS/SSE pub/sub layer shares.
*/
import { suite, test, before, after } from 'node:test';
import { strictEqual, deepStrictEqual } from 'node:assert/strict';
import { createRequire } from 'node:module';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { setupHarperWithFixture, teardownHarper, type ContextWithHarper } from '@harperfast/integration-testing';

const require = createRequire(import.meta.url);
// harper's `exports` map only exposes ".", so 'harper/dist/bin/harper.js' is
// not resolvable; resolve the CLI from the package main entry instead.
const harperBinPath = resolve(dirname(require.resolve('harper')), 'bin/harper.js');

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: harperBinPath resolution is duplicated verbatim in both test files

Both mqtt.test.ts (line 21) and rest.test.ts (line 19) contain the exact same three-liner:

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

If the escape hatch logic ever changes (e.g. harper exposes an exports entry for the bin path), it will need to be updated in two places. Consider extracting this to a shared integrationTests/harperBin.ts helper and importing it from both test files.


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

function authHeaders(ctx: ContextWithHarper): Record<string, string> {
const { username, password } = ctx.harper.admin;
const token = Buffer.from(`${username}:${password}`).toString('base64');
return {
'Authorization': `Basic ${token}`,
'Content-Type': 'application/json',
};
}

suite('REST pub/sub backbone', (ctx: ContextWithHarper) => {
before(async () => {
await setupHarperWithFixture(ctx, FIXTURE_PATH, { harperBinPath });
});

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

test('PUT a sensor reading and read it back', async () => {
const url = `${ctx.harper.httpURL}/Sensors/101`;
const reading = { id: '101', temp: 72.5, location: 'warehouse' };

const putRes = await fetch(url, {
method: 'PUT',
headers: authHeaders(ctx),
body: JSON.stringify(reading),
});
strictEqual(putRes.ok, true, `PUT failed: ${putRes.status} ${await putRes.text()}`);

const getRes = await fetch(url, { headers: authHeaders(ctx) });
strictEqual(getRes.status, 200);
const body = (await getRes.json()) as Record<string, unknown>;
strictEqual(body.id, '101');
strictEqual(body.temp, 72.5);
strictEqual(body.location, 'warehouse');
});

test('PUT to Topics stores arbitrary message data', async () => {
const url = `${ctx.harper.httpURL}/Topics/room-1`;
const message = { id: 'room-1', data: { hello: 'world', n: 42 } };

const putRes = await fetch(url, {
method: 'PUT',
headers: authHeaders(ctx),
body: JSON.stringify(message),
});
strictEqual(putRes.ok, true, `PUT failed: ${putRes.status} ${await putRes.text()}`);

const getRes = await fetch(url, { headers: authHeaders(ctx) });
strictEqual(getRes.status, 200);
const body = (await getRes.json()) as Record<string, unknown>;
deepStrictEqual(body.data, { hello: 'world', n: 42 });
});
});
Loading