diff --git a/docs/README.md b/docs/README.md index fef2f22..4e4622c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,9 @@ Use it to understand what the project is for, which constraints must hold, which - the major runtime parts and how they fit together - `docs/architecture/auth-model.md` - the current runtime auth model and transitional bot token path +- `docs/architecture/testing.md` + - the repository's behavioral, integration-first testing strategy + - preferred test surfaces and when unit tests are justified ### Decision history - `docs/architecture/adrs/` @@ -65,6 +68,7 @@ Use it to understand what the project is for, which constraints must hold, which - Architecture guardrails: read `docs/architecture/invariants.md` - System shape: read `docs/architecture/overview.md` - Auth and bot identity model: read `docs/architecture/auth-model.md` +- Testing strategy and preferred test boundaries: read `docs/architecture/testing.md` - Terminology and identity definitions: read `docs/glossary.md` - "Why is it designed this way?": read the ADRs - "What is intentionally imperfect right now?": read `docs/project/debt.md` diff --git a/docs/architecture/testing.md b/docs/architecture/testing.md new file mode 100644 index 0000000..c8da5a6 --- /dev/null +++ b/docs/architecture/testing.md @@ -0,0 +1,153 @@ +# `orfe` testing strategy + +## Summary + +`orfe` prefers **behavioral tests over structural tests**. + +When choosing a test shape, optimize for the most confidence per test: + +1. prefer **live E2E** when the flow is high-value, safe, and deterministic +2. otherwise prefer **integration tests with minimal mocking** +3. use **unit tests rarely and intentionally** + +Broad behavioral coverage is more valuable here than a large count of narrow tests or inflated coverage numbers. + +This strategy preserves the architecture invariants from ADR 0001, ADR 0002, ADR 0007, and `docs/architecture/invariants.md`: tests should reinforce `orfe` as a generic runtime, keep the wrapper/core boundary explicit, and treat command slices as the semantic owners of command behavior. + +## Decision rule + +Before adding a test, ask: + +- what public or architectural boundary am I protecting? +- can a broader behavioral test cover this with the same or better confidence? +- am I mocking only true external boundaries? +- if this is a unit test, why would a broader integration test be insufficient or too costly? + +If a test mostly restates implementation structure, command metadata, or wrapper passthrough, it is probably not the right default. + +## Preferred test surfaces in this repository + +### 1. CLI boundary + +Prefer CLI tests when protecting: + +- command-line parsing +- usage errors and help output +- JSON success and error envelopes +- caller requirements for direct CLI usage + +Use the real `runCli` path with minimal stubbing around config, auth, and network boundaries. + +### 2. OpenCode tool boundary + +Prefer OpenCode tool tests when protecting: + +- `context.agent` handling +- tool-specific input rejection +- OpenCode result envelopes +- plugin/tool contract behavior that is not visible from CLI tests + +Test the real `executeOrfeTool` path. Only keep plugin-shape tests when the plugin schema itself is part of the public contract under protection. + +### 3. Core runtime boundary + +Prefer core tests when protecting: + +- wrapper-independent command behavior +- command dispatch, validation, and dependency loading rules +- auth/config/GitHub requirements at the runtime boundary + +These tests should exercise the real `runOrfeCore` composition path rather than mocking internal collaborators slice-by-slice. + +### 4. Command + GitHub integration path + +For GitHub-backed commands, default to integration tests that: + +- run the real command through CLI, tool, or core +- use real slice definitions and handlers +- mock only external boundaries such as GitHub HTTP, machine-local auth config, or repo-local config loading + +This is the main practical default for `orfe` today. + +### 5. Template runtime behavior + +Template loading, validation, provenance, and normalization should be tested behaviorally at the template/runtime boundary rather than by inspecting template-loading implementation details. + +## Mocking guidance + +Mock only true external boundaries when practical: + +- GitHub network calls +- machine-local auth/config files +- repo-local config discovery when a test needs a controlled repo shape +- runtime host context such as `context.agent` + +Avoid over-mocking internal collaborators when the real integration path is easy to exercise. + +## When unit tests are justified + +Unit tests are acceptable, but they are the exception rather than the default. + +Use them mainly for: + +- sensitive auth or security logic +- tricky normalization or validation logic +- small pure functions with failure modes that are hard to diagnose from higher-level tests +- performance-sensitive logic where isolated feedback materially helps + +Do **not** default to unit tests just because they are easy to write or increase coverage percentages. + +## Low-value test patterns to avoid + +Treat these categories skeptically and keep them only when they protect a meaningful public contract: + +- **thin forwarding tests** that only prove one wrapper passes inputs straight through +- **source-text assertions** that inspect implementation files instead of runtime behavior +- **shallow metadata tests** that mostly restate command literals, option declarations, or examples +- **duplicated routing/help assertions across layers** when one slice-local suite plus one real boundary suite already covers the contract +- **over-mocked tests** that replace practical internal integration with isolated doubles + +Metadata tests are justified only when the metadata itself is the contract, such as published package entrypoints or the OpenCode plugin schema. + +## Current suite review and changes applied in issue #159 + +This issue reviewed the existing suite against the strategy above and reduced several low-signal categories: + +- removed thin forwarding tests for the CLI entrypoint wrapper and OpenCode core-input passthrough +- removed duplicated runtime-routing/help tests from broad cross-cutting suites when slice-local help tests already covered the same behavior +- removed metadata-heavy command definition tests that mainly restated static option declarations instead of protecting runtime behavior +- removed the CLI source shebang source-text assertion because it inspected implementation text rather than observable behavior +- kept stronger behavioral tests around CLI execution, OpenCode behavior, core runtime behavior, template validation, and GitHub-backed command flows +- kept contract-oriented metadata checks only where the metadata itself is meaningful public surface, such as package entrypoints and plugin schema exposure + +The suite should continue moving toward fewer but broader behavioral tests, especially when a command already has strong CLI/core/tool coverage. + +## Live E2E guidance + +Live GitHub E2E is worth keeping or adding later for a **small smoke set**, not for every workflow. + +Highest-value candidates are: + +- bot-auth token minting against a real sandbox installation +- one read/write issue flow in a disposable sandbox repo +- one PR creation or update flow in a disposable sandbox repo +- one project status mutation smoke test if the sandbox setup is reliable + +Live E2E should stay selective because `orfe` depends on bot auth, GitHub state, and external API stability. Integration tests with mocked external boundaries remain the main default until safe sandbox E2E is routine. + +## Reviewer checklist + +When reviewing a new test, prefer these outcomes: + +- a meaningful repo boundary is exercised +- internal mocking is minimal +- the test protects behavior, not implementation layout +- a unit test includes a clear reason it is narrower than the default strategy +- no one is using coverage percentage as the primary success metric + +## Related docs + +- `docs/README.md` +- `docs/architecture/invariants.md` +- `docs/architecture/overview.md` +- `docs/project/debt.md` diff --git a/docs/project/debt.md b/docs/project/debt.md index 07e2550..a74a99c 100644 --- a/docs/project/debt.md +++ b/docs/project/debt.md @@ -38,3 +38,8 @@ This file keeps known documentation, architecture, and process debt visible so i - **Impact:** the repository now has a versioned template foundation under `.orfe/templates/`, but `.github/ISSUE_TEMPLATE/feature.md` and `.github/pull_request_template.md` still remain active human-facing fallback aids. That creates a temporary dual-source risk for artifact structure expectations. - **Current treatment:** treat versioned repo-defined templates as the canonical runtime source for validated agent-authored artifacts, while keeping the GitHub-native templates as transitional workflow aids that `orfe` does not read or depend on. - **Follow-up direction:** once contract-driven authoring and validation are in routine use, reduce or realign the GitHub-native templates intentionally so durable structure expectations do not drift. + +### 8. Live GitHub E2E coverage is still intentionally limited +- **Impact:** the testing strategy prefers live E2E where practical, but the current suite still relies mainly on integration tests with mocked GitHub boundaries. That leaves a gap in real bot-auth and live GitHub smoke coverage. +- **Current treatment:** keep integration tests as the main default because they are deterministic and practical, and reserve live E2E for future high-value smoke coverage only. +- **Follow-up direction:** add a small sandboxed live E2E smoke set for token minting and a few representative issue/PR/project flows when the repository has a safe, reliable sandbox path. diff --git a/src/commands/auth/token/definition.test.ts b/src/commands/auth/token/definition.test.ts deleted file mode 100644 index 8b89505..0000000 --- a/src/commands/auth/token/definition.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { authTokenCommand } from './definition.js'; - -test('auth token definition requires repo input and exposes token metadata examples', () => { - assertDefinitionIdentity(authTokenCommand, { name: 'auth token', group: 'auth', leaf: 'token', execution: 'github' }); - assertOption(authTokenCommand, 'repo', { flag: '--repo', type: 'string', required: true }); - assertValidInputExample(authTokenCommand); - assert.equal(authTokenCommand.successDataExample.app_slug, 'GR3G-BOT'); -}); diff --git a/src/commands/help/definition.test.ts b/src/commands/help/definition.test.ts index 82634ad..14e698b 100644 --- a/src/commands/help/definition.test.ts +++ b/src/commands/help/definition.test.ts @@ -4,16 +4,22 @@ import { test } from 'vitest'; import { COMMANDS, helpCommand } from '../index.js'; import { validateCommandInput } from '../registry/index.js'; -import { assertDefinitionIdentity, assertOption } from '../../../test/support/definition-test.js'; import { createHelpCommandSuccessData, createHelpRootSuccessData } from './definition.js'; test('help definition stays a top-level runtime command with optional targeted lookup', () => { - assertDefinitionIdentity(helpCommand, { name: 'help', group: 'help', leaf: 'help', execution: 'runtime', topLevel: true }); - assertOption(helpCommand, 'command_name', { - flag: '--command-name', - type: 'string', - description: 'Return detailed help for one canonical command name.', - }); + assert.equal(helpCommand.name, 'help'); + assert.equal(helpCommand.group, 'help'); + assert.equal(helpCommand.leaf, 'help'); + assert.equal(helpCommand.execution, 'runtime'); + assert.equal(helpCommand.topLevel, true); + assert.deepEqual(helpCommand.options, [ + { + key: 'command_name', + flag: '--command-name', + description: 'Return detailed help for one canonical command name.', + type: 'string', + }, + ]); assert.equal(helpCommand.requiresCaller, false); assert.equal(helpCommand.requiresRepoConfig, false); assert.equal(helpCommand.requiresAuthConfig, false); diff --git a/src/commands/issue/comment/definition.test.ts b/src/commands/issue/comment/definition.test.ts deleted file mode 100644 index 09f5d9e..0000000 --- a/src/commands/issue/comment/definition.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { issueCommentCommand } from './definition.js'; - -test('issue comment definition requires issue_number and body', () => { - assertDefinitionIdentity(issueCommentCommand, { name: 'issue comment', group: 'issue', leaf: 'comment', execution: 'github' }); - assertOption(issueCommentCommand, 'issue_number', { flag: '--issue-number', type: 'number', required: true }); - assertOption(issueCommentCommand, 'body', { flag: '--body', type: 'string', required: true }); - assertValidInputExample(issueCommentCommand); - assert.equal(issueCommentCommand.successDataExample.created, true); -}); diff --git a/src/commands/issue/create/definition.test.ts b/src/commands/issue/create/definition.test.ts deleted file mode 100644 index 8871112..0000000 --- a/src/commands/issue/create/definition.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { issueCreateCommand } from './definition.js'; - -test('issue create definition captures template, project, and multi-value option metadata', () => { - assertDefinitionIdentity(issueCreateCommand, { name: 'issue create', group: 'issue', leaf: 'create', execution: 'github' }); - assertOption(issueCreateCommand, 'title', { flag: '--title', type: 'string', required: true }); - assertOption(issueCreateCommand, 'labels', { flag: '--label', type: 'string-array' }); - assertOption(issueCreateCommand, 'add_to_project', { flag: '--add-to-project', type: 'boolean' }); - assertValidInputExample(issueCreateCommand); - assert.equal(issueCreateCommand.successDataExample.created, true); -}); diff --git a/src/commands/issue/get/definition.test.ts b/src/commands/issue/get/definition.test.ts deleted file mode 100644 index 9f71274..0000000 --- a/src/commands/issue/get/definition.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { issueGetCommand } from './definition.js'; - -test('issue get definition keeps issue_number required and repo optional', () => { - assertDefinitionIdentity(issueGetCommand, { name: 'issue get', group: 'issue', leaf: 'get', execution: 'github' }); - assertOption(issueGetCommand, 'issue_number', { flag: '--issue-number', type: 'number', required: true }); - assertOption(issueGetCommand, 'repo', { flag: '--repo', type: 'string' }); - assertValidInputExample(issueGetCommand); - assert.equal(issueGetCommand.successDataExample.issue_number, 13); -}); diff --git a/src/commands/issue/set-state/cli.test.ts b/src/commands/issue/set-state/cli.test.ts index 1c9ca22..100a300 100644 --- a/src/commands/issue/set-state/cli.test.ts +++ b/src/commands/issue/set-state/cli.test.ts @@ -246,3 +246,36 @@ test('runCli formats core invalid_usage errors as CLI usage failures', async () assert.match(result.stderr, /Example: ORFE_CALLER_NAME=Greg orfe issue set-state --issue-number 14 --state closed --state-reason completed/); assert.match(result.stderr, /See: orfe issue set-state --help/); }); + +test('runCli requires --duplicate-of when closing an issue as duplicate', async () => { + const result = await invokeCli(['issue', 'set-state', '--issue-number', '14', '--state', 'closed', '--state-reason', 'duplicate'], { + env: { ORFE_CALLER_NAME: 'Greg' }, + loadRepoConfigImpl: async () => { + throw new OrfeError('internal_error', 'loadRepoConfigImpl should not run'); + }, + }); + + assert.equal(result.exitCode, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /issue set-state requires --duplicate-of when state_reason=duplicate\./); + assert.match(result.stderr, /Usage: orfe issue set-state/); + assert.match(result.stderr, /See: orfe issue set-state --help/); +}); + +test('runCli rejects marking an issue as a duplicate of itself', async () => { + const result = await invokeCli( + ['issue', 'set-state', '--issue-number', '14', '--state', 'closed', '--state-reason', 'duplicate', '--duplicate-of', '14'], + { + env: { ORFE_CALLER_NAME: 'Greg' }, + loadRepoConfigImpl: async () => { + throw new OrfeError('internal_error', 'loadRepoConfigImpl should not run'); + }, + }, + ); + + assert.equal(result.exitCode, 2); + assert.equal(result.stdout, ''); + assert.match(result.stderr, /issue set-state cannot mark an issue as a duplicate of itself\./); + assert.match(result.stderr, /Usage: orfe issue set-state/); + assert.match(result.stderr, /See: orfe issue set-state --help/); +}); diff --git a/src/commands/issue/set-state/definition.test.ts b/src/commands/issue/set-state/definition.test.ts deleted file mode 100644 index 48d7c49..0000000 --- a/src/commands/issue/set-state/definition.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { validateCommandInput } from '../../registry/index.js'; -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { issueSetStateCommand } from './definition.js'; - -test('issue set-state definition keeps state enums and duplicate metadata in the contract', () => { - assertDefinitionIdentity(issueSetStateCommand, { name: 'issue set-state', group: 'issue', leaf: 'set-state', execution: 'github' }); - assertOption(issueSetStateCommand, 'state', { - flag: '--state', - type: 'enum', - required: true, - enumValues: ['open', 'closed'], - }); - assertOption(issueSetStateCommand, 'state_reason', { - flag: '--state-reason', - type: 'enum', - enumValues: ['completed', 'not_planned', 'duplicate'], - }); - assertValidInputExample(issueSetStateCommand); - assert.equal(issueSetStateCommand.successDataExample.changed, true); -}); - -test('issue set-state definition validation enforces duplicate-specific rules', () => { - assert.throws( - () => validateCommandInput(issueSetStateCommand, { issue_number: 13, state: 'open', state_reason: 'completed' }), - /issue set-state only allows state_reason when --state closed is used\./, - ); - assert.throws( - () => validateCommandInput(issueSetStateCommand, { issue_number: 13, state: 'closed', state_reason: 'duplicate' }), - /issue set-state requires --duplicate-of when state_reason=duplicate\./, - ); -}); diff --git a/src/commands/issue/update/definition.test.ts b/src/commands/issue/update/definition.test.ts deleted file mode 100644 index d627c93..0000000 --- a/src/commands/issue/update/definition.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { validateCommandInput } from '../../registry/index.js'; -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { issueUpdateCommand } from './definition.js'; - -test('issue update definition keeps clear-* mutation flags and valid example wiring', () => { - assertDefinitionIdentity(issueUpdateCommand, { name: 'issue update', group: 'issue', leaf: 'update', execution: 'github' }); - assertOption(issueUpdateCommand, 'clear_labels', { flag: '--clear-labels', type: 'boolean' }); - assertOption(issueUpdateCommand, 'clear_assignees', { flag: '--clear-assignees', type: 'boolean' }); - assertValidInputExample(issueUpdateCommand); - assert.equal(issueUpdateCommand.successDataExample.changed, true); -}); - -test('issue update definition validation rejects missing or conflicting mutations', () => { - assert.throws(() => validateCommandInput(issueUpdateCommand, { issue_number: 13 }), /issue update requires at least one mutation option\./); - assert.throws( - () => validateCommandInput(issueUpdateCommand, { issue_number: 13, labels: ['bug'], clear_labels: true }), - /issue update does not allow labels together with --clear-labels\./, - ); -}); diff --git a/src/commands/issue/validate/cli.test.ts b/src/commands/issue/validate/cli.test.ts index 09a2a8b..f70620a 100644 --- a/src/commands/issue/validate/cli.test.ts +++ b/src/commands/issue/validate/cli.test.ts @@ -2,9 +2,10 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { createGitHubClientFactory, createRuntimeDependencies, invokeCli } from '../../../../test/support/cli-test.js'; +import { invokeCli } from '../../../../test/support/cli-test.js'; +import { createRepoConfig } from '../../../../test/support/command-runtime.js'; -test('runCli prints structured success JSON for issue validate', async () => { +test('runCli validates issue bodies without caller identity, auth config, or GitHub access', async () => { const result = await invokeCli( [ 'issue', @@ -15,9 +16,11 @@ test('runCli prints structured success JSON for issue validate', async () => { 'formal-work-item@1.0.0', ], { - env: { ORFE_CALLER_NAME: 'Greg' }, - ...createRuntimeDependencies(), - githubClientFactory: createGitHubClientFactory(), + env: {}, + loadRepoConfigImpl: async () => createRepoConfig(), + loadAuthConfigImpl: async () => { + throw new Error('loadAuthConfigImpl should not run'); + }, }, ); @@ -53,9 +56,11 @@ test('runCli prints structured issue validation failures for issue validate', as 'formal-work-item@1.0.0', ], { - env: { ORFE_CALLER_NAME: 'Greg' }, - ...createRuntimeDependencies(), - githubClientFactory: createGitHubClientFactory(), + env: {}, + loadRepoConfigImpl: async () => createRepoConfig(), + loadAuthConfigImpl: async () => { + throw new Error('loadAuthConfigImpl should not run'); + }, }, ); diff --git a/src/commands/issue/validate/definition.test.ts b/src/commands/issue/validate/definition.test.ts deleted file mode 100644 index ce95474..0000000 --- a/src/commands/issue/validate/definition.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { issueValidateCommand } from './definition.js'; - -test('issue validate definition keeps body validation local and auth-free', () => { - assertDefinitionIdentity(issueValidateCommand, { name: 'issue validate', group: 'issue', leaf: 'validate', execution: 'github' }); - assertOption(issueValidateCommand, 'body', { flag: '--body', type: 'string', required: true }); - assertOption(issueValidateCommand, 'template', { flag: '--template', type: 'string' }); - assertValidInputExample(issueValidateCommand); - assert.equal(issueValidateCommand.requiresCaller, false); - assert.equal(issueValidateCommand.requiresAuthConfig, false); - assert.equal(issueValidateCommand.requiresGitHubAccess, false); - assert.equal(issueValidateCommand.successDataExample.template_source, 'explicit'); -}); diff --git a/src/commands/issue/validate/tool.test.ts b/src/commands/issue/validate/tool.test.ts index 7d72b7b..f4d8943 100644 --- a/src/commands/issue/validate/tool.test.ts +++ b/src/commands/issue/validate/tool.test.ts @@ -2,11 +2,12 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { runToolCommand } from '../../../../test/support/command-runtime.js'; +import { executeOrfeTool } from '../../../../src/opencode/tool.js'; +import { createToolDependencies } from '../../../../test/support/command-runtime.js'; -test('executeOrfeTool returns structured issue validation output', async () => { - const result = await runToolCommand({ - input: { +test('executeOrfeTool validates issue bodies without caller context for auth-free commands', async () => { + const result = await executeOrfeTool( + { command: 'issue validate', body: [ '## Problem / context', @@ -49,7 +50,17 @@ test('executeOrfeTool returns structured issue validation output', async () => { ].join('\n'), template: 'formal-work-item@1.0.0', }, - }); + { + cwd: '/tmp/repo', + }, + createToolDependencies({ + overrides: { + loadAuthConfigImpl: async () => { + throw new Error('loadAuthConfigImpl should not run'); + }, + }, + }), + ); assert.equal(result.ok, true); if (result.ok) { diff --git a/src/commands/pr/comment/definition.test.ts b/src/commands/pr/comment/definition.test.ts deleted file mode 100644 index 7a1f385..0000000 --- a/src/commands/pr/comment/definition.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { prCommentCommand } from './definition.js'; - -test('pr comment definition requires pr_number and body', () => { - assertDefinitionIdentity(prCommentCommand, { name: 'pr comment', group: 'pr', leaf: 'comment', execution: 'github' }); - assertOption(prCommentCommand, 'pr_number', { flag: '--pr-number', type: 'number', required: true }); - assertOption(prCommentCommand, 'body', { flag: '--body', type: 'string', required: true }); - assertValidInputExample(prCommentCommand); - assert.equal(prCommentCommand.successDataExample.created, true); -}); diff --git a/src/commands/pr/get-or-create/definition.test.ts b/src/commands/pr/get-or-create/definition.test.ts deleted file mode 100644 index 87b6eed..0000000 --- a/src/commands/pr/get-or-create/definition.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { prGetOrCreateCommand } from './definition.js'; - -test('pr get-or-create definition keeps branch, title, and draft metadata aligned', () => { - assertDefinitionIdentity(prGetOrCreateCommand, { name: 'pr get-or-create', group: 'pr', leaf: 'get-or-create', execution: 'github' }); - assertOption(prGetOrCreateCommand, 'head', { flag: '--head', type: 'string', required: true }); - assertOption(prGetOrCreateCommand, 'title', { flag: '--title', type: 'string', required: true }); - assertOption(prGetOrCreateCommand, 'draft', { flag: '--draft', type: 'boolean' }); - assertValidInputExample(prGetOrCreateCommand); - assert.equal(prGetOrCreateCommand.successDataExample.created, false); -}); diff --git a/src/commands/pr/get/definition.test.ts b/src/commands/pr/get/definition.test.ts deleted file mode 100644 index 813b661..0000000 --- a/src/commands/pr/get/definition.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { prGetCommand } from './definition.js'; - -test('pr get definition keeps pr_number required and repo optional', () => { - assertDefinitionIdentity(prGetCommand, { name: 'pr get', group: 'pr', leaf: 'get', execution: 'github' }); - assertOption(prGetCommand, 'pr_number', { flag: '--pr-number', type: 'number', required: true }); - assertOption(prGetCommand, 'repo', { flag: '--repo', type: 'string' }); - assertValidInputExample(prGetCommand); - assert.equal(prGetCommand.successDataExample.pr_number, 9); -}); diff --git a/src/commands/pr/reply/definition.test.ts b/src/commands/pr/reply/definition.test.ts deleted file mode 100644 index 7d463ac..0000000 --- a/src/commands/pr/reply/definition.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { prReplyCommand } from './definition.js'; - -test('pr reply definition requires pr_number, comment_id, and body', () => { - assertDefinitionIdentity(prReplyCommand, { name: 'pr reply', group: 'pr', leaf: 'reply', execution: 'github' }); - assertOption(prReplyCommand, 'pr_number', { flag: '--pr-number', type: 'number', required: true }); - assertOption(prReplyCommand, 'comment_id', { flag: '--comment-id', type: 'number', required: true }); - assertOption(prReplyCommand, 'body', { flag: '--body', type: 'string', required: true }); - assertValidInputExample(prReplyCommand); - assert.equal(prReplyCommand.successDataExample.in_reply_to_comment_id, 123456); -}); diff --git a/src/commands/pr/submit-review/definition.test.ts b/src/commands/pr/submit-review/definition.test.ts deleted file mode 100644 index cd44bbe..0000000 --- a/src/commands/pr/submit-review/definition.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { validateCommandInput } from '../../registry/index.js'; -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { prSubmitReviewCommand } from './definition.js'; - -test('pr submit-review definition keeps review submission inputs explicit', () => { - assertDefinitionIdentity(prSubmitReviewCommand, { - name: 'pr submit-review', - group: 'pr', - leaf: 'submit-review', - execution: 'github', - }); - assertOption(prSubmitReviewCommand, 'pr_number', { flag: '--pr-number', type: 'number', required: true }); - assertOption(prSubmitReviewCommand, 'event', { flag: '--event', type: 'string', required: true }); - assertOption(prSubmitReviewCommand, 'body', { flag: '--body', type: 'string', required: true }); - assertValidInputExample(prSubmitReviewCommand); - assert.equal(prSubmitReviewCommand.successDataExample.submitted, true); -}); - -test('pr submit-review definition validation rejects unknown review events', () => { - assert.throws( - () => validateCommandInput(prSubmitReviewCommand, { pr_number: 9, event: 'merge', body: 'Looks good' }), - /Review event must be one of: approve, request-changes, comment\./, - ); -}); diff --git a/src/commands/pr/update/definition.test.ts b/src/commands/pr/update/definition.test.ts deleted file mode 100644 index eff0c14..0000000 --- a/src/commands/pr/update/definition.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { validateCommandInput } from '../../registry/index.js'; -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { prUpdateCommand } from './definition.js'; - -test('pr update definition keeps title/body mutation metadata aligned', () => { - assertDefinitionIdentity(prUpdateCommand, { name: 'pr update', group: 'pr', leaf: 'update', execution: 'github' }); - assertOption(prUpdateCommand, 'pr_number', { flag: '--pr-number', type: 'number', required: true }); - assertOption(prUpdateCommand, 'template', { flag: '--template', type: 'string' }); - assertValidInputExample(prUpdateCommand); - assert.equal(prUpdateCommand.successDataExample.changed, true); -}); - -test('pr update definition validation rejects template-only and empty mutations', () => { - assert.throws( - () => validateCommandInput(prUpdateCommand, { pr_number: 9, template: 'implementation-ready@1.0.0' }), - /pr update only allows template together with --body\./, - ); - assert.throws(() => validateCommandInput(prUpdateCommand, { pr_number: 9 }), /pr update requires at least one mutation option\./); -}); diff --git a/src/commands/pr/validate/cli.test.ts b/src/commands/pr/validate/cli.test.ts index b7b707d..59e566d 100644 --- a/src/commands/pr/validate/cli.test.ts +++ b/src/commands/pr/validate/cli.test.ts @@ -2,9 +2,10 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { createGitHubClientFactory, createRuntimeDependencies, invokeCli } from '../../../../test/support/cli-test.js'; +import { invokeCli } from '../../../../test/support/cli-test.js'; +import { createRepoConfig } from '../../../../test/support/command-runtime.js'; -test('runCli prints structured success JSON for pr validate', async () => { +test('runCli validates PR bodies without caller identity, auth config, or GitHub access', async () => { const result = await invokeCli( [ 'pr', @@ -15,9 +16,11 @@ test('runCli prints structured success JSON for pr validate', async () => { 'implementation-ready@1.0.0', ], { - env: { ORFE_CALLER_NAME: 'Greg' }, - ...createRuntimeDependencies(), - githubClientFactory: createGitHubClientFactory(), + env: {}, + loadRepoConfigImpl: async () => createRepoConfig(), + loadAuthConfigImpl: async () => { + throw new Error('loadAuthConfigImpl should not run'); + }, }, ); @@ -44,9 +47,11 @@ test('runCli prints structured success JSON for pr validate', async () => { test('runCli prints structured PR validation failures for pr validate', async () => { const result = await invokeCli(['pr', 'validate', '--body', 'Ref: #58\n\nCloses: #58', '--template', 'implementation-ready@1.0.0'], { - env: { ORFE_CALLER_NAME: 'Greg' }, - ...createRuntimeDependencies(), - githubClientFactory: createGitHubClientFactory(), + env: {}, + loadRepoConfigImpl: async () => createRepoConfig(), + loadAuthConfigImpl: async () => { + throw new Error('loadAuthConfigImpl should not run'); + }, }); assert.equal(result.exitCode, 0); diff --git a/src/commands/pr/validate/definition.test.ts b/src/commands/pr/validate/definition.test.ts deleted file mode 100644 index 2e0f93e..0000000 --- a/src/commands/pr/validate/definition.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { prValidateCommand } from './definition.js'; - -test('pr validate definition keeps template validation local and auth-free', () => { - assertDefinitionIdentity(prValidateCommand, { name: 'pr validate', group: 'pr', leaf: 'validate', execution: 'github' }); - assertOption(prValidateCommand, 'body', { flag: '--body', type: 'string', required: true }); - assertOption(prValidateCommand, 'template', { flag: '--template', type: 'string' }); - assertValidInputExample(prValidateCommand); - assert.equal(prValidateCommand.requiresCaller, false); - assert.equal(prValidateCommand.requiresAuthConfig, false); - assert.equal(prValidateCommand.requiresGitHubAccess, false); - assert.equal(prValidateCommand.successDataExample.template?.template_name, 'implementation-ready'); -}); diff --git a/src/commands/project/get-status/definition.test.ts b/src/commands/project/get-status/definition.test.ts deleted file mode 100644 index da6f4b7..0000000 --- a/src/commands/project/get-status/definition.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { projectGetStatusCommand } from './definition.js'; - -test('project get-status definition keeps item-type enums and project overrides explicit', () => { - assertDefinitionIdentity(projectGetStatusCommand, { - name: 'project get-status', - group: 'project', - leaf: 'get-status', - execution: 'github', - }); - assertOption(projectGetStatusCommand, 'item_type', { - flag: '--item-type', - type: 'enum', - required: true, - enumValues: ['issue', 'pr'], - }); - assertOption(projectGetStatusCommand, 'item_number', { flag: '--item-number', type: 'number', required: true }); - assertValidInputExample(projectGetStatusCommand); - assert.equal(projectGetStatusCommand.successDataExample.status, 'In Progress'); -}); diff --git a/src/commands/project/set-status/definition.test.ts b/src/commands/project/set-status/definition.test.ts deleted file mode 100644 index 6425098..0000000 --- a/src/commands/project/set-status/definition.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; - -import { assertDefinitionIdentity, assertOption, assertValidInputExample } from '../../../../test/support/definition-test.js'; -import { projectSetStatusCommand } from './definition.js'; - -test('project set-status definition keeps target item and status mutation inputs explicit', () => { - assertDefinitionIdentity(projectSetStatusCommand, { - name: 'project set-status', - group: 'project', - leaf: 'set-status', - execution: 'github', - }); - assertOption(projectSetStatusCommand, 'item_type', { - flag: '--item-type', - type: 'enum', - required: true, - enumValues: ['issue', 'pr'], - }); - assertOption(projectSetStatusCommand, 'status', { flag: '--status', type: 'string', required: true }); - assertValidInputExample(projectSetStatusCommand); - assert.equal(projectSetStatusCommand.successDataExample.previous_status, 'Todo'); -}); diff --git a/test/cli/entrypoint.test.ts b/test/cli/entrypoint.test.ts deleted file mode 100644 index 3168d1a..0000000 --- a/test/cli/entrypoint.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import assert from 'node:assert/strict'; - -import { test } from 'vitest'; -import { runCliEntrypoint } from '../../src/cli/entrypoint.js'; - -import type { runCli } from '../../src/cli/run.js'; - -test('runCliEntrypoint forwards explicit argv to runCli and sets process.exitCode', async () => { - const observedCalls: string[][] = []; - const processStub: { exitCode: number | undefined } = { exitCode: undefined }; - const runCliImpl: typeof runCli = async (args) => { - observedCalls.push(args); - return 23; - }; - - const exitCode = await runCliEntrypoint({ - argv: ['issue', 'get', '--issue-number', '14'], - runCliImpl, - processImpl: processStub, - }); - - assert.equal(exitCode, 23); - assert.deepEqual(observedCalls, [['issue', 'get', '--issue-number', '14']]); - assert.equal(processStub.exitCode, 23); -}); - -test('runCliEntrypoint falls back to process argv tail when argv is omitted', async () => { - const processStub: { exitCode: number | undefined } = { exitCode: undefined }; - const originalArgv = process.argv; - const runCliImpl: typeof runCli = async (args) => { - assert.deepEqual(args, ['issue', 'get', '--issue-number', '14']); - return 0; - }; - - process.argv = ['/usr/bin/node', '/tmp/orfe', 'issue', 'get', '--issue-number', '14']; - - try { - const exitCode = await runCliEntrypoint({ - runCliImpl, - processImpl: processStub, - }); - - assert.equal(exitCode, 0); - assert.equal(processStub.exitCode, 0); - } finally { - process.argv = originalArgv; - } -}); diff --git a/test/core/runtime-routing.test.ts b/test/core/runtime-routing.test.ts deleted file mode 100644 index 02b079b..0000000 --- a/test/core/runtime-routing.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; - -import { COMMANDS } from '../../src/commands/index.js'; -import { createHelpCommandSuccessData, createHelpRootSuccessData } from '../../src/commands/help/definition.js'; -import { runOrfeCore } from '../../src/core/run.js'; - -test('runOrfeCore returns runtime info without caller, config, auth, or GitHub access', async () => { - const result = await runOrfeCore( - { - callerName: '', - command: 'runtime info', - input: {}, - entrypoint: 'cli', - }, - { - loadRepoConfigImpl: async () => { - throw new Error('loadRepoConfigImpl should not run'); - }, - loadAuthConfigImpl: async () => { - throw new Error('loadAuthConfigImpl should not run'); - }, - }, - ); - - assert.equal(result.ok, true); - assert.equal(result.command, 'runtime info'); - assert.equal(result.repo, undefined); - const data = result.data as { orfe_version: string; entrypoint: string }; - assert.match(data.orfe_version, /^\d+\.\d+\.\d+/); - assert.deepEqual(data, { - orfe_version: data.orfe_version, - entrypoint: 'cli', - }); -}); - -test('runOrfeCore returns root help without caller, config, auth, or GitHub access', async () => { - const result = await runOrfeCore( - { - callerName: '', - command: 'help', - input: {}, - entrypoint: 'opencode-plugin', - }, - { - loadRepoConfigImpl: async () => { - throw new Error('loadRepoConfigImpl should not run'); - }, - loadAuthConfigImpl: async () => { - throw new Error('loadAuthConfigImpl should not run'); - }, - }, - ); - - assert.equal(result.ok, true); - assert.equal(result.command, 'help'); - assert.equal(result.repo, undefined); - assert.deepEqual(result.data, createHelpRootSuccessData(COMMANDS)); -}); - -test('runOrfeCore returns targeted command help without caller, config, auth, or GitHub access', async () => { - const result = await runOrfeCore( - { - callerName: '', - command: 'help', - input: { command_name: 'runtime info' }, - entrypoint: 'opencode-plugin', - }, - { - loadRepoConfigImpl: async () => { - throw new Error('loadRepoConfigImpl should not run'); - }, - loadAuthConfigImpl: async () => { - throw new Error('loadAuthConfigImpl should not run'); - }, - }, - ); - - assert.equal(result.ok, true); - assert.equal(result.command, 'help'); - assert.equal(result.repo, undefined); - assert.deepEqual(result.data, createHelpCommandSuccessData(COMMANDS, 'runtime info')); -}); - -test('runOrfeCore returns targeted command help with explicit requirements for representative commands', async () => { - for (const commandName of ['issue get', 'pr get-or-create', 'project set-status'] as const) { - const result = await runOrfeCore( - { - callerName: '', - command: 'help', - input: { command_name: commandName }, - entrypoint: 'opencode-plugin', - }, - { - loadRepoConfigImpl: async () => { - throw new Error('loadRepoConfigImpl should not run'); - }, - loadAuthConfigImpl: async () => { - throw new Error('loadAuthConfigImpl should not run'); - }, - }, - ); - - assert.equal(result.ok, true); - assert.equal(result.command, 'help'); - assert.deepEqual(result.data, createHelpCommandSuccessData(COMMANDS, commandName)); - } -}); diff --git a/test/opencode/core-input-forwarding.test.ts b/test/opencode/core-input-forwarding.test.ts deleted file mode 100644 index c7f8fb6..0000000 --- a/test/opencode/core-input-forwarding.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; - -import type { OrfeCoreRequest } from '../../src/core/types.js'; -import type { SuccessResponse } from '../../src/runtime/response.js'; -import { executeOrfeTool } from '../../src/opencode/tool.js'; - -test('executeOrfeTool forwards caller identity and common path overrides as plain core input', async () => { - let capturedRequest: OrfeCoreRequest | undefined; - let receivedAgentInCore = false; - - const result = await executeOrfeTool( - { - command: 'issue get', - issue_number: 14, - config: '/tmp/.orfe/config.json', - auth_config: '/tmp/auth.json', - }, - { - agent: { name: 'Greg', role: 'implementation-owner' }, - cwd: '/tmp/repo', - }, - { - runOrfeCoreImpl: async (request) => { - capturedRequest = request; - receivedAgentInCore = 'agent' in (request as unknown as Record); - - return { - ok: true, - command: 'issue get', - repo: 'throw-if-null/orfe', - data: { issue_number: 14 }, - } satisfies SuccessResponse>; - }, - }, - ); - - assert.deepEqual(result, { - ok: true, - command: 'issue get', - repo: 'throw-if-null/orfe', - data: { issue_number: 14 }, - }); - assert.deepEqual(capturedRequest, { - callerName: 'Greg', - command: 'issue get', - input: { issue_number: 14 }, - entrypoint: 'opencode-plugin', - configPath: '/tmp/.orfe/config.json', - authConfigPath: '/tmp/auth.json', - cwd: '/tmp/repo', - logger: capturedRequest?.logger, - }); - assert.equal(typeof capturedRequest?.logger?.error, 'function'); - assert.equal(capturedRequest?.logger?.level, 'error'); - assert.equal(receivedAgentInCore, false); -}); diff --git a/test/opencode/runtime-routing.test.ts b/test/opencode/runtime-routing.test.ts deleted file mode 100644 index 50b2300..0000000 --- a/test/opencode/runtime-routing.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import assert from 'node:assert/strict'; -import { test } from 'vitest'; - -import { COMMANDS } from '../../src/commands/index.js'; -import { createHelpCommandSuccessData, createHelpRootSuccessData } from '../../src/commands/help/definition.js'; -import { executeOrfeTool } from '../../src/opencode/tool.js'; - -test('executeOrfeTool returns runtime info through the shared success envelope without caller context', async () => { - const result = await executeOrfeTool( - { - command: 'runtime info', - }, - {}, - { - loadRepoConfigImpl: async () => { - throw new Error('loadRepoConfigImpl should not run'); - }, - loadAuthConfigImpl: async () => { - throw new Error('loadAuthConfigImpl should not run'); - }, - }, - ); - - assert.equal(result.ok, true); - if (result.ok) { - assert.equal(result.command, 'runtime info'); - assert.equal(result.repo, undefined); - assert.match(String((result.data as { orfe_version: string }).orfe_version), /^\d+\.\d+\.\d+/); - assert.deepEqual(result.data, { - orfe_version: (result.data as { orfe_version: string }).orfe_version, - entrypoint: 'opencode-plugin', - }); - } -}); - -test('executeOrfeTool returns root help through the shared success envelope without caller context', async () => { - const result = await executeOrfeTool( - { - command: 'help', - }, - {}, - { - loadRepoConfigImpl: async () => { - throw new Error('loadRepoConfigImpl should not run'); - }, - loadAuthConfigImpl: async () => { - throw new Error('loadAuthConfigImpl should not run'); - }, - }, - ); - - assert.equal(result.ok, true); - if (result.ok) { - assert.equal(result.command, 'help'); - assert.equal(result.repo, undefined); - assert.deepEqual(result.data, createHelpRootSuccessData(COMMANDS)); - } -}); - -test('executeOrfeTool returns targeted command help through the shared success envelope without caller context', async () => { - const result = await executeOrfeTool( - { - command: 'help', - command_name: 'issue get', - }, - {}, - { - loadRepoConfigImpl: async () => { - throw new Error('loadRepoConfigImpl should not run'); - }, - loadAuthConfigImpl: async () => { - throw new Error('loadAuthConfigImpl should not run'); - }, - }, - ); - - assert.equal(result.ok, true); - if (result.ok) { - assert.equal(result.command, 'help'); - assert.equal(result.repo, undefined); - assert.deepEqual(result.data, createHelpCommandSuccessData(COMMANDS, 'issue get')); - } -}); - -test('executeOrfeTool returns representative targeted help across issue, pr, and project commands', async () => { - for (const commandName of ['issue get', 'pr update', 'project set-status'] as const) { - const result = await executeOrfeTool( - { - command: 'help', - command_name: commandName, - }, - {}, - { - loadRepoConfigImpl: async () => { - throw new Error('loadRepoConfigImpl should not run'); - }, - loadAuthConfigImpl: async () => { - throw new Error('loadAuthConfigImpl should not run'); - }, - }, - ); - - assert.equal(result.ok, true); - if (result.ok) { - assert.deepEqual(result.data, createHelpCommandSuccessData(COMMANDS, commandName)); - } - } -}); diff --git a/test/package.test.ts b/test/package.test.ts index 6c1a7be..5cfbafe 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -27,12 +27,6 @@ test('package manifest keeps the public CLI and plugin entrypoints stable', asyn assert.ok(files?.includes('README.md')); }); -test('CLI source keeps a node shebang for packaged execution', async () => { - const cliSource = await readFile(resolve(workspaceRoot, 'src/cli/entrypoint.ts'), 'utf8'); - - assert.match(cliSource, /^#!\/usr\/bin\/env node/m); -}); - test('runtime info reads the active package version at runtime', async () => { const packageJson = await readJsonFile(resolve(workspaceRoot, 'package.json')); diff --git a/test/support/definition-test.ts b/test/support/definition-test.ts deleted file mode 100644 index 7df3c81..0000000 --- a/test/support/definition-test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import assert from 'node:assert/strict'; - -import { validateCommandInput, type CommandDefinition, type CommandOptionDefinition } from '../../src/commands/registry/index.js'; - -interface ExpectedDefinitionIdentity { - name: string; - group: string; - leaf: string; - execution: 'github' | 'runtime'; - topLevel?: boolean; -} - -export function assertDefinitionIdentity(definition: CommandDefinition, expected: ExpectedDefinitionIdentity): void { - assert.equal(definition.name, expected.name); - assert.equal(definition.group, expected.group); - assert.equal(definition.leaf, expected.leaf); - assert.equal(definition.execution, expected.execution); - assert.equal(definition.topLevel ?? false, expected.topLevel ?? false); -} - -export function assertOption( - definition: CommandDefinition, - key: string, - expected: Partial, -): CommandOptionDefinition { - const option = definition.options.find((candidate) => candidate.key === key); - assert.ok(option, `Expected option "${key}" on command "${definition.name}".`); - - for (const [propertyName, propertyValue] of Object.entries(expected) as Array<[ - keyof CommandOptionDefinition, - CommandOptionDefinition[keyof CommandOptionDefinition] | undefined, - ]>) { - assert.deepEqual(option[propertyName], propertyValue); - } - - return option; -} - -export function assertValidInputExample(definition: CommandDefinition): void { - assert.deepEqual( - validateCommandInput(definition as CommandDefinition>, definition.validInputExample), - definition.validInputExample, - ); -}