Skip to content

Commit 905392b

Browse files
authored
ci: add subprocess-based tutorial tests (#58)
* test: add tests for most tutorials Excludes identity update (disable key), register name, document update, document delete * ci: add test ci * test: fix assert extracted IDs and handle $id keys in test output Fail early when extractId returns null so downstream tests don't silently skip. Also support the $id key format used by document output. Update readme
1 parent fdda0ce commit 905392b

7 files changed

Lines changed: 493 additions & 1 deletion

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: Tutorial Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
inputs:
10+
test_suite:
11+
description: 'Which tests to run'
12+
type: choice
13+
options:
14+
- read-only
15+
- read-write
16+
- all
17+
default: read-only
18+
19+
permissions:
20+
contents: read
21+
22+
concurrency:
23+
group: tutorial-tests-${{ github.workflow }}-${{ github.ref }}
24+
cancel-in-progress: true
25+
26+
jobs:
27+
test-read-only:
28+
runs-on: ubuntu-latest
29+
timeout-minutes: 10
30+
if: >
31+
github.event_name != 'workflow_dispatch' ||
32+
inputs.test_suite == 'read-only' ||
33+
inputs.test_suite == 'all'
34+
steps:
35+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
36+
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
37+
with:
38+
node-version: '20'
39+
- run: npm ci
40+
- name: Run read-only tutorial tests
41+
env:
42+
NETWORK: testnet
43+
PLATFORM_MNEMONIC: ${{ secrets.PLATFORM_MNEMONIC }}
44+
run: npm run test:read-only
45+
46+
test-read-write:
47+
runs-on: ubuntu-latest
48+
timeout-minutes: 30
49+
if: >
50+
github.event_name == 'workflow_dispatch' &&
51+
(inputs.test_suite == 'read-write' || inputs.test_suite == 'all')
52+
concurrency:
53+
group: tutorial-read-write
54+
cancel-in-progress: false
55+
steps:
56+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
57+
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
58+
with:
59+
node-version: '20'
60+
- run: npm ci
61+
- name: Run read-write tutorial tests
62+
env:
63+
NETWORK: testnet
64+
PLATFORM_MNEMONIC: ${{ secrets.PLATFORM_MNEMONIC }}
65+
run: npm run test:read-write

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,24 @@ contract](./2-Contracts-and-Documents/contract-register-minimal.mjs), set `DATA_
4747
Some client configuration options are included as comments in
4848
[`setupDashClient.mjs`](./setupDashClient.mjs) if more advanced configuration is required.
4949

50+
## Testing
51+
52+
Tests run each tutorial as a subprocess and validate its output. No test framework
53+
dependencies are required — tests use the Node.js built-in test runner.
54+
55+
Ensure your `.env` file is configured (see [`.env.example`](./.env.example)) before running tests.
56+
57+
```shell
58+
# Read-only tests (default) — safe to run, no credits consumed
59+
npm test
60+
61+
# Write tests — registers identities/contracts/documents (consumes testnet credits)
62+
npm run test:read-write
63+
64+
# All tests
65+
npm run test:all
66+
```
67+
5068
## Contributing
5169

5270
PRs accepted.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@
66
"scripts": {
77
"fmt": "npx prettier@2 --write '**/*.{md,js,mjs}'",
88
"lint": "npx -p typescript@4 tsc",
9-
"test": "echo \"Error: no test specified\" && exit 1"
9+
"test": "node --test --test-timeout=120000 test/read-only.test.mjs",
10+
"test:read-only": "node --test --test-timeout=120000 test/read-only.test.mjs",
11+
"test:read-write": "node --test --test-timeout=300000 --test-concurrency=1 test/read-write.test.mjs",
12+
"test:all": "node --test --test-timeout=300000 --test-concurrency=1 test/read-only.test.mjs test/read-write.test.mjs"
1013
},
1114
"repository": {
1215
"type": "git",

test/assertions.mjs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import assert from 'node:assert/strict';
2+
3+
/**
4+
* Assert that a tutorial run succeeded:
5+
* - Process was not killed (timeout)
6+
* - Exit code is 0
7+
* - No error patterns found in stdout or stderr
8+
* - All expected patterns found in stdout
9+
*/
10+
export function assertTutorialSuccess(result, entry) {
11+
assert.equal(
12+
result.killed,
13+
false,
14+
`Tutorial "${entry.name}" was killed (timeout)`,
15+
);
16+
17+
assert.equal(
18+
result.exitCode,
19+
0,
20+
`Tutorial "${entry.name}" exited with code ${result.exitCode}.\n` +
21+
`STDERR: ${result.stderr}\nSTDOUT: ${result.stdout}`,
22+
);
23+
24+
if (entry.errorPatterns) {
25+
for (const pat of entry.errorPatterns) {
26+
const re = new RegExp(pat);
27+
assert.equal(
28+
re.test(result.stderr) || re.test(result.stdout),
29+
false,
30+
`Tutorial "${entry.name}" output matched error pattern: ${pat}\n` +
31+
`STDERR: ${result.stderr}\nSTDOUT: ${result.stdout}`,
32+
);
33+
}
34+
}
35+
36+
if (entry.expectedPatterns) {
37+
for (const pat of entry.expectedPatterns) {
38+
assert.match(
39+
result.stdout,
40+
new RegExp(pat),
41+
`Tutorial "${entry.name}" stdout missing expected pattern: ${pat}\n` +
42+
`STDOUT: ${result.stdout}`,
43+
);
44+
}
45+
}
46+
}
47+
48+
/**
49+
* Extract a captured value from tutorial stdout using a regex with a capture group.
50+
* Returns the first capture group match, or null if no match.
51+
*/
52+
export function extractFromOutput(stdout, regex) {
53+
const match = stdout.match(regex);
54+
return match ? match[1] : null;
55+
}
56+
57+
/**
58+
* Extract an `id` or `$id` field from util.inspect or JSON output.
59+
* Handles `'$id': 'VALUE'` (inspect), `"$id": "VALUE"` (JSON),
60+
* `id: 'VALUE'` (inspect), and `"id": "VALUE"` (JSON).
61+
* Tries `$id` first since it's more specific.
62+
*/
63+
export function extractId(stdout) {
64+
return (
65+
extractFromOutput(stdout, /'\$id':\s*'([^']+)'/) ??
66+
extractFromOutput(stdout, /"\$id"\s*:\s*"([^"]+)"/) ??
67+
extractFromOutput(stdout, /"id"\s*:\s*"([^"]+)"/) ??
68+
extractFromOutput(stdout, /id:\s*'([^']+)'/)
69+
);
70+
}

test/read-only.test.mjs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, it } from 'node:test';
2+
import { runTutorial } from './run-tutorial.mjs';
3+
import { assertTutorialSuccess } from './assertions.mjs';
4+
5+
const tutorials = [
6+
{
7+
path: 'connect.mjs',
8+
name: 'connect',
9+
expectedPatterns: ['Connected\\. System status:'],
10+
errorPatterns: ['Failed to fetch'],
11+
timeoutMs: 30_000,
12+
},
13+
{
14+
path: 'create-wallet.mjs',
15+
name: 'create-wallet',
16+
expectedPatterns: ['Mnemonic:', 'Platform address:'],
17+
errorPatterns: ['Something went wrong'],
18+
timeoutMs: 30_000,
19+
},
20+
{
21+
path: '1-Identities-and-Names/identity-retrieve.mjs',
22+
name: 'identity-retrieve',
23+
expectedPatterns: ['Identity retrieved:'],
24+
errorPatterns: ['Something went wrong'],
25+
},
26+
{
27+
path: '1-Identities-and-Names/name-resolve-by-name.mjs',
28+
name: 'name-resolve-by-name',
29+
expectedPatterns: ['Identity ID for'],
30+
errorPatterns: ['Something went wrong'],
31+
},
32+
{
33+
path: '1-Identities-and-Names/name-search-by-name.mjs',
34+
name: 'name-search-by-name',
35+
expectedPatterns: ['\\.dash'],
36+
errorPatterns: ['Something went wrong'],
37+
},
38+
{
39+
path: '1-Identities-and-Names/name-get-identity-names.mjs',
40+
name: 'name-get-identity-names',
41+
expectedPatterns: ['Name\\(s\\) retrieved'],
42+
errorPatterns: ['Something went wrong'],
43+
},
44+
{
45+
path: '2-Contracts-and-Documents/contract-retrieve.mjs',
46+
name: 'contract-retrieve',
47+
expectedPatterns: ['Contract retrieved:'],
48+
errorPatterns: ['Something went wrong'],
49+
},
50+
{
51+
path: '2-Contracts-and-Documents/contract-retrieve-history.mjs',
52+
name: 'contract-retrieve-history',
53+
expectedPatterns: ['Version at'],
54+
errorPatterns: ['Something went wrong'],
55+
},
56+
{
57+
path: '2-Contracts-and-Documents/document-retrieve.mjs',
58+
name: 'document-retrieve',
59+
expectedPatterns: ['Document:'],
60+
errorPatterns: ['Something went wrong'],
61+
},
62+
];
63+
64+
describe('Read-only tutorials', () => {
65+
for (const entry of tutorials) {
66+
it(entry.name, { timeout: entry.timeoutMs ?? 120_000 }, async () => {
67+
const result = await runTutorial(entry.path, {
68+
env: entry.env,
69+
timeoutMs: entry.timeoutMs,
70+
});
71+
assertTutorialSuccess(result, entry);
72+
});
73+
}
74+
});

0 commit comments

Comments
 (0)