Archon/packages/workflows/src/executor-shared.test.ts
Rasmus Widing 3b8857d977 feat: add $DOCS_DIR variable for configurable documentation path (#982)
Projects with docs outside `docs/` (e.g., `packages/docs-web/src/content/docs/`)
get broken bundled commands because the path is hardcoded. Add `docs.path` to
`.archon/config.yaml` and thread it through the workflow engine as `$DOCS_DIR`
(default: `docs/`), following the same pipeline as `$BASE_BRANCH`.

Changes:
- Add `docs.path` to RepoConfig and `docsPath` to MergedConfig/WorkflowConfig
- Thread `docsDir` through executor-shared, executor, and dag-executor
- Update bundled commands to use `$DOCS_DIR` instead of hardcoded `docs/`
- Add optional docs path prompt to `archon setup`
- Add variable reference and configuration documentation
- Resolve pre-existing merge conflicts in server/api.ts

Fixes #982
2026-04-06 16:26:59 +03:00

270 lines
7.1 KiB
TypeScript

import { describe, it, expect, mock } from 'bun:test';
// Mock logger before importing module under test
const mockLogFn = mock(() => {});
const mockLogger = {
info: mockLogFn,
warn: mockLogFn,
error: mockLogFn,
debug: mockLogFn,
trace: mockLogFn,
fatal: mockLogFn,
child: mock(() => mockLogger),
bindings: mock(() => ({ module: 'test' })),
isLevelEnabled: mock(() => true),
level: 'info',
};
mock.module('@archon/paths', () => ({
createLogger: mock(() => mockLogger),
}));
import {
substituteWorkflowVariables,
buildPromptWithContext,
detectCreditExhaustion,
} from './executor-shared';
describe('substituteWorkflowVariables', () => {
it('replaces $WORKFLOW_ID with the run ID', () => {
const { prompt } = substituteWorkflowVariables(
'Run ID: $WORKFLOW_ID',
'run-123',
'hello',
'/tmp/artifacts',
'main',
'docs/'
);
expect(prompt).toBe('Run ID: run-123');
});
it('replaces $ARTIFACTS_DIR with the resolved path', () => {
const { prompt } = substituteWorkflowVariables(
'Save to $ARTIFACTS_DIR/output.txt',
'run-1',
'msg',
'/tmp/artifacts/runs/run-1',
'main',
'docs/'
);
expect(prompt).toBe('Save to /tmp/artifacts/runs/run-1/output.txt');
});
it('replaces $BASE_BRANCH with config value', () => {
const { prompt } = substituteWorkflowVariables(
'Merge into $BASE_BRANCH',
'run-1',
'msg',
'/tmp',
'develop',
'docs/'
);
expect(prompt).toBe('Merge into develop');
});
it('throws when $BASE_BRANCH is referenced but empty', () => {
expect(() =>
substituteWorkflowVariables('Merge into $BASE_BRANCH', 'run-1', 'msg', '/tmp', '', 'docs/')
).toThrow('No base branch could be resolved');
});
it('does not throw when $BASE_BRANCH is not referenced and baseBranch is empty', () => {
const { prompt } = substituteWorkflowVariables(
'No branch reference here',
'run-1',
'msg',
'/tmp',
'',
'docs/'
);
expect(prompt).toBe('No branch reference here');
});
it('replaces $USER_MESSAGE and $ARGUMENTS with user message', () => {
const { prompt } = substituteWorkflowVariables(
'Goal: $USER_MESSAGE. Args: $ARGUMENTS',
'run-1',
'add dark mode',
'/tmp',
'main',
'docs/'
);
expect(prompt).toBe('Goal: add dark mode. Args: add dark mode');
});
it('replaces $DOCS_DIR with configured path', () => {
const { prompt } = substituteWorkflowVariables(
'Check $DOCS_DIR for changes',
'run-1',
'msg',
'/tmp',
'main',
'packages/docs-web/src/content/docs'
);
expect(prompt).toBe('Check packages/docs-web/src/content/docs for changes');
});
it('replaces $DOCS_DIR with default docs/ when default passed', () => {
const { prompt } = substituteWorkflowVariables(
'Check $DOCS_DIR for changes',
'run-1',
'msg',
'/tmp',
'main',
'docs/'
);
expect(prompt).toBe('Check docs/ for changes');
});
it('does not affect prompts without $DOCS_DIR', () => {
const { prompt } = substituteWorkflowVariables(
'No docs reference here',
'run-1',
'msg',
'/tmp',
'main',
'custom/docs/'
);
expect(prompt).toBe('No docs reference here');
});
it('replaces $CONTEXT when issueContext is provided', () => {
const { prompt, contextSubstituted } = substituteWorkflowVariables(
'Fix this: $CONTEXT',
'run-1',
'msg',
'/tmp',
'main',
'docs/',
'## Issue #42\nBug report'
);
expect(prompt).toBe('Fix this: ## Issue #42\nBug report');
expect(contextSubstituted).toBe(true);
});
it('replaces $ISSUE_CONTEXT and $EXTERNAL_CONTEXT with issueContext', () => {
const { prompt } = substituteWorkflowVariables(
'Issue: $ISSUE_CONTEXT. External: $EXTERNAL_CONTEXT',
'run-1',
'msg',
'/tmp',
'main',
'docs/',
'context-data'
);
expect(prompt).toBe('Issue: context-data. External: context-data');
});
it('clears context variables when issueContext is undefined', () => {
const { prompt, contextSubstituted } = substituteWorkflowVariables(
'Context: $CONTEXT here',
'run-1',
'msg',
'/tmp',
'main',
'docs/'
);
expect(prompt).toBe('Context: here');
expect(contextSubstituted).toBe(false);
});
it('replaces $REJECTION_REASON with rejection reason', () => {
const { prompt } = substituteWorkflowVariables(
'Fix based on: $REJECTION_REASON',
'run-1',
'msg',
'/tmp',
'main',
'docs/',
undefined,
undefined,
'Missing error handling'
);
expect(prompt).toBe('Fix based on: Missing error handling');
});
it('clears $REJECTION_REASON when not provided', () => {
const { prompt } = substituteWorkflowVariables(
'Fix: $REJECTION_REASON',
'run-1',
'msg',
'/tmp',
'main',
'docs/'
);
expect(prompt).toBe('Fix: ');
});
});
describe('buildPromptWithContext', () => {
it('appends issueContext when no context variable in template', () => {
const result = buildPromptWithContext(
'Do the thing',
'run-1',
'msg',
'/tmp',
'main',
'docs/',
'## Issue #42\nDetails here',
'test prompt'
);
expect(result).toContain('Do the thing');
expect(result).toContain('## Issue #42');
});
it('does not append issueContext when $CONTEXT was substituted', () => {
const result = buildPromptWithContext(
'Fix this: $CONTEXT',
'run-1',
'msg',
'/tmp',
'main',
'docs/',
'## Issue #42\nDetails here',
'test prompt'
);
// Context was substituted inline, should not be appended again
const contextCount = (result.match(/## Issue #42/g) ?? []).length;
expect(contextCount).toBe(1);
});
it('returns prompt unchanged when no issueContext provided', () => {
const result = buildPromptWithContext(
'Do the thing',
'run-1',
'msg',
'/tmp',
'main',
'docs/',
undefined,
'test prompt'
);
expect(result).toBe('Do the thing');
});
});
describe('detectCreditExhaustion', () => {
it('detects "You\'re out of extra usage" (exact SDK phrase)', () => {
const result = detectCreditExhaustion("You're out of extra usage · resets in 2h");
expect(result).toBe('Credit exhaustion detected — resume when credits reset');
});
it('detects "out of credits" phrase', () => {
expect(detectCreditExhaustion('Sorry, you are out of credits.')).not.toBeNull();
});
it('detects "credit balance" phrase', () => {
expect(detectCreditExhaustion('Your credit balance is too low.')).not.toBeNull();
});
it('returns null for normal output', () => {
expect(detectCreditExhaustion('Here is the investigation summary...')).toBeNull();
});
it('detects "insufficient credit" phrase', () => {
expect(detectCreditExhaustion('Insufficient credit to continue.')).not.toBeNull();
});
it('is case-insensitive', () => {
expect(detectCreditExhaustion("YOU'RE OUT OF EXTRA USAGE")).not.toBeNull();
});
});