-
Notifications
You must be signed in to change notification settings - Fork 0
initial commit for v5 harper upgrade #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
dde889a
6258499
1f82555
6348ee4
65be4a4
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,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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| graphqlSchema: | ||
| files: 'schema.graphql' | ||
|
|
||
| rest: true |
| 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" | ||
| } |
| 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 | ||
| } |
| 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++) { | ||
| 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); | ||
| }); | ||
| }); | ||
| 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'); | ||
|
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: Both 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 |
||
|
|
||
| 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 }); | ||
| }); | ||
| }); | ||
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.
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.