Feat v0.7.2 package runners ci gate #56

Merged
xcaliber merged 17 commits from feat/v0.7.2-package-runners-ci-gate into main 2026-04-02 12:10:57 +00:00
30 changed files with 1397 additions and 26 deletions
Showing only changes of commit 448071d6b9 - Show all commits

View File

@@ -366,6 +366,52 @@ jobs:
psql -c "DROP DATABASE IF EXISTS armature_ci;" postgres
echo "✓ Dropped CI test database"
# ── Stage 1d: Surface Test Runners ──────────
# Boots the server in Docker, runs all installed test-runner packages
# via Playwright, and asserts zero failures.
#
# Runs when: backend or frontend changed (runners test both tiers).
# Skipped when: only docs changed.
test-runners:
runs-on: ubuntu-latest
needs: [detect-changes, test-sqlite]
if: |
!cancelled() && !failure() &&
(needs.detect-changes.outputs.backend == 'true' ||
needs.detect-changes.outputs.frontend == 'true' ||
needs.detect-changes.outputs.infra == 'true')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build Docker image
run: docker compose build
- name: Boot server (SQLite)
env:
BUNDLED_PACKAGES: '*'
ARMATURE_ADMIN_USERNAME: admin
ARMATURE_ADMIN_PASSWORD: admin
ARMATURE_ADMIN_EMAIL: admin@test.local
run: docker compose up -d
- name: Wait for healthy
run: ./ci/wait-for-healthy.sh http://localhost:3000
- name: Install Playwright
run: npx playwright install --with-deps chromium
- name: Run surface tests
env:
SERVER_URL: http://localhost:3000
ADMIN_USER: admin
ADMIN_PASS: admin
run: ./ci/run-surface-tests.sh
- name: Teardown
if: always()
run: docker compose down -v
# ── Stage 2: Build, Database, Deploy ─────────
#
# Depends on all test jobs. Skipped jobs (due to path gating)
@@ -374,7 +420,7 @@ jobs:
# Skipped entirely for docs-only changes (nothing to build).
build-and-deploy:
runs-on: ubuntu-latest
needs: [detect-changes, test-go-pg, test-frontend, test-sqlite]
needs: [detect-changes, test-go-pg, test-frontend, test-sqlite, test-runners]
# Run unless: a needed job failed, the workflow was cancelled, or it's docs-only.
# Skipped test jobs (path-gated) are fine — they don't block.
if: |

View File

@@ -1,6 +1,6 @@
# Armature — Roadmap
## Current: v0.6.18 — CI Bundle Wiring
## Current: v0.7.2 — Package Runners + CI Gate
Self-hosted extensible platform. Auth, identity, packages, Starlark sandbox,
storage, realtime, and ops are kernel primitives. Everything else is an extension.
@@ -143,13 +143,13 @@ Design doc: `docs/DESIGN-surface-runners.md`
| Step | Status | Description |
|------|--------|-------------|
| Notes runner | | CRUD, folders, tags, backlinks, search, rendering. |
| Chat runner | | `requires: ["chat", "chat-core"]`. Channels, messaging, renderers. |
| Schedules runner | | CRUD, cron, toggle, Starlark exec. |
| Workflow runner | | `requires: ["content-approval"]`. Install detection, stages, signoff. |
| Renderer runner | | `requires: ["mermaid-renderer"]`. Register contract, post-render hooks. |
| Runner result API | | `POST /api/v1/test-runners/run`, `GET /results`. CI can curl and assert. |
| CI integration | | `test-runners` stage in Gitea CI. PG + SQLite. Zero failures = pass. |
| Notes runner | done | `requires: ["notes"]`. 3 suites (crud, folders, tags-search), 12 tests. |
| Chat runner | done | `requires: ["chat", "chat-core"]`. 2 suites (conversations, messaging), 9 tests. |
| Schedules runner | done | `requires: ["schedules"]`. 1 suite (crud), 5 tests. |
| Workflow runner | done | `requires: ["content-approval"]`. 1 suite (lifecycle), 5 tests. |
| Renderer runner | done | `requires: ["mermaid-renderer"]`. 1 suite (contract), 4 tests. |
| Runner result API | done | `POST/GET /api/v1/admin/test-runners/results`. In-memory store, 4 Go tests. |
| CI integration | done | `test-runners` stage in Gitea CI. Playwright driver, `wait-for-healthy.sh`. |
### v0.7.3 — Headless E2E Automation

View File

@@ -1 +1 @@
0.7.0
0.7.2

56
ci/run-surface-tests.sh Executable file
View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════
# Surface Test Runner — CI Entrypoint
# ═══════════════════════════════════════════════
#
# Authenticates as admin, navigates to the test-runners surface
# via Playwright, runs all test suites, and asserts zero failures.
#
# Prerequisites:
# - Server running at $SERVER_URL (default: http://localhost:3000)
# - npx playwright install chromium
# - ADMIN_USER / ADMIN_PASS env vars (default: admin/admin)
#
# Exit codes: 0 = all tests passed, 1 = failures detected
set -euo pipefail
SERVER_URL="${SERVER_URL:-http://localhost:3000}"
ADMIN_USER="${ADMIN_USER:-admin}"
ADMIN_PASS="${ADMIN_PASS:-admin}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${YELLOW}═══ Surface Test Runner ═══${NC}"
echo " Server: ${SERVER_URL}"
# ── Authenticate ─────────────────────────────
echo -e "${YELLOW}Authenticating...${NC}"
TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \
-H "Content-Type: application/json" \
-d "{\"username\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \
| node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).token)}catch(e){process.exit(1)}})")
if [ -z "$TOKEN" ]; then
echo -e "${RED}Failed to authenticate${NC}"
exit 1
fi
echo -e "${GREEN}Authenticated${NC}"
# ── Run via Playwright ───────────────────────
echo -e "${YELLOW}Running surface tests via Playwright...${NC}"
node "$(dirname "$0")/surface-test-driver.js" \
--server="${SERVER_URL}" \
--token="${TOKEN}"
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
echo -e "${GREEN}═══ All surface tests passed ═══${NC}"
else
echo -e "${RED}═══ Surface tests FAILED ═══${NC}"
fi
exit $EXIT_CODE

