Feat v0.7.2 package runners ci gate #56
@@ -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: |
|
||||
|
||||
16
ROADMAP.md
16
ROADMAP.md
@@ -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
|
||||
|
||||
|
||||
56
ci/run-surface-tests.sh
Executable file
56
ci/run-surface-tests.sh
Executable 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
141
ci/surface-test-driver.js
Normal 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
21
ci/wait-for-healthy.sh
Executable 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
|
||||
59
packages/chat-runner/js/conversations.js
Normal file
59
packages/chat-runner/js/conversations.js
Normal 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;
|
||||
});
|
||||
});
|
||||
})();
|
||||
63
packages/chat-runner/js/main.js
Normal file
63
packages/chat-runner/js/main.js
Normal 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();
|
||||
})();
|
||||
60
packages/chat-runner/js/messaging.js
Normal file
60
packages/chat-runner/js/messaging.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
9
packages/chat-runner/manifest.json
Normal file
9
packages/chat-runner/manifest.json
Normal 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."
|
||||
}
|
||||
59
packages/notes-runner/js/folders.js
Normal file
59
packages/notes-runner/js/folders.js
Normal 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');
|
||||
});
|
||||
});
|
||||
})();
|
||||
64
packages/notes-runner/js/main.js
Normal file
64
packages/notes-runner/js/main.js
Normal 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();
|
||||
})();
|
||||
63
packages/notes-runner/js/notes-crud.js
Normal file
63
packages/notes-runner/js/notes-crud.js
Normal 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;
|
||||
});
|
||||
});
|
||||
})();
|
||||
66
packages/notes-runner/js/tags-search.js
Normal file
66
packages/notes-runner/js/tags-search.js
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
9
packages/notes-runner/manifest.json
Normal file
9
packages/notes-runner/manifest.json
Normal 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."
|
||||
}
|
||||
58
packages/renderer-runner/js/main.js
Normal file
58
packages/renderer-runner/js/main.js
Normal 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();
|
||||
})();
|
||||
51
packages/renderer-runner/js/renderer-contract.js
Normal file
51
packages/renderer-runner/js/renderer-contract.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
9
packages/renderer-runner/manifest.json
Normal file
9
packages/renderer-runner/manifest.json
Normal 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."
|
||||
}
|
||||
62
packages/schedules-runner/js/main.js
Normal file
62
packages/schedules-runner/js/main.js
Normal 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();
|
||||
})();
|
||||
65
packages/schedules-runner/js/schedules-crud.js
Normal file
65
packages/schedules-runner/js/schedules-crud.js
Normal 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;
|
||||
});
|
||||
});
|
||||
})();
|
||||
9
packages/schedules-runner/manifest.json
Normal file
9
packages/schedules-runner/manifest.json
Normal 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."
|
||||
}
|
||||
@@ -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;
|
||||
}) || [];
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
62
packages/workflow-runner/js/main.js
Normal file
62
packages/workflow-runner/js/main.js
Normal 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();
|
||||
})();
|
||||
87
packages/workflow-runner/js/workflow-lifecycle.js
Normal file
87
packages/workflow-runner/js/workflow-lifecycle.js
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
9
packages/workflow-runner/manifest.json
Normal file
9
packages/workflow-runner/manifest.json
Normal 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."
|
||||
}
|
||||
83
server/handlers/test_runners.go
Normal file
83
server/handlers/test_runners.go
Normal 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)
|
||||
}
|
||||
125
server/handlers/test_runners_test.go
Normal file
125
server/handlers/test_runners_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user