Implement Script Testing Framework and Enhance Test Context Management
- Introduced a new script testing mode to allow testing of agent handler scripts (hooks, tools, etc.) using a Go-like interface, enabling better unit testing of TypeScript/JavaScript code. - Enhanced the `LoadScripts` function to skip test files during script loading, ensuring only relevant scripts are processed. - Refactored the test context creation to support custom context configurations via a JSON file, allowing for flexible authorization and metadata management during tests. - Updated the test runner to handle script tests, including the ability to filter tests using regex patterns and manage custom context data. - Improved documentation to include details on script testing usage, input formats, and available assertions, enhancing developer experience and clarity.
This commit is contained in:
parent
aacb81fb52
commit
5e64c78435
15 changed files with 3048 additions and 119 deletions
|
|
@ -75,6 +75,11 @@ func LoadScripts(srcDir string) (*hook.Script, map[string]*Script, error) {
|
||||||
// Get relative path for determining if it's index
|
// Get relative path for determining if it's index
|
||||||
relPath := strings.TrimPrefix(file, root+"/")
|
relPath := strings.TrimPrefix(file, root+"/")
|
||||||
|
|
||||||
|
// Skip test files (*_test.ts, *_test.js)
|
||||||
|
if strings.HasSuffix(relPath, "_test.ts") || strings.HasSuffix(relPath, "_test.js") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Check if it's the root index.ts/js (hook script)
|
// Check if it's the root index.ts/js (hook script)
|
||||||
// Only src/index.ts is the hook script, not src/foo/index.ts
|
// Only src/index.ts is the hook script, not src/foo/index.ts
|
||||||
isRootIndex := relPath == "index.ts" || relPath == "index.js"
|
isRootIndex := relPath == "index.ts" || relPath == "index.js"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
Agent Test Package provides a framework for testing AI agents with structured test cases.
|
Agent Test Package provides a framework for testing AI agents with structured test cases.
|
||||||
It supports batch testing, report generation, stability analysis, and CI integration.
|
It supports batch testing, report generation, stability analysis, and CI integration.
|
||||||
|
|
||||||
|
Additionally, it supports **Script Testing** for testing Agent handler scripts (hooks, tools, etc.) with a Go-like testing interface.
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -23,6 +25,12 @@ yao agent test -i assistants/keyword/tests/inputs.jsonl --runs 5
|
||||||
|
|
||||||
# Generate HTML report
|
# Generate HTML report
|
||||||
yao agent test -i assistants/keyword/tests/inputs.jsonl -r report.html -o report.html
|
yao agent test -i assistants/keyword/tests/inputs.jsonl -r report.html -o report.html
|
||||||
|
|
||||||
|
# Run script tests (test agent handler scripts)
|
||||||
|
yao agent test -i scripts.expense.setup -v
|
||||||
|
|
||||||
|
# Run script tests with specific user/team context
|
||||||
|
yao agent test -i scripts.expense.tools -u admin -t ops-team -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
@ -77,7 +85,7 @@ yao agent test -i tests/inputs.jsonl \
|
||||||
|
|
||||||
### Input Modes
|
### Input Modes
|
||||||
|
|
||||||
The `-i` flag supports two input modes:
|
The `-i` flag supports three input modes:
|
||||||
|
|
||||||
**1. JSONL File Mode** - Load test cases from a file:
|
**1. JSONL File Mode** - Load test cases from a file:
|
||||||
|
|
||||||
|
|
@ -104,6 +112,33 @@ When using direct message mode:
|
||||||
- Output is printed to stdout (or saved to `-o` if specified)
|
- Output is printed to stdout (or saved to `-o` if specified)
|
||||||
- Useful for quick testing and debugging
|
- Useful for quick testing and debugging
|
||||||
|
|
||||||
|
**3. Script Test Mode** - Test agent handler scripts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests in a script module
|
||||||
|
yao agent test -i scripts.expense.setup -v
|
||||||
|
|
||||||
|
# Run with specific user/team context
|
||||||
|
yao agent test -i scripts.expense.tools -u admin -t ops-team
|
||||||
|
|
||||||
|
# Run with timeout
|
||||||
|
yao agent test -i scripts.expense.setup --timeout 30s
|
||||||
|
|
||||||
|
# Run specific tests by pattern (like go test -run)
|
||||||
|
yao agent test -i scripts.expense.setup -run TestSystemReady
|
||||||
|
|
||||||
|
# Run tests matching a regex pattern
|
||||||
|
yao agent test -i scripts.expense.setup -run "TestSystem.*"
|
||||||
|
```
|
||||||
|
|
||||||
|
When using script test mode:
|
||||||
|
|
||||||
|
- Input starts with `scripts.` prefix to indicate script testing
|
||||||
|
- Maps to the script file (e.g., `scripts.expense.setup` → `expense/src/setup_test.ts`)
|
||||||
|
- Automatically discovers and runs all `Test*` functions in the script
|
||||||
|
- Uses Go-like testing interface with assertions
|
||||||
|
- See [Script Testing](#script-testing) section for details
|
||||||
|
|
||||||
### Default Output Path
|
### Default Output Path
|
||||||
|
|
||||||
When `-o` is not specified and using JSONL file mode, the output file is automatically generated in the same directory as the input file:
|
When `-o` is not specified and using JSONL file mode, the output file is automatically generated in the same directory as the input file:
|
||||||
|
|
@ -131,8 +166,10 @@ When using direct message mode without `-o`, output is printed to stdout.
|
||||||
| `-c` | `--connector` | Override connector | agent default | `-c openai.gpt4` |
|
| `-c` | `--connector` | Override connector | agent default | `-c openai.gpt4` |
|
||||||
| `-u` | `--user` | Test user ID (global override) | "test-user" | `-u admin` |
|
| `-u` | `--user` | Test user ID (global override) | "test-user" | `-u admin` |
|
||||||
| `-t` | `--team` | Test team ID (global override) | "test-team" | `-t ops-team` |
|
| `-t` | `--team` | Test team ID (global override) | "test-team" | `-t ops-team` |
|
||||||
|
| | `--ctx` | Path to context JSON file | - | `--ctx tests/context.json` |
|
||||||
| `-r` | `--reporter` | Custom reporter agent ID | - (use built-in) | `-r report.beautiful` |
|
| `-r` | `--reporter` | Custom reporter agent ID | - (use built-in) | `-r report.beautiful` |
|
||||||
| | `--runs` | Number of runs for stability analysis | 1 | `--runs 5` |
|
| | `--runs` | Number of runs for stability analysis | 1 | `--runs 5` |
|
||||||
|
| | `--run` | Regex pattern to filter tests | - | `--run "TestSystem.*"` |
|
||||||
| | `--timeout` | Default timeout per test case | 5m | `--timeout 10m` |
|
| | `--timeout` | Default timeout per test case | 5m | `--timeout 10m` |
|
||||||
| | `--parallel` | Number of parallel test cases | 1 | `--parallel 4` |
|
| | `--parallel` | Number of parallel test cases | 1 | `--parallel 4` |
|
||||||
| `-v` | `--verbose` | Verbose output | false | `-v` |
|
| `-v` | `--verbose` | Verbose output | false | `-v` |
|
||||||
|
|
@ -330,6 +367,437 @@ Create a custom reporter agent at `assistants/reporters/my-reporter/`:
|
||||||
- Make it printable
|
- Make it printable
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Script Testing
|
||||||
|
|
||||||
|
Script Testing allows you to test Agent handler scripts (hooks, tools, setup functions, etc.) using a Go-like testing interface. This is useful for unit testing individual functions in your agent's TypeScript/JavaScript code.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run script tests
|
||||||
|
yao agent test -i scripts.expense.setup -v
|
||||||
|
|
||||||
|
# With user/team context
|
||||||
|
yao agent test -i scripts.expense.setup -u admin -t ops-team -v
|
||||||
|
|
||||||
|
# With timeout
|
||||||
|
yao agent test -i scripts.expense.setup --timeout 30s -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Script Resolution
|
||||||
|
|
||||||
|
The `scripts.` prefix indicates script test mode. The script is resolved as follows:
|
||||||
|
|
||||||
|
| Input | Script Path | Test File |
|
||||||
|
| ----------------------- | ---------------------- | --------------------------- |
|
||||||
|
| `scripts.expense.setup` | `expense/src/setup.ts` | `expense/src/setup_test.ts` |
|
||||||
|
| `scripts.expense.tools` | `expense/src/tools.ts` | `expense/src/tools_test.ts` |
|
||||||
|
| `scripts.keyword.index` | `keyword/src/index.ts` | `keyword/src/index_test.ts` |
|
||||||
|
|
||||||
|
The test file naming convention is `{module}_test.ts` (similar to Go's `_test.go` convention).
|
||||||
|
|
||||||
|
### Test Function Signature
|
||||||
|
|
||||||
|
Test functions must follow this signature:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function TestFunctionName(t: testing.T, ctx: agent.Context) {
|
||||||
|
// Test logic here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
|
||||||
|
- Function name must start with `Test` (case-sensitive)
|
||||||
|
- First parameter `t` is the testing object with assertions
|
||||||
|
- Second parameter `ctx` is the agent context (same as used in hooks/tools)
|
||||||
|
- Functions not starting with `Test` are ignored (can be used as helpers)
|
||||||
|
|
||||||
|
### Example Test File
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// setup_test.ts
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// Test the SystemReady function
|
||||||
|
function TestSystemReady(t: testing.T, ctx: agent.Context) {
|
||||||
|
const { assert } = t;
|
||||||
|
|
||||||
|
// Call the function being tested
|
||||||
|
const result = SystemReady(ctx);
|
||||||
|
|
||||||
|
// Assert the result
|
||||||
|
assert.True(result, "SystemReady should return true");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test error case
|
||||||
|
function TestSystemReadyWithInvalidContext(t: testing.T, ctx: agent.Context) {
|
||||||
|
const { assert } = t;
|
||||||
|
|
||||||
|
// Modify context to simulate error condition
|
||||||
|
ctx.User = null;
|
||||||
|
|
||||||
|
const result = SystemReady(ctx);
|
||||||
|
assert.False(result, "SystemReady should return false when user is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function (not a test - doesn't start with "Test")
|
||||||
|
function createMockData() {
|
||||||
|
return { id: 1, name: "test" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with helper
|
||||||
|
function TestSetupWithMockData(t: testing.T, ctx: agent.Context) {
|
||||||
|
const { assert } = t;
|
||||||
|
const mockData = createMockData();
|
||||||
|
|
||||||
|
const result = Setup(ctx, mockData);
|
||||||
|
assert.NotNil(result, "Setup should return a result");
|
||||||
|
assert.Equal(result.id, 1, "Result ID should match");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Object (`t`)
|
||||||
|
|
||||||
|
The `t` parameter provides the testing interface:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface testing.T {
|
||||||
|
// Assertions object
|
||||||
|
assert: testing.Assert;
|
||||||
|
|
||||||
|
// Test metadata
|
||||||
|
name: string; // Current test function name
|
||||||
|
failed: boolean; // Whether the test has failed
|
||||||
|
|
||||||
|
// Logging (output appears in test report)
|
||||||
|
log(...args: any[]): void; // Log info message
|
||||||
|
error(...args: any[]): void; // Log error message
|
||||||
|
|
||||||
|
// Control flow
|
||||||
|
skip(reason?: string): void; // Skip this test
|
||||||
|
fail(reason?: string): void; // Mark test as failed
|
||||||
|
fatal(reason?: string): void; // Mark as failed and stop execution
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Assertions (`t.assert`)
|
||||||
|
|
||||||
|
The `assert` object provides assertion methods:
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
| ----------------------------------- | ---------------------------------- |
|
||||||
|
| `True(value, message?)` | Assert value is true |
|
||||||
|
| `False(value, message?)` | Assert value is false |
|
||||||
|
| `Equal(actual, expected, message?)` | Assert deep equality |
|
||||||
|
| `NotEqual(actual, expected, msg?)` | Assert not equal |
|
||||||
|
| `Nil(value, message?)` | Assert value is null/undefined |
|
||||||
|
| `NotNil(value, message?)` | Assert value is not null/undefined |
|
||||||
|
| `Contains(str, substr, message?)` | Assert string contains substring |
|
||||||
|
| `NotContains(str, substr, msg?)` | Assert string does not contain |
|
||||||
|
| `Len(value, length, message?)` | Assert array/string length |
|
||||||
|
| `Greater(a, b, message?)` | Assert a > b |
|
||||||
|
| `GreaterOrEqual(a, b, message?)` | Assert a >= b |
|
||||||
|
| `Less(a, b, message?)` | Assert a < b |
|
||||||
|
| `LessOrEqual(a, b, message?)` | Assert a <= b |
|
||||||
|
| `Error(err, message?)` | Assert err is an error |
|
||||||
|
| `NoError(err, message?)` | Assert err is null/undefined |
|
||||||
|
| `Panic(fn, message?)` | Assert function throws |
|
||||||
|
| `NoPanic(fn, message?)` | Assert function does not throw |
|
||||||
|
| `Match(value, pattern, message?)` | Assert value matches regex |
|
||||||
|
| `NotMatch(value, pattern, msg?)` | Assert value does not match regex |
|
||||||
|
| `JSONPath(obj, path, expected, m?)` | Assert JSON path value |
|
||||||
|
| `Type(value, typeName, message?)` | Assert value type |
|
||||||
|
|
||||||
|
### Agent Context (`ctx`)
|
||||||
|
|
||||||
|
The `ctx` parameter is the same `agent.Context` used in agent hooks and tools:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface agent.Context {
|
||||||
|
// User information (from -u flag or default)
|
||||||
|
User: {
|
||||||
|
ID: string;
|
||||||
|
Name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Team information (from -t flag or default)
|
||||||
|
Team: {
|
||||||
|
ID: string;
|
||||||
|
Name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Locale (default: "en-us")
|
||||||
|
Locale: string;
|
||||||
|
|
||||||
|
// Client information
|
||||||
|
Client: {
|
||||||
|
Type: string; // "test"
|
||||||
|
IP: string; // "127.0.0.1"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Metadata (can be set via test case)
|
||||||
|
Metadata: Record<string, any>;
|
||||||
|
|
||||||
|
// Chat/Session ID
|
||||||
|
ChatID: string;
|
||||||
|
|
||||||
|
// Assistant ID (resolved from script path)
|
||||||
|
AssistantID: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Script Test Output
|
||||||
|
|
||||||
|
Script test results are reported in the same format as agent tests:
|
||||||
|
|
||||||
|
```
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
Script Test: scripts.expense.setup
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
Script: expense/src/setup_test.ts
|
||||||
|
Tests: 3 functions
|
||||||
|
User: test-user
|
||||||
|
Team: test-team
|
||||||
|
───────────────────────────────────────────────────────────────────────────────
|
||||||
|
Running Tests
|
||||||
|
───────────────────────────────────────────────────────────────────────────────
|
||||||
|
► [TestSystemReady] ...
|
||||||
|
✓ PASSED (12ms)
|
||||||
|
|
||||||
|
► [TestSystemReadyWithInvalidContext] ...
|
||||||
|
✓ PASSED (8ms)
|
||||||
|
|
||||||
|
► [TestSetupWithMockData] ...
|
||||||
|
✗ FAILED (15ms)
|
||||||
|
└─ assertion failed: Result ID should match
|
||||||
|
expected: 1
|
||||||
|
actual: 2
|
||||||
|
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
Summary: 2 passed, 1 failed, 0 skipped (35ms)
|
||||||
|
═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
```
|
||||||
|
|
||||||
|
### Script Test Options
|
||||||
|
|
||||||
|
Script tests support the following command line options:
|
||||||
|
|
||||||
|
| Flag | Description | Default | Example |
|
||||||
|
| ------------- | -------------------------------- | ----------- | -------------------- |
|
||||||
|
| `-u` | User ID for context | "test-user" | `-u admin` |
|
||||||
|
| `-t` | Team ID for context | "test-team" | `-t ops-team` |
|
||||||
|
| `--ctx` | Path to context JSON file | - | `--ctx context.json` |
|
||||||
|
| `-v` | Verbose output | false | `-v` |
|
||||||
|
| `--run` | Regex to filter tests | - | `--run "TestSystem"` |
|
||||||
|
| `--timeout` | Timeout per test function | 30s | `--timeout 1m` |
|
||||||
|
| `--fail-fast` | Stop on first failure | false | `--fail-fast` |
|
||||||
|
| `-o` | Output file for report | stdout | `-o report.json` |
|
||||||
|
| `-r` | Reporter agent for custom report | - | `-r report.html` |
|
||||||
|
|
||||||
|
The `--run` flag accepts a Go-style regex pattern to filter which tests to run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run only TestSystemReady
|
||||||
|
yao agent test -i scripts.expense.setup --run TestSystemReady
|
||||||
|
|
||||||
|
# Run all tests starting with "TestSystem"
|
||||||
|
yao agent test -i scripts.expense.setup --run "TestSystem.*"
|
||||||
|
|
||||||
|
# Run tests containing "Error"
|
||||||
|
yao agent test -i scripts.expense.setup --run ".*Error.*"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Context Configuration
|
||||||
|
|
||||||
|
The `--ctx` flag allows you to provide a JSON file with custom context configuration, giving full control over authorization data, metadata, and client information:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use custom context file
|
||||||
|
yao agent test -i scripts.expense.setup --ctx tests/context.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
**Context JSON Format:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authorized": {
|
||||||
|
"sub": "user-12345",
|
||||||
|
"client_id": "my-app",
|
||||||
|
"scope": "read write",
|
||||||
|
"session_id": "sess-abc123",
|
||||||
|
"user_id": "admin",
|
||||||
|
"team_id": "team-001",
|
||||||
|
"tenant_id": "acme-corp",
|
||||||
|
"remember_me": false,
|
||||||
|
"constraints": {
|
||||||
|
"owner_only": false,
|
||||||
|
"creator_only": false,
|
||||||
|
"editor_only": false,
|
||||||
|
"team_only": true,
|
||||||
|
"extra": {
|
||||||
|
"department": "engineering",
|
||||||
|
"region": "us-west"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"request_id": "req-123",
|
||||||
|
"trace_id": "trace-456",
|
||||||
|
"custom_field": "custom_value"
|
||||||
|
},
|
||||||
|
"client": {
|
||||||
|
"type": "web",
|
||||||
|
"user_agent": "Mozilla/5.0",
|
||||||
|
"ip": "192.168.1.100"
|
||||||
|
},
|
||||||
|
"locale": "zh-cn",
|
||||||
|
"referer": "https://example.com/dashboard"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field Descriptions:**
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| -------------------------- | --------------------------------------------------- |
|
||||||
|
| `authorized.sub` | Subject identifier (JWT sub claim) |
|
||||||
|
| `authorized.client_id` | OAuth client ID |
|
||||||
|
| `authorized.scope` | Access scope |
|
||||||
|
| `authorized.session_id` | Session identifier |
|
||||||
|
| `authorized.user_id` | User identifier (overrides -u flag) |
|
||||||
|
| `authorized.team_id` | Team identifier (overrides -t flag) |
|
||||||
|
| `authorized.tenant_id` | Tenant identifier |
|
||||||
|
| `authorized.remember_me` | Remember me flag |
|
||||||
|
| `authorized.constraints` | Data access constraints (set by ACL enforcement) |
|
||||||
|
| `constraints.owner_only` | Only access owner's data |
|
||||||
|
| `constraints.creator_only` | Only access creator's data |
|
||||||
|
| `constraints.editor_only` | Only access editor's data |
|
||||||
|
| `constraints.team_only` | Only access team's data (filter by team_id) |
|
||||||
|
| `constraints.extra` | User-defined constraints (department, region, etc.) |
|
||||||
|
| `metadata` | Custom metadata passed to context |
|
||||||
|
| `client.type` | Client type (web, mobile, test, etc.) |
|
||||||
|
| `client.user_agent` | Client user agent string |
|
||||||
|
| `client.ip` | Client IP address |
|
||||||
|
| `locale` | Locale setting (e.g., "en-us", "zh-cn") |
|
||||||
|
| `referer` | Request referer URL |
|
||||||
|
|
||||||
|
**Priority:** When both `-u`/`-t` flags and `--ctx` file are provided, the context file values take precedence.
|
||||||
|
|
||||||
|
### Script Test Report Format
|
||||||
|
|
||||||
|
When using `-o` to save results:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "script_test",
|
||||||
|
"script": "scripts.expense.setup",
|
||||||
|
"script_path": "expense/src/setup_test.ts",
|
||||||
|
"summary": {
|
||||||
|
"total": 3,
|
||||||
|
"passed": 2,
|
||||||
|
"failed": 1,
|
||||||
|
"skipped": 0,
|
||||||
|
"duration_ms": 35
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"user_id": "test-user",
|
||||||
|
"team_id": "test-team",
|
||||||
|
"locale": "en-us"
|
||||||
|
},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"name": "TestSystemReady",
|
||||||
|
"status": "passed",
|
||||||
|
"duration_ms": 12,
|
||||||
|
"logs": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TestSystemReadyWithInvalidContext",
|
||||||
|
"status": "passed",
|
||||||
|
"duration_ms": 8,
|
||||||
|
"logs": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "TestSetupWithMockData",
|
||||||
|
"status": "failed",
|
||||||
|
"duration_ms": 15,
|
||||||
|
"error": "assertion failed: Result ID should match",
|
||||||
|
"assertion": {
|
||||||
|
"type": "Equal",
|
||||||
|
"expected": 1,
|
||||||
|
"actual": 2,
|
||||||
|
"message": "Result ID should match"
|
||||||
|
},
|
||||||
|
"logs": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"started_at": "2024-12-17T10:00:00Z",
|
||||||
|
"completed_at": "2024-12-17T10:00:00Z",
|
||||||
|
"version": "0.10.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
1. **Naming Convention**: Use descriptive test names that explain what's being tested
|
||||||
|
|
||||||
|
- Good: `TestSystemReadyWithValidUser`, `TestSetupReturnsErrorOnMissingConfig`
|
||||||
|
- Bad: `Test1`, `TestIt`
|
||||||
|
|
||||||
|
2. **One Assertion Per Concept**: Each test should verify one behavior
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good: Focused tests
|
||||||
|
function TestSetupCreatesDatabase(t, ctx) { ... }
|
||||||
|
function TestSetupInitializesCache(t, ctx) { ... }
|
||||||
|
|
||||||
|
// Bad: Testing too many things
|
||||||
|
function TestSetup(t, ctx) {
|
||||||
|
// tests database, cache, config, etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Use Helper Functions**: Extract common setup logic
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function setupTestContext(ctx) {
|
||||||
|
ctx.Metadata.testMode = true;
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestFeatureA(t, ctx) {
|
||||||
|
ctx = setupTestContext(ctx);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Test Error Cases**: Don't just test happy paths
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function TestSetupWithMissingConfig(t, ctx) {
|
||||||
|
const { assert } = t;
|
||||||
|
ctx.Metadata.config = null;
|
||||||
|
|
||||||
|
const result = Setup(ctx);
|
||||||
|
assert.Error(result.error, "Should return error for missing config");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Clean Up**: If your test modifies global state, clean up after
|
||||||
|
```typescript
|
||||||
|
function TestWithGlobalState(t, ctx) {
|
||||||
|
const originalValue = GlobalConfig.value;
|
||||||
|
try {
|
||||||
|
GlobalConfig.value = "test";
|
||||||
|
// ... test logic
|
||||||
|
} finally {
|
||||||
|
GlobalConfig.value = originalValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Input Format (JSONL)
|
## Input Format (JSONL)
|
||||||
|
|
||||||
Each line in the input file is a JSON object with the following structure:
|
Each line in the input file is a JSON object with the following structure:
|
||||||
|
|
@ -632,8 +1100,13 @@ agent/test/
|
||||||
├── runner.go # Test runner implementation
|
├── runner.go # Test runner implementation
|
||||||
├── loader.go # Test case loader
|
├── loader.go # Test case loader
|
||||||
├── resolver.go # Agent resolver
|
├── resolver.go # Agent resolver
|
||||||
├── environment.go # Test environment setup
|
├── context.go # Test context creation
|
||||||
├── stability.go # Stability analysis
|
├── assert.go # Assertion implementation
|
||||||
|
├── input.go # Input parsing
|
||||||
|
├── output.go # Output formatting
|
||||||
|
├── script.go # Script test runner (NEW)
|
||||||
|
├── script_types.go # Script test types (NEW)
|
||||||
|
├── script_assert.go # Script assertion bindings (NEW)
|
||||||
└── reporter/
|
└── reporter/
|
||||||
├── json.go # JSON reporter
|
├── json.go # JSON reporter
|
||||||
├── html.go # HTML reporter
|
├── html.go # HTML reporter
|
||||||
|
|
@ -665,7 +1138,74 @@ Executes test cases against an agent:
|
||||||
- Executes each test case (optionally multiple runs)
|
- Executes each test case (optionally multiple runs)
|
||||||
- Collects results and stability metrics
|
- Collects results and stability metrics
|
||||||
|
|
||||||
### 5. Reporter
|
### 5. ScriptRunner (NEW)
|
||||||
|
|
||||||
|
Executes script tests for agent handler scripts:
|
||||||
|
|
||||||
|
- Resolves script path from `scripts.` prefix
|
||||||
|
- Discovers `Test*` functions in the script
|
||||||
|
- Creates test context with environment
|
||||||
|
- Executes each test function with testing object and context
|
||||||
|
- Collects results and generates report
|
||||||
|
|
||||||
|
### 6. ScriptTestCase (NEW)
|
||||||
|
|
||||||
|
Represents a single script test function:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ScriptTestCase struct {
|
||||||
|
Name string // Function name (e.g., "TestSystemReady")
|
||||||
|
Function string // Full function reference
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. ScriptTestResult (NEW)
|
||||||
|
|
||||||
|
Represents the result of running a script test function:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ScriptTestResult struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status Status `json:"status"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Assertion *AssertionInfo `json:"assertion,omitempty"`
|
||||||
|
Logs []string `json:"logs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssertionInfo struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Expected interface{} `json:"expected,omitempty"`
|
||||||
|
Actual interface{} `json:"actual,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. ScriptTestReport (NEW)
|
||||||
|
|
||||||
|
Represents the complete script test report:
|
||||||
|
|
||||||
|
```go
|
||||||
|
type ScriptTestReport struct {
|
||||||
|
Type string `json:"type"` // "script_test"
|
||||||
|
Script string `json:"script"`
|
||||||
|
ScriptPath string `json:"script_path"`
|
||||||
|
Summary *ScriptTestSummary `json:"summary"`
|
||||||
|
Environment *Environment `json:"environment"`
|
||||||
|
Results []*ScriptTestResult `json:"results"`
|
||||||
|
Metadata *ReportMetadata `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptTestSummary struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Passed int `json:"passed"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. Reporter
|
||||||
|
|
||||||
Generates reports in various formats. The format is determined by the `-o` file extension:
|
Generates reports in various formats. The format is determined by the `-o` file extension:
|
||||||
|
|
||||||
|
|
@ -713,7 +1253,8 @@ This allows for fully customizable report generation using AI agents
|
||||||
```go
|
```go
|
||||||
type Options struct {
|
type Options struct {
|
||||||
// Input/Output
|
// Input/Output
|
||||||
InputFile string // Path to inputs.jsonl
|
Input string // Input source: file path, message, or scripts.xxx
|
||||||
|
InputMode InputMode // Auto-detected: file, message, or script
|
||||||
OutputFile string // Path to output report
|
OutputFile string // Path to output report
|
||||||
|
|
||||||
// Agent Selection
|
// Agent Selection
|
||||||
|
|
@ -737,6 +1278,186 @@ type Options struct {
|
||||||
Verbose bool // Verbose output
|
Verbose bool // Verbose output
|
||||||
FailFast bool // Stop on first failure
|
FailFast bool // Stop on first failure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InputMode represents the input mode for test cases
|
||||||
|
type InputMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
InputModeFile InputMode = "file" // JSONL file input
|
||||||
|
InputModeMessage InputMode = "message" // Direct message input
|
||||||
|
InputModeScript InputMode = "script" // Script test mode (NEW)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input Mode Detection
|
||||||
|
|
||||||
|
The input mode is automatically detected based on the input value:
|
||||||
|
|
||||||
|
| Input Pattern | Mode | Description |
|
||||||
|
| ----------------- | --------- | -------------------------- |
|
||||||
|
| `scripts.xxx.yyy` | `script` | Script test mode |
|
||||||
|
| `*.jsonl` | `file` | JSONL file mode |
|
||||||
|
| `path/to/file` | `file` | File path (if file exists) |
|
||||||
|
| `"any text"` | `message` | Direct message mode |
|
||||||
|
|
||||||
|
```go
|
||||||
|
func DetectInputMode(input string) InputMode {
|
||||||
|
// Check for script test prefix
|
||||||
|
if strings.HasPrefix(input, "scripts.") {
|
||||||
|
return InputModeScript
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a file path
|
||||||
|
if strings.HasSuffix(input, ".jsonl") || fileExists(input) {
|
||||||
|
return InputModeFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to message mode
|
||||||
|
return InputModeMessage
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Script Testing Implementation
|
||||||
|
|
||||||
|
### Script Resolution
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ResolveScript resolves the script path from scripts.xxx.yyy format
|
||||||
|
func ResolveScript(input string) (*ScriptInfo, error) {
|
||||||
|
// Remove "scripts." prefix
|
||||||
|
path := strings.TrimPrefix(input, "scripts.")
|
||||||
|
|
||||||
|
// Split into parts: "expense.setup" -> ["expense", "setup"]
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil, fmt.Errorf("invalid script path: %s", input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build paths
|
||||||
|
// assistantDir: expense
|
||||||
|
// moduleName: setup
|
||||||
|
// scriptPath: expense/src/setup.ts
|
||||||
|
// testPath: expense/src/setup_test.ts
|
||||||
|
assistantDir := parts[0]
|
||||||
|
moduleName := parts[1]
|
||||||
|
|
||||||
|
return &ScriptInfo{
|
||||||
|
ID: input,
|
||||||
|
Assistant: assistantDir,
|
||||||
|
Module: moduleName,
|
||||||
|
ScriptPath: filepath.Join(assistantDir, "src", moduleName+".ts"),
|
||||||
|
TestPath: filepath.Join(assistantDir, "src", moduleName+"_test.ts"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Function Discovery
|
||||||
|
|
||||||
|
Test functions are discovered by scanning the script for functions starting with `Test`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DiscoverTests finds all Test* functions in the script
|
||||||
|
func DiscoverTests(scriptPath string) ([]*ScriptTestCase, error) {
|
||||||
|
// Use the JavaScript runtime to list exported functions
|
||||||
|
// Filter for functions starting with "Test"
|
||||||
|
// Return list of test cases
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Object Binding
|
||||||
|
|
||||||
|
The `testing.T` object is provided to test functions via JavaScript runtime binding:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// TestingT represents the testing object passed to test functions
|
||||||
|
type TestingT struct {
|
||||||
|
name string
|
||||||
|
failed bool
|
||||||
|
skipped bool
|
||||||
|
logs []string
|
||||||
|
assert *AssertObject
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssertObject provides assertion methods
|
||||||
|
type AssertObject struct {
|
||||||
|
t *TestingT
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AssertObject) True(value bool, message ...string) {
|
||||||
|
if !value {
|
||||||
|
a.t.fail(formatMessage("expected true, got false", message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *AssertObject) Equal(actual, expected interface{}, message ...string) {
|
||||||
|
if !reflect.DeepEqual(actual, expected) {
|
||||||
|
a.t.fail(formatMessage(
|
||||||
|
fmt.Sprintf("expected %v, got %v", expected, actual),
|
||||||
|
message,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... other assertion methods
|
||||||
|
```
|
||||||
|
|
||||||
|
### Script Execution Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Parse input: "scripts.expense.setup"
|
||||||
|
2. Resolve script info:
|
||||||
|
- TestPath: expense/src/setup_test.ts
|
||||||
|
- ScriptPath: expense/src/setup.ts
|
||||||
|
3. Discover test functions: [TestSystemReady, TestSetupWithMockData, ...]
|
||||||
|
4. For each test function:
|
||||||
|
a. Create testing.T object
|
||||||
|
b. Create agent.Context with environment
|
||||||
|
c. Execute: TestFunction(t, ctx)
|
||||||
|
d. Collect result (passed/failed/skipped)
|
||||||
|
5. Generate report
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with Existing Runner
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (r *Executor) Run() (*Report, error) {
|
||||||
|
switch r.opts.InputMode {
|
||||||
|
case InputModeScript:
|
||||||
|
return r.RunScriptTests()
|
||||||
|
case InputModeMessage:
|
||||||
|
return r.RunDirect()
|
||||||
|
default:
|
||||||
|
return r.RunTests()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Executor) RunScriptTests() (*Report, error) {
|
||||||
|
// 1. Resolve script
|
||||||
|
scriptInfo, err := ResolveScript(r.opts.Input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Discover tests
|
||||||
|
tests, err := DiscoverTests(scriptInfo.TestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Run each test
|
||||||
|
results := make([]*ScriptTestResult, 0, len(tests))
|
||||||
|
for _, tc := range tests {
|
||||||
|
result := r.runScriptTest(tc, scriptInfo)
|
||||||
|
results = append(results, result)
|
||||||
|
|
||||||
|
if r.opts.FailFast && result.Status == StatusFailed {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Generate report
|
||||||
|
return r.buildScriptReport(scriptInfo, results), nil
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Exit Codes
|
## Exit Codes
|
||||||
|
|
@ -785,3 +1506,8 @@ The command exits with code 1 if any tests fail, making it easy to integrate wit
|
||||||
5. **Diff Reports**: Compare results between runs
|
5. **Diff Reports**: Compare results between runs
|
||||||
6. **Flaky Test Detection**: Automatic identification of unstable tests
|
6. **Flaky Test Detection**: Automatic identification of unstable tests
|
||||||
7. **Test Prioritization**: Run most important/failing tests first
|
7. **Test Prioritization**: Run most important/failing tests first
|
||||||
|
8. **Script Test Enhancements**:
|
||||||
|
- Parallel script test execution
|
||||||
|
- Setup/Teardown hooks (`TestMain`, `BeforeEach`, `AfterEach`)
|
||||||
|
- Mocking utilities for external dependencies
|
||||||
|
- Code coverage for TypeScript/JavaScript scripts
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ A testing framework for Yao AI agents with support for assertions, stability ana
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
### Agent Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test with direct message (auto-detect agent from current directory)
|
# Test with direct message (auto-detect agent from current directory)
|
||||||
cd assistants/keyword
|
cd assistants/keyword
|
||||||
|
|
@ -22,9 +24,22 @@ yao agent test -i tests/inputs.jsonl -o report.html
|
||||||
yao agent test -i tests/inputs.jsonl --runs 5
|
yao agent test -i tests/inputs.jsonl --runs 5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Script Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test agent handler scripts (hooks, tools, setup functions)
|
||||||
|
yao agent test -i scripts.expense.setup -v
|
||||||
|
|
||||||
|
# Run specific tests with regex filter
|
||||||
|
yao agent test -i scripts.expense.setup --run "TestSystemReady" -v
|
||||||
|
|
||||||
|
# Run with custom context (authorization, metadata)
|
||||||
|
yao agent test -i scripts.expense.setup --ctx tests/context.json -v
|
||||||
|
```
|
||||||
|
|
||||||
## Input Modes
|
## Input Modes
|
||||||
|
|
||||||
The `-i` flag supports two input modes:
|
The `-i` flag supports three input modes:
|
||||||
|
|
||||||
### 1. JSONL File Mode
|
### 1. JSONL File Mode
|
||||||
|
|
||||||
|
|
@ -51,22 +66,101 @@ yao agent test -i "Hello" -n workers.system.keyword
|
||||||
|
|
||||||
Output is printed to stdout (or saved to `-o` if specified).
|
Output is printed to stdout (or saved to `-o` if specified).
|
||||||
|
|
||||||
|
### 3. Script Test Mode
|
||||||
|
|
||||||
|
Test agent handler scripts (hooks, tools, setup functions):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests in a script module
|
||||||
|
yao agent test -i scripts.expense.setup -v
|
||||||
|
|
||||||
|
# Run specific tests with filtering
|
||||||
|
yao agent test -i scripts.expense.setup --run "TestSystemReady"
|
||||||
|
|
||||||
|
# Run with custom context
|
||||||
|
yao agent test -i scripts.expense.setup --ctx tests/context.json -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Script test input format: `scripts.<assistant>.<module>` (e.g., `scripts.expense.setup` → `assistants/expense/src/setup_test.ts`).
|
||||||
|
|
||||||
|
**Writing Test Scripts:**
|
||||||
|
|
||||||
|
Test scripts should be placed alongside the source files with `_test.ts` or `_test.js` suffix:
|
||||||
|
|
||||||
|
```
|
||||||
|
assistants/expense/src/
|
||||||
|
├── setup.ts # Source file
|
||||||
|
├── setup_test.ts # Test file
|
||||||
|
├── tools.ts
|
||||||
|
└── tools_test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Test functions must follow the naming convention `Test*` and accept `(t: testing.T, ctx: agent.Context)`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// assistants/expense/src/setup_test.ts
|
||||||
|
import { SystemReady } from "./setup";
|
||||||
|
|
||||||
|
// Test function signature: function Test*(t: testing.T, ctx: agent.Context)
|
||||||
|
export function TestSystemReady(t: testing.T, ctx: agent.Context) {
|
||||||
|
const result = SystemReady(ctx);
|
||||||
|
|
||||||
|
// Use t.assert for assertions
|
||||||
|
t.assert.True(result.success, "SystemReady should succeed");
|
||||||
|
t.assert.Equal(result.status, "ready", "Status should be ready");
|
||||||
|
t.assert.NotNil(result.data, "Data should not be nil");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TestSystemReadyError(t: testing.T, ctx: agent.Context) {
|
||||||
|
// Access context properties
|
||||||
|
console.log("Testing with user:", ctx.authorized.user_id);
|
||||||
|
|
||||||
|
// Test error handling
|
||||||
|
const result = SystemReady(ctx);
|
||||||
|
t.assert.False(result.error, "Should not have error");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available Assertions:**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
| -------------------------------- | ------------------------------ |
|
||||||
|
| `t.assert.True(value, msg)` | Assert value is true |
|
||||||
|
| `t.assert.False(value, msg)` | Assert value is false |
|
||||||
|
| `t.assert.Equal(a, b, msg)` | Assert a equals b |
|
||||||
|
| `t.assert.NotEqual(a, b, msg)` | Assert a not equals b |
|
||||||
|
| `t.assert.Nil(value, msg)` | Assert value is null/undefined |
|
||||||
|
| `t.assert.NotNil(value, msg)` | Assert value is not nil |
|
||||||
|
| `t.assert.Contains(s, sub, msg)` | Assert string contains substr |
|
||||||
|
| `t.assert.Len(arr, n, msg)` | Assert array/string length |
|
||||||
|
|
||||||
|
**Test Control:**
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
| -------------- | ---------------------------- |
|
||||||
|
| `t.Log(msg)` | Log a message |
|
||||||
|
| `t.Error(msg)` | Mark test as failed with msg |
|
||||||
|
| `t.Fatal(msg)` | Mark failed and stop test |
|
||||||
|
| `t.Skip(msg)` | Skip this test |
|
||||||
|
|
||||||
## Command Line Options
|
## Command Line Options
|
||||||
|
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
| ------------- | ---------------------------------------- | -------------------------- |
|
| ------------- | -------------------------------------------------- | -------------------------- |
|
||||||
| `-i` | Input: JSONL file path or direct message | (required) |
|
| `-i` | Input: JSONL file path, message, or script ID | (required) |
|
||||||
| `-o` | Output file path | `output-{timestamp}.jsonl` |
|
| `-o` | Output file path | `output-{timestamp}.jsonl` |
|
||||||
| `-n` | Agent ID (optional, auto-detected) | auto-detect |
|
| `-n` | Agent ID (optional, auto-detected) | auto-detect |
|
||||||
| `-c` | Override connector | agent default |
|
| `-c` | Override connector | agent default |
|
||||||
| `-u` | Test user ID | `test-user` |
|
| `-u` | Test user ID | `test-user` |
|
||||||
| `-t` | Test team ID | `test-team` |
|
| `-t` | Test team ID | `test-team` |
|
||||||
| `-r` | Reporter agent ID | built-in |
|
| `--ctx` | Path to context JSON file for custom authorization | - |
|
||||||
| `--runs` | Runs per test (stability analysis) | 1 |
|
| `-r` | Reporter agent ID | built-in |
|
||||||
| `--timeout` | Timeout per test | 5m |
|
| `--runs` | Runs per test (stability analysis) | 1 |
|
||||||
| `--parallel` | Parallel test cases | 1 |
|
| `--run` | Regex pattern to filter which tests to run | - |
|
||||||
| `-v` | Verbose output | false |
|
| `--timeout` | Timeout per test | 5m |
|
||||||
| `--fail-fast` | Stop on first failure | false |
|
| `--parallel` | Parallel test cases | 1 |
|
||||||
|
| `-v` | Verbose output | false |
|
||||||
|
| `--fail-fast` | Stop on first failure | false |
|
||||||
|
|
||||||
## Agent Resolution
|
## Agent Resolution
|
||||||
|
|
||||||
|
|
@ -119,24 +213,24 @@ Each line is a JSON object:
|
||||||
|
|
||||||
The `options` field allows per-test-case configuration that maps to `context.Options`:
|
The `options` field allows per-test-case configuration that maps to `context.Options`:
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------------------ | ------- | -------------------------------------------------- |
|
| ------------------------ | ------ | -------------------------------------------- |
|
||||||
| `connector` | string | Override connector (e.g., `"deepseek.v3"`) |
|
| `connector` | string | Override connector (e.g., `"deepseek.v3"`) |
|
||||||
| `mode` | string | Agent mode (default: `"chat"`) |
|
| `mode` | string | Agent mode (default: `"chat"`) |
|
||||||
| `search` | bool | Enable/disable search mode (default: `true`) |
|
| `search` | bool | Enable/disable search mode (default: `true`) |
|
||||||
| `disable_global_prompts` | bool | Temporarily disable global prompts |
|
| `disable_global_prompts` | bool | Temporarily disable global prompts |
|
||||||
| `metadata` | map | Custom data passed to hooks (e.g., scenario) |
|
| `metadata` | map | Custom data passed to hooks (e.g., scenario) |
|
||||||
| `skip` | object | Skip configuration (see below) |
|
| `skip` | object | Skip configuration (see below) |
|
||||||
|
|
||||||
#### Options.skip
|
#### Options.skip
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| --------- | ---- | ------------------------ |
|
| --------- | ---- | ----------------------- |
|
||||||
| `history` | bool | Skip history loading |
|
| `history` | bool | Skip history loading |
|
||||||
| `trace` | bool | Skip trace logging |
|
| `trace` | bool | Skip trace logging |
|
||||||
| `output` | bool | Skip output to client |
|
| `output` | bool | Skip output to client |
|
||||||
| `keyword` | bool | Skip keyword extraction |
|
| `keyword` | bool | Skip keyword extraction |
|
||||||
| `search` | bool | Skip auto search |
|
| `search` | bool | Skip auto search |
|
||||||
|
|
||||||
**Example with options:**
|
**Example with options:**
|
||||||
|
|
||||||
|
|
@ -146,10 +240,18 @@ The `options` field allows per-test-case configuration that maps to `context.Opt
|
||||||
"input": "Query users with status active",
|
"input": "Query users with status active",
|
||||||
"options": {
|
"options": {
|
||||||
"connector": "deepseek.v3",
|
"connector": "deepseek.v3",
|
||||||
"metadata": {"scenario": "filter"},
|
"metadata": {
|
||||||
"skip": {"trace": true}
|
"scenario": "filter"
|
||||||
|
},
|
||||||
|
"skip": {
|
||||||
|
"trace": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"assert": {"type": "json_path", "path": "from", "value": "users"}
|
"assert": {
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "from",
|
||||||
|
"value": "users"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -290,7 +392,17 @@ return { pass: true, message: "Validation passed" };
|
||||||
**Multiple expected values (OR logic):**
|
**Multiple expected values (OR logic):**
|
||||||
|
|
||||||
```jsonl
|
```jsonl
|
||||||
{"id": "T005", "assert": {"type": "json_path", "path": "error", "value": ["missing_schema", "missing_query"]}}
|
{
|
||||||
|
"id": "T005",
|
||||||
|
"assert": {
|
||||||
|
"type": "json_path",
|
||||||
|
"path": "error",
|
||||||
|
"value": [
|
||||||
|
"missing_schema",
|
||||||
|
"missing_query"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This passes if `error` equals either `"missing_schema"` or `"missing_query"`.
|
This passes if `error` equals either `"missing_schema"` or `"missing_query"`.
|
||||||
|
|
@ -382,6 +494,9 @@ yao agent test -i tests/inputs.jsonl -o results.jsonl --fail-fast
|
||||||
|
|
||||||
# Parse JSONL results
|
# Parse JSONL results
|
||||||
cat results.jsonl | jq 'select(.type == "summary")'
|
cat results.jsonl | jq 'select(.type == "summary")'
|
||||||
|
|
||||||
|
# Run script tests
|
||||||
|
yao agent test -i scripts.expense.setup --fail-fast
|
||||||
```
|
```
|
||||||
|
|
||||||
### GitHub Actions Example
|
### GitHub Actions Example
|
||||||
|
|
@ -394,6 +509,18 @@ cat results.jsonl | jq 'select(.type == "summary")'
|
||||||
--runs 3 \
|
--runs 3 \
|
||||||
-o report.json
|
-o report.json
|
||||||
|
|
||||||
|
- name: Run Script Tests
|
||||||
|
run: |
|
||||||
|
yao agent test -i scripts.expense.setup -v
|
||||||
|
yao agent test -i scripts.expense.tools -v
|
||||||
|
|
||||||
|
- name: Run Script Tests with Custom Context
|
||||||
|
run: |
|
||||||
|
yao agent test -i scripts.expense.setup \
|
||||||
|
--ctx tests/context.json \
|
||||||
|
--run "TestSystem.*" \
|
||||||
|
-v
|
||||||
|
|
||||||
- name: Check Stability
|
- name: Check Stability
|
||||||
run: |
|
run: |
|
||||||
jq -e '.results | all(.pass_rate >= 80)' report.json
|
jq -e '.results | all(.pass_rate >= 80)' report.json
|
||||||
|
|
@ -401,6 +528,8 @@ cat results.jsonl | jq 'select(.type == "summary")'
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
### Agent Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Quick development test (auto-detect agent)
|
# Quick development test (auto-detect agent)
|
||||||
cd assistants/keyword
|
cd assistants/keyword
|
||||||
|
|
@ -440,6 +569,32 @@ yao agent test -i tests/inputs.jsonl \
|
||||||
-o report.html
|
-o report.html
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Script Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests in a script module
|
||||||
|
yao agent test -i scripts.expense.setup -v
|
||||||
|
|
||||||
|
# Run specific tests with regex filter
|
||||||
|
yao agent test -i scripts.expense.setup --run "TestSystemReady"
|
||||||
|
|
||||||
|
# Run tests matching a pattern
|
||||||
|
yao agent test -i scripts.expense.setup --run "TestSystem.*" -v
|
||||||
|
|
||||||
|
# Run with custom context (authorization, metadata, etc.)
|
||||||
|
yao agent test -i scripts.expense.setup --ctx tests/context.json -v
|
||||||
|
|
||||||
|
# Run with specific user/team
|
||||||
|
yao agent test -i scripts.expense.setup -u admin -t ops-team -v
|
||||||
|
|
||||||
|
# Combine options
|
||||||
|
yao agent test -i scripts.expense.setup \
|
||||||
|
--ctx tests/context.json \
|
||||||
|
--run "TestSystem.*" \
|
||||||
|
--timeout 30s \
|
||||||
|
-v
|
||||||
|
```
|
||||||
|
|
||||||
## Exit Codes
|
## Exit Codes
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
|
|
|
||||||
|
|
@ -255,13 +255,27 @@ func (a *Asserter) assertJSONPath(assertion *Assertion, output interface{}) *Ass
|
||||||
result.Actual = actual
|
result.Actual = actual
|
||||||
|
|
||||||
// Compare expected value with actual value
|
// Compare expected value with actual value
|
||||||
// First, try direct comparison (handles both primitive values and arrays)
|
// If expected is an array, check if actual matches ANY element (IN semantics)
|
||||||
if validateOutput(actual, assertion.Value) {
|
if expectedArr, ok := assertion.Value.([]interface{}); ok {
|
||||||
result.Passed = true
|
// Check if actual is one of the expected values
|
||||||
result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path)
|
for _, expectedItem := range expectedArr {
|
||||||
} else {
|
if validateOutput(actual, expectedItem) {
|
||||||
|
result.Passed = true
|
||||||
|
result.Message = fmt.Sprintf("path '%s' equals one of expected values", assertion.Path)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
result.Passed = false
|
result.Passed = false
|
||||||
result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual)
|
result.Message = fmt.Sprintf("path '%s': expected one of %v, got %v", assertion.Path, assertion.Value, actual)
|
||||||
|
} else {
|
||||||
|
// Direct comparison for non-array expected values
|
||||||
|
if validateOutput(actual, assertion.Value) {
|
||||||
|
result.Passed = true
|
||||||
|
result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path)
|
||||||
|
} else {
|
||||||
|
result.Passed = false
|
||||||
|
result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,7 @@ import (
|
||||||
// but configurable via Environment
|
// but configurable via Environment
|
||||||
func NewTestContext(chatID, assistantID string, env *Environment) *context.Context {
|
func NewTestContext(chatID, assistantID string, env *Environment) *context.Context {
|
||||||
// Build authorized info from environment
|
// Build authorized info from environment
|
||||||
authorized := &types.AuthorizedInfo{
|
authorized := buildAuthorizedInfo(env)
|
||||||
Subject: env.UserID,
|
|
||||||
UserID: env.UserID,
|
|
||||||
TenantID: env.TeamID,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create context with standard initialization
|
// Create context with standard initialization
|
||||||
ctx := context.New(stdContext.Background(), authorized, chatID)
|
ctx := context.New(stdContext.Background(), authorized, chatID)
|
||||||
|
|
@ -34,6 +30,13 @@ func NewTestContext(chatID, assistantID string, env *Environment) *context.Conte
|
||||||
ctx.IDGenerator = message.NewIDGenerator()
|
ctx.IDGenerator = message.NewIDGenerator()
|
||||||
ctx.Metadata = make(map[string]interface{})
|
ctx.Metadata = make(map[string]interface{})
|
||||||
|
|
||||||
|
// Apply metadata from context config if available
|
||||||
|
if env.ContextConfig != nil && env.ContextConfig.Metadata != nil {
|
||||||
|
for k, v := range env.ContextConfig.Metadata {
|
||||||
|
ctx.Metadata[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize interrupt controller
|
// Initialize interrupt controller
|
||||||
ctx.Interrupt = context.NewInterruptController()
|
ctx.Interrupt = context.NewInterruptController()
|
||||||
|
|
||||||
|
|
@ -47,6 +50,56 @@ func NewTestContext(chatID, assistantID string, env *Environment) *context.Conte
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildAuthorizedInfo builds AuthorizedInfo from Environment
|
||||||
|
func buildAuthorizedInfo(env *Environment) *types.AuthorizedInfo {
|
||||||
|
authorized := &types.AuthorizedInfo{
|
||||||
|
Subject: env.UserID,
|
||||||
|
UserID: env.UserID,
|
||||||
|
TenantID: env.TeamID,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply custom authorized config if available
|
||||||
|
if env.ContextConfig != nil && env.ContextConfig.Authorized != nil {
|
||||||
|
authCfg := env.ContextConfig.Authorized
|
||||||
|
|
||||||
|
if authCfg.Sub != "" {
|
||||||
|
authorized.Subject = authCfg.Sub
|
||||||
|
}
|
||||||
|
if authCfg.ClientID != "" {
|
||||||
|
authorized.ClientID = authCfg.ClientID
|
||||||
|
}
|
||||||
|
if authCfg.Scope != "" {
|
||||||
|
authorized.Scope = authCfg.Scope
|
||||||
|
}
|
||||||
|
if authCfg.SessionID != "" {
|
||||||
|
authorized.SessionID = authCfg.SessionID
|
||||||
|
}
|
||||||
|
if authCfg.UserID != "" {
|
||||||
|
authorized.UserID = authCfg.UserID
|
||||||
|
}
|
||||||
|
if authCfg.TeamID != "" {
|
||||||
|
authorized.TeamID = authCfg.TeamID
|
||||||
|
}
|
||||||
|
if authCfg.TenantID != "" {
|
||||||
|
authorized.TenantID = authCfg.TenantID
|
||||||
|
}
|
||||||
|
authorized.RememberMe = authCfg.RememberMe
|
||||||
|
|
||||||
|
// Apply constraints
|
||||||
|
if authCfg.Constraints != nil {
|
||||||
|
authorized.Constraints = types.DataConstraints{
|
||||||
|
OwnerOnly: authCfg.Constraints.OwnerOnly,
|
||||||
|
CreatorOnly: authCfg.Constraints.CreatorOnly,
|
||||||
|
EditorOnly: authCfg.Constraints.EditorOnly,
|
||||||
|
TeamOnly: authCfg.Constraints.TeamOnly,
|
||||||
|
Extra: authCfg.Constraints.Extra,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return authorized
|
||||||
|
}
|
||||||
|
|
||||||
// NewTestContextFromOptions creates a test context from test options and test case
|
// NewTestContextFromOptions creates a test context from test options and test case
|
||||||
func NewTestContextFromOptions(chatID, assistantID string, opts *Options, tc *Case) *context.Context {
|
func NewTestContextFromOptions(chatID, assistantID string, opts *Options, tc *Case) *context.Context {
|
||||||
// Get environment from test case (with options override)
|
// Get environment from test case (with options override)
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,52 @@ func (w *OutputWriter) DirectOutput(output interface{}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ScriptTestSummary prints the script test summary
|
||||||
|
func (w *OutputWriter) ScriptTestSummary(summary *ScriptTestSummary, duration time.Duration) {
|
||||||
|
w.SubHeader("Summary")
|
||||||
|
|
||||||
|
// Results
|
||||||
|
color.New(color.FgWhite).Printf(" Total: ")
|
||||||
|
fmt.Printf("%d\n", summary.Total)
|
||||||
|
|
||||||
|
color.New(color.FgWhite).Printf(" Passed: ")
|
||||||
|
if summary.Passed > 0 {
|
||||||
|
color.New(color.FgGreen).Printf("%d\n", summary.Passed)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%d\n", summary.Passed)
|
||||||
|
}
|
||||||
|
|
||||||
|
color.New(color.FgWhite).Printf(" Failed: ")
|
||||||
|
if summary.Failed > 0 {
|
||||||
|
color.New(color.FgRed).Printf("%d\n", summary.Failed)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%d\n", summary.Failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if summary.Skipped > 0 {
|
||||||
|
color.New(color.FgWhite).Printf(" Skipped: ")
|
||||||
|
color.New(color.FgYellow).Printf("%d\n", summary.Skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass rate
|
||||||
|
passRate := float64(0)
|
||||||
|
if summary.Total > 0 {
|
||||||
|
passRate = float64(summary.Passed) / float64(summary.Total) * 100
|
||||||
|
}
|
||||||
|
color.New(color.FgWhite).Printf(" Pass Rate: ")
|
||||||
|
if passRate == 100 {
|
||||||
|
color.New(color.FgGreen, color.Bold).Printf("%.1f%%\n", passRate)
|
||||||
|
} else if passRate >= 80 {
|
||||||
|
color.New(color.FgYellow).Printf("%.1f%%\n", passRate)
|
||||||
|
} else {
|
||||||
|
color.New(color.FgRed).Printf("%.1f%%\n", passRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration
|
||||||
|
color.New(color.FgWhite).Printf(" Duration: ")
|
||||||
|
fmt.Printf("%s\n", formatDuration(duration))
|
||||||
|
}
|
||||||
|
|
||||||
// StabilityResult prints stability analysis result for a test case
|
// StabilityResult prints stability analysis result for a test case
|
||||||
func (w *OutputWriter) StabilityResult(sr *StabilityResult) {
|
func (w *OutputWriter) StabilityResult(sr *StabilityResult) {
|
||||||
color.New(color.FgWhite).Printf(" [%s] ", sr.ID)
|
color.New(color.FgWhite).Printf(" [%s] ", sr.ID)
|
||||||
|
|
|
||||||
|
|
@ -213,8 +213,16 @@ func DefaultOptions() *Options {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectInputMode detects the input mode from the input string
|
// DetectInputMode detects the input mode from the input string
|
||||||
// Returns InputModeFile if input looks like a file path, InputModeMessage otherwise
|
// Returns:
|
||||||
|
// - InputModeScript: if input starts with "scripts."
|
||||||
|
// - InputModeFile: if input ends with ".jsonl" or is an existing file
|
||||||
|
// - InputModeMessage: otherwise (direct message mode)
|
||||||
func DetectInputMode(input string) InputMode {
|
func DetectInputMode(input string) InputMode {
|
||||||
|
// Check for script test prefix
|
||||||
|
if strings.HasPrefix(input, "scripts.") {
|
||||||
|
return InputModeScript
|
||||||
|
}
|
||||||
|
|
||||||
// If input ends with .jsonl or .json, treat as file
|
// If input ends with .jsonl or .json, treat as file
|
||||||
if strings.HasSuffix(input, ".jsonl") || strings.HasSuffix(input, ".json") {
|
if strings.HasSuffix(input, ".jsonl") || strings.HasSuffix(input, ".json") {
|
||||||
return InputModeFile
|
return InputModeFile
|
||||||
|
|
@ -269,6 +277,12 @@ func MergeOptions(opts *Options, defaults *Options) *Options {
|
||||||
if opts.ReporterID != "" {
|
if opts.ReporterID != "" {
|
||||||
result.ReporterID = opts.ReporterID
|
result.ReporterID = opts.ReporterID
|
||||||
}
|
}
|
||||||
|
if opts.ContextFile != "" {
|
||||||
|
result.ContextFile = opts.ContextFile
|
||||||
|
}
|
||||||
|
if opts.Run != "" {
|
||||||
|
result.Run = opts.Run
|
||||||
|
}
|
||||||
if opts.Verbose {
|
if opts.Verbose {
|
||||||
result.Verbose = opts.Verbose
|
result.Verbose = opts.Verbose
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ func NewRunner(opts *Options) *Executor {
|
||||||
|
|
||||||
// Run executes all test cases and returns a report
|
// Run executes all test cases and returns a report
|
||||||
func (r *Executor) Run() (*Report, error) {
|
func (r *Executor) Run() (*Report, error) {
|
||||||
|
// For script test mode, use script runner
|
||||||
|
if r.opts.InputMode == InputModeScript {
|
||||||
|
return r.RunScriptTests()
|
||||||
|
}
|
||||||
|
|
||||||
// For direct message mode, use simplified output (development mode)
|
// For direct message mode, use simplified output (development mode)
|
||||||
if r.opts.InputMode == InputModeMessage {
|
if r.opts.InputMode == InputModeMessage {
|
||||||
return r.RunDirect()
|
return r.RunDirect()
|
||||||
|
|
@ -41,6 +46,33 @@ func (r *Executor) Run() (*Report, error) {
|
||||||
return r.RunTests()
|
return r.RunTests()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunScriptTests executes script tests and returns a report
|
||||||
|
func (r *Executor) RunScriptTests() (*Report, error) {
|
||||||
|
scriptRunner := NewScriptRunner(r.opts)
|
||||||
|
scriptReport, err := scriptRunner.Run()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to standard report for unified output handling
|
||||||
|
report := scriptReport.ToReport()
|
||||||
|
|
||||||
|
// Write output if specified
|
||||||
|
if r.opts.OutputFile != "" {
|
||||||
|
err = r.writeOutput(report)
|
||||||
|
if err != nil {
|
||||||
|
r.output.Error("Failed to write output: %s", err.Error())
|
||||||
|
} else {
|
||||||
|
r.output.OutputFile(r.opts.OutputFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print final result
|
||||||
|
r.output.FinalResult(!report.HasFailures())
|
||||||
|
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RunDirect executes a single direct message and outputs the result directly
|
// RunDirect executes a single direct message and outputs the result directly
|
||||||
// This is optimized for development/debugging scenarios
|
// This is optimized for development/debugging scenarios
|
||||||
func (r *Executor) RunDirect() (*Report, error) {
|
func (r *Executor) RunDirect() (*Report, error) {
|
||||||
|
|
|
||||||
482
agent/test/script.go
Normal file
482
agent/test/script.go
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
package test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/application"
|
||||||
|
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||||
|
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||||
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
"rogchap.com/v8go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScriptRunner executes script tests
|
||||||
|
type ScriptRunner struct {
|
||||||
|
opts *Options
|
||||||
|
output *OutputWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScriptRunner creates a new script test runner
|
||||||
|
func NewScriptRunner(opts *Options) *ScriptRunner {
|
||||||
|
return &ScriptRunner{
|
||||||
|
opts: opts,
|
||||||
|
output: NewOutputWriter(opts.Verbose),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveScript resolves the script path from scripts.xxx.yyy format
|
||||||
|
func ResolveScript(input string) (*ScriptInfo, error) {
|
||||||
|
// Remove "scripts." prefix
|
||||||
|
path := strings.TrimPrefix(input, "scripts.")
|
||||||
|
|
||||||
|
// Split into parts: "expense.setup" -> ["expense", "setup"]
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return nil, fmt.Errorf("invalid script path: %s (expected format: scripts.assistant.module)", input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build paths
|
||||||
|
// assistantDir: expense
|
||||||
|
// moduleName: setup
|
||||||
|
// scriptPath: expense/src/setup.ts (or assistants/expense/src/setup.ts)
|
||||||
|
// testPath: expense/src/setup_test.ts
|
||||||
|
assistantDir := parts[0]
|
||||||
|
moduleName := parts[1]
|
||||||
|
|
||||||
|
// Try different path patterns
|
||||||
|
basePaths := []string{
|
||||||
|
filepath.Join("assistants", assistantDir, "src"),
|
||||||
|
filepath.Join(assistantDir, "src"),
|
||||||
|
}
|
||||||
|
|
||||||
|
var scriptPath, testPath string
|
||||||
|
for _, basePath := range basePaths {
|
||||||
|
// Check for TypeScript files first, then JavaScript
|
||||||
|
for _, ext := range []string{".ts", ".js"} {
|
||||||
|
candidateScript := filepath.Join(basePath, moduleName+ext)
|
||||||
|
candidateTest := filepath.Join(basePath, moduleName+"_test"+ext)
|
||||||
|
|
||||||
|
// Check if test file exists
|
||||||
|
exists, err := application.App.Exists(candidateTest)
|
||||||
|
if err == nil && exists {
|
||||||
|
scriptPath = candidateScript
|
||||||
|
testPath = candidateTest
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if testPath != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if testPath == "" {
|
||||||
|
return nil, fmt.Errorf("test file not found for %s (tried: %s)", input, strings.Join(basePaths, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ScriptInfo{
|
||||||
|
ID: input,
|
||||||
|
Assistant: assistantDir,
|
||||||
|
Module: moduleName,
|
||||||
|
ScriptPath: scriptPath,
|
||||||
|
TestPath: testPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscoverTests finds all Test* functions in the script
|
||||||
|
func DiscoverTests(scriptPath string) ([]*ScriptTestCase, error) {
|
||||||
|
// Read the script file
|
||||||
|
content, err := application.App.Read(scriptPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read script: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the script to find Test* functions
|
||||||
|
// We use a simple regex-like approach to find function declarations
|
||||||
|
tests := make([]*ScriptTestCase, 0)
|
||||||
|
lines := strings.Split(string(content), "\n")
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
|
// Match function declarations: function TestXxx( or export function TestXxx(
|
||||||
|
if strings.Contains(line, "function Test") {
|
||||||
|
// Extract function name
|
||||||
|
name := extractFunctionName(line)
|
||||||
|
if name != "" && strings.HasPrefix(name, "Test") {
|
||||||
|
tests = append(tests, &ScriptTestCase{
|
||||||
|
Name: name,
|
||||||
|
Function: name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tests, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFunctionName extracts the function name from a line
|
||||||
|
func extractFunctionName(line string) string {
|
||||||
|
// Remove "export" prefix if present
|
||||||
|
line = strings.TrimPrefix(line, "export ")
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
|
// Match "function Name("
|
||||||
|
if !strings.HasPrefix(line, "function ") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
line = strings.TrimPrefix(line, "function ")
|
||||||
|
|
||||||
|
// Find the opening parenthesis
|
||||||
|
idx := strings.Index(line, "(")
|
||||||
|
if idx == -1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(line[:idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterTests filters test cases by a regex pattern (similar to go test -run)
|
||||||
|
func (r *ScriptRunner) filterTests(tests []*ScriptTestCase, pattern string) ([]*ScriptTestCase, error) {
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]*ScriptTestCase, 0)
|
||||||
|
for _, tc := range tests {
|
||||||
|
if re.MatchString(tc.Name) {
|
||||||
|
filtered = append(filtered, tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes all script tests and returns a report
|
||||||
|
func (r *ScriptRunner) Run() (*ScriptTestReport, error) {
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// Resolve script
|
||||||
|
scriptInfo, err := ResolveScript(r.opts.Input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print header
|
||||||
|
r.output.Header("Script Test")
|
||||||
|
r.output.Info("Script: %s", scriptInfo.TestPath)
|
||||||
|
|
||||||
|
// Discover tests
|
||||||
|
tests, err := DiscoverTests(scriptInfo.TestPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter tests by -run pattern if specified
|
||||||
|
if r.opts.Run != "" {
|
||||||
|
tests, err = r.filterTests(tests, r.opts.Run)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid -run pattern: %w", err)
|
||||||
|
}
|
||||||
|
r.output.Info("Tests: %d functions (filtered by: %s)", len(tests), r.opts.Run)
|
||||||
|
} else {
|
||||||
|
r.output.Info("Tests: %d functions", len(tests))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tests) == 0 {
|
||||||
|
r.output.Warning("No tests to run")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load context config if specified
|
||||||
|
var ctxConfig *ContextConfig
|
||||||
|
if r.opts.ContextFile != "" {
|
||||||
|
var err error
|
||||||
|
ctxConfig, err = LoadContextConfig(r.opts.ContextFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load context file: %w", err)
|
||||||
|
}
|
||||||
|
r.output.Info("Context: %s", r.opts.ContextFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create environment with optional context config
|
||||||
|
var env *Environment
|
||||||
|
if ctxConfig != nil {
|
||||||
|
env = NewEnvironmentWithContext(r.opts.UserID, r.opts.TeamID, ctxConfig)
|
||||||
|
} else {
|
||||||
|
env = NewEnvironment(r.opts.UserID, r.opts.TeamID)
|
||||||
|
}
|
||||||
|
r.output.Info("User: %s", env.UserID)
|
||||||
|
r.output.Info("Team: %s", env.TeamID)
|
||||||
|
|
||||||
|
// Load all scripts from src directory (including the test file)
|
||||||
|
// This ensures imports can be resolved properly
|
||||||
|
srcDir := filepath.Dir(scriptInfo.TestPath)
|
||||||
|
loadedCount, err := r.loadAllScripts(srcDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load scripts: %w", err)
|
||||||
|
}
|
||||||
|
r.output.Info("Loaded: %d scripts", loadedCount)
|
||||||
|
|
||||||
|
// Create report
|
||||||
|
report := &ScriptTestReport{
|
||||||
|
Type: "script_test",
|
||||||
|
Script: scriptInfo.ID,
|
||||||
|
ScriptPath: scriptInfo.TestPath,
|
||||||
|
Summary: &ScriptTestSummary{Total: len(tests)},
|
||||||
|
Environment: env,
|
||||||
|
Results: make([]*ScriptTestResult, 0, len(tests)),
|
||||||
|
Metadata: &ScriptTestMetadata{
|
||||||
|
StartedAt: startTime,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run tests
|
||||||
|
r.output.SubHeader("Running Tests")
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
result := r.runScriptTest(tc, scriptInfo, env)
|
||||||
|
report.Results = append(report.Results, result)
|
||||||
|
|
||||||
|
// Update summary
|
||||||
|
switch result.Status {
|
||||||
|
case StatusPassed:
|
||||||
|
report.Summary.Passed++
|
||||||
|
case StatusFailed:
|
||||||
|
report.Summary.Failed++
|
||||||
|
case StatusSkipped:
|
||||||
|
report.Summary.Skipped++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check fail-fast
|
||||||
|
if r.opts.FailFast && result.Status == StatusFailed {
|
||||||
|
r.output.Warning("Stopping due to --fail-fast")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete report
|
||||||
|
report.Summary.DurationMs = time.Since(startTime).Milliseconds()
|
||||||
|
report.Metadata.CompletedAt = time.Now()
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
r.output.ScriptTestSummary(report.Summary, time.Since(startTime))
|
||||||
|
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runScriptTest runs a single script test function
|
||||||
|
func (r *ScriptRunner) runScriptTest(tc *ScriptTestCase, scriptInfo *ScriptInfo, env *Environment) *ScriptTestResult {
|
||||||
|
r.output.TestStart(tc.Name, "", 1)
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
result := &ScriptTestResult{
|
||||||
|
Name: tc.Name,
|
||||||
|
Status: StatusPassed,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create testing.T object
|
||||||
|
testingT := NewTestingT(tc.Name)
|
||||||
|
|
||||||
|
// Create agent context
|
||||||
|
chatID := fmt.Sprintf("script-test-%s", tc.Name)
|
||||||
|
agentCtx := NewTestContext(chatID, scriptInfo.Assistant, env)
|
||||||
|
defer agentCtx.Release()
|
||||||
|
|
||||||
|
// Execute the test function
|
||||||
|
err := r.executeTestFunction(tc, scriptInfo, testingT, agentCtx)
|
||||||
|
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
result.DurationMs = duration.Milliseconds()
|
||||||
|
result.Logs = testingT.Logs()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
result.Status = StatusError
|
||||||
|
result.Error = err.Error()
|
||||||
|
r.output.TestResult(result.Status, duration)
|
||||||
|
r.output.TestError(result.Error)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if testingT.Skipped() {
|
||||||
|
result.Status = StatusSkipped
|
||||||
|
r.output.TestResult(result.Status, duration)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if testingT.Failed() {
|
||||||
|
result.Status = StatusFailed
|
||||||
|
errors := testingT.Errors()
|
||||||
|
if len(errors) > 0 {
|
||||||
|
result.Error = errors[0]
|
||||||
|
}
|
||||||
|
result.Assertion = testingT.AssertionInfo()
|
||||||
|
r.output.TestResult(result.Status, duration)
|
||||||
|
r.output.TestError(result.Error)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
r.output.TestResult(result.Status, duration)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAllScripts loads all scripts from the src directory
|
||||||
|
// This ensures that imports can be resolved properly
|
||||||
|
func (r *ScriptRunner) loadAllScripts(srcDir string) (int, error) {
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
// Check if src directory exists
|
||||||
|
exists, err := application.App.Exists(srcDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
return 0, fmt.Errorf("src directory not found: %s", srcDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk through src directory to find all script files
|
||||||
|
exts := []string{"*.ts", "*.js"}
|
||||||
|
|
||||||
|
err = application.App.Walk(srcDir, func(root, file string, isdir bool) error {
|
||||||
|
if isdir {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path
|
||||||
|
relPath := strings.TrimPrefix(file, root+"/")
|
||||||
|
|
||||||
|
// Generate script ID from file path
|
||||||
|
scriptID := generateTestScriptID(file, root)
|
||||||
|
|
||||||
|
// Load the script
|
||||||
|
_, err := v8.Load(file, scriptID)
|
||||||
|
if err != nil {
|
||||||
|
// Log warning but continue loading other scripts
|
||||||
|
if r.opts.Verbose {
|
||||||
|
r.output.Warning("Failed to load %s: %v", relPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
count++
|
||||||
|
if r.opts.Verbose {
|
||||||
|
r.output.Verbose("Loaded: %s", relPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}, exts...)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return count, fmt.Errorf("failed to walk src directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateTestScriptID generates a script ID from file path for testing
|
||||||
|
func generateTestScriptID(filePath string, srcDir string) string {
|
||||||
|
// Normalize path separators
|
||||||
|
filePath = filepath.ToSlash(filePath)
|
||||||
|
srcDir = filepath.ToSlash(srcDir)
|
||||||
|
|
||||||
|
// Remove src directory prefix
|
||||||
|
relPath := strings.TrimPrefix(filePath, srcDir+"/")
|
||||||
|
relPath = strings.TrimPrefix(relPath, "/")
|
||||||
|
|
||||||
|
// Remove file extension
|
||||||
|
relPath = strings.TrimSuffix(relPath, filepath.Ext(relPath))
|
||||||
|
|
||||||
|
// Replace path separators with dots and add test prefix
|
||||||
|
scriptID := "test." + strings.ReplaceAll(relPath, "/", ".")
|
||||||
|
|
||||||
|
return scriptID
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeTestFunction executes a single test function using V8
|
||||||
|
func (r *ScriptRunner) executeTestFunction(tc *ScriptTestCase, scriptInfo *ScriptInfo, testingT *TestingT, agentCtx *context.Context) error {
|
||||||
|
// Get the test script (already loaded by loadAllScripts)
|
||||||
|
testScriptID := generateTestScriptID(scriptInfo.TestPath, filepath.Dir(scriptInfo.TestPath))
|
||||||
|
script, ok := v8.Scripts[testScriptID]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("test script not found: %s (id: %s)", scriptInfo.TestPath, testScriptID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new script context
|
||||||
|
scriptCtx, err := script.NewContext("", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create script context: %w", err)
|
||||||
|
}
|
||||||
|
defer scriptCtx.Close()
|
||||||
|
|
||||||
|
// Get the V8 context
|
||||||
|
v8ctx := scriptCtx.Context
|
||||||
|
|
||||||
|
// Create testing.T JavaScript object
|
||||||
|
testingTObj, err := NewTestingTObject(v8ctx, testingT)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create testing.T object: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create agent context JavaScript object
|
||||||
|
agentCtxObj, err := agentCtx.JsValue(v8ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create agent context object: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the test function
|
||||||
|
global := v8ctx.Global()
|
||||||
|
fnValue, err := global.Get(tc.Function)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get test function %s: %w", tc.Function, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fnValue.IsFunction() {
|
||||||
|
return fmt.Errorf("test function %s is not a function", tc.Function)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn, err := fnValue.AsFunction()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to convert to function: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call the test function with (t, ctx)
|
||||||
|
_, err = fn.Call(global, testingTObj, agentCtxObj)
|
||||||
|
if err != nil {
|
||||||
|
// Check if this is an assertion failure or a real error
|
||||||
|
if testingT.Failed() {
|
||||||
|
// Assertion failure - already recorded
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("test function error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterTestingGlobals registers testing-related global functions for V8
|
||||||
|
// This is called once during initialization
|
||||||
|
func RegisterTestingGlobals() {
|
||||||
|
v8.RegisterFunction("__testing_log", testingLogEmbed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testingLogEmbed provides a console.log-like function for tests
|
||||||
|
func testingLogEmbed(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||||
|
return v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
args := info.Args()
|
||||||
|
parts := make([]string, len(args))
|
||||||
|
for i, arg := range args {
|
||||||
|
goVal, err := bridge.GoValue(arg, info.Context())
|
||||||
|
if err != nil {
|
||||||
|
parts[i] = arg.String()
|
||||||
|
} else {
|
||||||
|
parts[i] = fmt.Sprintf("%v", goVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println(strings.Join(parts, " "))
|
||||||
|
return v8go.Undefined(iso)
|
||||||
|
})
|
||||||
|
}
|
||||||
1000
agent/test/script_assert.go
Normal file
1000
agent/test/script_assert.go
Normal file
File diff suppressed because it is too large
Load diff
170
agent/test/script_types.go
Normal file
170
agent/test/script_types.go
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
package test
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ScriptInfo contains information about the script being tested
|
||||||
|
type ScriptInfo struct {
|
||||||
|
// ID is the script identifier (e.g., "scripts.expense.setup")
|
||||||
|
ID string `json:"id"`
|
||||||
|
|
||||||
|
// Assistant is the assistant directory name (e.g., "expense")
|
||||||
|
Assistant string `json:"assistant"`
|
||||||
|
|
||||||
|
// Module is the module name (e.g., "setup")
|
||||||
|
Module string `json:"module"`
|
||||||
|
|
||||||
|
// ScriptPath is the path to the main script file (e.g., "expense/src/setup.ts")
|
||||||
|
ScriptPath string `json:"script_path"`
|
||||||
|
|
||||||
|
// TestPath is the path to the test script file (e.g., "expense/src/setup_test.ts")
|
||||||
|
TestPath string `json:"test_path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptTestCase represents a single script test function
|
||||||
|
type ScriptTestCase struct {
|
||||||
|
// Name is the test function name (e.g., "TestSystemReady")
|
||||||
|
Name string `json:"name"`
|
||||||
|
|
||||||
|
// Function is the full function reference
|
||||||
|
Function string `json:"function"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptTestResult represents the result of running a script test function
|
||||||
|
type ScriptTestResult struct {
|
||||||
|
// Name is the test function name
|
||||||
|
Name string `json:"name"`
|
||||||
|
|
||||||
|
// Status is the test execution status
|
||||||
|
Status Status `json:"status"`
|
||||||
|
|
||||||
|
// DurationMs is the execution duration in milliseconds
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
|
||||||
|
// Error contains the error message if the test failed
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
|
||||||
|
// Assertion contains assertion failure details
|
||||||
|
Assertion *ScriptAssertionInfo `json:"assertion,omitempty"`
|
||||||
|
|
||||||
|
// Logs contains log messages from the test
|
||||||
|
Logs []string `json:"logs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptAssertionInfo contains details about an assertion failure
|
||||||
|
type ScriptAssertionInfo struct {
|
||||||
|
// Type is the assertion type (e.g., "Equal", "True")
|
||||||
|
Type string `json:"type"`
|
||||||
|
|
||||||
|
// Expected is the expected value
|
||||||
|
Expected interface{} `json:"expected,omitempty"`
|
||||||
|
|
||||||
|
// Actual is the actual value
|
||||||
|
Actual interface{} `json:"actual,omitempty"`
|
||||||
|
|
||||||
|
// Message is the custom failure message
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptTestSummary contains aggregated statistics for script tests
|
||||||
|
type ScriptTestSummary struct {
|
||||||
|
// Total number of test functions
|
||||||
|
Total int `json:"total"`
|
||||||
|
|
||||||
|
// Passed number of test functions that passed
|
||||||
|
Passed int `json:"passed"`
|
||||||
|
|
||||||
|
// Failed number of test functions that failed
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
|
||||||
|
// Skipped number of test functions that were skipped
|
||||||
|
Skipped int `json:"skipped"`
|
||||||
|
|
||||||
|
// DurationMs is the total execution duration in milliseconds
|
||||||
|
DurationMs int64 `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptTestReport represents the complete script test report
|
||||||
|
type ScriptTestReport struct {
|
||||||
|
// Type indicates this is a script test report
|
||||||
|
Type string `json:"type"` // "script_test"
|
||||||
|
|
||||||
|
// Script is the script identifier (e.g., "scripts.expense.setup")
|
||||||
|
Script string `json:"script"`
|
||||||
|
|
||||||
|
// ScriptPath is the path to the test script file
|
||||||
|
ScriptPath string `json:"script_path"`
|
||||||
|
|
||||||
|
// Summary contains aggregated statistics
|
||||||
|
Summary *ScriptTestSummary `json:"summary"`
|
||||||
|
|
||||||
|
// Environment contains the test environment configuration
|
||||||
|
Environment *Environment `json:"environment"`
|
||||||
|
|
||||||
|
// Results contains individual test results
|
||||||
|
Results []*ScriptTestResult `json:"results"`
|
||||||
|
|
||||||
|
// Metadata contains additional report metadata
|
||||||
|
Metadata *ScriptTestMetadata `json:"metadata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScriptTestMetadata contains metadata about the script test report
|
||||||
|
type ScriptTestMetadata struct {
|
||||||
|
// StartedAt is when the test run started
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
|
||||||
|
// CompletedAt is when the test run completed
|
||||||
|
CompletedAt time.Time `json:"completed_at"`
|
||||||
|
|
||||||
|
// Version is the Yao version
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasFailures returns true if there are any failed tests
|
||||||
|
func (r *ScriptTestReport) HasFailures() bool {
|
||||||
|
return r.Summary.Failed > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// PassRate returns the pass rate as a percentage (0-100)
|
||||||
|
func (r *ScriptTestReport) PassRate() float64 {
|
||||||
|
if r.Summary.Total == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(r.Summary.Passed) / float64(r.Summary.Total) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToReport converts ScriptTestReport to a standard Report for unified reporting
|
||||||
|
func (r *ScriptTestReport) ToReport() *Report {
|
||||||
|
return &Report{
|
||||||
|
Summary: &Summary{
|
||||||
|
Total: r.Summary.Total,
|
||||||
|
Passed: r.Summary.Passed,
|
||||||
|
Failed: r.Summary.Failed,
|
||||||
|
Skipped: r.Summary.Skipped,
|
||||||
|
DurationMs: r.Summary.DurationMs,
|
||||||
|
AgentID: r.Script,
|
||||||
|
AgentPath: r.ScriptPath,
|
||||||
|
},
|
||||||
|
Environment: r.Environment,
|
||||||
|
Results: r.toResults(),
|
||||||
|
Metadata: &ReportMetadata{
|
||||||
|
StartedAt: r.Metadata.StartedAt,
|
||||||
|
CompletedAt: r.Metadata.CompletedAt,
|
||||||
|
Version: r.Metadata.Version,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toResults converts script test results to standard results
|
||||||
|
func (r *ScriptTestReport) toResults() []*Result {
|
||||||
|
results := make([]*Result, len(r.Results))
|
||||||
|
for i, sr := range r.Results {
|
||||||
|
results[i] = &Result{
|
||||||
|
ID: sr.Name,
|
||||||
|
Status: sr.Status,
|
||||||
|
Input: sr.Name,
|
||||||
|
DurationMs: sr.DurationMs,
|
||||||
|
Error: sr.Error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package test
|
package test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
|
@ -57,6 +60,8 @@ const (
|
||||||
InputModeFile InputMode = "file"
|
InputModeFile InputMode = "file"
|
||||||
// InputModeMessage indicates input from a direct message string
|
// InputModeMessage indicates input from a direct message string
|
||||||
InputModeMessage InputMode = "message"
|
InputModeMessage InputMode = "message"
|
||||||
|
// InputModeScript indicates script test mode (testing agent handler scripts)
|
||||||
|
InputModeScript InputMode = "script"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options represents the configuration options for running tests
|
// Options represents the configuration options for running tests
|
||||||
|
|
@ -96,6 +101,14 @@ type Options struct {
|
||||||
// Locale is the locale for the test context (default: "en-us")
|
// Locale is the locale for the test context (default: "en-us")
|
||||||
Locale string `json:"locale,omitempty"`
|
Locale string `json:"locale,omitempty"`
|
||||||
|
|
||||||
|
// ContextFile is the path to a JSON file containing custom context data (-ctx flag)
|
||||||
|
// This allows full customization of authorized info, metadata, etc.
|
||||||
|
ContextFile string `json:"context_file,omitempty"`
|
||||||
|
|
||||||
|
// ContextData is the parsed context data from ContextFile
|
||||||
|
// This is populated internally after loading the file
|
||||||
|
ContextData *ContextConfig `json:"-"`
|
||||||
|
|
||||||
// Execution
|
// Execution
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
||||||
|
|
@ -126,6 +139,92 @@ type Options struct {
|
||||||
|
|
||||||
// FailFast stops execution on first failure
|
// FailFast stops execution on first failure
|
||||||
FailFast bool `json:"fail_fast,omitempty"`
|
FailFast bool `json:"fail_fast,omitempty"`
|
||||||
|
|
||||||
|
// Run is a regex pattern to filter which tests to run (similar to go test -run)
|
||||||
|
// Only tests matching the pattern will be executed
|
||||||
|
// Example: "TestSystem" matches TestSystemReady, TestSystemError, etc.
|
||||||
|
Run string `json:"run,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextConfig represents custom context configuration from JSON file
|
||||||
|
// This allows full customization of the test context including authorized info
|
||||||
|
type ContextConfig struct {
|
||||||
|
// Authorized contains custom authorization data
|
||||||
|
Authorized *AuthorizedConfig `json:"authorized,omitempty"`
|
||||||
|
|
||||||
|
// Metadata contains custom metadata to pass to the context
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
|
||||||
|
// Client contains custom client information
|
||||||
|
Client *ClientConfig `json:"client,omitempty"`
|
||||||
|
|
||||||
|
// Locale overrides the locale setting
|
||||||
|
Locale string `json:"locale,omitempty"`
|
||||||
|
|
||||||
|
// Referer overrides the referer setting
|
||||||
|
Referer string `json:"referer,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizedConfig represents custom authorization configuration
|
||||||
|
// Matches the structure of types.AuthorizedInfo from openapi/oauth/types
|
||||||
|
type AuthorizedConfig struct {
|
||||||
|
// Sub is the subject identifier (JWT sub claim)
|
||||||
|
Sub string `json:"sub,omitempty"`
|
||||||
|
|
||||||
|
// ClientID is the OAuth client ID
|
||||||
|
ClientID string `json:"client_id,omitempty"`
|
||||||
|
|
||||||
|
// Scope is the access scope
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
|
||||||
|
// SessionID is the session identifier
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
|
|
||||||
|
// UserID is the user identifier
|
||||||
|
UserID string `json:"user_id,omitempty"`
|
||||||
|
|
||||||
|
// TeamID is the team identifier
|
||||||
|
TeamID string `json:"team_id,omitempty"`
|
||||||
|
|
||||||
|
// TenantID is the tenant identifier
|
||||||
|
TenantID string `json:"tenant_id,omitempty"`
|
||||||
|
|
||||||
|
// RememberMe is the remember me flag
|
||||||
|
RememberMe bool `json:"remember_me,omitempty"`
|
||||||
|
|
||||||
|
// Constraints contains data access constraints (set by ACL enforcement)
|
||||||
|
Constraints *DataConstraintsConfig `json:"constraints,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataConstraintsConfig represents data access constraints
|
||||||
|
// Matches the structure of types.DataConstraints from openapi/oauth/types
|
||||||
|
type DataConstraintsConfig struct {
|
||||||
|
// OwnerOnly - only access owner's data
|
||||||
|
OwnerOnly bool `json:"owner_only,omitempty"`
|
||||||
|
|
||||||
|
// CreatorOnly - only access creator's data
|
||||||
|
CreatorOnly bool `json:"creator_only,omitempty"`
|
||||||
|
|
||||||
|
// EditorOnly - only access editor's data
|
||||||
|
EditorOnly bool `json:"editor_only,omitempty"`
|
||||||
|
|
||||||
|
// TeamOnly - only access team's data (filter by team_id)
|
||||||
|
TeamOnly bool `json:"team_only,omitempty"`
|
||||||
|
|
||||||
|
// Extra contains user-defined constraints (department, region, etc.)
|
||||||
|
Extra map[string]interface{} `json:"extra,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientConfig represents custom client configuration
|
||||||
|
type ClientConfig struct {
|
||||||
|
// Type is the client type (e.g., "web", "mobile", "test")
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
|
||||||
|
// UserAgent is the client user agent string
|
||||||
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
|
|
||||||
|
// IP is the client IP address
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Environment configures the test execution context
|
// Environment configures the test execution context
|
||||||
|
|
@ -150,6 +249,9 @@ type Environment struct {
|
||||||
|
|
||||||
// Accept is the accept format (default: "standard")
|
// Accept is the accept format (default: "standard")
|
||||||
Accept string `json:"accept"`
|
Accept string `json:"accept"`
|
||||||
|
|
||||||
|
// ContextConfig contains custom context configuration (from -ctx flag)
|
||||||
|
ContextConfig *ContextConfig `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEnvironment creates a new test environment with defaults
|
// NewEnvironment creates a new test environment with defaults
|
||||||
|
|
@ -175,6 +277,61 @@ func NewEnvironment(userID, teamID string) *Environment {
|
||||||
return env
|
return env
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewEnvironmentWithContext creates a new test environment with custom context config
|
||||||
|
func NewEnvironmentWithContext(userID, teamID string, ctxConfig *ContextConfig) *Environment {
|
||||||
|
env := NewEnvironment(userID, teamID)
|
||||||
|
|
||||||
|
if ctxConfig == nil {
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
env.ContextConfig = ctxConfig
|
||||||
|
|
||||||
|
// Override with context config values
|
||||||
|
if ctxConfig.Locale != "" {
|
||||||
|
env.Locale = ctxConfig.Locale
|
||||||
|
}
|
||||||
|
if ctxConfig.Referer != "" {
|
||||||
|
env.Referer = ctxConfig.Referer
|
||||||
|
}
|
||||||
|
if ctxConfig.Client != nil {
|
||||||
|
if ctxConfig.Client.Type != "" {
|
||||||
|
env.ClientType = ctxConfig.Client.Type
|
||||||
|
}
|
||||||
|
if ctxConfig.Client.IP != "" {
|
||||||
|
env.ClientIP = ctxConfig.Client.IP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ctxConfig.Authorized != nil {
|
||||||
|
if ctxConfig.Authorized.UserID != "" {
|
||||||
|
env.UserID = ctxConfig.Authorized.UserID
|
||||||
|
}
|
||||||
|
// TeamID takes precedence over TenantID for team override
|
||||||
|
if ctxConfig.Authorized.TeamID != "" {
|
||||||
|
env.TeamID = ctxConfig.Authorized.TeamID
|
||||||
|
} else if ctxConfig.Authorized.TenantID != "" {
|
||||||
|
env.TeamID = ctxConfig.Authorized.TenantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadContextConfig loads context configuration from a JSON file
|
||||||
|
func LoadContextConfig(filePath string) (*ContextConfig, error) {
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read context file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var config ContextConfig
|
||||||
|
if err := json.Unmarshal(data, &config); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse context file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Case represents a single test case loaded from JSONL
|
// Case represents a single test case loaded from JSONL
|
||||||
type Case struct {
|
type Case struct {
|
||||||
// ID is the unique identifier for this test case (e.g., "T001")
|
// ID is the unique identifier for this test case (e.g., "T001")
|
||||||
|
|
|
||||||
177
cmd/README.md
177
cmd/README.md
|
|
@ -14,17 +14,17 @@ go install github.com/yaoapp/yao@latest
|
||||||
|
|
||||||
## Global Flags
|
## Global Flags
|
||||||
|
|
||||||
| Flag | Short | Description |
|
| Flag | Short | Description |
|
||||||
|------|-------|-------------|
|
| -------- | ----- | ------------------------------- |
|
||||||
| `--app` | `-a` | Application directory path |
|
| `--app` | `-a` | Application directory path |
|
||||||
| `--file` | `-f` | Application package file (.yaz) |
|
| `--file` | `-f` | Application package file (.yaz) |
|
||||||
| `--key` | `-k` | Application license key |
|
| `--key` | `-k` | Application license key |
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|----------|-------------|
|
| ---------- | -------------------------------------------- |
|
||||||
| `YAO_ROOT` | Application root directory |
|
| `YAO_ROOT` | Application root directory |
|
||||||
| `YAO_LANG` | Language setting (e.g., `zh-CN` for Chinese) |
|
| `YAO_LANG` | Language setting (e.g., `zh-CN` for Chinese) |
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
@ -46,10 +46,10 @@ yao start --debug
|
||||||
|
|
||||||
**Flags:**
|
**Flags:**
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|------|-------------|
|
| -------------------- | ----------------------------- |
|
||||||
| `--debug` | Enable development/debug mode |
|
| `--debug` | Enable development/debug mode |
|
||||||
| `--disable-watching` | Disable file watching |
|
| `--disable-watching` | Disable file watching |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -70,9 +70,9 @@ yao run -s models.user.Find 1
|
||||||
|
|
||||||
**Flags:**
|
**Flags:**
|
||||||
|
|
||||||
| Flag | Short | Description |
|
| Flag | Short | Description |
|
||||||
|------|-------|-------------|
|
| ---------- | ----- | ---------------------------------------- |
|
||||||
| `--silent` | `-s` | Silent mode - output result as JSON only |
|
| `--silent` | `-s` | Silent mode - output result as JSON only |
|
||||||
|
|
||||||
**Argument Syntax:**
|
**Argument Syntax:**
|
||||||
|
|
||||||
|
|
@ -102,11 +102,11 @@ yao migrate --reset
|
||||||
|
|
||||||
**Flags:**
|
**Flags:**
|
||||||
|
|
||||||
| Flag | Short | Description |
|
| Flag | Short | Description |
|
||||||
|------|-------|-------------|
|
| --------- | ----- | -------------------------------- |
|
||||||
| `--name` | `-n` | Specific model name to migrate |
|
| `--name` | `-n` | Specific model name to migrate |
|
||||||
| `--force` | | Force migrate in production mode |
|
| `--force` | | Force migrate in production mode |
|
||||||
| `--reset` | | Drop tables before migration |
|
| `--reset` | | Drop tables before migration |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -134,8 +134,8 @@ yao version --all
|
||||||
|
|
||||||
**Flags:**
|
**Flags:**
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
|------|-------------|
|
| ------- | -------------------------------------------------------------------- |
|
||||||
| `--all` | Print all version information (Go version, commit, build time, etc.) |
|
| `--all` | Print all version information (Go version, commit, build time, etc.) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -146,7 +146,7 @@ Commands for testing and managing AI agents.
|
||||||
|
|
||||||
### `yao agent test`
|
### `yao agent test`
|
||||||
|
|
||||||
Test an agent with input cases from a JSONL file or direct message.
|
Test an agent with input cases from a JSONL file, direct message, or script tests.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Test with direct message (development mode)
|
# Test with direct message (development mode)
|
||||||
|
|
@ -169,45 +169,104 @@ yao agent test -i tests/inputs.jsonl --parallel 4
|
||||||
|
|
||||||
# Verbose output
|
# Verbose output
|
||||||
yao agent test -i tests/inputs.jsonl -v
|
yao agent test -i tests/inputs.jsonl -v
|
||||||
|
|
||||||
|
# Script tests (test agent handler scripts)
|
||||||
|
yao agent test -i scripts.expense.setup -v
|
||||||
|
|
||||||
|
# Script tests with test filtering
|
||||||
|
yao agent test -i scripts.expense.setup --run "TestSystemReady"
|
||||||
|
|
||||||
|
# Script tests with custom context
|
||||||
|
yao agent test -i scripts.expense.setup --ctx tests/context.json -v
|
||||||
```
|
```
|
||||||
|
|
||||||
**Flags:**
|
**Flags:**
|
||||||
|
|
||||||
| Flag | Short | Description |
|
| Flag | Short | Description |
|
||||||
|------|-------|-------------|
|
| ------------- | ----- | ---------------------------------------------------------------- |
|
||||||
| `--input` | `-i` | Input: JSONL file path or direct message (required) |
|
| `--input` | `-i` | Input: JSONL file path, message, or script ID (required) |
|
||||||
| `--output` | `-o` | Output file path (default: `output-{timestamp}.jsonl`) |
|
| `--output` | `-o` | Output file path (default: `output-{timestamp}.jsonl`) |
|
||||||
| `--name` | `-n` | Agent ID (default: auto-detect from path) |
|
| `--name` | `-n` | Agent ID (default: auto-detect from path) |
|
||||||
| `--connector` | `-c` | Override default connector |
|
| `--connector` | `-c` | Override default connector |
|
||||||
| `--user` | `-u` | Test user ID (default: `test-user`) |
|
| `--user` | `-u` | Test user ID (default: `test-user`) |
|
||||||
| `--team` | `-t` | Test team ID (default: `test-team`) |
|
| `--team` | `-t` | Test team ID (default: `test-team`) |
|
||||||
| `--reporter` | `-r` | Reporter agent ID for custom report generation |
|
| `--ctx` | | Path to context JSON file for custom authorization |
|
||||||
| `--runs` | | Number of runs per test case for stability analysis (default: 1) |
|
| `--reporter` | `-r` | Reporter agent ID for custom report generation |
|
||||||
| `--timeout` | | Timeout per test case (default: `5m`) |
|
| `--runs` | | Number of runs per test case for stability analysis (default: 1) |
|
||||||
| `--parallel` | | Number of parallel test cases (default: 1) |
|
| `--run` | | Regex pattern to filter which tests to run |
|
||||||
| `--verbose` | `-v` | Enable verbose output |
|
| `--timeout` | | Timeout per test case (default: `5m`) |
|
||||||
| `--fail-fast` | | Stop on first failure |
|
| `--parallel` | | Number of parallel test cases (default: 1) |
|
||||||
| `--app` | `-a` | Application directory |
|
| `--verbose` | `-v` | Enable verbose output |
|
||||||
| `--env` | `-e` | Environment file |
|
| `--fail-fast` | | Stop on first failure |
|
||||||
|
| `--app` | `-a` | Application directory |
|
||||||
|
| `--env` | `-e` | Environment file |
|
||||||
|
|
||||||
**Input Modes:**
|
**Input Modes:**
|
||||||
|
|
||||||
1. **Direct Message Mode**: For quick development/debugging
|
1. **Direct Message Mode**: For quick development/debugging
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yao agent test -i "Hello world" -n my.agent
|
yao agent test -i "Hello world" -n my.agent
|
||||||
```
|
```
|
||||||
|
|
||||||
- Outputs result directly to stdout
|
- Outputs result directly to stdout
|
||||||
- No report file generated
|
- No report file generated
|
||||||
- Ideal for iterative development
|
- Ideal for iterative development
|
||||||
|
|
||||||
2. **File Mode**: For comprehensive testing
|
2. **File Mode**: For comprehensive testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yao agent test -i tests/inputs.jsonl
|
yao agent test -i tests/inputs.jsonl
|
||||||
```
|
```
|
||||||
|
|
||||||
- Reads test cases from JSONL file
|
- Reads test cases from JSONL file
|
||||||
- Generates detailed report
|
- Generates detailed report
|
||||||
- Supports stability analysis
|
- Supports stability analysis
|
||||||
|
|
||||||
|
3. **Script Test Mode**: For testing agent handler scripts
|
||||||
|
```bash
|
||||||
|
yao agent test -i scripts.expense.setup -v
|
||||||
|
```
|
||||||
|
- Tests TypeScript/JavaScript handler scripts (hooks, tools, setup functions)
|
||||||
|
- Input format: `scripts.<assistant>.<module>` (e.g., `scripts.expense.setup`)
|
||||||
|
- Automatically discovers and runs all `Test*` functions
|
||||||
|
- Uses Go-like testing interface with assertions
|
||||||
|
|
||||||
|
**Script Test Function Signature:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// assistants/expense/src/setup_test.ts
|
||||||
|
import { SystemReady } from "./setup";
|
||||||
|
|
||||||
|
export function TestSystemReady(t: testing.T, ctx: agent.Context) {
|
||||||
|
const result = SystemReady(ctx);
|
||||||
|
t.assert.True(result.success, "SystemReady should succeed");
|
||||||
|
t.assert.Equal(result.status, "ready", "Status should be ready");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Context JSON Format (for `--ctx` flag):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"authorized": {
|
||||||
|
"sub": "user-12345",
|
||||||
|
"client_id": "my-app",
|
||||||
|
"user_id": "admin",
|
||||||
|
"team_id": "team-001",
|
||||||
|
"tenant_id": "acme-corp",
|
||||||
|
"constraints": {
|
||||||
|
"owner_only": true,
|
||||||
|
"team_only": false,
|
||||||
|
"extra": { "department": "engineering" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": { "request_id": "req-123" },
|
||||||
|
"client": { "type": "web", "ip": "192.168.1.100" },
|
||||||
|
"locale": "zh-cn"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**JSONL Input Format:**
|
**JSONL Input Format:**
|
||||||
|
|
||||||
```jsonl
|
```jsonl
|
||||||
|
|
@ -221,12 +280,12 @@ yao agent test -i tests/inputs.jsonl -v
|
||||||
|
|
||||||
**Output Formats:**
|
**Output Formats:**
|
||||||
|
|
||||||
| Extension | Format | Description |
|
| Extension | Format | Description |
|
||||||
|-----------|--------|-------------|
|
| --------- | -------- | -------------------------- |
|
||||||
| `.jsonl` | JSONL | Streaming format (default) |
|
| `.jsonl` | JSONL | Streaming format (default) |
|
||||||
| `.json` | JSON | Complete structured report |
|
| `.json` | JSON | Complete structured report |
|
||||||
| `.md` | Markdown | Human-readable with tables |
|
| `.md` | Markdown | Human-readable with tables |
|
||||||
| `.html` | HTML | Interactive web report |
|
| `.html` | HTML | Interactive web report |
|
||||||
|
|
||||||
**Agent Resolution:**
|
**Agent Resolution:**
|
||||||
|
|
||||||
|
|
@ -281,11 +340,11 @@ yao sui trans default index -l "en-US,zh-CN,ja-JP"
|
||||||
|
|
||||||
**SUI Flags:**
|
**SUI Flags:**
|
||||||
|
|
||||||
| Flag | Short | Description |
|
| Flag | Short | Description |
|
||||||
|------|-------|-------------|
|
| ----------- | ----- | ----------------------------------------- |
|
||||||
| `--data` | `-d` | Session data as JSON (prefix with `::`) |
|
| `--data` | `-d` | Session data as JSON (prefix with `::`) |
|
||||||
| `--debug` | `-D` | Enable debug mode |
|
| `--debug` | `-D` | Enable debug mode |
|
||||||
| `--locales` | `-l` | Locales for translation (comma-separated) |
|
| `--locales` | `-l` | Locales for translation (comma-separated) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -313,6 +372,15 @@ yao sui watch default home
|
||||||
# Run comprehensive agent tests
|
# Run comprehensive agent tests
|
||||||
yao agent test -i tests/inputs.jsonl -o report.html -v
|
yao agent test -i tests/inputs.jsonl -o report.html -v
|
||||||
|
|
||||||
|
# Run script tests for agent handlers
|
||||||
|
yao agent test -i scripts.expense.setup -v
|
||||||
|
|
||||||
|
# Run specific script tests with filtering
|
||||||
|
yao agent test -i scripts.expense.setup --run "TestSystem.*" -v
|
||||||
|
|
||||||
|
# Run script tests with custom context
|
||||||
|
yao agent test -i scripts.expense.setup --ctx tests/context.json -v
|
||||||
|
|
||||||
# Stability analysis (run each test 10 times)
|
# Stability analysis (run each test 10 times)
|
||||||
yao agent test -i tests/inputs.jsonl --runs 10 -o stability-report.json
|
yao agent test -i tests/inputs.jsonl --runs 10 -o stability-report.json
|
||||||
|
|
||||||
|
|
@ -337,10 +405,10 @@ yao migrate -n user --reset --force
|
||||||
|
|
||||||
## Exit Codes
|
## Exit Codes
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
|------|-------------|
|
| ---- | --------------------- |
|
||||||
| 0 | Success |
|
| 0 | Success |
|
||||||
| 1 | Error or test failure |
|
| 1 | Error or test failure |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -370,4 +438,3 @@ myapp/
|
||||||
- [Yao Documentation](https://yaoapps.com/docs)
|
- [Yao Documentation](https://yaoapps.com/docs)
|
||||||
- [Agent Test Design](../agent/test/DESIGN.md)
|
- [Agent Test Design](../agent/test/DESIGN.md)
|
||||||
- [SUI Documentation](https://yaoapps.com/docs/sui)
|
- [SUI Documentation](https://yaoapps.com/docs/sui)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,10 @@ var langs = map[string]string{
|
||||||
"Override connector": "覆盖连接器",
|
"Override connector": "覆盖连接器",
|
||||||
"Test user ID (default: test-user)": "测试用户 ID (默认: test-user)",
|
"Test user ID (default: test-user)": "测试用户 ID (默认: test-user)",
|
||||||
"Test team ID (default: test-team)": "测试团队 ID (默认: test-team)",
|
"Test team ID (default: test-team)": "测试团队 ID (默认: test-team)",
|
||||||
|
"Path to context JSON file for custom authorization": "自定义认证信息的 JSON 文件路径",
|
||||||
"Reporter agent ID for custom report": "自定义报告生成器智能体 ID",
|
"Reporter agent ID for custom report": "自定义报告生成器智能体 ID",
|
||||||
"Number of runs for stability analysis": "稳定性分析的运行次数",
|
"Number of runs for stability analysis": "稳定性分析的运行次数",
|
||||||
|
"Regex pattern to filter which tests to run": "用于过滤测试的正则表达式",
|
||||||
"Default timeout per test case": "每个测试用例的默认超时时间",
|
"Default timeout per test case": "每个测试用例的默认超时时间",
|
||||||
"Number of parallel test cases": "并行测试用例数",
|
"Number of parallel test cases": "并行测试用例数",
|
||||||
"Verbose output": "详细输出",
|
"Verbose output": "详细输出",
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,10 @@ var (
|
||||||
testConnector string
|
testConnector string
|
||||||
testUser string
|
testUser string
|
||||||
testTeam string
|
testTeam string
|
||||||
|
testContext string // --ctx flag for custom context JSON file
|
||||||
testReporter string
|
testReporter string
|
||||||
testRuns int
|
testRuns int
|
||||||
|
testRun string // --run flag for test filtering (regex pattern)
|
||||||
testTimeout string
|
testTimeout string
|
||||||
testParallel int
|
testParallel int
|
||||||
testVerbose bool
|
testVerbose bool
|
||||||
|
|
@ -140,19 +142,21 @@ var TestCmd = &cobra.Command{
|
||||||
|
|
||||||
// Build test options
|
// Build test options
|
||||||
opts := &test.Options{
|
opts := &test.Options{
|
||||||
Input: testInput,
|
Input: testInput,
|
||||||
InputMode: inputMode,
|
InputMode: inputMode,
|
||||||
OutputFile: testOutput,
|
OutputFile: testOutput,
|
||||||
AgentID: testAgent,
|
AgentID: testAgent,
|
||||||
Connector: testConnector,
|
Connector: testConnector,
|
||||||
UserID: testUser,
|
UserID: testUser,
|
||||||
TeamID: testTeam,
|
TeamID: testTeam,
|
||||||
ReporterID: testReporter,
|
ContextFile: testContext,
|
||||||
Runs: testRuns,
|
ReporterID: testReporter,
|
||||||
Timeout: timeout,
|
Runs: testRuns,
|
||||||
Parallel: testParallel,
|
Run: testRun,
|
||||||
Verbose: testVerbose,
|
Timeout: timeout,
|
||||||
FailFast: testFailFast,
|
Parallel: testParallel,
|
||||||
|
Verbose: testVerbose,
|
||||||
|
FailFast: testFailFast,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge with defaults
|
// Merge with defaults
|
||||||
|
|
@ -232,8 +236,10 @@ func init() {
|
||||||
TestCmd.Flags().StringVarP(&testConnector, "connector", "c", "", L("Override connector"))
|
TestCmd.Flags().StringVarP(&testConnector, "connector", "c", "", L("Override connector"))
|
||||||
TestCmd.Flags().StringVarP(&testUser, "user", "u", "", L("Test user ID (default: test-user)"))
|
TestCmd.Flags().StringVarP(&testUser, "user", "u", "", L("Test user ID (default: test-user)"))
|
||||||
TestCmd.Flags().StringVarP(&testTeam, "team", "t", "", L("Test team ID (default: test-team)"))
|
TestCmd.Flags().StringVarP(&testTeam, "team", "t", "", L("Test team ID (default: test-team)"))
|
||||||
|
TestCmd.Flags().StringVar(&testContext, "ctx", "", L("Path to context JSON file for custom authorization"))
|
||||||
TestCmd.Flags().StringVarP(&testReporter, "reporter", "r", "", L("Reporter agent ID for custom report"))
|
TestCmd.Flags().StringVarP(&testReporter, "reporter", "r", "", L("Reporter agent ID for custom report"))
|
||||||
TestCmd.Flags().IntVar(&testRuns, "runs", 1, L("Number of runs for stability analysis"))
|
TestCmd.Flags().IntVar(&testRuns, "runs", 1, L("Number of runs for stability analysis"))
|
||||||
|
TestCmd.Flags().StringVar(&testRun, "run", "", L("Regex pattern to filter which tests to run"))
|
||||||
TestCmd.Flags().StringVar(&testTimeout, "timeout", "5m", L("Default timeout per test case"))
|
TestCmd.Flags().StringVar(&testTimeout, "timeout", "5m", L("Default timeout per test case"))
|
||||||
TestCmd.Flags().IntVar(&testParallel, "parallel", 1, L("Number of parallel test cases"))
|
TestCmd.Flags().IntVar(&testParallel, "parallel", 1, L("Number of parallel test cases"))
|
||||||
TestCmd.Flags().BoolVarP(&testVerbose, "verbose", "v", false, L("Verbose output"))
|
TestCmd.Flags().BoolVarP(&testVerbose, "verbose", "v", false, L("Verbose output"))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue