Skip to content
Merged
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
65 changes: 65 additions & 0 deletions .github/workflows/test-tutorials.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: Tutorial Tests

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
test_suite:
description: 'Which tests to run'
type: choice
options:
- read-only
- read-write
- all
default: read-only

permissions:
contents: read

concurrency:
group: tutorial-tests-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
test-read-only:
runs-on: ubuntu-latest
timeout-minutes: 10
if: >
github.event_name != 'workflow_dispatch' ||
inputs.test_suite == 'read-only' ||
inputs.test_suite == 'all'
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '20'
- run: npm ci
- name: Run read-only tutorial tests
env:
NETWORK: testnet
PLATFORM_MNEMONIC: ${{ secrets.PLATFORM_MNEMONIC }}
run: npm run test:read-only

test-read-write:
runs-on: ubuntu-latest
timeout-minutes: 30
if: >
github.event_name == 'workflow_dispatch' &&
(inputs.test_suite == 'read-write' || inputs.test_suite == 'all')
concurrency:
group: tutorial-read-write
cancel-in-progress: false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: '20'
- run: npm ci
- name: Run read-write tutorial tests
env:
NETWORK: testnet
PLATFORM_MNEMONIC: ${{ secrets.PLATFORM_MNEMONIC }}
run: npm run test:read-write
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ contract](./2-Contracts-and-Documents/contract-register-minimal.mjs), set `DATA_
Some client configuration options are included as comments in
[`setupDashClient.mjs`](./setupDashClient.mjs) if more advanced configuration is required.

## Testing

Tests run each tutorial as a subprocess and validate its output. No test framework
dependencies are required — tests use the Node.js built-in test runner.

Ensure your `.env` file is configured (see [`.env.example`](./.env.example)) before running tests.

```shell
# Read-only tests (default) — safe to run, no credits consumed
npm test

# Write tests — registers identities/contracts/documents (consumes testnet credits)
npm run test:read-write

# All tests
npm run test:all
```

## Contributing

PRs accepted.
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@
"scripts": {
"fmt": "npx prettier@2 --write '**/*.{md,js,mjs}'",
"lint": "npx -p typescript@4 tsc",
"test": "echo \"Error: no test specified\" && exit 1"
"test": "node --test --test-timeout=120000 test/read-only.test.mjs",
"test:read-only": "node --test --test-timeout=120000 test/read-only.test.mjs",
"test:read-write": "node --test --test-timeout=300000 --test-concurrency=1 test/read-write.test.mjs",
"test:all": "node --test --test-timeout=300000 --test-concurrency=1 test/read-only.test.mjs test/read-write.test.mjs"
},
"repository": {
"type": "git",
Expand Down
70 changes: 70 additions & 0 deletions test/assertions.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';

/**
* Assert that a tutorial run succeeded:
* - Process was not killed (timeout)
* - Exit code is 0
* - No error patterns found in stdout or stderr
* - All expected patterns found in stdout
*/
export function assertTutorialSuccess(result, entry) {
assert.equal(
result.killed,
false,
`Tutorial "${entry.name}" was killed (timeout)`,
);

assert.equal(
result.exitCode,
0,
`Tutorial "${entry.name}" exited with code ${result.exitCode}.\n` +
`STDERR: ${result.stderr}\nSTDOUT: ${result.stdout}`,
);

if (entry.errorPatterns) {
for (const pat of entry.errorPatterns) {
const re = new RegExp(pat);
assert.equal(
re.test(result.stderr) || re.test(result.stdout),
false,
`Tutorial "${entry.name}" output matched error pattern: ${pat}\n` +
`STDERR: ${result.stderr}\nSTDOUT: ${result.stdout}`,
);
}
}

if (entry.expectedPatterns) {
for (const pat of entry.expectedPatterns) {
assert.match(
result.stdout,
new RegExp(pat),
`Tutorial "${entry.name}" stdout missing expected pattern: ${pat}\n` +
`STDOUT: ${result.stdout}`,
);
}
}
}

/**
* Extract a captured value from tutorial stdout using a regex with a capture group.
* Returns the first capture group match, or null if no match.
*/
export function extractFromOutput(stdout, regex) {
const match = stdout.match(regex);
return match ? match[1] : null;
}

/**
* Extract an `id` or `$id` field from util.inspect or JSON output.
* Handles `'$id': 'VALUE'` (inspect), `"$id": "VALUE"` (JSON),
* `id: 'VALUE'` (inspect), and `"id": "VALUE"` (JSON).
* Tries `$id` first since it's more specific.
*/
export function extractId(stdout) {
return (
extractFromOutput(stdout, /'\$id':\s*'([^']+)'/) ??
extractFromOutput(stdout, /"\$id"\s*:\s*"([^"]+)"/) ??
extractFromOutput(stdout, /"id"\s*:\s*"([^"]+)"/) ??
extractFromOutput(stdout, /id:\s*'([^']+)'/)
);
}
74 changes: 74 additions & 0 deletions test/read-only.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it } from 'node:test';
import { runTutorial } from './run-tutorial.mjs';
import { assertTutorialSuccess } from './assertions.mjs';

const tutorials = [
{
path: 'connect.mjs',
name: 'connect',
expectedPatterns: ['Connected\\. System status:'],
errorPatterns: ['Failed to fetch'],
timeoutMs: 30_000,
},
{
path: 'create-wallet.mjs',
name: 'create-wallet',
expectedPatterns: ['Mnemonic:', 'Platform address:'],
errorPatterns: ['Something went wrong'],
timeoutMs: 30_000,
},
{
path: '1-Identities-and-Names/identity-retrieve.mjs',
name: 'identity-retrieve',
expectedPatterns: ['Identity retrieved:'],
errorPatterns: ['Something went wrong'],
},
{
path: '1-Identities-and-Names/name-resolve-by-name.mjs',
name: 'name-resolve-by-name',
expectedPatterns: ['Identity ID for'],
errorPatterns: ['Something went wrong'],
},
{
path: '1-Identities-and-Names/name-search-by-name.mjs',
name: 'name-search-by-name',
expectedPatterns: ['\\.dash'],
errorPatterns: ['Something went wrong'],
},
{
path: '1-Identities-and-Names/name-get-identity-names.mjs',
name: 'name-get-identity-names',
expectedPatterns: ['Name\\(s\\) retrieved'],
errorPatterns: ['Something went wrong'],
},
{
path: '2-Contracts-and-Documents/contract-retrieve.mjs',
name: 'contract-retrieve',
expectedPatterns: ['Contract retrieved:'],
errorPatterns: ['Something went wrong'],
},
{
path: '2-Contracts-and-Documents/contract-retrieve-history.mjs',
name: 'contract-retrieve-history',
expectedPatterns: ['Version at'],
errorPatterns: ['Something went wrong'],
},
{
path: '2-Contracts-and-Documents/document-retrieve.mjs',
name: 'document-retrieve',
expectedPatterns: ['Document:'],
errorPatterns: ['Something went wrong'],
},
];

describe('Read-only tutorials', () => {
for (const entry of tutorials) {
it(entry.name, { timeout: entry.timeoutMs ?? 120_000 }, async () => {
const result = await runTutorial(entry.path, {
env: entry.env,
timeoutMs: entry.timeoutMs,
});
assertTutorialSuccess(result, entry);
});
}
});
Loading