141
ci/surface-test-driver.js Normal file
View File

@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Surface Test Driver — Playwright-based CI runner
*
* Navigates to /s/test-runners as admin, clicks "Run All",
* waits for completion, then fetches results from the API.
*
* Usage:
* node ci/surface-test-driver.js --server=http://localhost:3000 --token=TOKEN
*
* Exit codes: 0 = all passed, 1 = failures
*/
'use strict';
const { chromium } = require('playwright');
// Parse args
const args = {};
process.argv.slice(2).forEach(a => {
const [k, v] = a.replace(/^--/, '').split('=');
args[k] = v;
});
const SERVER = args.server || 'http://localhost:3000';
const TOKEN = args.token || '';
if (!TOKEN) {
console.error('ERROR: --token is required');
process.exit(1);
}
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
// Set auth cookie/token
await context.addCookies([{
name: 'token',
value: TOKEN,
domain: new URL(SERVER).hostname,
path: '/',
}]);
const page = await context.newPage();
// Collect console errors
const consoleErrors = [];
page.on('console', msg => {
if (msg.type() === 'error') consoleErrors.push(msg.text());
});
try {
// Navigate to test runners surface
console.log('Navigating to test-runners surface...');
await page.goto(SERVER + '/s/test-runners', { waitUntil: 'networkidle', timeout: 30000 });
// Wait for runners to load
console.log('Waiting for runners to load...');
await page.waitForSelector('[data-testid="run-all"], button', { timeout: 30000 });
// Wait a bit for all runner scripts to finish loading
await page.waitForTimeout(3000);
// Click "Run All" button
console.log('Clicking Run All...');
const runAllBtn = await page.$('button:has-text("Run All")');
if (!runAllBtn) {
console.error('ERROR: "Run All" button not found');
await browser.close();
process.exit(1);
}
await runAllBtn.click();
// Wait for results — poll until running state clears
console.log('Waiting for test completion...');
// The running indicator disappears when tests finish
// Max wait: 5 minutes
const maxWait = 300000;
const start = Date.now();
let done = false;
while (!done && (Date.now() - start) < maxWait) {
await page.waitForTimeout(2000);
// Check if results are available via API
try {
const resp = await page.evaluate(async (serverUrl) => {
const r = await fetch(serverUrl + '/api/v1/admin/test-runners/results', {
credentials: 'include'
});
if (r.status === 200) return await r.json();
return null;
}, SERVER);
if (resp && resp.summary) {
done = true;
console.log('\n═══ Results ═══');
console.log(` Total: ${resp.summary.total}`);
console.log(` Passed: ${resp.summary.passed}`);
console.log(` Failed: ${resp.summary.failed}`);
console.log(` Warned: ${resp.summary.warned}`);
console.log(` Skipped: ${resp.summary.skipped}`);
console.log(` Duration: ${resp.duration_ms}ms`);
if (resp.summary.failed > 0) {
console.log('\n═══ Failures ═══');
for (const suite of (resp.suites || [])) {
for (const test of (suite.tests || [])) {
if (test.status === 'failed') {
console.log(`${suite.name} > ${test.name}: ${test.detail || 'failed'}`);
}
}
}
await browser.close();
process.exit(1);
}
await browser.close();
process.exit(0);
}
} catch (e) {
// Results not ready yet
}
}
if (!done) {
console.error('ERROR: Tests did not complete within timeout');
await browser.close();
process.exit(1);
}
} catch (e) {
console.error('Driver error:', e.message);
if (consoleErrors.length > 0) {
console.error('\nBrowser console errors:');
consoleErrors.forEach(e => console.error(' ' + e));
}
await browser.close();
process.exit(1);
}
})();

21
ci/wait-for-healthy.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════
# Wait for Armature server to be healthy
# ═══════════════════════════════════════════════
# Usage: ./ci/wait-for-healthy.sh [URL] [MAX_RETRIES]
set -euo pipefail
HOST="${1:-http://localhost:3000}"
MAX_RETRIES="${2:-60}"
echo "Waiting for ${HOST} to be healthy..."
for i in $(seq 1 "$MAX_RETRIES"); do
if curl -sf "${HOST}/api/v1/auth/login" -o /dev/null 2>/dev/null; then
echo "Server healthy after ${i}s"
exit 0
fi
sleep 1
done
echo "Server not healthy after ${MAX_RETRIES}s"
exit 1

View File

