opencastle 0.27.1 → 0.27.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/convoy/dashboard-types.d.ts +146 -0
- package/dist/cli/convoy/dashboard-types.d.ts.map +1 -0
- package/dist/cli/convoy/dashboard-types.js +2 -0
- package/dist/cli/convoy/dashboard-types.js.map +1 -0
- package/dist/cli/convoy/engine.d.ts +0 -1
- package/dist/cli/convoy/engine.d.ts.map +1 -1
- package/dist/cli/convoy/engine.js +31 -99
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +88 -1
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/event-schemas.d.ts +9 -0
- package/dist/cli/convoy/event-schemas.d.ts.map +1 -0
- package/dist/cli/convoy/event-schemas.js +185 -0
- package/dist/cli/convoy/event-schemas.js.map +1 -0
- package/dist/cli/convoy/events.d.ts +8 -0
- package/dist/cli/convoy/events.d.ts.map +1 -1
- package/dist/cli/convoy/events.js +117 -5
- package/dist/cli/convoy/events.js.map +1 -1
- package/dist/cli/convoy/events.test.js +173 -3
- package/dist/cli/convoy/events.test.js.map +1 -1
- package/dist/cli/convoy/log-merge.test.d.ts +2 -0
- package/dist/cli/convoy/log-merge.test.d.ts.map +1 -0
- package/dist/cli/convoy/log-merge.test.js +147 -0
- package/dist/cli/convoy/log-merge.test.js.map +1 -0
- package/dist/cli/convoy/store.d.ts +52 -2
- package/dist/cli/convoy/store.d.ts.map +1 -1
- package/dist/cli/convoy/store.js +244 -17
- package/dist/cli/convoy/store.js.map +1 -1
- package/dist/cli/convoy/store.test.js +481 -22
- package/dist/cli/convoy/store.test.js.map +1 -1
- package/dist/cli/convoy/types.d.ts +271 -3
- package/dist/cli/convoy/types.d.ts.map +1 -1
- package/dist/cli/convoy/types.js +42 -1
- package/dist/cli/convoy/types.js.map +1 -1
- package/dist/cli/log.d.ts +11 -0
- package/dist/cli/log.d.ts.map +1 -1
- package/dist/cli/log.js +114 -2
- package/dist/cli/log.js.map +1 -1
- package/package.json +5 -1
- package/src/cli/convoy/TELEMETRY.md +203 -0
- package/src/cli/convoy/dashboard-types.ts +141 -0
- package/src/cli/convoy/engine.test.ts +99 -1
- package/src/cli/convoy/engine.ts +27 -96
- package/src/cli/convoy/event-schemas.ts +195 -0
- package/src/cli/convoy/events.test.ts +207 -3
- package/src/cli/convoy/events.ts +119 -5
- package/src/cli/convoy/log-merge.test.ts +179 -0
- package/src/cli/convoy/store.test.ts +545 -22
- package/src/cli/convoy/store.ts +274 -21
- package/src/cli/convoy/types.ts +108 -3
- package/src/cli/log.ts +120 -2
- package/src/dashboard/dist/_astro/{index.DtnyD8a5.css → index.6L3_HsPT.css} +1 -1
- package/src/dashboard/dist/data/.gitkeep +0 -0
- package/src/dashboard/dist/data/convoy-list.json +1 -0
- package/src/dashboard/dist/data/overall-stats.json +24 -0
- package/src/dashboard/dist/index.html +701 -3
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/public/data/.gitkeep +0 -0
- package/src/dashboard/public/data/convoy-list.json +1 -0
- package/src/dashboard/public/data/overall-stats.json +24 -0
- package/src/dashboard/scripts/etl.test.ts +210 -0
- package/src/dashboard/scripts/etl.ts +108 -0
- package/src/dashboard/scripts/integration-test.ts +504 -0
- package/src/dashboard/src/pages/index.astro +854 -15
- package/src/dashboard/src/styles/dashboard.css +557 -1
- package/src/orchestrator/prompts/generate-convoy.prompt.md +212 -13
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test for the dashboard ETL → Astro build → HTML pipeline.
|
|
3
|
+
* Usage: npx tsx src/dashboard/scripts/integration-test.ts
|
|
4
|
+
* Exit 0 if all tests pass, exit 1 if any fail.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdtempSync, rmSync, realpathSync, readFileSync, existsSync } from 'node:fs'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join, resolve, dirname } from 'node:path'
|
|
10
|
+
import { execSync } from 'node:child_process'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
import type { ConvoyTaskStatus, ConvoyStatus } from '../../cli/convoy/types.js'
|
|
13
|
+
|
|
14
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
15
|
+
const __dirname = dirname(__filename)
|
|
16
|
+
const WORKSPACE_ROOT = resolve(__dirname, '..', '..', '..')
|
|
17
|
+
|
|
18
|
+
// ── Colours ───────────────────────────────────────────────────────────────────
|
|
19
|
+
const c = {
|
|
20
|
+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
21
|
+
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
|
|
22
|
+
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
23
|
+
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Test runner ───────────────────────────────────────────────────────────────
|
|
27
|
+
let passed = 0
|
|
28
|
+
let failed = 0
|
|
29
|
+
|
|
30
|
+
async function test(name: string, fn: () => void | Promise<void>): Promise<void> {
|
|
31
|
+
try {
|
|
32
|
+
await fn()
|
|
33
|
+
passed++
|
|
34
|
+
console.log(` ${c.green('✓')} ${name}`)
|
|
35
|
+
} catch (err) {
|
|
36
|
+
failed++
|
|
37
|
+
console.error(` ${c.red('✗')} ${name}`)
|
|
38
|
+
const msg = (err as Error).message ?? String(err)
|
|
39
|
+
msg.split('\n').slice(0, 6).forEach((line) => console.error(` ${c.dim(line)}`))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assert(condition: boolean, message: string): void {
|
|
44
|
+
if (!condition) throw new Error(message)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function execCmd(cmd: string, timeoutMs = 120_000): void {
|
|
48
|
+
try {
|
|
49
|
+
execSync(cmd, { cwd: WORKSPACE_ROOT, stdio: 'pipe', timeout: timeoutMs })
|
|
50
|
+
} catch (err) {
|
|
51
|
+
const e = err as { stderr?: Buffer; stdout?: Buffer; message?: string }
|
|
52
|
+
const detail =
|
|
53
|
+
e.stderr?.toString().trim() ?? e.stdout?.toString().trim() ?? String(e.message ?? '')
|
|
54
|
+
throw new Error(`Command failed: ${cmd}\n${detail.slice(0, 600)}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
59
|
+
interface TaskFixture {
|
|
60
|
+
id: string
|
|
61
|
+
agent: string
|
|
62
|
+
model: string
|
|
63
|
+
phase: number
|
|
64
|
+
status: ConvoyTaskStatus
|
|
65
|
+
retries: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ConvoyFixture {
|
|
69
|
+
id: string
|
|
70
|
+
name: string
|
|
71
|
+
status: ConvoyStatus
|
|
72
|
+
startedAt: string
|
|
73
|
+
finishedAt: string | null
|
|
74
|
+
tasks: TaskFixture[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const FIXTURES: ConvoyFixture[] = [
|
|
78
|
+
{
|
|
79
|
+
id: 'c-done-001',
|
|
80
|
+
name: 'Feature: Authentication',
|
|
81
|
+
status: 'done',
|
|
82
|
+
startedAt: '2026-03-01T09:01:00.000Z',
|
|
83
|
+
finishedAt: '2026-03-01T11:30:00.000Z',
|
|
84
|
+
tasks: [
|
|
85
|
+
{ id: 'c-done-t-1', agent: 'developer', model: 'claude-opus-4-6', phase: 1, status: 'done', retries: 0 },
|
|
86
|
+
{ id: 'c-done-t-2', agent: 'reviewer', model: 'claude-sonnet-4-5', phase: 1, status: 'done', retries: 0 },
|
|
87
|
+
{ id: 'c-done-t-3', agent: 'architect', model: 'gpt-4o', phase: 2, status: 'done', retries: 0 },
|
|
88
|
+
{ id: 'c-done-t-4', agent: 'developer', model: 'claude-opus-4-6', phase: 2, status: 'done', retries: 0 },
|
|
89
|
+
{ id: 'c-done-t-5', agent: 'reviewer', model: 'claude-sonnet-4-5', phase: 3, status: 'done', retries: 0 },
|
|
90
|
+
{ id: 'c-done-t-6', agent: 'architect', model: 'gpt-4o', phase: 3, status: 'failed', retries: 2 },
|
|
91
|
+
{ id: 'c-done-t-7', agent: 'developer', model: 'claude-opus-4-6', phase: 4, status: 'running', retries: 0 },
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'c-fail-001',
|
|
96
|
+
name: 'Feature: Payment Integration',
|
|
97
|
+
status: 'failed',
|
|
98
|
+
startedAt: '2026-03-02T10:01:00.000Z',
|
|
99
|
+
finishedAt: '2026-03-02T12:00:00.000Z',
|
|
100
|
+
tasks: [
|
|
101
|
+
{ id: 'c-fail-t-1', agent: 'developer', model: 'claude-opus-4-6', phase: 1, status: 'done', retries: 0 },
|
|
102
|
+
{ id: 'c-fail-t-2', agent: 'reviewer', model: 'claude-sonnet-4-5', phase: 1, status: 'done', retries: 0 },
|
|
103
|
+
{ id: 'c-fail-t-3', agent: 'architect', model: 'gpt-4o', phase: 2, status: 'done', retries: 0 },
|
|
104
|
+
{ id: 'c-fail-t-4', agent: 'developer', model: 'claude-opus-4-6', phase: 2, status: 'failed', retries: 3 },
|
|
105
|
+
{ id: 'c-fail-t-5', agent: 'reviewer', model: 'claude-sonnet-4-5', phase: 3, status: 'failed', retries: 3 },
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: 'c-run-001',
|
|
110
|
+
name: 'Refactor: Database Layer',
|
|
111
|
+
status: 'running',
|
|
112
|
+
startedAt: '2026-03-03T08:01:00.000Z',
|
|
113
|
+
finishedAt: null,
|
|
114
|
+
tasks: [
|
|
115
|
+
{ id: 'c-run-t-1', agent: 'developer', model: 'claude-opus-4-6', phase: 1, status: 'done', retries: 0 },
|
|
116
|
+
{ id: 'c-run-t-2', agent: 'reviewer', model: 'claude-sonnet-4-5', phase: 1, status: 'done', retries: 0 },
|
|
117
|
+
{ id: 'c-run-t-3', agent: 'architect', model: 'gpt-4o', phase: 2, status: 'running', retries: 0 },
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
const TOTAL_CONVOYS = FIXTURES.length // 3
|
|
123
|
+
const TOTAL_TASKS = FIXTURES.reduce((sum, f) => sum + f.tasks.length, 0) // 15
|
|
124
|
+
|
|
125
|
+
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
126
|
+
const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'dash-int-')))
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
// ── Phase A: ETL Smoke Test ────────────────────────────────────────────────
|
|
130
|
+
console.log(c.bold('\n Phase A: ETL Smoke Test with Realistic Data\n'))
|
|
131
|
+
|
|
132
|
+
const dbPath = join(tmpDir, 'test.db')
|
|
133
|
+
const etlOutDir = join(tmpDir, 'etl-out')
|
|
134
|
+
let etlResult: { convoyCount: number; taskCount: number } | null = null
|
|
135
|
+
|
|
136
|
+
await test('seed database and run ETL', async () => {
|
|
137
|
+
const { createConvoyStore } = await import('../../cli/convoy/store.js')
|
|
138
|
+
const store = createConvoyStore(dbPath)
|
|
139
|
+
try {
|
|
140
|
+
for (const [i, f] of FIXTURES.entries()) {
|
|
141
|
+
const createdAt = `2026-03-0${i + 1}T09:00:00.000Z`
|
|
142
|
+
store.insertConvoy({
|
|
143
|
+
id: f.id,
|
|
144
|
+
name: f.name,
|
|
145
|
+
spec_hash: `hash-${f.id}`,
|
|
146
|
+
status: f.status,
|
|
147
|
+
branch: f.status === 'done' ? 'main' : null,
|
|
148
|
+
created_at: createdAt,
|
|
149
|
+
spec_yaml: 'tasks: []',
|
|
150
|
+
})
|
|
151
|
+
const extra: { started_at: string; finished_at?: string; total_tokens: number; total_cost_usd: number } = {
|
|
152
|
+
started_at: f.startedAt,
|
|
153
|
+
total_tokens: 30_000 * (i + 1),
|
|
154
|
+
total_cost_usd: 0.95 * (i + 1),
|
|
155
|
+
}
|
|
156
|
+
if (f.finishedAt) extra.finished_at = f.finishedAt
|
|
157
|
+
store.updateConvoyStatus(f.id, f.status, extra)
|
|
158
|
+
|
|
159
|
+
for (const t of f.tasks) {
|
|
160
|
+
store.insertTask({
|
|
161
|
+
id: t.id,
|
|
162
|
+
convoy_id: f.id,
|
|
163
|
+
phase: t.phase,
|
|
164
|
+
prompt: `Prompt for ${t.id}`,
|
|
165
|
+
agent: t.agent,
|
|
166
|
+
adapter: null,
|
|
167
|
+
model: t.model,
|
|
168
|
+
timeout_ms: 1_800_000,
|
|
169
|
+
status: t.status,
|
|
170
|
+
retries: t.retries,
|
|
171
|
+
depends_on: null,
|
|
172
|
+
files: null,
|
|
173
|
+
gates: null,
|
|
174
|
+
max_retries: 3,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// DLQ entries for the failed convoy
|
|
180
|
+
store.insertDlqEntry({
|
|
181
|
+
id: 'dlq-001',
|
|
182
|
+
convoy_id: 'c-fail-001',
|
|
183
|
+
task_id: 'c-fail-t-4',
|
|
184
|
+
agent: 'developer',
|
|
185
|
+
failure_type: 'timeout',
|
|
186
|
+
error_output: 'Task timed out after 30 minutes',
|
|
187
|
+
attempts: 3,
|
|
188
|
+
tokens_spent: null,
|
|
189
|
+
escalation_task_id: null,
|
|
190
|
+
resolved: 0,
|
|
191
|
+
resolution: null,
|
|
192
|
+
created_at: '2026-03-02T10:30:00.000Z',
|
|
193
|
+
resolved_at: null,
|
|
194
|
+
})
|
|
195
|
+
store.insertDlqEntry({
|
|
196
|
+
id: 'dlq-002',
|
|
197
|
+
convoy_id: 'c-fail-001',
|
|
198
|
+
task_id: 'c-fail-t-5',
|
|
199
|
+
agent: 'reviewer',
|
|
200
|
+
failure_type: 'gate_failure',
|
|
201
|
+
error_output: 'Secret scan found potential leak',
|
|
202
|
+
attempts: 2,
|
|
203
|
+
tokens_spent: null,
|
|
204
|
+
escalation_task_id: null,
|
|
205
|
+
resolved: 0,
|
|
206
|
+
resolution: null,
|
|
207
|
+
created_at: '2026-03-02T11:00:00.000Z',
|
|
208
|
+
resolved_at: null,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// Events for the done convoy
|
|
212
|
+
store.insertEvent({
|
|
213
|
+
convoy_id: 'c-done-001',
|
|
214
|
+
task_id: null,
|
|
215
|
+
worker_id: null,
|
|
216
|
+
type: 'convoy_started',
|
|
217
|
+
data: JSON.stringify({ name: 'Feature: Authentication' }),
|
|
218
|
+
created_at: '2026-03-01T09:01:00.000Z',
|
|
219
|
+
})
|
|
220
|
+
store.insertEvent({
|
|
221
|
+
convoy_id: 'c-done-001',
|
|
222
|
+
task_id: 'c-done-t-1',
|
|
223
|
+
worker_id: null,
|
|
224
|
+
type: 'task_done',
|
|
225
|
+
data: JSON.stringify({ phase: 1, agent: 'developer' }),
|
|
226
|
+
created_at: '2026-03-01T10:00:00.000Z',
|
|
227
|
+
})
|
|
228
|
+
store.insertEvent({
|
|
229
|
+
convoy_id: 'c-done-001',
|
|
230
|
+
task_id: null,
|
|
231
|
+
worker_id: null,
|
|
232
|
+
type: 'convoy_finished',
|
|
233
|
+
data: JSON.stringify({ status: 'done', total_tokens: 30_000 }),
|
|
234
|
+
created_at: '2026-03-01T11:30:00.000Z',
|
|
235
|
+
})
|
|
236
|
+
} finally {
|
|
237
|
+
store.close()
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const { runEtl } = await import('./etl.js')
|
|
241
|
+
etlResult = await runEtl({ dbPath, outputDir: etlOutDir })
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
await test(`ETL convoyCount === ${TOTAL_CONVOYS}`, () => {
|
|
245
|
+
assert(etlResult !== null, 'seed/ETL test failed — skipping')
|
|
246
|
+
assert(etlResult!.convoyCount === TOTAL_CONVOYS, `Expected ${TOTAL_CONVOYS}, got ${etlResult!.convoyCount}`)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
await test(`ETL taskCount === ${TOTAL_TASKS}`, () => {
|
|
250
|
+
assert(etlResult !== null, 'seed/ETL test failed — skipping')
|
|
251
|
+
assert(etlResult!.taskCount === TOTAL_TASKS, `Expected ${TOTAL_TASKS}, got ${etlResult!.taskCount}`)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
await test('overall-stats.json: total convoy count is 3', () => {
|
|
255
|
+
const stats = JSON.parse(readFileSync(join(etlOutDir, 'overall-stats.json'), 'utf8'))
|
|
256
|
+
assert(stats.convoyCounts.total === 3, `Expected 3, got ${stats.convoyCounts.total}`)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
await test('overall-stats.json: 1 done, 1 failed, 1 running', () => {
|
|
260
|
+
const stats = JSON.parse(readFileSync(join(etlOutDir, 'overall-stats.json'), 'utf8'))
|
|
261
|
+
assert(stats.convoyCounts.done === 1, `done: expected 1, got ${stats.convoyCounts.done}`)
|
|
262
|
+
assert(stats.convoyCounts.failed === 1, `failed: expected 1, got ${stats.convoyCounts.failed}`)
|
|
263
|
+
assert(stats.convoyCounts.running === 1, `running: expected 1, got ${stats.convoyCounts.running}`)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
await test('convoy-list.json has 3 entries', () => {
|
|
267
|
+
const list = JSON.parse(readFileSync(join(etlOutDir, 'convoy-list.json'), 'utf8'))
|
|
268
|
+
assert(list.length === 3, `Expected 3, got ${list.length}`)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
await test('per-convoy detail files exist for all 3 convoys', () => {
|
|
272
|
+
for (const f of FIXTURES) {
|
|
273
|
+
assert(
|
|
274
|
+
existsSync(join(etlOutDir, 'convoys', `${f.id}.json`)),
|
|
275
|
+
`Missing convoys/${f.id}.json`,
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
await test('c-done-001 detail has 7 tasks', () => {
|
|
281
|
+
const detail = JSON.parse(readFileSync(join(etlOutDir, 'convoys', 'c-done-001.json'), 'utf8'))
|
|
282
|
+
assert(detail.tasks.length === 7, `Expected 7 tasks, got ${detail.tasks.length}`)
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
await test('c-fail-001 detail has 5 tasks', () => {
|
|
286
|
+
const detail = JSON.parse(readFileSync(join(etlOutDir, 'convoys', 'c-fail-001.json'), 'utf8'))
|
|
287
|
+
assert(detail.tasks.length === 5, `Expected 5 tasks, got ${detail.tasks.length}`)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
await test('c-run-001 detail has 3 tasks', () => {
|
|
291
|
+
const detail = JSON.parse(readFileSync(join(etlOutDir, 'convoys', 'c-run-001.json'), 'utf8'))
|
|
292
|
+
assert(detail.tasks.length === 3, `Expected 3 tasks, got ${detail.tasks.length}`)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
await test('c-fail-001 detail has 2 DLQ entries', () => {
|
|
296
|
+
const detail = JSON.parse(readFileSync(join(etlOutDir, 'convoys', 'c-fail-001.json'), 'utf8'))
|
|
297
|
+
assert(detail.dlq_count === 2, `Expected 2 DLQ entries, got ${detail.dlq_count}`)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
await test('c-done-001 detail has at least 3 events', () => {
|
|
301
|
+
const detail = JSON.parse(readFileSync(join(etlOutDir, 'convoys', 'c-done-001.json'), 'utf8'))
|
|
302
|
+
assert(detail.events.length >= 3, `Expected >= 3 events, got ${detail.events.length}`)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
await test('overall-stats.json has required top-level keys', () => {
|
|
306
|
+
const stats = JSON.parse(readFileSync(join(etlOutDir, 'overall-stats.json'), 'utf8'))
|
|
307
|
+
for (const key of ['convoyCounts', 'durationStats', 'tokenCostTotals', 'topAgents', 'topModels', 'dlqSummary']) {
|
|
308
|
+
assert(key in stats, `Missing key "${key}" in overall-stats.json`)
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
// ── Phase B: Astro Build Verification ─────────────────────────────────────
|
|
313
|
+
console.log(c.bold('\n Phase B: Astro Build Verification\n'))
|
|
314
|
+
|
|
315
|
+
await test('npm run dashboard:etl exits with code 0', () => {
|
|
316
|
+
execCmd('npm run dashboard:etl', 60_000)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
await test('npx astro build --root src/dashboard exits with code 0', () => {
|
|
320
|
+
execCmd('npx astro build --root src/dashboard', 180_000)
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
const distHtmlPath = join(WORKSPACE_ROOT, 'src', 'dashboard', 'dist', 'index.html')
|
|
324
|
+
|
|
325
|
+
await test('src/dashboard/dist/index.html exists', () => {
|
|
326
|
+
assert(existsSync(distHtmlPath), `HTML not found at ${distHtmlPath}`)
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
// ── Phase C: Content Verification ─────────────────────────────────────────
|
|
330
|
+
console.log(c.bold('\n Phase C: Content Verification\n'))
|
|
331
|
+
|
|
332
|
+
let html = ''
|
|
333
|
+
await test('read dist/index.html (non-empty)', () => {
|
|
334
|
+
assert(existsSync(distHtmlPath), 'dist/index.html missing — Phase B may have failed')
|
|
335
|
+
html = readFileSync(distHtmlPath, 'utf8')
|
|
336
|
+
assert(html.length > 2000, `HTML suspiciously small: ${html.length} bytes`)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
const requiredIds: Array<[string, string]> = [
|
|
340
|
+
['convoy-select', 'id="convoy-select"'],
|
|
341
|
+
['overall-section', 'id="overall-section"'],
|
|
342
|
+
['overall-total-runs KPI', 'id="overall-total-runs"'],
|
|
343
|
+
['overall-running KPI', 'id="overall-running"'],
|
|
344
|
+
['overall-success-rate KPI', 'id="overall-success-rate"'],
|
|
345
|
+
['overall-avg-duration KPI', 'id="overall-avg-duration"'],
|
|
346
|
+
['overall-total-tokens KPI', 'id="overall-total-tokens"'],
|
|
347
|
+
['overall-total-cost KPI', 'id="overall-total-cost"'],
|
|
348
|
+
['tasks-section', 'id="tasks-section"'],
|
|
349
|
+
['quality-section', 'id="quality-section"'],
|
|
350
|
+
['reliability-section', 'id="reliability-section"'],
|
|
351
|
+
['drift-section', 'id="drift-section"'],
|
|
352
|
+
['outputs-section', 'id="outputs-section"'],
|
|
353
|
+
['event-timeline-section', 'id="event-timeline-section"'],
|
|
354
|
+
['export-btn', 'id="export-btn"'],
|
|
355
|
+
['selected-convoy-name', 'id="selected-convoy-name"'],
|
|
356
|
+
['selected-convoy-status', 'id="selected-convoy-status"'],
|
|
357
|
+
]
|
|
358
|
+
|
|
359
|
+
for (const [label, needle] of requiredIds) {
|
|
360
|
+
await test(`HTML contains ${label}`, () => {
|
|
361
|
+
assert(html.includes(needle), `Missing element: ${needle}`)
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Phase C2: Data Population Verification ──────────────────────────────────────
|
|
366
|
+
console.log(c.bold('\n Phase C2: Data Population Verification\n'))
|
|
367
|
+
|
|
368
|
+
await test('HTML contains __DASHBOARD_DATA__ script block', () => {
|
|
369
|
+
assert(html.includes('__DASHBOARD_DATA__'), 'Missing __DASHBOARD_DATA__ injection in HTML')
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
await test('HTML contains overall stats data (convoyCounts)', () => {
|
|
373
|
+
assert(html.includes('convoyCounts'), 'Missing convoyCounts in rendered HTML data')
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
await test('HTML contains convoy list data (convoy-list)', () => {
|
|
377
|
+
// The convoy selector should have convoy names populated
|
|
378
|
+
assert(
|
|
379
|
+
html.includes('convoy-list') || html.includes('convoyList'),
|
|
380
|
+
'Missing convoy list data reference in rendered HTML',
|
|
381
|
+
)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
// ── Phase D: Accessibility Audit ──────────────────────────────────────────
|
|
385
|
+
console.log(c.bold('\n Phase D: Accessibility Audit\n'))
|
|
386
|
+
|
|
387
|
+
// Strip <script> blocks so JS template strings don't produce false positives
|
|
388
|
+
const htmlNoScripts = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
389
|
+
|
|
390
|
+
await test('all <img> elements have alt attribute', () => {
|
|
391
|
+
const imgRe = /<img\s[^>]*>/gi
|
|
392
|
+
const imgs = htmlNoScripts.match(imgRe) ?? []
|
|
393
|
+
const missing = imgs.filter((tag) => !/\balt\s*=/.test(tag))
|
|
394
|
+
assert(
|
|
395
|
+
missing.length === 0,
|
|
396
|
+
`${missing.length} <img> without alt attribute:\n${missing.join('\n')}`,
|
|
397
|
+
)
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
await test('all <th> elements have scope attribute', () => {
|
|
401
|
+
const thRe = /<th\b[^>]*>/gi
|
|
402
|
+
const ths = htmlNoScripts.match(thRe) ?? []
|
|
403
|
+
const missing = ths.filter((tag) => !/\bscope\s*=/.test(tag))
|
|
404
|
+
assert(
|
|
405
|
+
missing.length === 0,
|
|
406
|
+
`${missing.length} <th> without scope attribute:\n${missing.join('\n')}`,
|
|
407
|
+
)
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
await test('all <button> elements have accessible label (text or aria-label or title)', () => {
|
|
411
|
+
// Match opening tag + content + closing tag across lines (non-greedy)
|
|
412
|
+
const btnRe = /<button([^>]*)>([\s\S]*?)<\/button>/gi
|
|
413
|
+
const violations: string[] = []
|
|
414
|
+
let m: RegExpExecArray | null
|
|
415
|
+
while ((m = btnRe.exec(htmlNoScripts)) !== null) {
|
|
416
|
+
const attrs = m[1]
|
|
417
|
+
const textContent = m[2].replace(/<[^>]+>/g, '').trim()
|
|
418
|
+
const hasAriaLabel = /\baria-label\s*=\s*["'][^"']+["']/.test(attrs)
|
|
419
|
+
const hasTitle = /\btitle\s*=\s*["'][^"']+["']/.test(attrs)
|
|
420
|
+
if (!hasAriaLabel && !hasTitle && textContent.length === 0) {
|
|
421
|
+
violations.push(`<button${attrs.slice(0, 100)}>`)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
assert(
|
|
425
|
+
violations.length === 0,
|
|
426
|
+
`${violations.length} <button> without accessible text:\n${violations.join('\n')}`,
|
|
427
|
+
)
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
await test('sidebar navigation links have aria-label attributes', () => {
|
|
431
|
+
// Sidebar <a> elements should have aria-label="...section"
|
|
432
|
+
const count = (htmlNoScripts.match(/aria-label="[^"]*section[^"]*"/g) ?? []).length
|
|
433
|
+
assert(count >= 5, `Expected at least 5 nav aria-label="...section" attributes, found ${count}`)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
await test('status-badge element has role attribute', () => {
|
|
437
|
+
assert(
|
|
438
|
+
htmlNoScripts.includes('id="selected-convoy-status"'),
|
|
439
|
+
'Missing status badge element',
|
|
440
|
+
)
|
|
441
|
+
// The status badge should have role="status" for screen readers
|
|
442
|
+
const badgeRe = /<[^>]+id="selected-convoy-status"[^>]*>/
|
|
443
|
+
const match = htmlNoScripts.match(badgeRe)
|
|
444
|
+
if (match) {
|
|
445
|
+
assert(/\brole\s*=/.test(match[0]), 'Status badge is missing role attribute')
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// ── Phase E: Empty Data Edge Case ─────────────────────────────────────────
|
|
450
|
+
console.log(c.bold('\n Phase E: Empty Data Edge Case\n'))
|
|
451
|
+
|
|
452
|
+
const emptyOutDir = join(tmpDir, 'empty-etl-out')
|
|
453
|
+
let emptyResult: { convoyCount: number; taskCount: number } | null = null
|
|
454
|
+
|
|
455
|
+
await test('ETL with non-existent DB returns convoyCount 0 and taskCount 0', async () => {
|
|
456
|
+
const { runEtl } = await import('./etl.js')
|
|
457
|
+
emptyResult = await runEtl({
|
|
458
|
+
dbPath: join(tmpDir, 'nonexistent.db'),
|
|
459
|
+
outputDir: emptyOutDir,
|
|
460
|
+
})
|
|
461
|
+
assert(emptyResult.convoyCount === 0, `Expected 0 convoys, got ${emptyResult.convoyCount}`)
|
|
462
|
+
assert(emptyResult.taskCount === 0, `Expected 0 tasks, got ${emptyResult.taskCount}`)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
await test('empty overall-stats.json has zero counts', () => {
|
|
466
|
+
const stats = JSON.parse(readFileSync(join(emptyOutDir, 'overall-stats.json'), 'utf8'))
|
|
467
|
+
assert(stats.convoyCounts.total === 0, `total: expected 0, got ${stats.convoyCounts.total}`)
|
|
468
|
+
assert(stats.convoyCounts.running === 0, `running: expected 0, got ${stats.convoyCounts.running}`)
|
|
469
|
+
assert(stats.convoyCounts.done === 0, `done: expected 0, got ${stats.convoyCounts.done}`)
|
|
470
|
+
assert(stats.convoyCounts.failed === 0, `failed: expected 0, got ${stats.convoyCounts.failed}`)
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
await test('empty convoy-list.json is an empty array', () => {
|
|
474
|
+
const list = JSON.parse(readFileSync(join(emptyOutDir, 'convoy-list.json'), 'utf8'))
|
|
475
|
+
assert(Array.isArray(list) && list.length === 0, `Expected [], got ${JSON.stringify(list)}`)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
await test('dashboard Astro build succeeds with empty data', async () => {
|
|
479
|
+
// Write empty JSON to public/data so the build uses empty state
|
|
480
|
+
const publicDataDir = join(WORKSPACE_ROOT, 'src', 'dashboard', 'public', 'data')
|
|
481
|
+
const { runEtl } = await import('./etl.js')
|
|
482
|
+
await runEtl({
|
|
483
|
+
dbPath: join(tmpDir, 'nonexistent.db'),
|
|
484
|
+
outputDir: publicDataDir,
|
|
485
|
+
})
|
|
486
|
+
execCmd('npx astro build --root src/dashboard', 180_000)
|
|
487
|
+
const builtHtml = readFileSync(distHtmlPath, 'utf8')
|
|
488
|
+
assert(builtHtml.includes('id="convoy-select"'), 'convoy-select missing in empty-data build')
|
|
489
|
+
assert(builtHtml.includes('id="overall-section"'), 'overall-section missing in empty-data build')
|
|
490
|
+
assert(builtHtml.includes('id="tasks-section"'), 'tasks-section missing in empty-data build')
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
} finally {
|
|
494
|
+
// Best-effort restore of public/data
|
|
495
|
+
try {
|
|
496
|
+
execSync('npm run dashboard:etl', { cwd: WORKSPACE_ROOT, stdio: 'pipe', timeout: 60_000 })
|
|
497
|
+
} catch { /* no real DB present — that's fine */ }
|
|
498
|
+
|
|
499
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const failStr = failed > 0 ? c.red(String(failed)) : String(0)
|
|
503
|
+
console.log(`\n ${c.bold('Results:')} ${c.green(String(passed))} passed, ${failStr} failed\n`)
|
|
504
|
+
process.exit(failed > 0 ? 1 : 0)
|