diff --git a/.github/workflows/test-tutorials.yml b/.github/workflows/test-tutorials.yml new file mode 100644 index 0000000..ae68954 --- /dev/null +++ b/.github/workflows/test-tutorials.yml @@ -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 diff --git a/README.md b/README.md index c1dd977..b2a0595 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package.json b/package.json index 5801872..be042d6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/assertions.mjs b/test/assertions.mjs new file mode 100644 index 0000000..a53104a --- /dev/null +++ b/test/assertions.mjs @@ -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*'([^']+)'/) + ); +} diff --git a/test/read-only.test.mjs b/test/read-only.test.mjs new file mode 100644 index 0000000..83a3fcb --- /dev/null +++ b/test/read-only.test.mjs @@ -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); + }); + } +}); diff --git a/test/read-write.test.mjs b/test/read-write.test.mjs new file mode 100644 index 0000000..3a2a4c5 --- /dev/null +++ b/test/read-write.test.mjs @@ -0,0 +1,221 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { runTutorial } from './run-tutorial.mjs'; +import { assertTutorialSuccess, extractId } from './assertions.mjs'; + +// Accumulated state passed forward as env vars to dependent tutorials. +const state = {}; + +describe('Write tutorials (sequential)', { concurrency: 1 }, () => { + // ----------------------------------------------------------------------- + // Phase 1: Identity + // ----------------------------------------------------------------------- + + it('identity-register', { timeout: 180_000 }, async () => { + const result = await runTutorial( + '1-Identities-and-Names/identity-register.mjs', + { timeoutMs: 180_000 }, + ); + + // This tutorial has a known SDK bug workaround — it may extract the + // identity ID from an error message. Either path prints "Identity registered!". + assert.match( + result.stdout, + /Identity registered!/, + `Expected identity registration output.\nSTDOUT: ${result.stdout}\nSTDERR: ${result.stderr}`, + ); + console.log(result.stdout) + }); + + it('identity-retrieve', { timeout: 120_000 }, async () => { + const result = await runTutorial( + '1-Identities-and-Names/identity-retrieve.mjs', + { timeoutMs: 120_000 }, + ); + assertTutorialSuccess(result, { + name: 'identity-retrieve', + expectedPatterns: ['Identity retrieved:'], + errorPatterns: ['Something went wrong'], + }); + }); + + it('identity-topup', { timeout: 120_000 }, async () => { + const result = await runTutorial( + '1-Identities-and-Names/identity-topup.mjs', + { timeoutMs: 120_000 }, + ); + assertTutorialSuccess(result, { + name: 'identity-topup', + expectedPatterns: ['Top-up result:', 'Final balance:'], + errorPatterns: ['Something went wrong'], + }); + }); + + it('identity-transfer-credits', { timeout: 120_000 }, async () => { + const result = await runTutorial( + '1-Identities-and-Names/identity-transfer-credits.mjs', + { timeoutMs: 120_000 }, + ); + assertTutorialSuccess(result, { + name: 'identity-transfer-credits', + expectedPatterns: ['balance after transfer:'], + errorPatterns: ['Something went wrong'], + }); + }); + + it('identity-withdraw-credits', { timeout: 120_000 }, async () => { + const result = await runTutorial( + '1-Identities-and-Names/identity-withdraw-credits.mjs', + { timeoutMs: 120_000 }, + ); + assertTutorialSuccess(result, { + name: 'identity-withdraw-credits', + expectedPatterns: ['balance after withdrawal:'], + errorPatterns: ['Something went wrong'], + }); + }); + + it('identity-update-add-key', { timeout: 120_000 }, async () => { + const result = await runTutorial( + '1-Identities-and-Names/identity-update-add-key.mjs', + { timeoutMs: 120_000 }, + ); + assertTutorialSuccess(result, { + name: 'identity-update-add-key', + expectedPatterns: ['Identity updated:'], + errorPatterns: ['Something went wrong'], + }); + }); + + // Skipped: identity-update-disable-key.mjs — KEY_ID = 99 hardcoded + // Skipped: name-register.mjs — NAME_LABEL = 'alice' hardcoded + + // ----------------------------------------------------------------------- + // Phase 2: Contracts + // ----------------------------------------------------------------------- + + it('contract-register-minimal', { timeout: 180_000 }, async () => { + const result = await runTutorial( + '2-Contracts-and-Documents/contract-register-minimal.mjs', + { timeoutMs: 180_000 }, + ); + assertTutorialSuccess(result, { + name: 'contract-register-minimal', + expectedPatterns: ['Contract registered:'], + errorPatterns: ['Something went wrong'], + }); + const id = extractId(result.stdout); + assert.ok(id, `Failed to extract contract ID from stdout:\n${result.stdout}`); + state.dataContractId = id; + }); + + it('contract-register-indexed', { timeout: 180_000 }, async () => { + const result = await runTutorial( + '2-Contracts-and-Documents/contract-register-indexed.mjs', + { timeoutMs: 180_000 }, + ); + assertTutorialSuccess(result, { + name: 'contract-register-indexed', + expectedPatterns: ['Contract registered:'], + errorPatterns: ['Something went wrong'], + }); + }); + + it('contract-register-binary', { timeout: 180_000 }, async () => { + const result = await runTutorial( + '2-Contracts-and-Documents/contract-register-binary.mjs', + { timeoutMs: 180_000 }, + ); + assertTutorialSuccess(result, { + name: 'contract-register-binary', + expectedPatterns: ['Contract registered:'], + errorPatterns: ['Something went wrong'], + }); + }); + + it('contract-register-timestamps', { timeout: 180_000 }, async () => { + const result = await runTutorial( + '2-Contracts-and-Documents/contract-register-timestamps.mjs', + { timeoutMs: 180_000 }, + ); + assertTutorialSuccess(result, { + name: 'contract-register-timestamps', + expectedPatterns: ['Contract registered:'], + errorPatterns: ['Something went wrong'], + }); + }); + + it('contract-register-history', { timeout: 180_000 }, async () => { + const result = await runTutorial( + '2-Contracts-and-Documents/contract-register-history.mjs', + { timeoutMs: 180_000 }, + ); + assertTutorialSuccess(result, { + name: 'contract-register-history', + expectedPatterns: ['Contract registered:'], + errorPatterns: ['Something went wrong'], + }); + }); + + it('contract-register-nft', { timeout: 180_000 }, async () => { + const result = await runTutorial( + '2-Contracts-and-Documents/contract-register-nft.mjs', + { timeoutMs: 180_000 }, + ); + assertTutorialSuccess(result, { + name: 'contract-register-nft', + expectedPatterns: ['Contract registered:'], + errorPatterns: ['Something went wrong'], + }); + }); + + it('contract-update-minimal', { timeout: 120_000 }, async (ctx) => { + if (!state.dataContractId) { + ctx.skip('No DATA_CONTRACT_ID (contract-register-minimal must pass first)'); + return; + } + const result = await runTutorial( + '2-Contracts-and-Documents/contract-update-minimal.mjs', + { + env: { DATA_CONTRACT_ID: state.dataContractId }, + timeoutMs: 120_000, + }, + ); + assertTutorialSuccess(result, { + name: 'contract-update-minimal', + expectedPatterns: ['Contract updated:'], + errorPatterns: ['Something went wrong'], + }); + }); + + // ----------------------------------------------------------------------- + // Phase 3: Documents (depend on contract from Phase 2) + // ----------------------------------------------------------------------- + + it('document-submit', { timeout: 120_000 }, async (ctx) => { + if (!state.dataContractId) { + ctx.skip('No DATA_CONTRACT_ID (contract-register-minimal must pass first)'); + return; + } + const result = await runTutorial( + '2-Contracts-and-Documents/document-submit.mjs', + { + env: { DATA_CONTRACT_ID: state.dataContractId }, + timeoutMs: 120_000, + }, + ); + assertTutorialSuccess(result, { + name: 'document-submit', + expectedPatterns: ['Document submitted:'], + errorPatterns: ['Something went wrong'], + }); + + const docId = extractId(result.stdout); + assert.ok(docId, `Failed to extract document ID from stdout:\n${result.stdout}`); + state.documentId = docId; + }); + + // TODO: document-update.mjs and document-delete.mjs need a + // `process.env.DOCUMENT_ID ??` prefix before they can be tested here. + // Once added, use state.documentId (extracted above) via env var. +}); diff --git a/test/run-tutorial.mjs b/test/run-tutorial.mjs new file mode 100644 index 0000000..8d48dca --- /dev/null +++ b/test/run-tutorial.mjs @@ -0,0 +1,41 @@ +import { execFile } from 'node:child_process'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); +const REPO_ROOT = resolve(__dirname, '..'); + +/** + * Run a tutorial script as a subprocess and capture its output. + * + * @param {string} scriptPath - Path relative to repo root (e.g. 'connect.mjs') + * @param {object} [options] + * @param {object} [options.env] - Extra environment variables to merge + * @param {number} [options.timeoutMs=120000] - Timeout in milliseconds + * @returns {Promise<{stdout: string, stderr: string, exitCode: number, killed: boolean}>} + */ +export function runTutorial(scriptPath, options = {}) { + const { env = {}, timeoutMs = 120_000 } = options; + const absolutePath = resolve(REPO_ROOT, scriptPath); + + return new Promise((resolveP) => { + execFile( + 'node', + [absolutePath], + { + cwd: REPO_ROOT, + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0', ...env }, + timeout: timeoutMs, + maxBuffer: 1024 * 1024, + }, + (error, stdout, stderr) => { + resolveP({ + stdout: stdout.toString(), + stderr: stderr.toString(), + exitCode: error?.code ?? (error ? 1 : 0), + killed: error?.killed ?? false, + }); + }, + ); + }); +}