@@ -0,0 +1,59 @@
/**
* Chat Runner — chat/conversations suite
*
* Tests conversation CRUD via the chat-core ext API.
*/
(function () {
'use strict';
var api = window.CR.api;
sw.testing.suite('chat/conversations', async function (s) {
var convId;
s.test('create conversation', async function (t) {
var r = await api.post('/conversations', {
title: 'Runner Test Convo ' + Date.now(),
type: 'direct'
});
t.assert.ok(r.id, 'conversation has id');
t.assert.ok(r.title, 'conversation has title');
convId = r.id;
s.track('conversation', convId);
});
s.test('get conversation', async function (t) {
t.assert.ok(convId, 'convId from previous test');
var r = await api.get('/conversations/' + convId);
t.assert.eq(r.id, convId, 'id matches');
});
s.test('update conversation', async function (t) {
t.assert.ok(convId, 'convId from previous test');
var r = await api.put('/conversations/' + convId, {
title: 'Updated Convo Title'
});
t.assert.eq(r.title, 'Updated Convo Title', 'title updated');
});
s.test('list conversations', async function (t) {
var r = await api.get('/conversations');
var list = Array.isArray(r) ? r : (r.data || []);
t.assert.ok(Array.isArray(list), 'response is array');
var found = list.some(function (c) { return c.id === convId; });
t.assert.ok(found, 'created conversation in list');
});
s.test('delete conversation', async function (t) {
t.assert.ok(convId, 'convId from previous test');
await api.del('/conversations/' + convId);
try {
await api.get('/conversations/' + convId);
t.assert.ok(false, 'expected error after delete');
} catch (e) {
t.assert.ok(true, 'conversation not found after delete');
}
convId = null;
});
});
})();

View File

@@ -0,0 +1,63 @@
/**
* Chat Runner — Entry Point
*
* Boot SDK, load test modules in dependency order.
* Each module is an IIFE that registers suites via sw.testing.suite().
*/
(async function () {
'use strict';
try {
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
console.warn('[ChatRunner] SDK boot failed:', e.message);
}
window.CR = {
base: window.__BASE__ || '',
api: sw.api.ext('chat-core')
};
var surfaceId = 'chat-runner';
var assetBase = '/surfaces/' + surfaceId + '/js/';
if (window.CR.base) assetBase = window.CR.base + assetBase;
var modules = [
'conversations.js',
'messaging.js'
];
var loaded = 0;
function loadNext() {
if (loaded >= modules.length) { onReady(); return; }
var script = document.createElement('script');
script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0') + '.' + Date.now();
script.onload = function () { loaded++; loadNext(); };
script.onerror = function () {
console.error('[ChatRunner] Failed to load: ' + modules[loaded]);
loaded++; loadNext();
};
document.body.appendChild(script);
}
function onReady() {
console.log('[ChatRunner] All modules loaded — ' + sw.testing.suites().length + ' suites registered');
var manifest = window.__MANIFEST__ || {};
if (manifest.id === surfaceId) {
window.location.href = (window.__BASE__ || '') + '/s/test-runners';
}
}
loadNext();
})();

View File

@@ -0,0 +1,60 @@
/**
* Chat Runner — chat/messaging suite
*
* Tests message CRUD and search within conversations.
*/
(function () {
'use strict';
var api = window.CR.api;
sw.testing.suite('chat/messaging', async function (s) {
var convId, msgId;
s.beforeAll(async function () {
var r = await api.post('/conversations', {
title: 'Messaging Test ' + Date.now(),
type: 'direct'
});
convId = r.id;
s.track('conversation', convId);
});
s.test('send message', async function (t) {
var r = await api.post('/messages/' + convId, {
content: 'Hello from chat-runner test!',
content_type: 'text'
});
t.assert.ok(r.id, 'message has id');
t.assert.ok(r.content, 'message has content');
msgId = r.id;
});
s.test('list messages', async function (t) {
var r = await api.get('/messages/' + convId);
var list = Array.isArray(r) ? r : (r.data || r.messages || []);
t.assert.ok(Array.isArray(list), 'messages is array');
t.assert.ok(list.length > 0, 'at least one message');
var found = list.some(function (m) { return m.id === msgId; });
t.assert.ok(found, 'sent message appears in list');
});
s.test('search conversations', async function (t) {
var r = await api.get('/search?q=chat-runner');
var list = Array.isArray(r) ? r : (r.data || r.conversations || r.results || []);
t.assert.ok(Array.isArray(list), 'search returns array');
if (list.length === 0) {
t.warn('Search returned empty — may need time for indexing');
}
});
s.test('mark read', async function (t) {
try {
await api.post('/read/' + convId, {});
t.assert.ok(true, 'mark read succeeded');
} catch (e) {
t.warn('mark read failed: ' + e.message);
}
});
});
})();

View File

@@ -0,0 +1,9 @@
{
"id": "chat-runner",
"icon": "💬",
"type": "test-runner",
"title": "Chat Runner",
"auth": "admin",
"version": "0.1.0",
"description": "Integration tests for Chat package — conversations, messaging, search."
}

View File

@@ -0,0 +1,59 @@
/**
* Notes Runner — notes/folders suite
*
* Tests folder CRUD and note-folder association.
*/
(function () {
'use strict';
var api = window.NR.api;
sw.testing.suite('notes/folders', async function (s) {
var folderId, noteId;
s.test('create folder', async function (t) {
var r = await api.post('/folders', {
name: 'runner-test-folder-' + Date.now()
});
t.assert.ok(r.id, 'folder has id');
t.assert.ok(r.name, 'folder has name');
folderId = r.id;
s.track('folder', folderId);
});
s.test('create note in folder', async function (t) {
t.assert.ok(folderId, 'folderId from previous test');
var r = await api.post('/notes', {
title: 'Folder Note',
body: 'Note in test folder',
folder_id: folderId
});
t.assert.ok(r.id, 'note has id');
t.assert.eq(r.folder_id, folderId, 'note assigned to folder');
noteId = r.id;
s.track('note', noteId);
});
s.test('move note to folder', async function (t) {
var f2 = await api.post('/folders', {
name: 'runner-move-target-' + Date.now()
});
s.track('folder', f2.id);
t.assert.ok(noteId, 'noteId from previous test');
await api.post('/notes/move', {
note_id: noteId,
folder_id: f2.id
});
var note = await api.get('/notes/' + noteId);
t.assert.eq(note.folder_id, f2.id, 'note moved to new folder');
});
s.test('list folders', async function (t) {
var r = await api.get('/folders');
t.assert.ok(Array.isArray(r), 'response is array');
var found = r.some(function (f) { return f.id === folderId; });
t.assert.ok(found, 'created folder appears in list');
});
});
})();

