mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-10-02 05:24:34 +08:00
feat(view): display workflow status in active changes (#807)
* feat(view): display workflow status in active changes Active changes now show workflow artifact status below each entry, indicating schema name and completion state of each artifact (done✓, ready→, blocked). Powered by loadChangeContext and formatChangeStatus. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * chore(release): track dashboard workflow status * fix(view): neutralize controls in workflow output * docs(view): move workflow status docs to docs-lab * docs(view): preserve store in status guidance --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Clay Good <hi@claygood.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
Clay Good
parent
070de01dfa
commit
c21d897261
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@fission-ai/openspec": minor
|
||||
---
|
||||
|
||||
### New Features
|
||||
|
||||
- **Dashboard workflow status**: `openspec view` now shows each active change's schema and which artifacts are done, ready, blocked, or skipped. Task progress remains visible if a workflow cannot be loaded. Thanks to @Million-mo for the original contribution in #807.
|
||||
@@ -661,6 +661,21 @@ Use openspec list --changes or openspec list --specs for detailed views
|
||||
|
||||
A `Task Progress` summary line appears when any change has tasks underway.
|
||||
|
||||
Each active change also shows its schema and artifact states below its task progress bar:
|
||||
|
||||
```text
|
||||
└─ [spec-driven] proposal✓ specs→ design→ tasks✓
|
||||
```
|
||||
|
||||
| Marker | Artifact state |
|
||||
|---|---|
|
||||
| `✓` | Its output exists. An existing tasks artifact is done even when its checklist is unfinished. |
|
||||
| `→` | It is ready to create. |
|
||||
| No marker | It is blocked by a missing dependency. |
|
||||
| `(skipped)` | The change skips it. |
|
||||
|
||||
If a workflow cannot be loaded, view prints a warning and keeps that change's task progress visible. Run `openspec status --change <name>` to inspect the workflow separately. After `openspec view --store <id>`, pass the same `--store <id>` to status.
|
||||
|
||||
**Exit codes**
|
||||
|
||||
- `0`: dashboard printed.
|
||||
|
||||
@@ -45,6 +45,35 @@ The dashboard SHALL show active changes with visual progress indicators.
|
||||
- **AND** treat missing progress values as 0% for ordering
|
||||
- **AND** break ties by change identifier in ascending alphabetical order to keep output deterministic
|
||||
|
||||
### Requirement: Active Change Workflow Status
|
||||
|
||||
The dashboard SHALL show each active change's schema and artifact states beneath its task progress, using the same workflow resolution as `openspec status`. Workflow status SHALL NOT change task progress, change categories, or sorting.
|
||||
|
||||
#### Scenario: Workflow states
|
||||
|
||||
- **WHEN** an active change's workflow can be loaded
|
||||
- **THEN** show the schema name and artifacts in dependency order
|
||||
- **AND** mark existing artifact outputs with `✓`, ready artifacts with `→`, blocked artifacts with no symbol, and skipped artifacts with `(skipped)`
|
||||
- **AND** treat an existing tasks artifact as done even when its implementation checklist is unfinished
|
||||
|
||||
#### Scenario: Store-local workflow
|
||||
|
||||
- **WHEN** the dashboard targets a store through `--store` or a project store pointer
|
||||
- **THEN** resolve workflow schemas and artifact files from that store
|
||||
- **AND** use the store's default schema for changes without a schema in their metadata
|
||||
|
||||
#### Scenario: Invalid workflow
|
||||
|
||||
- **WHEN** an active change's metadata or schema cannot be loaded
|
||||
- **THEN** print a warning identifying the change and the error
|
||||
- **AND** omit only that change's workflow status while retaining its task progress and rendering other changes
|
||||
|
||||
#### Scenario: Terminal controls in workflow text
|
||||
|
||||
- **WHEN** schema names, artifact identifiers, or workflow errors contain terminal control characters
|
||||
- **THEN** replace those characters with inert text in the dashboard output
|
||||
- **AND** preserve the underlying identifiers and workflow states
|
||||
|
||||
### Requirement: Completed Changes Display
|
||||
|
||||
The dashboard SHALL list completed changes in a separate section, only showing changes with ALL tasks completed.
|
||||
@@ -126,4 +155,3 @@ The dashboard SHALL display changes without tasks in a separate "Draft" section.
|
||||
|
||||
- **WHEN** multiple draft changes exist
|
||||
- **THEN** system sorts them alphabetically by name
|
||||
|
||||
|
||||
+39
-3
@@ -4,6 +4,7 @@ import chalk from 'chalk';
|
||||
import { getTaskProgressForChange, formatTaskStatus } from '../utils/task-progress.js';
|
||||
import { MarkdownParser } from './parsers/markdown-parser.js';
|
||||
import { discoverSpecFiles } from '../utils/spec-discovery.js';
|
||||
import { loadChangeContext, formatChangeStatus, type ChangeStatus } from './artifact-graph/index.js';
|
||||
|
||||
export class ViewCommand {
|
||||
async execute(targetPath: string = '.'): Promise<void> {
|
||||
@@ -51,6 +52,10 @@ export class ViewCommand {
|
||||
console.log(
|
||||
` ${chalk.yellow('◉')} ${chalk.bold(change.name.padEnd(maxNameLength))} ${progressBar} ${chalk.dim(`${percentage}%`)}`
|
||||
);
|
||||
if (change.workflowStatus) {
|
||||
const { schemaName, artifacts } = change.workflowStatus;
|
||||
console.log(` ${chalk.dim(`└─ [${this.sanitizeWorkflowText(schemaName)}]`)} ${this.formatWorkflowArtifacts(artifacts)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,17 +90,18 @@ export class ViewCommand {
|
||||
|
||||
private async getChangesData(openspecDir: string): Promise<{
|
||||
draft: Array<{ name: string }>;
|
||||
active: Array<{ name: string; progress: { total: number; completed: number } }>;
|
||||
active: Array<{ name: string; progress: { total: number; completed: number }; workflowStatus?: ChangeStatus }>;
|
||||
completed: Array<{ name: string }>;
|
||||
}> {
|
||||
const changesDir = path.join(openspecDir, 'changes');
|
||||
const projectRoot = path.dirname(openspecDir);
|
||||
|
||||
if (!fs.existsSync(changesDir)) {
|
||||
return { draft: [], active: [], completed: [] };
|
||||
}
|
||||
|
||||
const draft: Array<{ name: string }> = [];
|
||||
const active: Array<{ name: string; progress: { total: number; completed: number } }> = [];
|
||||
const active: Array<{ name: string; progress: { total: number; completed: number }; workflowStatus?: ChangeStatus }> = [];
|
||||
const completed: Array<{ name: string }> = [];
|
||||
|
||||
const entries = fs.readdirSync(changesDir, { withFileTypes: true });
|
||||
@@ -112,7 +118,16 @@ export class ViewCommand {
|
||||
completed.push({ name: entry.name });
|
||||
} else {
|
||||
// Has tasks but not all complete
|
||||
active.push({ name: entry.name, progress });
|
||||
let workflowStatus: ChangeStatus | undefined;
|
||||
try {
|
||||
workflowStatus = formatChangeStatus(loadChangeContext(projectRoot, entry.name));
|
||||
} catch (error) {
|
||||
// Preserve task progress even when this change's workflow cannot be loaded.
|
||||
console.warn(chalk.yellow(this.sanitizeWorkflowText(
|
||||
`Could not load workflow status for "${entry.name}": ${error instanceof Error ? error.message : String(error)}`
|
||||
)));
|
||||
}
|
||||
active.push({ name: entry.name, progress, workflowStatus });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -202,6 +217,27 @@ export class ViewCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeWorkflowText(value: string): string {
|
||||
// Metadata may contain terminal controls; mask them before adding our own colors.
|
||||
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, '?');
|
||||
}
|
||||
|
||||
private formatWorkflowArtifacts(artifacts: ChangeStatus['artifacts']): string {
|
||||
return artifacts.map((artifact) => {
|
||||
const id = this.sanitizeWorkflowText(artifact.id);
|
||||
switch (artifact.status) {
|
||||
case 'done':
|
||||
return `${id}${chalk.green('✓')}`;
|
||||
case 'ready':
|
||||
return `${id}${chalk.cyan('→')}`;
|
||||
case 'skipped':
|
||||
return chalk.dim(`${id} (skipped)`);
|
||||
case 'blocked':
|
||||
return chalk.dim(id);
|
||||
}
|
||||
}).join(' ');
|
||||
}
|
||||
|
||||
private createProgressBar(completed: number, total: number, width: number = 20): string {
|
||||
if (total === 0) return chalk.dim('─'.repeat(width));
|
||||
|
||||
|
||||
@@ -13,8 +13,38 @@ import { cleanupTempPath } from '../helpers/temp-cleanup.js';
|
||||
*/
|
||||
|
||||
const STORE_ID = 'view-store';
|
||||
const SCHEMA_NAME = 'store-billing';
|
||||
const TIMEOUT_MS = 60_000;
|
||||
|
||||
const STORE_SCHEMA = `name: ${SCHEMA_NAME}
|
||||
version: 1
|
||||
description: Billing workflow owned by the registered store
|
||||
artifacts:
|
||||
- id: proposal
|
||||
generates: planning/proposal.md
|
||||
description: Billing change proposal
|
||||
template: proposal.md
|
||||
requires: []
|
||||
- id: specs
|
||||
generates: specs/**/*.md
|
||||
description: Billing behavior
|
||||
template: spec.md
|
||||
requires: [proposal]
|
||||
- id: design
|
||||
generates: planning/design.md
|
||||
description: Billing implementation design
|
||||
template: design.md
|
||||
requires: [specs]
|
||||
- id: tasks
|
||||
generates: execution/tasks.md
|
||||
description: Implementation checklist
|
||||
template: tasks.md
|
||||
requires: [design]
|
||||
apply:
|
||||
requires: [tasks]
|
||||
tracks: execution/tasks.md
|
||||
`;
|
||||
|
||||
let base: string;
|
||||
let storeRoot: string;
|
||||
let pointerProject: string;
|
||||
@@ -60,10 +90,40 @@ beforeAll(async () => {
|
||||
await fs.mkdir(specDir, { recursive: true });
|
||||
await fs.writeFile(path.join(specDir, 'spec.md'), SPEC);
|
||||
|
||||
const schemaDir = path.join(storeRoot, 'openspec', 'schemas', SCHEMA_NAME);
|
||||
await fs.mkdir(path.join(schemaDir, 'templates'), { recursive: true });
|
||||
await fs.writeFile(path.join(schemaDir, 'schema.yaml'), STORE_SCHEMA);
|
||||
for (const template of ['proposal.md', 'spec.md', 'design.md', 'tasks.md']) {
|
||||
await fs.writeFile(path.join(schemaDir, 'templates', template), '# Billing\n');
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(storeRoot, 'openspec', 'config.yaml'),
|
||||
`schema: ${SCHEMA_NAME}\n`
|
||||
);
|
||||
|
||||
for (const changeName of ['billing-update', 'billing-refactor']) {
|
||||
const changeDir = path.join(storeRoot, 'openspec', 'changes', changeName);
|
||||
await fs.mkdir(path.join(changeDir, 'planning'), { recursive: true });
|
||||
await fs.mkdir(path.join(changeDir, 'execution'), { recursive: true });
|
||||
await fs.writeFile(path.join(changeDir, 'planning', 'proposal.md'), '# Update billing\n');
|
||||
// A written tasks artifact is done even while its checklist is unfinished.
|
||||
await fs.writeFile(
|
||||
path.join(changeDir, 'execution', 'tasks.md'),
|
||||
'- [x] Audit billing\n- [ ] Implement billing change\n'
|
||||
);
|
||||
if (changeName === 'billing-refactor') {
|
||||
await fs.writeFile(
|
||||
path.join(changeDir, '.openspec.yaml'),
|
||||
`schema: ${SCHEMA_NAME}\nskip_specs: true\n`
|
||||
);
|
||||
}
|
||||
// billing-update deliberately inherits the store's schema from config.yaml.
|
||||
}
|
||||
|
||||
await fs.mkdir(path.join(pointerProject, 'openspec'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(pointerProject, 'openspec', 'config.yaml'),
|
||||
`store: ${STORE_ID}\n`
|
||||
`store: ${STORE_ID}\nschema: spec-driven\n`
|
||||
);
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
@@ -72,36 +132,46 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe('openspec view root resolution', () => {
|
||||
it(
|
||||
'follows a store pointer declared in openspec/config.yaml',
|
||||
async () => {
|
||||
const result = await runCLI(['view'], {
|
||||
cwd: pointerProject,
|
||||
env,
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
it.each([
|
||||
{ route: 'a declared store pointer', directory: 'project', storeArgs: [] },
|
||||
{ route: 'explicit --store', directory: 'outside', storeArgs: ['--store', STORE_ID] },
|
||||
])(
|
||||
'resolves workflow schemas and artifacts from the store via $route',
|
||||
async ({ directory, storeArgs }) => {
|
||||
const cwd = path.join(base, directory);
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
const options = { cwd, env, timeoutMs: TIMEOUT_MS };
|
||||
const result = await runCLI(['view', ...storeArgs], options);
|
||||
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain('1 specs, 1 requirements');
|
||||
expect(result.stdout).toContain('billing');
|
||||
},
|
||||
TIMEOUT_MS
|
||||
);
|
||||
expect(result.stdout).toContain('Active Changes: 2 in progress');
|
||||
expect(result.stdout).toContain('Task Progress: 2/4 (50% complete)');
|
||||
const lines = result.stdout.split(/\r?\n/);
|
||||
|
||||
it(
|
||||
'targets a registered store when --store is passed',
|
||||
async () => {
|
||||
const outside = path.join(base, 'outside');
|
||||
await fs.mkdir(outside, { recursive: true });
|
||||
for (const changeName of ['billing-update', 'billing-refactor']) {
|
||||
const skipped = changeName === 'billing-refactor';
|
||||
const statusResult = await runCLI(
|
||||
['status', '--change', changeName, '--json', ...storeArgs],
|
||||
options
|
||||
);
|
||||
expect(statusResult.exitCode, statusResult.stderr).toBe(0);
|
||||
const status = JSON.parse(statusResult.stdout);
|
||||
expect(status.schemaName).toBe(SCHEMA_NAME);
|
||||
expect(status.artifacts).toMatchObject([
|
||||
{ id: 'proposal', status: 'done' },
|
||||
{ id: 'specs', status: skipped ? 'skipped' : 'ready' },
|
||||
{ id: 'design', status: skipped ? 'ready' : 'blocked' },
|
||||
{ id: 'tasks', status: 'done' },
|
||||
]);
|
||||
|
||||
const result = await runCLI(['view', '--store', STORE_ID], {
|
||||
cwd: outside,
|
||||
env,
|
||||
timeoutMs: TIMEOUT_MS,
|
||||
});
|
||||
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
expect(result.stdout).toContain('1 specs, 1 requirements');
|
||||
const changeLine = lines.findIndex((line) => line.includes(`◉ ${changeName}`));
|
||||
expect(changeLine).toBeGreaterThanOrEqual(0);
|
||||
expect.soft(lines[changeLine + 1]).toBe(
|
||||
` └─ [${SCHEMA_NAME}] proposal✓ ${skipped ? 'specs (skipped) design→' : 'specs→ design'} tasks✓`
|
||||
);
|
||||
}
|
||||
},
|
||||
TIMEOUT_MS
|
||||
);
|
||||
|
||||
+126
-4
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
@@ -13,6 +13,8 @@ describe('ViewCommand', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openspec-view-test-'));
|
||||
vi.stubEnv('XDG_DATA_HOME', path.join(tempDir, 'data'));
|
||||
vi.stubEnv('XDG_CONFIG_HOME', path.join(tempDir, 'config'));
|
||||
|
||||
originalLog = console.log;
|
||||
console.log = (...args: any[]) => {
|
||||
@@ -24,9 +26,123 @@ describe('ViewCommand', () => {
|
||||
|
||||
afterEach(async () => {
|
||||
console.log = originalLog;
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('shows workflow states for legacy active changes without changing task progress', async () => {
|
||||
const changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
for (const name of ['planning', 'implementing']) {
|
||||
await fs.mkdir(path.join(changesDir, name), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, name, 'tasks.md'), '- [x] Done\n- [ ] Pending\n');
|
||||
}
|
||||
await fs.writeFile(path.join(changesDir, 'implementing', 'proposal.md'), '# Proposal\n');
|
||||
|
||||
await new ViewCommand().execute(tempDir);
|
||||
|
||||
const lines = logOutput.map(stripAnsi);
|
||||
const planningIndex = lines.findIndex(line => line.includes('◉') && line.includes('planning'));
|
||||
const implementingIndex = lines.findIndex(line => line.includes('◉') && line.includes('implementing'));
|
||||
expect(lines[planningIndex + 1]).toBe(' └─ [spec-driven] proposal→ specs design tasks✓');
|
||||
expect(lines[implementingIndex + 1]).toBe(' └─ [spec-driven] proposal✓ specs→ design→ tasks✓');
|
||||
expect(lines[planningIndex]).toContain('50%');
|
||||
expect(lines[implementingIndex]).toContain('50%');
|
||||
expect(lines.join('\n')).toContain('Task Progress: 2/4 (50% complete)');
|
||||
});
|
||||
|
||||
it('distinguishes skipped specs from completed and blocked artifacts', async () => {
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', 'refactor');
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: spec-driven\nskip_specs: true\n');
|
||||
await fs.writeFile(path.join(changeDir, 'proposal.md'), '# Proposal\n');
|
||||
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Refactor\n');
|
||||
|
||||
await new ViewCommand().execute(tempDir);
|
||||
|
||||
expect(logOutput.map(stripAnsi)).toContain(
|
||||
' └─ [spec-driven] proposal✓ specs (skipped) design→ tasks✓'
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['malformed YAML', 'schema: [', 'Invalid YAML'],
|
||||
['invalid metadata', 'schema: 123\n', 'Invalid metadata'],
|
||||
['unknown schema', 'schema: missing-workflow\n', "Unknown schema 'missing-workflow'"],
|
||||
])('warns about %s without hiding changes or breaking the dashboard', async (_name, metadata, error) => {
|
||||
const changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
for (const name of ['broken', 'healthy']) {
|
||||
await fs.mkdir(path.join(changesDir, name), { recursive: true });
|
||||
await fs.writeFile(path.join(changesDir, name, 'tasks.md'), '- [ ] Pending\n');
|
||||
}
|
||||
await fs.writeFile(path.join(changesDir, 'broken', '.openspec.yaml'), metadata);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await new ViewCommand().execute(tempDir);
|
||||
|
||||
const lines = logOutput.map(stripAnsi);
|
||||
expect(lines.filter(line => line.includes('◉'))).toHaveLength(2);
|
||||
expect(lines.filter(line => line.includes('└─'))).toEqual([
|
||||
' └─ [spec-driven] proposal→ specs design tasks✓',
|
||||
]);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(stripAnsi(String(warn.mock.calls[0][0]))).toContain('Could not load workflow status for "broken":');
|
||||
expect(stripAnsi(String(warn.mock.calls[0][0]))).toContain(error);
|
||||
expect(lines.join('\n')).toContain('Task Progress: 0/2 (0% complete)');
|
||||
});
|
||||
|
||||
it('renders terminal controls in workflow errors as inert text', async () => {
|
||||
const changeDir = path.join(tempDir, 'openspec', 'changes', 'broken');
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Pending\n');
|
||||
await fs.writeFile(
|
||||
path.join(changeDir, '.openspec.yaml'),
|
||||
JSON.stringify({ schema: 'missing\u001b[2J\u009bH\r\nFORGED' })
|
||||
);
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
await new ViewCommand().execute(tempDir);
|
||||
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(stripAnsi(String(warn.mock.calls[0][0]))).toContain("Unknown schema 'missing?[2J?H??FORGED'");
|
||||
expect(logOutput.map(stripAnsi).join('\n')).toContain('Task Progress: 0/1 (0% complete)');
|
||||
});
|
||||
|
||||
it('renders terminal controls in every artifact state without changing workflow semantics', async () => {
|
||||
const openspecDir = path.join(tempDir, 'openspec');
|
||||
const schemaDir = path.join(openspecDir, 'schemas', 'custom');
|
||||
const changeDir = path.join(openspecDir, 'changes', 'display-controls');
|
||||
const ready = '設計\u001b[2J';
|
||||
const blocked = 'blocked\u009bH';
|
||||
const skipped = 'specs\r\n';
|
||||
const done = 'tasks\b\u0007';
|
||||
const artifact = (id: string, generates: string, requires: string[] = []) => ({
|
||||
id, generates, requires, description: 'Test artifact', template: 'template.md',
|
||||
});
|
||||
await fs.mkdir(schemaDir, { recursive: true });
|
||||
await fs.mkdir(changeDir, { recursive: true });
|
||||
await fs.writeFile(path.join(schemaDir, 'schema.yaml'), JSON.stringify({
|
||||
name: 'custom',
|
||||
version: 1,
|
||||
artifacts: [
|
||||
artifact(ready, 'design.md'),
|
||||
artifact(blocked, 'review.md', [ready]),
|
||||
artifact(skipped, 'specs/**/*.md'),
|
||||
artifact(done, 'tasks.md'),
|
||||
],
|
||||
apply: { requires: [done], tracks: 'tasks.md' },
|
||||
}));
|
||||
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: custom\nskip_specs: true\n');
|
||||
await fs.writeFile(path.join(changeDir, 'tasks.md'), '- [ ] Pending\n');
|
||||
|
||||
await new ViewCommand().execute(tempDir);
|
||||
|
||||
expect(logOutput.map(stripAnsi)).toContain(
|
||||
' └─ [custom] 設計?[2J→ blocked?H specs?? (skipped) tasks??✓'
|
||||
);
|
||||
expect(logOutput.map(stripAnsi).join('\n')).toContain('Task Progress: 0/1 (0% complete)');
|
||||
});
|
||||
|
||||
it('shows changes with no tasks in Draft section, not Completed', async () => {
|
||||
const changesDir = path.join(tempDir, 'openspec', 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
@@ -75,6 +191,7 @@ describe('ViewCommand', () => {
|
||||
expect(completedNames).toContain('completed-change');
|
||||
expect(completedNames).not.toContain('empty-change');
|
||||
expect(completedNames).not.toContain('no-tasks-change');
|
||||
expect(output).not.toContain('└─');
|
||||
});
|
||||
|
||||
it('sorts active changes by completion percentage ascending with deterministic tie-breakers', async () => {
|
||||
@@ -125,7 +242,7 @@ describe('ViewCommand', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('classifies a nested glob-tasks change as Active, not Draft (#1202)', async () => {
|
||||
it.each(['metadata', 'project config'])('uses a nested glob-tasks workflow from %s (#1202)', async (schemaSource) => {
|
||||
const openspecDir = path.join(tempDir, 'openspec');
|
||||
const changesDir = path.join(openspecDir, 'changes');
|
||||
await fs.mkdir(changesDir, { recursive: true });
|
||||
@@ -159,7 +276,12 @@ describe('ViewCommand', () => {
|
||||
const changeDir = path.join(changesDir, 'nested-change');
|
||||
await fs.mkdir(path.join(changeDir, 'backend'), { recursive: true });
|
||||
await fs.mkdir(path.join(changeDir, 'frontend'), { recursive: true });
|
||||
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: glob-tasks\n');
|
||||
if (schemaSource === 'metadata') {
|
||||
await fs.writeFile(path.join(changeDir, '.openspec.yaml'), 'schema: glob-tasks\n');
|
||||
await fs.writeFile(path.join(openspecDir, 'config.yaml'), 'schema: spec-driven\n');
|
||||
} else {
|
||||
await fs.writeFile(path.join(openspecDir, 'config.yaml'), 'schema: glob-tasks\n');
|
||||
}
|
||||
await fs.writeFile(path.join(changeDir, 'backend', 'tasks.md'), '- [x] 1.1 a\n- [x] 1.2 b\n');
|
||||
await fs.writeFile(path.join(changeDir, 'frontend', 'tasks.md'), '- [x] 2.1 a\n- [ ] 2.2 b\n- [ ] 2.3 c\n');
|
||||
|
||||
@@ -172,6 +294,7 @@ describe('ViewCommand', () => {
|
||||
const draftLines = logOutput.map(stripAnsi).filter(line => line.includes('○'));
|
||||
expect(draftLines.some(line => line.includes('nested-change'))).toBe(false);
|
||||
expect(output).toContain('60%');
|
||||
expect(output).toContain('└─ [glob-tasks] proposal→ tasks✓');
|
||||
});
|
||||
|
||||
it('keeps a change with unfinished sub-tasks in Active, not Completed (#1485)', async () => {
|
||||
@@ -241,4 +364,3 @@ describe('ViewCommand', () => {
|
||||
expect(activeLines.find(l => l.includes(longName))).toContain(longName);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user