Skip to content

Commit c11c01f

Browse files
committed
feat: implement Quality Protocol testing framework and migration operations
1 parent c5dc724 commit c11c01f

14 files changed

Lines changed: 530 additions & 0 deletions

File tree

examples/basic/qa/demo.test.json

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"name": "Integration Test Demo",
3+
"scenarios": [
4+
{
5+
"id": "scn_001",
6+
"name": "Check Health",
7+
"steps": [
8+
{
9+
"name": "Ping Server",
10+
"action": {
11+
"type": "api_call",
12+
"target": "/health",
13+
"payload": {
14+
"method": "GET"
15+
}
16+
},
17+
"assertions": [
18+
{
19+
"field": "status",
20+
"operator": "equals",
21+
"expectedValue": "ok"
22+
}
23+
]
24+
},
25+
{
26+
"name": "Check Version",
27+
"action": {
28+
"type": "api_call",
29+
"target": "/api",
30+
"payload": {
31+
"method": "GET"
32+
}
33+
},
34+
"assertions": [
35+
{
36+
"field": "version",
37+
"operator": "not_null",
38+
"expectedValue": true
39+
}
40+
]
41+
}
42+
]
43+
}
44+
]
45+
}

packages/cli/src/bin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { devCommand } from './commands/dev.js';
44
import { doctorCommand } from './commands/doctor.js';
55
import { createCommand } from './commands/create.js';
66
import { serveCommand } from './commands/serve.js';
7+
import { testCommand } from './commands/test.js';
78

89
const program = new Command();
910

@@ -18,5 +19,6 @@ program.addCommand(serveCommand);
1819
program.addCommand(devCommand);
1920
program.addCommand(doctorCommand);
2021
program.addCommand(createCommand);
22+
program.addCommand(testCommand);
2123

2224
program.parse(process.argv);

packages/cli/src/commands/test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { Command } from 'commander';
2+
import chalk from 'chalk';
3+
import path from 'path';
4+
import fs from 'fs';
5+
import { QA as CoreQA } from '@objectstack/core';
6+
import { QA } from '@objectstack/spec';
7+
8+
export const testCommand = new Command('test:run')
9+
.description('Run Quality Protocol test scenarios')
10+
.argument('[files]', 'Glob pattern for test files (e.g. "qa/*.test.json")', 'qa/*.test.json')
11+
.option('--url <url>', 'Target base URL', 'http://localhost:3000')
12+
.option('--token <token>', 'Authentication token')
13+
.action(async (filesPattern, options) => {
14+
console.log(chalk.bold(`\n🧪 ObjectStack Quality Protocol Runner`));
15+
console.log(chalk.dim(`-------------------------------------`));
16+
console.log(`Target: ${chalk.blue(options.url)}`);
17+
18+
// 1. Setup Runner
19+
const adapter = new CoreQA.HttpTestAdapter(options.url, options.token);
20+
const runner = new CoreQA.TestRunner(adapter);
21+
22+
// 2. Find Files (Simple implementation for now)
23+
// TODO: Use glob
24+
const cwd = process.cwd();
25+
const testFiles: string[] = [];
26+
27+
// Very basic file finding for demo - assume explicit path or check local dir
28+
if (fs.existsSync(filesPattern)) {
29+
testFiles.push(filesPattern);
30+
} else {
31+
// Simple directory scan
32+
const dir = path.dirname(filesPattern);
33+
const ext = path.extname(filesPattern);
34+
if (fs.existsSync(dir)) {
35+
const files = fs.readdirSync(dir).filter(f => f.endsWith(ext) || f.endsWith('.json'));
36+
files.forEach(f => testFiles.push(path.join(dir, f)));
37+
}
38+
}
39+
40+
if (testFiles.length === 0) {
41+
console.warn(chalk.yellow(`No test files found matching: ${filesPattern}`));
42+
// Create a demo test file if none exist?
43+
return;
44+
}
45+
46+
console.log(`Found ${testFiles.length} test suites.`);
47+
48+
// 3. Run Tests
49+
let totalPassed = 0;
50+
let totalFailed = 0;
51+
52+
for (const file of testFiles) {
53+
console.log(`\n📄 Running suite: ${chalk.bold(path.basename(file))}`);
54+
try {
55+
const content = fs.readFileSync(file, 'utf-8');
56+
const suite = JSON.parse(content) as QA.TestSuite; // Should validate with Zod
57+
58+
const results = await runner.runSuite(suite);
59+
60+
for (const result of results) {
61+
const icon = result.passed ? '✅' : '❌';
62+
console.log(` ${icon} Scenario: ${result.scenarioId} (${result.duration}ms)`);
63+
if (!result.passed) {
64+
console.error(chalk.red(` Error: ${result.error}`));
65+
result.steps.forEach(step => {
66+
if (!step.passed) {
67+
console.error(chalk.red(` Step Failed: ${step.stepName}`));
68+
if (step.output) console.error(` Output:`, step.output);
69+
if (step.error) console.error(` Error:`, step.error);
70+
}
71+
});
72+
totalFailed++;
73+
} else {
74+
totalPassed++;
75+
}
76+
}
77+
} catch (e) {
78+
console.error(chalk.red(`Failed to load or run suite ${file}: ${e}`));
79+
totalFailed++; // Count suite failure
80+
}
81+
}
82+
83+
// 4. Summary
84+
console.log(chalk.dim(`\n-------------------------------------`));
85+
if (totalFailed > 0) {
86+
console.log(chalk.red(`FAILED: ${totalFailed} scenarios failed. ${totalPassed} passed.`));
87+
process.exit(1);
88+
} else {
89+
console.log(chalk.green(`SUCCESS: All ${totalPassed} scenarios passed.`));
90+
process.exit(0);
91+
}
92+
});

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export * from './types.js';
1111
export * from './logger.js';
1212
export * from './plugin-loader.js';
1313
export * from './enhanced-kernel.js';
14+
export * as QA from './qa/index.js';
1415