View File

@@ -0,0 +1,64 @@
/**
* Notes Runner — Entry Point
*
* Boot SDK, load test modules in dependency order.
* Each module is an IIFE that registers suites via sw.testing.suite().
*/
(async function () {
'use strict';
try {
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
console.warn('[NotesRunner] SDK boot failed:', e.message);
}
window.NR = {
base: window.__BASE__ || '',
api: sw.api.ext('notes')
};
var surfaceId = 'notes-runner';
var assetBase = '/surfaces/' + surfaceId + '/js/';
if (window.NR.base) assetBase = window.NR.base + assetBase;
var modules = [
'notes-crud.js',
'folders.js',
'tags-search.js'
];
var loaded = 0;
function loadNext() {
if (loaded >= modules.length) { onReady(); return; }
var script = document.createElement('script');
script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0') + '.' + Date.now();
script.onload = function () { loaded++; loadNext(); };
script.onerror = function () {
console.error('[NotesRunner] Failed to load: ' + modules[loaded]);
loaded++; loadNext();
};
document.body.appendChild(script);
}
function onReady() {
console.log('[NotesRunner] All modules loaded — ' + sw.testing.suites().length + ' suites registered');
var manifest = window.__MANIFEST__ || {};
if (manifest.id === surfaceId) {
window.location.href = (window.__BASE__ || '') + '/s/test-runners';
}
}
loadNext();
})();

View File

@@ -0,0 +1,63 @@
/**
* Notes Runner — notes/crud suite
*
* Tests basic note CRUD operations via the Notes ext API.
*/
(function () {
'use strict';
var api = window.NR.api;
sw.testing.suite('notes/crud', async function (s) {
var noteId;
s.test('create note', async function (t) {
var r = await api.post('/notes', {
title: 'Runner Test Note',
body: '# Hello from notes-runner\n\nTest body content.'
});
t.assert.ok(r.id, 'note has id');
t.assert.eq(r.title, 'Runner Test Note', 'title matches');
t.assert.ok(r.body, 'body present');
t.assert.ok(r.created_at, 'created_at present');
noteId = r.id;
s.track('note', noteId);
});
s.test('get note by id', async function (t) {
t.assert.ok(noteId, 'noteId from previous test');
var r = await api.get('/notes/' + noteId);
t.assert.eq(r.id, noteId, 'id matches');
t.assert.eq(r.title, 'Runner Test Note', 'title matches');
});
s.test('update note', async function (t) {
t.assert.ok(noteId, 'noteId from previous test');
var r = await api.put('/notes/' + noteId, {
title: 'Updated Test Note',
body: '# Updated\n\nNew body.'
});
t.assert.eq(r.title, 'Updated Test Note', 'title updated');
});
s.test('list notes', async function (t) {
var r = await api.get('/notes');
t.assert.ok(Array.isArray(r), 'response is array');
var found = r.some(function (n) { return n.id === noteId; });
t.assert.ok(found, 'created note appears in list');
});
s.test('delete note', async function (t) {
t.assert.ok(noteId, 'noteId from previous test');
await api.del('/notes/' + noteId);
// Verify gone
try {
await api.get('/notes/' + noteId);
t.assert.ok(false, 'expected 404 after delete');
} catch (e) {
t.assert.ok(true, 'note not found after delete');
}
noteId = null;
});
});
})();

View File

@@ -0,0 +1,66 @@
/**
* Notes Runner — notes/tags-search suite
*
* Tests tags, search, and backlinks.
*/
(function () {
'use strict';
var api = window.NR.api;
sw.testing.suite('notes/tags-search', async function (s) {
var noteA, noteB;
s.beforeAll(async function () {
noteA = await api.post('/notes', {
title: 'Tag Test A',
body: 'First note with [[Tag Test B]] link and some searchable content: xyzzy.'
});
s.track('note', noteA.id);
noteB = await api.post('/notes', {
title: 'Tag Test B',
body: 'Second note — backlink target.'
});
s.track('note', noteB.id);
});
s.test('set tags on note', async function (t) {
await api.put('/tags/' + noteA.id, {
tags: ['runner-test', 'integration']
});
var tags = await api.get('/tags/' + noteA.id);
t.assert.ok(Array.isArray(tags), 'tags is array');
t.assert.ok(tags.indexOf('runner-test') !== -1, 'runner-test tag present');
t.assert.ok(tags.indexOf('integration') !== -1, 'integration tag present');
});
s.test('list all tags', async function (t) {
var tags = await api.get('/tags');
t.assert.ok(Array.isArray(tags), 'tags is array');
var found = tags.some(function (tag) {
var name = typeof tag === 'string' ? tag : (tag.tag || tag.name || '');
return name === 'runner-test';
});
t.assert.ok(found, 'runner-test tag appears in global list');
});
s.test('search notes', async function (t) {
var r = await api.get('/search?q=xyzzy');
t.assert.ok(Array.isArray(r), 'search returns array');
var found = r.some(function (n) { return n.id === noteA.id; });
t.assert.ok(found, 'noteA found by search term');
});
s.test('backlinks', async function (t) {
var links = await api.get('/backlinks/' + noteB.id);
t.assert.ok(Array.isArray(links), 'backlinks is array');
if (links.length === 0) {
t.warn('No backlinks found — wikilink resolution may not have matched by title');
} else {
var found = links.some(function (l) { return l.source_id === noteA.id; });
t.assert.ok(found, 'noteA is a backlink source for noteB');
}
});
});
})();

