Enhance File Handling and Input Parsing in Test Framework

- Introduced support for file attachments in test inputs using the `file://` protocol, allowing images, audio, and documents to be loaded and converted to appropriate formats.
- Updated `ParseInput` and related functions to handle file references, ensuring seamless integration of file content into messages.
- Enhanced error handling and path resolution for file loading, considering both relative paths and the `YAO_ROOT` environment variable.
- Expanded documentation to include examples of file attachments and their usage in test cases, improving clarity for users.
This commit is contained in:
Max 2025-12-25 11:50:58 +08:00
parent 97d0ad1d24
commit 7f44e442da
12 changed files with 1635 additions and 48 deletions

View file

@ -272,6 +272,111 @@ The `options.metadata` field is passed to agent hooks. For example, a Create Hoo
| `Message` | Single message | `{"role": "user", "content": "..."}` |
| `[]Message` | Conversation history | `[{"role": "user", ...}, {"role": "assistant", ...}]` |
### File Attachments
Test inputs support file attachments (images, audio, documents) using the `file://` protocol. Files are loaded and converted to appropriate formats for the LLM.
**Supported file types:**
| Type | Extensions | Format |
| ------ | ---------------------------------------------------------------------- | ------------------------------ |
| Image | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.bmp` | Base64 data URL in `image_url` |
| Audio | `.wav`, `.mp3`, `.flac`, `.ogg`, `.m4a` | Base64 in `input_audio` |
| Doc | `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.txt`, `.csv`, `.json` | Base64 data URL in `file` |
| Source | `.yao`, `.ts`, `.js`, `.go`, `.py`, `.rs`, `.java`, `.sql`, `.yaml`... | Base64 data URL in `file` |
**File path resolution:**
- **Relative paths**: Resolved relative to the JSONL input file's directory (for file mode) or current working directory (for message mode)
- **Absolute paths**: Used as-is
**Example with image attachment:**
```jsonl
{
"id": "T001",
"input": {
"role": "user",
"content": [
{
"type": "text",
"text": "Please analyze this invoice"
},
{
"type": "image",
"source": "file://fixtures/invoice.jpg"
}
]
},
"assert": {
"type": "contains",
"value": "amount"
}
}
```
**Example with multiple attachments:**
```jsonl
{
"id": "T002",
"input": {
"role": "user",
"content": [
{
"type": "text",
"text": "Process these receipts"
},
{
"type": "image",
"source": "file://fixtures/receipt1.png"
},
{
"type": "image",
"source": "file://fixtures/receipt2.png"
},
{
"type": "file",
"source": "file://fixtures/policy.pdf",
"name": "expense_policy.pdf"
}
]
}
}
```
**Example with audio:**
```jsonl
{
"id": "T003",
"input": {
"role": "user",
"content": [
{
"type": "text",
"text": "Transcribe this audio"
},
{
"type": "audio",
"source": "file://fixtures/recording.wav"
}
]
}
}
```
**Content part types:**
| Type | Fields | Description |
| ----------- | --------------------------------------- | -------------------------------- |
| `text` | `text` | Text content |
| `image` | `source` (file://) or `url` | Image attachment |
| `image_url` | `image_url: {url, detail?}` | Direct image URL (OpenAI format) |
| `audio` | `source` (file://) or `data`, `format` | Audio attachment |
| `file` | `source` (file://) or `url`, `filename` | Document attachment |
| `data` | `data: {sources: [...]}` | Data source references |
## Assertions
Use `assert` for flexible validation. If `assert` is defined, it takes precedence over `expected`.

View file

@ -255,27 +255,32 @@ func (a *Asserter) assertJSONPath(assertion *Assertion, output interface{}) *Ass
result.Actual = actual
// Compare expected value with actual value
// If expected is an array, check if actual matches ANY element (IN semantics)
// First try direct comparison (handles array-to-array comparison)
if validateOutput(actual, assertion.Value) {
result.Passed = true
result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path)
return result
}
// If expected is an array and direct comparison failed, check if actual matches ANY element (IN semantics)
// This is for cases like: expected: ["a", "b"], actual: "a" (actual is one of expected)
if expectedArr, ok := assertion.Value.([]interface{}); ok {
// Check if actual is one of the expected values
for _, expectedItem := range expectedArr {
if validateOutput(actual, expectedItem) {
result.Passed = true
result.Message = fmt.Sprintf("path '%s' equals one of expected values", assertion.Path)
return result
// Only apply IN semantics if actual is NOT an array (otherwise it was already compared above)
if _, actualIsArr := actual.([]interface{}); !actualIsArr {
for _, expectedItem := range expectedArr {
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.Message = fmt.Sprintf("path '%s': expected one of %v, got %v", assertion.Path, assertion.Value, actual)
result.Message = fmt.Sprintf("path '%s': expected %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)
}
// Direct comparison already failed above
result.Passed = false
result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual)
}
return result

305
agent/test/assert_test.go Normal file
View file

@ -0,0 +1,305 @@
package test
import (
"testing"
)
func TestAsserter_JSONPath_ArrayEquality(t *testing.T) {
asserter := NewAsserter()
tests := []struct {
name string
tc *Case
output interface{}
expected bool
errMsg string
}{
{
name: "array equals array - same content",
tc: &Case{
Assert: map[string]interface{}{
"type": "json_path",
"path": "search_types",
"value": []interface{}{"db"},
},
},
output: map[string]interface{}{
"search_types": []interface{}{"db"},
},
expected: true,
},
{
name: "array equals array - different content",
tc: &Case{
Assert: map[string]interface{}{
"type": "json_path",
"path": "search_types",
"value": []interface{}{"db"},
},
},
output: map[string]interface{}{
"search_types": []interface{}{"web"},
},
expected: false,
},
{
name: "array equals array - multiple elements",
tc: &Case{
Assert: map[string]interface{}{
"type": "json_path",
"path": "search_types",
"value": []interface{}{"web", "db"},
},
},
output: map[string]interface{}{
"search_types": []interface{}{"web", "db"},
},
expected: true,
},
{
name: "array equals array - different order",
tc: &Case{
Assert: map[string]interface{}{
"type": "json_path",
"path": "search_types",
"value": []interface{}{"db", "web"},
},
},
output: map[string]interface{}{
"search_types": []interface{}{"web", "db"},
},
expected: false, // Order matters for array equality
},
{
name: "scalar in array - match",
tc: &Case{
Assert: map[string]interface{}{
"type": "json_path",
"path": "status",
"value": []interface{}{"active", "pending"},
},
},
output: map[string]interface{}{
"status": "active",
},
expected: true, // "active" is one of ["active", "pending"]
},
{
name: "scalar in array - no match",
tc: &Case{
Assert: map[string]interface{}{
"type": "json_path",
"path": "status",
"value": []interface{}{"active", "pending"},
},
},
output: map[string]interface{}{
"status": "inactive",
},
expected: false,
},
{
name: "simple value comparison",
tc: &Case{
Assert: map[string]interface{}{
"type": "json_path",
"path": "need_search",
"value": true,
},
},
output: map[string]interface{}{
"need_search": true,
},
expected: true,
},
{
name: "nested path",
tc: &Case{
Assert: map[string]interface{}{
"type": "json_path",
"path": "result.count",
"value": float64(5),
},
},
output: map[string]interface{}{
"result": map[string]interface{}{
"count": float64(5),
},
},
expected: true,
},
{
name: "array index access",
tc: &Case{
Assert: map[string]interface{}{
"type": "json_path",
"path": "items[0].name",
"value": "first",
},
},
output: map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"name": "first"},
map[string]interface{}{"name": "second"},
},
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
passed, errMsg := asserter.Validate(tt.tc, tt.output)
if passed != tt.expected {
t.Errorf("Expected passed=%v, got passed=%v, error: %s", tt.expected, passed, errMsg)
}
})
}
}
func TestAsserter_Contains(t *testing.T) {
asserter := NewAsserter()
tests := []struct {
name string
tc *Case
output interface{}
expected bool
}{
{
name: "string contains substring",
tc: &Case{
Assert: map[string]interface{}{
"type": "contains",
"value": "hello",
},
},
output: "hello world",
expected: true,
},
{
name: "string does not contain",
tc: &Case{
Assert: map[string]interface{}{
"type": "contains",
"value": "goodbye",
},
},
output: "hello world",
expected: false,
},
{
name: "JSON contains field",
tc: &Case{
Assert: map[string]interface{}{
"type": "contains",
"value": "success",
},
},
output: map[string]interface{}{
"status": "success",
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
passed, _ := asserter.Validate(tt.tc, tt.output)
if passed != tt.expected {
t.Errorf("Expected passed=%v, got passed=%v", tt.expected, passed)
}
})
}
}
func TestAsserter_MultipleAssertions(t *testing.T) {
asserter := NewAsserter()
tc := &Case{
Assert: []interface{}{
map[string]interface{}{
"type": "json_path",
"path": "need_search",
"value": true,
},
map[string]interface{}{
"type": "json_path",
"path": "search_types",
"value": []interface{}{"web"},
},
},
}
output := map[string]interface{}{
"need_search": true,
"search_types": []interface{}{"web"},
}
passed, errMsg := asserter.Validate(tc, output)
if !passed {
t.Errorf("Expected all assertions to pass, got error: %s", errMsg)
}
}
func TestAsserter_Negate(t *testing.T) {
asserter := NewAsserter()
tc := &Case{
Assert: map[string]interface{}{
"type": "contains",
"value": "error",
"negate": true,
},
}
output := "success message"
passed, _ := asserter.Validate(tc, output)
if !passed {
t.Error("Expected negated assertion to pass")
}
}
func TestAsserter_Regex(t *testing.T) {
asserter := NewAsserter()
tests := []struct {
name string
tc *Case
output interface{}
expected bool
}{
{
name: "regex matches",
tc: &Case{
Assert: map[string]interface{}{
"type": "regex",
"value": `\d{3}-\d{4}`,
},
},
output: "Phone: 123-4567",
expected: true,
},
{
name: "regex does not match",
tc: &Case{
Assert: map[string]interface{}{
"type": "regex",
"value": `\d{3}-\d{4}`,
},
},
output: "No phone number here",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
passed, _ := asserter.Validate(tt.tc, tt.output)
if passed != tt.expected {
t.Errorf("Expected passed=%v, got passed=%v", tt.expected, passed)
}
})
}
}

View file

@ -1,22 +1,123 @@
package test
import (
"encoding/base64"
"fmt"
"mime"
"os"
"path/filepath"
"strings"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/yao/agent/context"
)
// FileProtocol is the protocol prefix for local file references
const FileProtocol = "file://"
// SupportedImageExtensions lists supported image file extensions
var SupportedImageExtensions = map[string]string{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
}
// SupportedAudioExtensions lists supported audio file extensions
var SupportedAudioExtensions = map[string]string{
".wav": "wav",
".mp3": "mp3",
".flac": "flac",
".ogg": "ogg",
".m4a": "m4a",
}
// SupportedFileExtensions lists supported document file extensions
var SupportedFileExtensions = map[string]string{
// Documents
".pdf": "application/pdf",
".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xls": "application/vnd.ms-excel",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".txt": "text/plain",
".csv": "text/csv",
".json": "application/json",
".xml": "application/xml",
".html": "text/html",
".htm": "text/html",
".md": "text/markdown",
// Source code
".yao": "application/json", // Yao DSL files (JSON-based)
".ts": "text/typescript", // TypeScript
".tsx": "text/typescript", // TypeScript JSX
".js": "text/javascript", // JavaScript
".jsx": "text/javascript", // JavaScript JSX
".go": "text/x-go", // Go
".py": "text/x-python", // Python
".rs": "text/x-rust", // Rust
".java": "text/x-java", // Java
".c": "text/x-c", // C
".cpp": "text/x-c++", // C++
".h": "text/x-c", // C header
".hpp": "text/x-c++", // C++ header
".rb": "text/x-ruby", // Ruby
".php": "text/x-php", // PHP
".sh": "text/x-shellscript", // Shell script
".bash": "text/x-shellscript", // Bash script
".zsh": "text/x-shellscript", // Zsh script
".sql": "text/x-sql", // SQL
".yaml": "text/yaml", // YAML
".yml": "text/yaml", // YAML
".toml": "text/x-toml", // TOML
".ini": "text/x-ini", // INI
".conf": "text/plain", // Config files
".css": "text/css", // CSS
".scss": "text/x-scss", // SCSS
".less": "text/x-less", // LESS
".vue": "text/x-vue", // Vue
".svelte": "text/x-svelte", // Svelte
}
// InputOptions configures how input is parsed
type InputOptions struct {
// BaseDir is the base directory for resolving relative file paths
// If empty, the current working directory is used
BaseDir string
}
// ParseInput converts various input formats to []context.Message
// Supported formats:
// - string: converted to single user message
// - map (Message): single message with role and content
// - []interface{} ([]Message): array of messages (conversation history)
func ParseInput(input interface{}) ([]context.Message, error) {
return ParseInputWithOptions(input, nil)
}
// ParseInputWithOptions converts various input formats to []context.Message with options
// Supported formats:
// - string: converted to single user message
// - map (Message): single message with role and content
// - []interface{} ([]Message): array of messages (conversation history)
//
// File references in content parts (type="image", "file", "audio") with "source" field
// starting with "file://" will be loaded and converted to appropriate format:
// - Images: converted to base64 data URL in image_url field
// - Audio: converted to base64 in input_audio field
// - Files: converted to base64 data URL in file field
func ParseInputWithOptions(input interface{}, opts *InputOptions) ([]context.Message, error) {
if input == nil {
return nil, fmt.Errorf("input is nil")
}
if opts == nil {
opts = &InputOptions{}
}
switch v := input.(type) {
case string:
// Simple string input -> single user message
@ -29,7 +130,7 @@ func ParseInput(input interface{}) ([]context.Message, error) {
case map[string]interface{}:
// Single message object
msg, err := parseMessageMap(v)
msg, err := parseMessageMap(v, opts)
if err != nil {
return nil, fmt.Errorf("failed to parse message: %w", err)
}
@ -41,7 +142,7 @@ func ParseInput(input interface{}) ([]context.Message, error) {
for i, item := range v {
switch m := item.(type) {
case map[string]interface{}:
msg, err := parseMessageMap(m)
msg, err := parseMessageMap(m, opts)
if err != nil {
return nil, fmt.Errorf("failed to parse message at index %d: %w", i, err)
}
@ -58,7 +159,7 @@ func ParseInput(input interface{}) ([]context.Message, error) {
}
// parseMessageMap converts a map to context.Message
func parseMessageMap(m map[string]interface{}) (*context.Message, error) {
func parseMessageMap(m map[string]interface{}, opts *InputOptions) (*context.Message, error) {
msg := &context.Message{}
// Parse role (required)
@ -71,7 +172,12 @@ func parseMessageMap(m map[string]interface{}) (*context.Message, error) {
// Parse content (required)
if content, ok := m["content"]; ok {
msg.Content = content
// Process content to handle file:// references
processedContent, err := processContent(content, opts)
if err != nil {
return nil, fmt.Errorf("failed to process content: %w", err)
}
msg.Content = processedContent
} else {
return nil, fmt.Errorf("message missing 'content' field")
}
@ -108,6 +214,389 @@ func parseMessageMap(m map[string]interface{}) (*context.Message, error) {
return msg, nil
}
// processContent processes content to handle file:// references
// Returns the processed content with files loaded and converted
func processContent(content interface{}, opts *InputOptions) (interface{}, error) {
switch v := content.(type) {
case string:
// Simple string content, no processing needed
return v, nil
case []interface{}:
// Array of content parts
processedParts := make([]context.ContentPart, 0, len(v))
for i, part := range v {
if partMap, ok := part.(map[string]interface{}); ok {
processedPart, err := processContentPart(partMap, opts)
if err != nil {
return nil, fmt.Errorf("failed to process content part at index %d: %w", i, err)
}
processedParts = append(processedParts, *processedPart)
} else {
return nil, fmt.Errorf("invalid content part type at index %d: expected object, got %T", i, part)
}
}
return processedParts, nil
case map[string]interface{}:
// Single content part
processedPart, err := processContentPart(v, opts)
if err != nil {
return nil, fmt.Errorf("failed to process content part: %w", err)
}
return []context.ContentPart{*processedPart}, nil
default:
return content, nil
}
}
// processContentPart processes a single content part map
// Handles file:// references and converts them to appropriate format
func processContentPart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) {
partType, _ := partMap["type"].(string)
switch partType {
case "text":
text, _ := partMap["text"].(string)
return &context.ContentPart{
Type: context.ContentText,
Text: text,
}, nil
case "image":
return processImagePart(partMap, opts)
case "image_url":
// Already in correct format, just parse it
return parseImageURLPart(partMap)
case "audio", "input_audio":
return processAudioPart(partMap, opts)
case "file":
return processFilePart(partMap, opts)
case "data":
return parseDataPart(partMap)
default:
// Unknown type, try to preserve as-is
return parseGenericPart(partMap)
}
}
// processImagePart processes an image content part
// Supports: source="file://path" for local files
func processImagePart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) {
source, hasSource := partMap["source"].(string)
// Check for file:// protocol
if hasSource && strings.HasPrefix(source, FileProtocol) {
filePath := strings.TrimPrefix(source, FileProtocol)
return loadImageFile(filePath, opts)
}
// Check for url field (already a URL or base64)
if url, ok := partMap["url"].(string); ok {
detail := context.DetailAuto
if d, ok := partMap["detail"].(string); ok {
detail = context.ImageDetailLevel(d)
}
return &context.ContentPart{
Type: context.ContentImageURL,
ImageURL: &context.ImageURL{
URL: url,
Detail: detail,
},
}, nil
}
return nil, fmt.Errorf("image part requires 'source' (file://...) or 'url' field")
}
// parseImageURLPart parses an image_url content part
func parseImageURLPart(partMap map[string]interface{}) (*context.ContentPart, error) {
imageURL, ok := partMap["image_url"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("image_url part requires 'image_url' object")
}
url, _ := imageURL["url"].(string)
detail := context.DetailAuto
if d, ok := imageURL["detail"].(string); ok {
detail = context.ImageDetailLevel(d)
}
return &context.ContentPart{
Type: context.ContentImageURL,
ImageURL: &context.ImageURL{
URL: url,
Detail: detail,
},
}, nil
}
// processAudioPart processes an audio content part
// Supports: source="file://path" for local files
func processAudioPart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) {
source, hasSource := partMap["source"].(string)
// Check for file:// protocol
if hasSource && strings.HasPrefix(source, FileProtocol) {
filePath := strings.TrimPrefix(source, FileProtocol)
return loadAudioFile(filePath, opts)
}
// Check for data field (already base64)
if data, ok := partMap["data"].(string); ok {
format, _ := partMap["format"].(string)
return &context.ContentPart{
Type: context.ContentInputAudio,
InputAudio: &context.InputAudio{
Data: data,
Format: format,
},
}, nil
}
// Check for input_audio field
if inputAudio, ok := partMap["input_audio"].(map[string]interface{}); ok {
data, _ := inputAudio["data"].(string)
format, _ := inputAudio["format"].(string)
return &context.ContentPart{
Type: context.ContentInputAudio,
InputAudio: &context.InputAudio{
Data: data,
Format: format,
},
}, nil
}
return nil, fmt.Errorf("audio part requires 'source' (file://...) or 'data'/'input_audio' field")
}
// processFilePart processes a file content part
// Supports: source="file://path" for local files
func processFilePart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) {
source, hasSource := partMap["source"].(string)
// Check for file:// protocol
if hasSource && strings.HasPrefix(source, FileProtocol) {
filePath := strings.TrimPrefix(source, FileProtocol)
name, _ := partMap["name"].(string)
return loadFile(filePath, name, opts)
}
// Check for url field (already a URL)
if url, ok := partMap["url"].(string); ok {
filename, _ := partMap["filename"].(string)
if filename == "" {
filename, _ = partMap["name"].(string)
}
return &context.ContentPart{
Type: context.ContentFile,
File: &context.FileAttachment{
URL: url,
Filename: filename,
},
}, nil
}
// Check for file field
if file, ok := partMap["file"].(map[string]interface{}); ok {
url, _ := file["url"].(string)
filename, _ := file["filename"].(string)
return &context.ContentPart{
Type: context.ContentFile,
File: &context.FileAttachment{
URL: url,
Filename: filename,
},
}, nil
}
return nil, fmt.Errorf("file part requires 'source' (file://...), 'url', or 'file' field")
}
// parseDataPart parses a data content part
func parseDataPart(partMap map[string]interface{}) (*context.ContentPart, error) {
data, ok := partMap["data"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("data part requires 'data' object")
}
// Convert to DataContent
dataContent := &context.DataContent{}
if sources, ok := data["sources"].([]interface{}); ok {
dataContent.Sources = make([]context.DataSource, 0, len(sources))
for _, src := range sources {
if srcMap, ok := src.(map[string]interface{}); ok {
ds := context.DataSource{}
if t, ok := srcMap["type"].(string); ok {
ds.Type = context.DataSourceType(t)
}
if id, ok := srcMap["id"].(string); ok {
ds.ID = id
}
if name, ok := srcMap["name"].(string); ok {
ds.Name = name
}
if filters, ok := srcMap["filters"].(map[string]interface{}); ok {
ds.Filters = filters
}
if metadata, ok := srcMap["metadata"].(map[string]interface{}); ok {
ds.Metadata = metadata
}
dataContent.Sources = append(dataContent.Sources, ds)
}
}
}
return &context.ContentPart{
Type: context.ContentData,
Data: dataContent,
}, nil
}
// parseGenericPart tries to parse an unknown content part type
func parseGenericPart(partMap map[string]interface{}) (*context.ContentPart, error) {
partType, _ := partMap["type"].(string)
// Try to create a basic ContentPart
part := &context.ContentPart{
Type: context.ContentPartType(partType),
}
// Try to extract text if present
if text, ok := partMap["text"].(string); ok {
part.Text = text
}
return part, nil
}
// loadImageFile loads an image file and converts it to a ContentPart
func loadImageFile(filePath string, opts *InputOptions) (*context.ContentPart, error) {
absPath := resolveFilePath(filePath, opts)
// Read file
data, err := os.ReadFile(absPath)
if err != nil {
return nil, fmt.Errorf("failed to read image file %s: %w", filePath, err)
}
// Determine MIME type
ext := strings.ToLower(filepath.Ext(absPath))
mimeType, ok := SupportedImageExtensions[ext]
if !ok {
// Try to detect from extension
mimeType = mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
}
// Encode to base64 data URL
b64Data := base64.StdEncoding.EncodeToString(data)
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data)
return &context.ContentPart{
Type: context.ContentImageURL,
ImageURL: &context.ImageURL{
URL: dataURL,
Detail: context.DetailAuto,
},
}, nil
}
// loadAudioFile loads an audio file and converts it to a ContentPart
func loadAudioFile(filePath string, opts *InputOptions) (*context.ContentPart, error) {
absPath := resolveFilePath(filePath, opts)
// Read file
data, err := os.ReadFile(absPath)
if err != nil {
return nil, fmt.Errorf("failed to read audio file %s: %w", filePath, err)
}
// Determine format from extension
ext := strings.ToLower(filepath.Ext(absPath))
format, ok := SupportedAudioExtensions[ext]
if !ok {
format = strings.TrimPrefix(ext, ".")
}
// Encode to base64
b64Data := base64.StdEncoding.EncodeToString(data)
return &context.ContentPart{
Type: context.ContentInputAudio,
InputAudio: &context.InputAudio{
Data: b64Data,
Format: format,
},
}, nil
}
// loadFile loads a file and converts it to a ContentPart
func loadFile(filePath string, name string, opts *InputOptions) (*context.ContentPart, error) {
absPath := resolveFilePath(filePath, opts)
// Read file
data, err := os.ReadFile(absPath)
if err != nil {
return nil, fmt.Errorf("failed to read file %s: %w", filePath, err)
}
// Determine filename
filename := name
if filename == "" {
filename = filepath.Base(absPath)
}
// Determine MIME type
ext := strings.ToLower(filepath.Ext(absPath))
mimeType, ok := SupportedFileExtensions[ext]
if !ok {
mimeType = mime.TypeByExtension(ext)
if mimeType == "" {
mimeType = "application/octet-stream"
}
}
// Encode to base64 data URL
b64Data := base64.StdEncoding.EncodeToString(data)
dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data)
return &context.ContentPart{
Type: context.ContentFile,
File: &context.FileAttachment{
URL: dataURL,
Filename: filename,
},
}, nil
}
// resolveFilePath resolves a file path relative to the base directory
// If the path is absolute, it's returned as-is
// If BaseDir is empty, the current working directory is used
func resolveFilePath(filePath string, opts *InputOptions) string {
// If path is absolute, return as-is
if filepath.IsAbs(filePath) {
return filePath
}
// If BaseDir is set, resolve relative to it
if opts != nil && opts.BaseDir != "" {
return filepath.Join(opts.BaseDir, filePath)
}
// Otherwise, resolve relative to current working directory
return filePath
}
// parseToolCall converts a map to context.ToolCall
func parseToolCall(m map[string]interface{}) (*context.ToolCall, error) {
tc := &context.ToolCall{}

487
agent/test/input_test.go Normal file
View file

@ -0,0 +1,487 @@
package test
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"github.com/yaoapp/yao/agent/context"
)
func TestParseInput_String(t *testing.T) {
input := "Hello world"
messages, err := ParseInput(input)
if err != nil {
t.Fatalf("ParseInput failed: %v", err)
}
if len(messages) != 1 {
t.Fatalf("Expected 1 message, got %d", len(messages))
}
if messages[0].Role != context.RoleUser {
t.Errorf("Expected role 'user', got '%s'", messages[0].Role)
}
content, ok := messages[0].Content.(string)
if !ok {
t.Fatalf("Expected string content, got %T", messages[0].Content)
}
if content != "Hello world" {
t.Errorf("Expected content 'Hello world', got '%s'", content)
}
}
func TestParseInput_MessageMap(t *testing.T) {
input := map[string]interface{}{
"role": "user",
"content": "Test message",
}
messages, err := ParseInput(input)
if err != nil {
t.Fatalf("ParseInput failed: %v", err)
}
if len(messages) != 1 {
t.Fatalf("Expected 1 message, got %d", len(messages))
}
if messages[0].Role != context.RoleUser {
t.Errorf("Expected role 'user', got '%s'", messages[0].Role)
}
}
func TestParseInput_MessageArray(t *testing.T) {
input := []interface{}{
map[string]interface{}{"role": "user", "content": "Hello"},
map[string]interface{}{"role": "assistant", "content": "Hi there"},
map[string]interface{}{"role": "user", "content": "Follow-up"},
}
messages, err := ParseInput(input)
if err != nil {
t.Fatalf("ParseInput failed: %v", err)
}
if len(messages) != 3 {
t.Fatalf("Expected 3 messages, got %d", len(messages))
}
if messages[0].Role != context.RoleUser {
t.Errorf("Expected first message role 'user', got '%s'", messages[0].Role)
}
if messages[1].Role != context.RoleAssistant {
t.Errorf("Expected second message role 'assistant', got '%s'", messages[1].Role)
}
}
func TestParseInput_ContentParts(t *testing.T) {
input := map[string]interface{}{
"role": "user",
"content": []interface{}{
map[string]interface{}{"type": "text", "text": "Analyze this"},
map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{
"url": "https://example.com/image.jpg",
"detail": "high",
}},
},
}
messages, err := ParseInput(input)
if err != nil {
t.Fatalf("ParseInput failed: %v", err)
}
if len(messages) != 1 {
t.Fatalf("Expected 1 message, got %d", len(messages))
}
parts, ok := messages[0].Content.([]context.ContentPart)
if !ok {
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
}
if len(parts) != 2 {
t.Fatalf("Expected 2 content parts, got %d", len(parts))
}
if parts[0].Type != context.ContentText {
t.Errorf("Expected first part type 'text', got '%s'", parts[0].Type)
}
if parts[0].Text != "Analyze this" {
t.Errorf("Expected text 'Analyze this', got '%s'", parts[0].Text)
}
if parts[1].Type != context.ContentImageURL {
t.Errorf("Expected second part type 'image_url', got '%s'", parts[1].Type)
}
if parts[1].ImageURL == nil {
t.Fatal("Expected ImageURL to be set")
}
if parts[1].ImageURL.URL != "https://example.com/image.jpg" {
t.Errorf("Expected URL 'https://example.com/image.jpg', got '%s'", parts[1].ImageURL.URL)
}
if parts[1].ImageURL.Detail != context.DetailHigh {
t.Errorf("Expected detail 'high', got '%s'", parts[1].ImageURL.Detail)
}
}
func TestParseInputWithOptions_FileProtocol_Image(t *testing.T) {
// Create a temporary test image file
tmpDir := t.TempDir()
imgPath := filepath.Join(tmpDir, "test.png")
// Create a minimal PNG file (1x1 pixel, red)
pngData := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk
0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xFE,
0xD4, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, // IEND chunk
0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
}
if err := os.WriteFile(imgPath, pngData, 0644); err != nil {
t.Fatalf("Failed to create test image: %v", err)
}
input := map[string]interface{}{
"role": "user",
"content": []interface{}{
map[string]interface{}{"type": "text", "text": "Analyze this image"},
map[string]interface{}{"type": "image", "source": "file://test.png"},
},
}
opts := &InputOptions{BaseDir: tmpDir}
messages, err := ParseInputWithOptions(input, opts)
if err != nil {
t.Fatalf("ParseInputWithOptions failed: %v", err)
}
if len(messages) != 1 {
t.Fatalf("Expected 1 message, got %d", len(messages))
}
parts, ok := messages[0].Content.([]context.ContentPart)
if !ok {
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
}
if len(parts) != 2 {
t.Fatalf("Expected 2 content parts, got %d", len(parts))
}
// Check image part
imgPart := parts[1]
if imgPart.Type != context.ContentImageURL {
t.Errorf("Expected type 'image_url', got '%s'", imgPart.Type)
}
if imgPart.ImageURL == nil {
t.Fatal("Expected ImageURL to be set")
}
if !strings.HasPrefix(imgPart.ImageURL.URL, "data:image/png;base64,") {
t.Errorf("Expected base64 data URL, got '%s'", imgPart.ImageURL.URL[:50])
}
// Verify the base64 content
b64Part := strings.TrimPrefix(imgPart.ImageURL.URL, "data:image/png;base64,")
decoded, err := base64.StdEncoding.DecodeString(b64Part)
if err != nil {
t.Fatalf("Failed to decode base64: %v", err)
}
if len(decoded) != len(pngData) {
t.Errorf("Decoded data length mismatch: expected %d, got %d", len(pngData), len(decoded))
}
}
func TestParseInputWithOptions_FileProtocol_Audio(t *testing.T) {
// Create a temporary test audio file
tmpDir := t.TempDir()
audioPath := filepath.Join(tmpDir, "test.wav")
// Create a minimal WAV file header
wavData := []byte{
0x52, 0x49, 0x46, 0x46, // "RIFF"
0x24, 0x00, 0x00, 0x00, // File size - 8
0x57, 0x41, 0x56, 0x45, // "WAVE"
0x66, 0x6D, 0x74, 0x20, // "fmt "
0x10, 0x00, 0x00, 0x00, // Subchunk1Size (16 for PCM)
0x01, 0x00, // AudioFormat (1 = PCM)
0x01, 0x00, // NumChannels (1 = mono)
0x44, 0xAC, 0x00, 0x00, // SampleRate (44100)
0x88, 0x58, 0x01, 0x00, // ByteRate
0x02, 0x00, // BlockAlign
0x10, 0x00, // BitsPerSample (16)
0x64, 0x61, 0x74, 0x61, // "data"
0x00, 0x00, 0x00, 0x00, // Subchunk2Size (0 = no data)
}
if err := os.WriteFile(audioPath, wavData, 0644); err != nil {
t.Fatalf("Failed to create test audio: %v", err)
}
input := map[string]interface{}{
"role": "user",
"content": []interface{}{
map[string]interface{}{"type": "text", "text": "Transcribe this"},
map[string]interface{}{"type": "audio", "source": "file://test.wav"},
},
}
opts := &InputOptions{BaseDir: tmpDir}
messages, err := ParseInputWithOptions(input, opts)
if err != nil {
t.Fatalf("ParseInputWithOptions failed: %v", err)
}
parts, ok := messages[0].Content.([]context.ContentPart)
if !ok {
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
}
// Check audio part
audioPart := parts[1]
if audioPart.Type != context.ContentInputAudio {
t.Errorf("Expected type 'input_audio', got '%s'", audioPart.Type)
}
if audioPart.InputAudio == nil {
t.Fatal("Expected InputAudio to be set")
}
if audioPart.InputAudio.Format != "wav" {
t.Errorf("Expected format 'wav', got '%s'", audioPart.InputAudio.Format)
}
if audioPart.InputAudio.Data == "" {
t.Error("Expected base64 data to be set")
}
}
func TestParseInputWithOptions_FileProtocol_File(t *testing.T) {
// Create a temporary test file
tmpDir := t.TempDir()
pdfPath := filepath.Join(tmpDir, "document.pdf")
// Create a minimal PDF file
pdfData := []byte("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF")
if err := os.WriteFile(pdfPath, pdfData, 0644); err != nil {
t.Fatalf("Failed to create test PDF: %v", err)
}
input := map[string]interface{}{
"role": "user",
"content": []interface{}{
map[string]interface{}{"type": "text", "text": "Analyze this document"},
map[string]interface{}{"type": "file", "source": "file://document.pdf", "name": "my_doc.pdf"},
},
}
opts := &InputOptions{BaseDir: tmpDir}
messages, err := ParseInputWithOptions(input, opts)
if err != nil {
t.Fatalf("ParseInputWithOptions failed: %v", err)
}
parts, ok := messages[0].Content.([]context.ContentPart)
if !ok {
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
}
// Check file part
filePart := parts[1]
if filePart.Type != context.ContentFile {
t.Errorf("Expected type 'file', got '%s'", filePart.Type)
}
if filePart.File == nil {
t.Fatal("Expected File to be set")
}
if filePart.File.Filename != "my_doc.pdf" {
t.Errorf("Expected filename 'my_doc.pdf', got '%s'", filePart.File.Filename)
}
if !strings.HasPrefix(filePart.File.URL, "data:application/pdf;base64,") {
t.Errorf("Expected base64 data URL with PDF mime type, got '%s'", filePart.File.URL[:40])
}
}
func TestParseInputWithOptions_FileProtocol_AbsolutePath(t *testing.T) {
// Create a temporary test image file
tmpDir := t.TempDir()
imgPath := filepath.Join(tmpDir, "absolute.png")
// Create a minimal PNG file
pngData := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41,
0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00,
0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xFE,
0xD4, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45,
0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
}
if err := os.WriteFile(imgPath, pngData, 0644); err != nil {
t.Fatalf("Failed to create test image: %v", err)
}
// Use absolute path
input := map[string]interface{}{
"role": "user",
"content": []interface{}{
map[string]interface{}{"type": "image", "source": "file://" + imgPath},
},
}
// BaseDir should be ignored for absolute paths
opts := &InputOptions{BaseDir: "/some/other/dir"}
messages, err := ParseInputWithOptions(input, opts)
if err != nil {
t.Fatalf("ParseInputWithOptions failed: %v", err)
}
parts, ok := messages[0].Content.([]context.ContentPart)
if !ok {
t.Fatalf("Expected []ContentPart, got %T", messages[0].Content)
}
if parts[0].Type != context.ContentImageURL {
t.Errorf("Expected type 'image_url', got '%s'", parts[0].Type)
}
}
func TestParseInputWithOptions_FileNotFound(t *testing.T) {
input := map[string]interface{}{
"role": "user",
"content": []interface{}{
map[string]interface{}{"type": "image", "source": "file://nonexistent.png"},
},
}
opts := &InputOptions{BaseDir: t.TempDir()}
_, err := ParseInputWithOptions(input, opts)
if err == nil {
t.Fatal("Expected error for non-existent file")
}
if !strings.Contains(err.Error(), "failed to read image file") {
t.Errorf("Expected 'failed to read image file' error, got: %v", err)
}
}
func TestResolveFilePath(t *testing.T) {
tests := []struct {
name string
filePath string
baseDir string
expected string
}{
{
name: "relative path with base dir",
filePath: "fixtures/image.png",
baseDir: "/app/tests",
expected: "/app/tests/fixtures/image.png",
},
{
name: "relative path without base dir",
filePath: "fixtures/image.png",
baseDir: "",
expected: "fixtures/image.png",
},
{
name: "absolute path ignores base dir",
filePath: "/absolute/path/image.png",
baseDir: "/app/tests",
expected: "/absolute/path/image.png",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
opts := &InputOptions{BaseDir: tt.baseDir}
result := resolveFilePath(tt.filePath, opts)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestExtractTextContent(t *testing.T) {
tests := []struct {
name string
content interface{}
expected string
}{
{
name: "string content",
content: "Hello world",
expected: "Hello world",
},
{
name: "content parts array",
content: []interface{}{
map[string]interface{}{"type": "text", "text": "First"},
map[string]interface{}{"type": "image", "source": "file://test.png"},
map[string]interface{}{"type": "text", "text": "Second"},
},
expected: "First\nSecond",
},
{
name: "nil content",
content: nil,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExtractTextContent(tt.content)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestSummarizeInput(t *testing.T) {
tests := []struct {
name string
input interface{}
maxLen int
expected string
}{
{
name: "short string",
input: "Hello",
maxLen: 10,
expected: "Hello",
},
{
name: "long string truncated",
input: "This is a very long message that should be truncated",
maxLen: 20,
expected: "This is a very lo...",
},
{
name: "message array - last user message",
input: []interface{}{
map[string]interface{}{"role": "user", "content": "First"},
map[string]interface{}{"role": "assistant", "content": "Response"},
map[string]interface{}{"role": "user", "content": "Last user message"},
},
maxLen: 50,
expected: "Last user message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SummarizeInput(tt.input, tt.maxLen)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}

View file

@ -25,8 +25,12 @@ func (l *JSONLLoader) Load() ([]*Case, error) {
}
// LoadFile loads test cases from a JSONL file
// If path is relative and YAO_ROOT is set, resolves relative to YAO_ROOT
func (l *JSONLLoader) LoadFile(path string) ([]*Case, error) {
file, err := os.Open(path)
// Resolve path relative to YAO_ROOT if it's a relative path
resolvedPath := ResolvePathWithYaoRoot(path)
file, err := os.Open(resolvedPath)
if err != nil {
return nil, fmt.Errorf("failed to open file %s: %w", path, err)
}

View file

@ -55,6 +55,7 @@ func (r *PathResolver) ResolveFromCwd() (*AgentInfo, error) {
// ResolveFromPath resolves the agent by traversing up from the input file path
// It looks for package.yao in parent directories
// If YAO_ROOT is set, it also considers paths relative to YAO_ROOT
func (r *PathResolver) ResolveFromPath(inputPath string) (*AgentInfo, error) {
// Get absolute path
absPath, err := filepath.Abs(inputPath)
@ -82,6 +83,37 @@ func (r *PathResolver) ResolveFromPath(inputPath string) (*AgentInfo, error) {
dir = parent
}
// If YAO_ROOT is set, try resolving relative to it
yaoRoot := os.Getenv("YAO_ROOT")
if yaoRoot != "" {
// Try the input path relative to YAO_ROOT
relPath := inputPath
// If inputPath is absolute, try to make it relative
if filepath.IsAbs(inputPath) {
// Check if inputPath is under YAO_ROOT
if rel, err := filepath.Rel(yaoRoot, inputPath); err == nil && !strings.HasPrefix(rel, "..") {
relPath = rel
}
}
// Traverse up from YAO_ROOT + relPath
dir = filepath.Join(yaoRoot, filepath.Dir(relPath))
for {
packagePath := filepath.Join(dir, "package.yao")
if _, err := os.Stat(packagePath); err == nil {
// Found package.yao
return r.loadAgentFromPath(dir, packagePath)
}
// Move to parent directory, but don't go above YAO_ROOT
parent := filepath.Dir(dir)
if parent == dir || !strings.HasPrefix(parent, yaoRoot) {
break
}
dir = parent
}
}
return nil, fmt.Errorf("no package.yao found in path hierarchy of %s", inputPath)
}
@ -180,7 +212,8 @@ func ValidateOptions(opts *Options) error {
// For file mode, check input file exists
if opts.InputMode == InputModeFile {
if _, err := os.Stat(opts.Input); os.IsNotExist(err) {
resolvedPath := ResolvePathWithYaoRoot(opts.Input)
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
return fmt.Errorf("input file not found: %s", opts.Input)
}
}
@ -297,7 +330,9 @@ func MergeOptions(opts *Options, defaults *Options) *Options {
// Format: {input_directory}/output-{timestamp}.jsonl
// Timestamp format: YYYYMMDDHHMMSS
func GenerateDefaultOutputPath(inputPath string) string {
dir := filepath.Dir(inputPath)
// Resolve input path considering YAO_ROOT
resolvedPath := ResolvePathWithYaoRoot(inputPath)
dir := filepath.Dir(resolvedPath)
timestamp := time.Now().Format("20060102150405")
filename := fmt.Sprintf("output-%s.jsonl", timestamp)
return filepath.Join(dir, filename)
@ -328,3 +363,19 @@ func CreateTestCaseFromMessage(message string) *Case {
Input: message,
}
}
// ResolvePathWithYaoRoot resolves a file path relative to current directory
// No fallback to YAO_ROOT - paths are always resolved from current working directory
func ResolvePathWithYaoRoot(path string) string {
// If path is absolute, return as-is
if filepath.IsAbs(path) {
return path
}
// Resolve relative to current directory
absPath, err := filepath.Abs(path)
if err != nil {
return path
}
return absPath
}

View file

@ -5,6 +5,7 @@ import (
stdContext "context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
@ -105,8 +106,9 @@ func (r *Executor) RunDirect() (*Report, error) {
defer cancel()
ctx.Context = timeoutCtx
// Parse input to messages
messages, err := tc.GetMessages()
// Parse input to messages with file loading support
inputOpts := r.getInputOptions()
messages, err := tc.GetMessagesWithOptions(inputOpts)
if err != nil {
return nil, fmt.Errorf("failed to parse input: %w", err)
}
@ -320,8 +322,10 @@ func (r *Executor) runSingleTest(ast *assistant.Assistant, tc *Case, agentID str
Options: tc.Options,
}
// Parse input to messages
messages, err := tc.GetMessages()
// Parse input to messages with file loading support
// BaseDir is derived from the input file directory
inputOpts := r.getInputOptions()
messages, err := tc.GetMessagesWithOptions(inputOpts)
if err != nil {
result.Status = StatusError
result.Error = fmt.Sprintf("failed to parse input: %s", err.Error())
@ -613,3 +617,19 @@ func validateOutput(actual, expected interface{}) bool {
return string(actualJSON) == string(expectedJSON)
}
// getInputOptions returns InputOptions based on the runner configuration
// BaseDir is derived from the input file directory (for file mode) or current working directory
func (r *Executor) getInputOptions() *InputOptions {
opts := &InputOptions{}
// For file mode, use the input file's directory as base
if r.opts.InputMode == InputModeFile && r.opts.Input != "" {
// Resolve path considering YAO_ROOT
resolvedPath := ResolvePathWithYaoRoot(r.opts.Input)
opts.BaseDir = filepath.Dir(resolvedPath)
}
// For message mode, BaseDir remains empty (uses current working directory)
return opts
}

View file

@ -492,6 +492,13 @@ func (tc *Case) GetMessages() ([]context.Message, error) {
return ParseInput(tc.Input)
}
// GetMessagesWithOptions converts the Input to a slice of context.Message with options
// This handles all input formats: string, Message, []Message
// It also processes file:// references in content parts
func (tc *Case) GetMessagesWithOptions(opts *InputOptions) ([]context.Message, error) {
return ParseInputWithOptions(tc.Input, opts)
}
// GetTimeout returns the timeout duration for this test case
// Returns the override timeout if set, otherwise returns the default
func (tc *Case) GetTimeout(defaultTimeout time.Duration) time.Duration {

61
bin/yao-dev Executable file
View file

@ -0,0 +1,61 @@
#!/bin/bash
# yao-dev - Run yao from source for real-time debugging
# Uses go -C to compile from source while keeping current directory as app root
# Get the real path of this script (resolve symlinks)
SCRIPT_PATH="${BASH_SOURCE[0]}"
while [ -L "$SCRIPT_PATH" ]; do
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
SCRIPT_PATH="$(readlink "$SCRIPT_PATH")"
# If the link is relative, resolve it relative to the directory
[[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_PATH"
done
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
# YAO source directory is the parent of bin/
YAO_SOURCE_DIR="$(dirname "$SCRIPT_DIR")"
if [ ! -d "$YAO_SOURCE_DIR" ]; then
echo "Error: yao source directory not found at $YAO_SOURCE_DIR"
exit 1
fi
if [ ! -f "$YAO_SOURCE_DIR/go.mod" ]; then
echo "Error: go.mod not found in $YAO_SOURCE_DIR, not a valid yao source directory"
exit 1
fi
# Find app root by looking for app.yao in current directory or parent directories
find_app_root() {
local dir="$(pwd)"
while [ "$dir" != "/" ]; do
if [ -f "$dir/app.yao" ] || [ -f "$dir/app.json" ] || [ -f "$dir/app.jsonc" ]; then
echo "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
# If not found, use current directory as fallback
pwd
}
# Set YAO_ROOT to app root directory
export YAO_ROOT="$(find_app_root)"
# Convert relative file paths in arguments to absolute paths
# This is needed because go -C changes the working directory
ARGS=()
for arg in "$@"; do
# Check if it looks like a relative path
if [[ "$arg" == ./* ]] || [[ "$arg" == ../* ]]; then
# Always convert to absolute path based on current directory
ARGS+=("$(pwd)/${arg#./}")
else
ARGS+=("$arg")
fi
done
# Use go -C to run from source directory while staying in current directory
exec go -C "$YAO_SOURCE_DIR" run . "${ARGS[@]}"

View file

@ -52,6 +52,7 @@ func L(words string) string {
// Boot sets the configuration
func Boot() {
// Use root from Init() unless appPath is explicitly specified
root := config.Conf.Root
if appPath != "" {
r, err := filepath.Abs(appPath)
@ -60,15 +61,13 @@ func Boot() {
}
root = r
}
// Load .env file, preserving the correct root
if envFile != "" {
config.Conf = config.LoadFrom(envFile)
config.Conf = config.LoadFromWithRoot(envFile, root)
} else {
config.Conf = config.LoadFrom(filepath.Join(root, ".env"))
config.Conf = config.LoadFromWithRoot(filepath.Join(root, ".env"), root)
}
if config.Conf.Mode == "production" {
config.Production()
} else if config.Conf.Mode == "development" {
config.Development()
}
config.ApplyMode()
}

View file

@ -32,52 +32,106 @@ func init() {
// Init setting
func Init() {
// Determine app root: YAO_ROOT env > find app.yao > current directory
root := os.Getenv("YAO_ROOT")
if root == "" {
root = findAppRoot()
}
if root == "" {
root = "."
}
filename, _ := filepath.Abs(filepath.Join(".", ".env"))
filename := filepath.Join(root, ".env")
if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) {
Conf = Load()
if Conf.Mode == "production" {
Production()
} else if Conf.Mode == "development" {
Development()
}
Conf = LoadWithRoot(root)
ApplyMode()
return
}
Conf = LoadFrom(filename)
if Conf.Mode == "production" {
// Load .env then override root if auto-detected
Conf = LoadFromWithRoot(filename, root)
ApplyMode()
}
// ApplyMode applies production or development mode based on Conf.Mode
func ApplyMode() {
switch Conf.Mode {
case "production":
Production()
} else if Conf.Mode == "development" {
case "development":
Development()
}
}
// findAppRoot finds the Yao application root directory by looking for app.yao
// It traverses up from the current directory until it finds app.yao or reaches the filesystem root
func findAppRoot() string {
dir, err := os.Getwd()
if err != nil {
return ""
}
for {
// Check for app.yao, app.json, or app.jsonc
for _, appFile := range []string{"app.yao", "app.json", "app.jsonc"} {
appFilePath := filepath.Join(dir, appFile)
if _, err := os.Stat(appFilePath); err == nil {
return dir
}
}
// Move to parent directory
parent := filepath.Dir(dir)
if parent == dir {
// Reached root, no app.yao found
break
}
dir = parent
}
return ""
}
// LoadFrom 从配置项中加载
func LoadFrom(envfile string) Config {
return LoadFromWithRoot(envfile, "")
}
// LoadFromWithRoot loads config from env file with optional root override
func LoadFromWithRoot(envfile string, root string) Config {
file, err := filepath.Abs(envfile)
if err != nil {
cfg := Load()
cfg := LoadWithRoot(root)
ReloadLog()
return cfg
}
// load from env
godotenv.Overload(file)
cfg := Load()
cfg := LoadWithRoot(root)
ReloadLog()
return cfg
}
// Load the config
func Load() Config {
return LoadWithRoot("")
}
// LoadWithRoot loads config with an optional root override
// If root is empty, uses YAO_ROOT env or current directory
func LoadWithRoot(root string) Config {
cfg := Config{}
if err := env.Parse(&cfg); err != nil {
exception.New("Can't read config %s", 500, err.Error()).Throw()
}
// Root path
cfg.Root, _ = filepath.Abs(cfg.Root)
// Root path: use provided root > env YAO_ROOT > default "."
if root != "" {
cfg.Root, _ = filepath.Abs(root)
} else {
cfg.Root, _ = filepath.Abs(cfg.Root)
}
// App Root
if cfg.AppSource == "" {