1516
// Re-export contracts from @objectstack/spec for backward compatibility
1617
export type {

packages/core/src/qa/adapter.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { QA } from '@objectstack/spec';
2+
3+
/**
4+
* Interface for executing test actions against a target system.
5+
* The target could be a local Kernel instance or a remote API.
6+
*/
7+
export interface TestExecutionAdapter {
8+
/**
9+
* Execute a single test action.
10+
* @param action The action to perform (create_record, api_call, etc.)
11+
* @returns The result of the action (e.g. created record, API response)
12+
*/
13+
execute(action: QA.TestAction, context: Record<string, unknown>): Promise<unknown>;
14+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { QA } from '@objectstack/spec';
2+
import { TestExecutionAdapter } from './adapter.js';
3+
4+
export class HttpTestAdapter implements TestExecutionAdapter {
5+
constructor(private baseUrl: string, private authToken?: string) {}
6+
7+
async execute(action: QA.TestAction, context: Record<string, unknown>): Promise<unknown> {
8+
const headers: Record<string, string> = {
9+
'Content-Type': 'application/json',
10+
};
11+
if (this.authToken) {
12+
headers['Authorization'] = `Bearer ${this.authToken}`;
13+
}
14+
// If action.user is specified, maybe add a specific header for impersonation if supported?
15+
if (action.user) {
16+
headers['X-Run-As'] = action.user;
17+
}
18+
19+
switch (action.type) {
20+
case 'create_record':
21+
return this.createRecord(action.target, action.payload || {}, headers);
22+
case 'update_record':
23+
return this.updateRecord(action.target, action.payload || {}, headers);
24+
case 'delete_record':
25+
return this.deleteRecord(action.target, action.payload || {}, headers);
26+
case 'read_record':
27+
return this.readRecord(action.target, action.payload || {}, headers);
28+
case 'query_records':
29+
return this.queryRecords(action.target, action.payload || {}, headers);
30+
case 'api_call':
31+
return this.rawApiCall(action.target, action.payload || {}, headers);
32+
case 'wait':
33+
const ms = Number(action.payload?.duration || 1000);
34+
return new Promise(resolve => setTimeout(() => resolve({ waited: ms }), ms));
35+
default:
36+
throw new Error(`Unsupported action type in HttpAdapter: ${action.type}`);
37+
}
38+
}
39+
40+
private async createRecord(objectName: string, data: Record<string, unknown>, headers: Record<string, string>) {
41+
const response = await fetch(`${this.baseUrl}/api/data/${objectName}`, {
42+
method: 'POST',
43+
headers,
44+
body: JSON.stringify(data)
45+
});
46+
return this.handleResponse(response);
47+
}
48+
49+
private async updateRecord(objectName: string, data: Record<string, unknown>, headers: Record<string, string>) {
50+
const id = data._id || data.id;
51+
if (!id) throw new Error('Update record requires _id or id in payload');
52+
const response = await fetch(`${this.baseUrl}/api/data/${objectName}/${id}`, {
53+
method: 'PUT',
54+
headers,
55+
body: JSON.stringify(data)
56+
});
57+
return this.handleResponse(response);
58+
}
59+
60+
private async deleteRecord(objectName: string, data: Record<string, unknown>, headers: Record<string, string>) {
61+
const id = data._id || data.id;
62+
if (!id) throw new Error('Delete record requires _id or id in payload');
63+
const response = await fetch(`${this.baseUrl}/api/data/${objectName}/${id}`, {
64+
method: 'DELETE',
65+
headers
66+
});
67+
return this.handleResponse(response);
68+
}
69+
70+
private async readRecord(objectName: string, data: Record<string, unknown>, headers: Record<string, string>) {
71+
const id = data._id || data.id;
72+
if (!id) throw new Error('Read record requires _id or id in payload');
73+
const response = await fetch(`${this.baseUrl}/api/data/${objectName}/${id}`, {
74+
method: 'GET',
75+
headers
76+
});
77+
return this.handleResponse(response);
78+
}
79+
80+
private async queryRecords(objectName: string, data: Record<string, unknown>, headers: Record<string, string>) {
81+
// Assuming query via POST or GraphQL-like endpoint
82+
const response = await fetch(`${this.baseUrl}/api/data/${objectName}/query`, {
83+
method: 'POST',
84+
headers,
85+
body: JSON.stringify(data)
86+
});
87+
return this.handleResponse(response);
88+
}
89+
90+
private async rawApiCall(endpoint: string, data: Record<string, unknown>, headers: Record<string, string>) {
91+
const method = (data.method as string) || 'GET';
92+
const body = data.body ? JSON.stringify(data.body) : undefined;
93+
const url = endpoint.startsWith('http') ? endpoint : `${this.baseUrl}${endpoint}`;
94+
95+
const response = await fetch(url, {
96+
method,
97+
headers,
98+
body
99+
});
100+
return this.handleResponse(response);
101+
}
102+
103+
private async handleResponse(response: Response) {
104+
if (!response.ok) {
105+
const text = await response.text();
106+
throw new Error(`HTTP Error ${response.status}: ${text}`);
107+
}
108+
const contentType = response.headers.get('content-type');
109+
if (contentType && contentType.includes('application/json')) {
110+
return response.json();
111+
}
112+
return response.text();
113+
}
114+
}

packages/core/src/qa/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './adapter.js';
2+
export * from './runner.js';
3+
export * from './http-adapter.js';

0 commit comments

Comments
 (0)