View File

@@ -0,0 +1,9 @@
{
"id": "notes-runner",
"icon": "📝",
"type": "test-runner",
"title": "Notes Runner",
"auth": "admin",
"version": "0.1.0",
"description": "Integration tests for Notes package — CRUD, folders, tags, backlinks, search."
}

View File

@@ -0,0 +1,58 @@
/**
* Renderer Runner — Entry Point
*
* Boot SDK, load test modules.
* Each module is an IIFE that registers suites via sw.testing.suite().
*/
(async function () {
'use strict';
try {
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
console.warn('[RendRunner] SDK boot failed:', e.message);
}
var surfaceId = 'renderer-runner';
var assetBase = '/surfaces/' + surfaceId + '/js/';
var rbase = window.__BASE__ || '';
if (rbase) assetBase = rbase + assetBase;
var modules = [
'renderer-contract.js'
];
var loaded = 0;
function loadNext() {
if (loaded >= modules.length) { onReady(); return; }
var script = document.createElement('script');
script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0') + '.' + Date.now();
script.onload = function () { loaded++; loadNext(); };
script.onerror = function () {
console.error('[RendRunner] Failed to load: ' + modules[loaded]);
loaded++; loadNext();
};
document.body.appendChild(script);
}
function onReady() {
console.log('[RendRunner] All modules loaded — ' + sw.testing.suites().length + ' suites registered');
var manifest = window.__MANIFEST__ || {};
if (manifest.id === surfaceId) {
window.location.href = (window.__BASE__ || '') + '/s/test-runners';
}
}
loadNext();
})();

View File

@@ -0,0 +1,51 @@
/**
* Renderer Runner — renderer/contract suite
*
* Tests the sw.renderers registry contract.
* Requires mermaid-renderer to be installed and loaded.
*/
(function () {
'use strict';
sw.testing.suite('renderer/contract', async function (s) {
s.test('sw.renderers exists', async function (t) {
t.assert.ok(sw.renderers, 'sw.renderers is defined');
t.assert.ok(typeof sw.renderers.register === 'function', 'register is a function');
t.assert.ok(typeof sw.renderers.list === 'function', 'list is a function');
t.assert.ok(typeof sw.renderers.runBlockRenderers === 'function', 'runBlockRenderers is a function');
t.assert.ok(typeof sw.renderers.runPostRenderers === 'function', 'runPostRenderers is a function');
});
s.test('mermaid renderer registered', async function (t) {
var info = sw.renderers.list();
t.assert.ok(info.block, 'block renderers list exists');
t.assert.ok(Array.isArray(info.block), 'block is array');
var mermaid = info.block.find(function (r) { return r.name === 'mermaid'; });
if (!mermaid) {
t.warn('Mermaid renderer not found in block list — extension may not have loaded yet');
return;
}
t.assert.ok(mermaid, 'mermaid renderer found');
t.assert.eq(mermaid.pattern, 'mermaid', 'pattern matches "mermaid"');
});
s.test('block renderer match', async function (t) {
// Test that runBlockRenderers returns true for 'mermaid' lang
// We need a container element for the render call
t.browserOnly(function () {
var container = document.createElement('div');
var handled = sw.renderers.runBlockRenderers('mermaid', 'graph LR; A-->B', container);
t.assert.ok(handled, 'mermaid block was handled by a renderer');
});
});
s.test('unmatched block returns false', async function (t) {
t.browserOnly(function () {
var container = document.createElement('div');
var handled = sw.renderers.runBlockRenderers('nonexistent-lang-xyz', 'code', container);
t.assert.eq(handled, false, 'unmatched lang returns false');
});
});
});
})();

View File

@@ -0,0 +1,9 @@
{
"id": "renderer-runner",
"icon": "🎨",
"type": "test-runner",
"title": "Renderer Runner",
"auth": "admin",
"version": "0.1.0",
"description": "Contract tests for renderer registry — registration, matching, block rendering."
}

View File

@@ -0,0 +1,62 @@
/**
* Schedules Runner — Entry Point
*
* Boot SDK, load test modules.
* Each module is an IIFE that registers suites via sw.testing.suite().
*/
(async function () {
'use strict';
try {
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
console.warn('[SchedRunner] SDK boot failed:', e.message);
}
window.SR = {
base: window.__BASE__ || '',
api: '/api/v1/schedules'
};
var surfaceId = 'schedules-runner';
var assetBase = '/surfaces/' + surfaceId + '/js/';
if (window.SR.base) assetBase = window.SR.base + assetBase;
var modules = [
'schedules-crud.js'
];
var loaded = 0;
function loadNext() {
if (loaded >= modules.length) { onReady(); return; }
var script = document.createElement('script');
script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0') + '.' + Date.now();
script.onload = function () { loaded++; loadNext(); };
script.onerror = function () {
console.error('[SchedRunner] Failed to load: ' + modules[loaded]);
loaded++; loadNext();
};
document.body.appendChild(script);
}
function onReady() {
console.log('[SchedRunner] All modules loaded — ' + sw.testing.suites().length + ' suites registered');
var manifest = window.__MANIFEST__ || {};
if (manifest.id === surfaceId) {
window.location.href = (window.__BASE__ || '') + '/s/test-runners';
}
}
loadNext();
})();

View File

@@ -0,0 +1,65 @@
/**
* Schedules Runner — schedules/crud suite
*
* Tests schedule CRUD via the kernel schedules API.
*/
(function () {
'use strict';
var API = window.SR.api;
sw.testing.suite('schedules/crud', async function (s) {
var schedId;
s.test('create schedule', async function (t) {
var r = await sw.api.post(API, {
name: 'runner-test-' + Date.now(),
cron_expr: '0 0 * * *',
script: 'print("hello from schedule runner")',
enabled: false
});
t.assert.ok(r.id, 'schedule has id');
t.assert.ok(r.name, 'schedule has name');
schedId = r.id;
s.track('schedule', schedId);
});
s.test('get schedule', async function (t) {
t.assert.ok(schedId, 'schedId from previous test');
var r = await sw.api.get(API + '/' + schedId);
t.assert.eq(r.id, schedId, 'id matches');
t.assert.ok(r.cron_expr, 'cron_expr present');
});
s.test('update schedule', async function (t) {
t.assert.ok(schedId, 'schedId from previous test');
var r = await sw.api.put(API + '/' + schedId, {
name: 'runner-test-updated',
cron_expr: '30 2 * * *'
});
t.assert.eq(r.name, 'runner-test-updated', 'name updated');
});
s.test('run schedule', async function (t) {
t.assert.ok(schedId, 'schedId from previous test');
try {
await sw.api.post(API + '/' + schedId + '/run', {});
t.assert.ok(true, 'run endpoint returned successfully');
} catch (e) {
t.warn('Schedule run failed: ' + e.message);
}
});
s.test('delete schedule', async function (t) {
t.assert.ok(schedId, 'schedId from previous test');
await sw.api.del(API + '/' + schedId);
try {
await sw.api.get(API + '/' + schedId);
t.assert.ok(false, 'expected error after delete');
} catch (e) {
t.assert.ok(true, 'schedule not found after delete');
}
schedId = null;
});
});
})();

View File

@@ -0,0 +1,9 @@
{
"id": "schedules-runner",
"icon": "⏰",
"type": "test-runner",
"title": "Schedules Runner",
"auth": "admin",
"version": "0.1.0",
"description": "Integration tests for Schedules package — CRUD, run, enable/disable."
}

View File

@@ -127,11 +127,22 @@
});
}
// Runner dependency declarations.
// Stored here (not in manifests) to avoid the package system marking
// runners as dormant due to install-order timing during bundled install.
var RUNNER_REQUIRES = {
'notes-runner': ['notes'],
'chat-runner': ['chat', 'chat-core'],
'schedules-runner': ['schedules'],
'workflow-runner': ['content-approval'],
'renderer-runner': ['mermaid-renderer']
};
// Check requires for a runner
function checkRequires(runner) {
var requires = runner.manifest?.requires || runner.requires || [];
var requires = RUNNER_REQUIRES[runner.id] || runner.manifest?.requires || runner.requires || [];
if (!requires.length || !allPkgs) return { met: requires, missing: [] };
var installedIds = allPkgs.map(function (p) { return p.id; });
var installedIds = allPkgs.filter(function (p) { return p.enabled; }).map(function (p) { return p.id; });
var met = [], missing = [];
for (var i = 0; i < requires.length; i++) {
if (installedIds.indexOf(requires[i]) !== -1) met.push(requires[i]);
@@ -141,6 +152,14 @@
}
// Run all suites
// Post results to kernel API for CI consumption (fire-and-forget)
function postResults(runnerId, data) {
sw.api.post('/api/v1/admin/test-runners/results', {
runner_id: runnerId || 'all',
results: data
}).catch(function (e) { console.warn('[TestRunners] Failed to store results:', e.message); });
}
var runAll = useCallback(async function () {
if (running) return;
setRunning(true);
@@ -149,6 +168,7 @@
try {
var r = await sw.testing.run();
setResults(r);
postResults('all', r);
} catch (e) {
setError('Run failed: ' + e.message);
}
@@ -162,8 +182,13 @@
setRunning(true);
setRunningSuite(runner.id);
var prefix = runner.id === 'icd-test-runner' ? 'icd/' :
runner.id === 'sdk-test-runner' ? 'sdk/' : runner.id + '/';
// Map runner package ID to suite name prefix.
// ICD/SDK runners use 'icd/'/'sdk/' prefixes.
// Package runners use the package name they test: 'notes/', 'chat/', etc.
var prefix;
if (runner.id === 'icd-test-runner') prefix = 'icd/';
else if (runner.id === 'sdk-test-runner') prefix = 'sdk/';
else prefix = runner.id.replace(/-runner$/, '') + '/';
var suiteNames = sw.testing.suites().filter(function (n) { return n.indexOf(prefix) === 0; });
var allSuiteResults = [];
@@ -186,12 +211,14 @@
if (r.suites[0] && r.suites[0].status === 'skipped') summary.skipped++;
}
setResults({
var runnerResults = {
timestamp: new Date().toISOString(),
duration_ms: Math.round(performance.now() - start),
summary: summary,
suites: allSuiteResults
});
};
setResults(runnerResults);
postResults(runner.id, runnerResults);
setRunning(false);
setRunningSuite(null);
}, [running]);
@@ -332,7 +359,8 @@
// Filter results for this runner's suites
var prefix = r.id === 'icd-test-runner' ? 'icd/' :
r.id === 'sdk-test-runner' ? 'sdk/' : r.id + '/';
r.id === 'sdk-test-runner' ? 'sdk/' :
r.id.replace(/-runner$/, '') + '/';
var runnerSuites = props.results?.suites?.filter(function (s) {
return s.name.indexOf(prefix) === 0;
}) || [];

View File

@@ -6,6 +6,6 @@
"route": "/s/test-runners",
"auth": "admin",
"layout": "single",
"version": "0.1.0",
"version": "0.2.0",
"description": "Run and view results from all installed test-runner packages."
}

View File

@@ -0,0 +1,62 @@
/**
* Workflow Runner — Entry Point
*
* Boot SDK, load test modules.
* Each module is an IIFE that registers suites via sw.testing.suite().
*/
(async function () {
'use strict';
try {
var base = window.__BASE__ || '';
var ver = window.__VERSION__ || '0';
if (!window.preact) {
var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
window.preact = { h, render };
window.hooks = hooksModule;
window.html = htmModule.default.bind(h);
}
var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
await sdk.boot();
} catch (e) {
console.warn('[WfRunner] SDK boot failed:', e.message);
}
window.WR = {
base: window.__BASE__ || '',
api: '/api/v1/workflows'
};
var surfaceId = 'workflow-runner';
var assetBase = '/surfaces/' + surfaceId + '/js/';
if (window.WR.base) assetBase = window.WR.base + assetBase;
var modules = [
'workflow-lifecycle.js'
];
var loaded = 0;
function loadNext() {
if (loaded >= modules.length) { onReady(); return; }
var script = document.createElement('script');
script.src = assetBase + modules[loaded] + '?v=' + (window.__VERSION__ || '0') + '.' + Date.now();
script.onload = function () { loaded++; loadNext(); };
script.onerror = function () {
console.error('[WfRunner] Failed to load: ' + modules[loaded]);
loaded++; loadNext();
};
document.body.appendChild(script);
}
function onReady() {
console.log('[WfRunner] All modules loaded — ' + sw.testing.suites().length + ' suites registered');
var manifest = window.__MANIFEST__ || {};
if (manifest.id === surfaceId) {
window.location.href = (window.__BASE__ || '') + '/s/test-runners';
}
}
loadNext();
})();

View File

@@ -0,0 +1,87 @@
/**
* Workflow Runner — workflow/lifecycle suite
*
* Tests workflow definitions, instance creation, and stage progression.
* Requires the content-approval workflow package to be installed.
*/
(function () {
'use strict';
var API = window.WR.api;
sw.testing.suite('workflow/lifecycle', async function (s) {
var defId, instanceId;
s.test('list workflow definitions', async function (t) {
var r = await sw.api.get(API);
var list = Array.isArray(r) ? r : (r.data || []);
t.assert.ok(Array.isArray(list), 'definitions is array');
// Find content-approval definition
var def = list.find(function (d) {
return d.slug === 'content-approval' || d.name === 'Content Approval';
});
if (!def) {
t.warn('content-approval definition not found — package may need activation');
// Try to find any definition
if (list.length > 0) {
def = list[0];
t.warn('Using first available definition: ' + (def.name || def.slug));
} else {
t.skip('No workflow definitions available');
return;
}
}
t.assert.ok(def.id, 'definition has id');
defId = def.id;
});
s.test('create workflow instance', async function (t) {
if (!defId) { t.skip('No definition from previous test'); return; }
var r = await sw.api.post(API + '/' + defId + '/instances', {
data: {
title: 'Runner test submission',
body: 'Test content from workflow-runner',
category: 'blog'
}
});
t.assert.ok(r.id, 'instance has id');
t.assert.ok(r.status || r.current_stage !== undefined, 'instance has status/stage');
instanceId = r.id;
s.track('workflow', instanceId);
});
s.test('get workflow instance', async function (t) {
if (!defId || !instanceId) { t.skip('No instance from previous test'); return; }
var r = await sw.api.get(API + '/' + defId + '/instances/' + instanceId);
t.assert.eq(r.id, instanceId, 'instance id matches');
});
s.test('advance stage', async function (t) {
if (!defId || !instanceId) { t.skip('No instance from previous test'); return; }
try {
var r = await sw.api.post(API + '/' + defId + '/instances/' + instanceId + '/advance', {
data: {
title: 'Runner test submission',
body: 'Test content from workflow-runner',
category: 'blog'
}
});
t.assert.ok(true, 'advance succeeded');
} catch (e) {
// Stage advance may fail if form validation requires specific fields
t.warn('Stage advance failed: ' + e.message);
}
});
s.test('list instances', async function (t) {
if (!defId) { t.skip('No definition from previous test'); return; }
var r = await sw.api.get(API + '/' + defId + '/instances');
var list = Array.isArray(r) ? r : (r.data || []);
t.assert.ok(Array.isArray(list), 'instances is array');
if (instanceId) {
var found = list.some(function (i) { return i.id === instanceId; });
t.assert.ok(found, 'created instance appears in list');
}
});
});
})();

View File

@@ -0,0 +1,9 @@
{
"id": "workflow-runner",
"icon": "🔄",
"type": "test-runner",
"title": "Workflow Runner",
"auth": "admin",
"version": "0.1.0",
"description": "Integration tests for Workflow package — definitions, instances, stage progression."
}

View File

@@ -0,0 +1,83 @@
package handlers
import (
"encoding/json"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// TestRunnerHandler serves the admin test-runners result API.
// Results are stored in memory — they reset on server restart.
type TestRunnerHandler struct {
mu sync.RWMutex
byID map[string]json.RawMessage // runner-id → last results
lastAll json.RawMessage // last "run all" results
updated time.Time
}
func NewTestRunnerHandler() *TestRunnerHandler {
return &TestRunnerHandler{
byID: make(map[string]json.RawMessage),
}
}
// StoreResults accepts results JSON from the browser after a test run.
// POST /api/v1/admin/test-runners/results
func (h *TestRunnerHandler) StoreResults(c *gin.Context) {
var body struct {
RunnerID string `json:"runner_id"`
Results json.RawMessage `json:"results"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
return
}
if len(body.Results) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "results field required"})
return
}
h.mu.Lock()
defer h.mu.Unlock()
h.updated = time.Now()
if body.RunnerID == "" || body.RunnerID == "all" {
h.lastAll = body.Results
} else {
h.byID[body.RunnerID] = body.Results
}
c.JSON(http.StatusOK, gin.H{"stored": true, "runner_id": body.RunnerID})
}
// GetAllResults returns the last stored "run all" results.
// GET /api/v1/admin/test-runners/results
func (h *TestRunnerHandler) GetAllResults(c *gin.Context) {
h.mu.RLock()
defer h.mu.RUnlock()
if h.lastAll == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "no results available"})
return
}
c.Data(http.StatusOK, "application/json", h.lastAll)
}
// GetRunnerResults returns the last stored results for a specific runner.
// GET /api/v1/admin/test-runners/results/:id
func (h *TestRunnerHandler) GetRunnerResults(c *gin.Context) {
id := c.Param("id")
h.mu.RLock()
defer h.mu.RUnlock()
data, ok := h.byID[id]
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "no results for runner: " + id})
return
}
c.Data(http.StatusOK, "application/json", data)
}

View File

@@ -0,0 +1,125 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func setupTestRunnerRouter() (*gin.Engine, *TestRunnerHandler) {
gin.SetMode(gin.TestMode)
h := NewTestRunnerHandler()
r := gin.New()
r.POST("/api/v1/admin/test-runners/results", h.StoreResults)
r.GET("/api/v1/admin/test-runners/results", h.GetAllResults)
r.GET("/api/v1/admin/test-runners/results/:id", h.GetRunnerResults)
return r, h
}
func TestTestRunnerGetAllEmpty(t *testing.T) {
r, _ := setupTestRunnerRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/v1/admin/test-runners/results", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
func TestTestRunnerStoreAndRetrieveAll(t *testing.T) {
r, _ := setupTestRunnerRouter()
results := `{"summary":{"total":5,"passed":5,"failed":0}}`
body, _ := json.Marshal(map[string]interface{}{
"runner_id": "all",
"results": json.RawMessage(results),
})
// Store
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/test-runners/results", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("store: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Retrieve
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/v1/admin/test-runners/results", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("get all: expected 200, got %d", w.Code)
}
var got map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
summary, _ := got["summary"].(map[string]interface{})
if summary == nil {
t.Fatal("expected summary in results")
}
if int(summary["failed"].(float64)) != 0 {
t.Fatalf("expected 0 failures, got %v", summary["failed"])
}
}
func TestTestRunnerStoreAndRetrieveByID(t *testing.T) {
r, _ := setupTestRunnerRouter()
results := `{"runner":"notes-runner","summary":{"total":3,"passed":3,"failed":0}}`
body, _ := json.Marshal(map[string]interface{}{
"runner_id": "notes-runner",
"results": json.RawMessage(results),
})
// Store
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/test-runners/results", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("store: expected 200, got %d", w.Code)
}
// Retrieve by ID
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/v1/admin/test-runners/results/notes-runner", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("get by id: expected 200, got %d", w.Code)
}
// Unknown ID → 404
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/api/v1/admin/test-runners/results/unknown", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("unknown id: expected 404, got %d", w.Code)
}
}
func TestTestRunnerStoreBadBody(t *testing.T) {
r, _ := setupTestRunnerRouter()
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/admin/test-runners/results", bytes.NewReader([]byte(`{}`)))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for empty results, got %d", w.Code)
}
}

View File

@@ -842,6 +842,12 @@ func main() {
admin.GET("/cluster", clusterH.ListNodes)
}
// ── Test Runners ────────────
trH := handlers.NewTestRunnerHandler()
admin.POST("/test-runners/results", trH.StoreResults)
admin.GET("/test-runners/results", trH.GetAllResults)
admin.GET("/test-runners/results/:id", trH.GetRunnerResults)
// ── Metrics ─────────────────
metricsCollector := metrics.NewCollector(
nodeID, database.DB, hub, bus, stores,

View File

@@ -18,14 +18,15 @@
/** Endpoint map for auto-cleanup by resource type. */
const CLEANUP_ENDPOINTS = {
channel: '/api/v1/channels/',
note: '/api/v1/ext/notes/notes/',
folder: '/api/v1/ext/notes/folders/',
workflow: '/api/v1/workflows/',
schedule: '/api/v1/schedules/',
package: '/api/v1/admin/packages/',
user: '/api/v1/admin/users/',
team: '/api/v1/admin/teams/',
channel: '/api/v1/channels/',
conversation: '/s/chat-core/api/conversations/',
note: '/s/notes/api/notes/',
folder: '/s/notes/api/folders/',
workflow: '/api/v1/workflows/',
schedule: '/api/v1/schedules/',
package: '/api/v1/admin/packages/',
user: '/api/v1/admin/users/',
team: '/api/v1/admin/teams/',
};
/** Sentinel for skip flow control. */