mirror of
https://github.com/Fission-AI/OpenSpec.git
synced 2026-09-14 20:16:53 +08:00
17af60c66e
* fix(archive): make the scenario-drift check fence-aware parseScenarioBlocks matched #### Scenario: headers on raw lines while the validator's countScenarios masks fenced code blocks (#1151). The drift check (#1391) inherited the raw scan, so a fenced scenario example in the current spec aborted an archive that validate had passed, and a fenced name in the MODIFIED block counted as keeping a scenario the block had actually dropped. Build the shared code-fence mask and skip masked lines in both the header scan and the block-end scan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(update): tear down the redirected request when the budget expires The overall request budget was armed inside the first send() and its callback closed over that hop's request. After a redirect the timer destroyed the already-dead first request, so a redirect target that trickled bytes kept resetting its idle timeout and held the socket open until the body-size cap. Track the in-flight request and have the budget timer destroy whichever one is open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(release): add changesets for user-facing changes missing from the 1.7.0 notes 18 feat/fix commits merged since v1.6.0 without a changeset, so the pending Version Packages PR would have released them silently: five tool integrations (ZCode, Hermes, CodeArts, Kimi Code rename, Codex skills-only), skills.sh distribution, symlinked schema dirs, nested spec discovery, drift multiplicity, checkbox markers, Windows welcome input, npx avoidance, doctor store drift, local dates, missing-core-workflows warning, store-aware main specs, open-questions guidance, and spec content guidance. Plus changesets for this branch's two fixes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(adapters): escape TOML-active characters in Gemini command files The gemini adapter interpolated the description into a TOML basic string and the body into a multiline basic string with no escaping. Every current template value happens to be safe; the first description with a double quote or backslash would silently produce invalid TOML for all Gemini command files. Escape both contexts (#1447 fixed the same class for the YAML adapters but scoped itself to YAML). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(update): harden install detection and redirect handling Three follow-ups from the release audit: - A path segment literally named volta (a user or project directory) classified the install as volta-managed and swallowed the upgrade offer. The undotted spelling now requires volta's own tools/image layout, matching how pnpm and yarn already demand corroboration. - The Windows npm-ownership fallback checked that the npm prefix exists, which is true of any X\node_modules\pkg tree, hand-copied ones included. Corroborate with the openspec.cmd shim npm actually writes. - A https registry redirecting to plain http was followed; a MITM on that reply controls the newer-version answer. Refuse the downgrade. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(cli): export zcodeAdapter from the barrel and sync a completion description zcode was registered but missing from the adapters barrel (its test imported the module directly), and the completion registry still carried the pre-#1062 description for the instructions command. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(parser): strip a UTF-8 BOM before parsing specs and deltas A BOM-prefixed delta spec (Windows editors, PowerShell Out-File) failed validate and archive with 'No delta sections found' because the first line never matched '## ADDED Requirements'. Strip the BOM in both normalizers, the same way tool detection already does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(cli): reject over-long change names with a validation message A 300-character change name surfaced two raw ENAMETOOLONG errno dumps from stat and mkdir. Bound the name at 200 characters in validateChangeName so the failure is a normal validation error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(archive): finish the early-sync no-op rules for MODIFIED and RENAMED Two asymmetries left over from the #1376/#1386/#1437 no-op work: - MODIFIED counted every delta as applied even when the block was byte-equal to the main spec, so a fully early-synced change rewrote the file (normalization churn), printed '~ N modified', and reported specsUpdated: true where its ADDED/REMOVED/RENAMED twins print 'Specs already in sync; no files changed.' Count only real replacements. - RENAMED's already-synced skip (source gone, target present) had no near-miss guard: a case/whitespace variant of the source still in the spec means a typo'd header, and REMOVED already hard-aborts on that signal. Apply the same guard, excluding the target itself so a case-only rename still no-ops. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(validate): stop reporting an unreadable specs dir as 'no deltas' The delta-validation loop swallowed every error as 'if no specs dir, treat as no deltas', so an EACCES capability folder produced the misleading 'Change must have at least one delta' while archive let the same error propagate. Tolerate only ENOENT and ENOTDIR (a stray specs file); anything else stays loud, matching discoverSpecFiles' documented fail-loud contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(update): say when commands-only delivery leaves a tool with nothing Under delivery: commands, update removed the skills of adapterless skills-only tools (Hermes, Kimi Code, Vibe, CodeArts, ForgeCode) without a word — leaving zero OpenSpec artifacts while the tool's detection dir kept re-suggesting an init that would also generate nothing. Print the same per-tool configuration correction init already prints, pointing at 'openspec config set delivery both'. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(completion): honor $ZSH and $ZSH_CUSTOM for Oh My Zsh installs The installer used a set $ZSH only as an is-installed signal and then wrote to ~/.oh-my-zsh regardless, so a custom OMZ location got a freshly created ~/.oh-my-zsh tree that no shell ever loads — and isInstalled/uninstall looked in the same wrong place. Route every path through the $ZSH/$ZSH_CUSTOM-aware helpers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(init): make the static welcome screen wait for the Enter it asks for The static branch printed 'Press Enter to select tools...' and returned immediately, so the Enter landed in the tool picker and submitted the pre-selected set sight-unseen. #1462 routed reduced-motion, OPENSPEC_NO_ANIMATION, --no-animation, NO_COLOR, and narrow-terminal users onto this path. Wait in a TTY; drop the prompt line when there is no TTY to wait on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(feedback): keep the manual fallback on every gh failure Only missing-gh and unauthenticated flows showed the formatted feedback and pre-filled submission URL; issues-disabled, network, or rate-limit failures printed gh's stderr and discarded the path to submit what the user had already typed. Route those through the same manual fallback, preserving gh's exit code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(update): model the npm shim in the Windows prefix fixture The ownership corroboration now checks for the openspec.cmd shim npm writes beside node_modules; the Homebrew-prefix fixture built the layout without it, so the test failed on windows-pwsh. Write the shim in the fixture and pin the inverse: the same shape with nothing npm wrote (a hand-copied portable tree) is not an npm install. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(update): require volta's full tools/image layout for the undotted spelling The corroboration used has('tools', 'image'), which is some() — volta AND (tools OR image) — so /srv/volta/tools/apps/... still classified as a Volta install and swallowed the upgrade offer. Require both segments, matching the real %LOCALAPPDATA%\Volta\tools\image layout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(adapters): escape control characters in Gemini multiline prompts escapeTomlMultilineBasicString handled backslashes and quote-triples but not the C0 controls that are as invalid in a multiline basic string as in a single-line one. Reuse TOML_CONTROL_CHARS, applied last so the escapes it introduces are not re-doubled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(completion): finish the $ZSH_CUSTOM support and isolate it in tests The fpath verification advice still grepped the literal custom/completions, which a relocated $ZSH_CUSTOM need never contain — grep the actual directory instead. The installer tests cleared only $ZSH, so on a machine exporting $ZSH_CUSTOM they would have written into (and deleted from) the developer's real OMZ custom dir — the same leakage class #1400 fixed for $ZSH. Clear/restore both, and pin the custom-location paths with two new tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(release): correct the hermes and zcode changeset wording Hermes is skills-only (no command adapter), and zcode's namespaced commands register /opsx:<id>, not /opsx-* — the release notes must not reintroduce the invocation-spelling confusion #1471 removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(feedback): pin the manual fallback on a non-label gh failure The new reportGhFailure output (formatted feedback + pre-filled URL) had no coverage; the network-failure test now asserts it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(completion): match fpath entries as literal strings in the OMZ guidance The verification advice interpolated the completions dir into grep "<dir>" where regex metacharacters make the check unreliable and quotes could break the displayed command. Print one fpath entry per line and match with grep -F on a shell-quoted literal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(adapters): never emit a bare carriage return in Gemini TOML prompts A lone CR is illegal in a multiline basic string — Python 3.13 tomllib rejects the file — and the control-char pass deliberately skipped it on the assumption it only appears as CRLF. Normalize CRLF to LF and escape any remaining CR as \r. The escaping guarantee is now parser-backed: smol-toml (new devDependency) round-trips every hostile body in the regression matrix (lone CR, CRLF, CR before a quote run, NUL/VT/FF, trailing backslash, four- and five-quote runs), and the same outputs were verified against Python tomllib. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * build(nix): update the pnpm deps hash for the smol-toml devDependency The lockfile changed, so the fixed-output derivation hash moved; value taken from the CI mismatch report. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
281 lines
10 KiB
TypeScript
281 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { ALL_WORKFLOWS, CORE_WORKFLOWS } from '../../src/core/profiles.js';
|
|
|
|
const { useKeypressMock, execFileSyncMock } = vi.hoisted(() => ({
|
|
useKeypressMock: vi.fn(),
|
|
execFileSyncMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('node:child_process', () => ({
|
|
execFileSync: execFileSyncMock,
|
|
}));
|
|
|
|
vi.mock('@inquirer/core', () => ({
|
|
createPrompt: vi.fn((view) => async (config: Record<string, never>) => {
|
|
let keypressHandler: ((key: { name: string; ctrl: boolean }) => void) | undefined;
|
|
useKeypressMock.mockImplementation((handler) => {
|
|
keypressHandler = handler;
|
|
});
|
|
|
|
return new Promise<void>((resolve) => {
|
|
view(config, resolve);
|
|
keypressHandler?.({ name: 'return', ctrl: false });
|
|
});
|
|
}),
|
|
isEnterKey: vi.fn((key) => key.name === 'return'),
|
|
useKeypress: useKeypressMock,
|
|
}));
|
|
|
|
describe('welcome screen', () => {
|
|
const originalNoColor = process.env.NO_COLOR;
|
|
const originalNoAnimation = process.env.OPENSPEC_NO_ANIMATION;
|
|
const originalStdinIsTTY = process.stdin.isTTY;
|
|
const originalStdoutIsTTY = process.stdout.isTTY;
|
|
const originalColumns = process.stdout.columns;
|
|
let writeSpy: ReturnType<typeof vi.spyOn<typeof process.stdout, 'write'>>;
|
|
|
|
const writtenOutput = () =>
|
|
writeSpy.mock.calls.map((call) => String(call[0])).join('');
|
|
|
|
// The animated path paints on a timer, so assert against the static fallback.
|
|
const renderStatically = () => {
|
|
Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
|
|
};
|
|
|
|
beforeEach(() => {
|
|
delete process.env.NO_COLOR;
|
|
delete process.env.OPENSPEC_NO_ANIMATION;
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
|
|
Object.defineProperty(process.stdout, 'columns', { value: 100, configurable: true });
|
|
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
useKeypressMock.mockClear();
|
|
// Deterministic default: no OS-level reduced-motion preference detectable,
|
|
// so animated-path tests behave the same on every machine.
|
|
execFileSyncMock.mockReset();
|
|
execFileSyncMock.mockImplementation(() => {
|
|
throw new Error('not available in tests');
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalNoColor === undefined) {
|
|
delete process.env.NO_COLOR;
|
|
} else {
|
|
process.env.NO_COLOR = originalNoColor;
|
|
}
|
|
if (originalNoAnimation === undefined) {
|
|
delete process.env.OPENSPEC_NO_ANIMATION;
|
|
} else {
|
|
process.env.OPENSPEC_NO_ANIMATION = originalNoAnimation;
|
|
}
|
|
Object.defineProperty(process.stdin, 'isTTY', { value: originalStdinIsTTY, configurable: true });
|
|
Object.defineProperty(process.stdout, 'isTTY', { value: originalStdoutIsTTY, configurable: true });
|
|
Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('uses an Inquirer prompt to wait for Enter', async () => {
|
|
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
|
|
|
|
await showWelcomeScreen(CORE_WORKFLOWS);
|
|
|
|
expect(useKeypressMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('only advertises commands the profile installs', async () => {
|
|
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
|
|
renderStatically();
|
|
|
|
await showWelcomeScreen(CORE_WORKFLOWS);
|
|
|
|
const output = writtenOutput();
|
|
|
|
expect(output).toContain('/opsx:propose');
|
|
expect(output).toContain('/opsx:apply');
|
|
expect(output).not.toContain('/opsx:new');
|
|
expect(output).not.toContain('/opsx:continue');
|
|
});
|
|
|
|
it('advertises expanded commands when a custom profile installs them', async () => {
|
|
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
|
|
renderStatically();
|
|
|
|
await showWelcomeScreen(['new', 'continue', 'apply']);
|
|
|
|
const output = writtenOutput();
|
|
|
|
expect(output).toContain('/opsx:new');
|
|
expect(output).toContain('/opsx:continue');
|
|
expect(output).not.toContain('/opsx:propose');
|
|
});
|
|
|
|
it('omits the quick start block when no onboarding workflow is installed', async () => {
|
|
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
|
|
renderStatically();
|
|
|
|
await showWelcomeScreen(['archive']);
|
|
|
|
const output = writtenOutput();
|
|
|
|
expect(output).toContain('Welcome to OpenSpec');
|
|
expect(output).not.toContain('Quick start after setup:');
|
|
});
|
|
|
|
it('does not promise opsx commands in the setup summary', async () => {
|
|
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
|
|
renderStatically();
|
|
|
|
// This screen runs before tool selection, and skills-only tools (Codex,
|
|
// Kimi Code, ...) correctly receive no command files, so the summary must
|
|
// not state that opsx slash commands are part of every setup.
|
|
await showWelcomeScreen(['archive']);
|
|
|
|
const output = writtenOutput();
|
|
|
|
expect(output).toContain('Agent Skills for AI tools');
|
|
expect(output).toContain('Workflow commands, if supported');
|
|
expect(output).not.toContain('opsx slash commands');
|
|
});
|
|
|
|
it('flags that the quick-start spelling varies by tool', async () => {
|
|
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
|
|
renderStatically();
|
|
|
|
// The quick start shows canonical names, but this screen renders one
|
|
// prompt before tools are picked — an Amazon Q user types @opsx-propose
|
|
// and a Codex user $openspec-propose, neither of which is shown here.
|
|
await showWelcomeScreen(['propose']);
|
|
|
|
const output = writtenOutput();
|
|
|
|
expect(output).toContain('/opsx:propose');
|
|
expect(output).toContain('spelling varies by tool');
|
|
});
|
|
|
|
it('omits the spelling caveat when there is no quick start block', async () => {
|
|
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
|
|
renderStatically();
|
|
|
|
await showWelcomeScreen(['archive']);
|
|
|
|
expect(writtenOutput()).not.toContain('spelling varies by tool');
|
|
});
|
|
|
|
it('keeps every rendered line inside the animation width budget', async () => {
|
|
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
|
|
renderStatically();
|
|
|
|
// The animated path moves the cursor up a fixed count of logical lines, so a
|
|
// line that wraps at the narrowest animating terminal (MIN_WIDTH = 60) makes
|
|
// each frame redraw lower than the last. Worst case is every command shown.
|
|
await showWelcomeScreen(ALL_WORKFLOWS);
|
|
|
|
const rendered = writtenOutput().replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
|
|
|
|
for (const line of rendered.split('\n')) {
|
|
expect(line.length).toBeLessThanOrEqual(59);
|
|
}
|
|
});
|
|
|
|
it('renders statically when OPENSPEC_NO_ANIMATION is set', async () => {
|
|
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
|
|
process.env.OPENSPEC_NO_ANIMATION = '1';
|
|
|
|
await showWelcomeScreen(CORE_WORKFLOWS);
|
|
|
|
// Static rendering still waits for the Enter the prompt line asks for;
|
|
// otherwise the keystroke falls through into the tool picker (#1462).
|
|
expect(useKeypressMock).toHaveBeenCalledOnce();
|
|
const output = writtenOutput();
|
|
expect(output).toContain('Welcome to OpenSpec');
|
|
expect(output).toContain('Press Enter');
|
|
// No cursor-up repaints: the frame is drawn exactly once.
|
|
expect(output).not.toMatch(/\x1b\[\d+A/);
|
|
});
|
|
|
|
it('honors OPENSPEC_NO_ANIMATION even when set to an empty value', async () => {
|
|
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
|
|
process.env.OPENSPEC_NO_ANIMATION = '';
|
|
|
|
await showWelcomeScreen(CORE_WORKFLOWS);
|
|
|
|
expect(useKeypressMock).toHaveBeenCalledOnce();
|
|
expect(writtenOutput()).not.toMatch(/\x1b\[\d+A/);
|
|
});
|
|
|
|
it('renders statically when animate is disabled via options', async () => {
|
|
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
|
|
|
|
await showWelcomeScreen(CORE_WORKFLOWS, { animate: false });
|
|
|
|
expect(useKeypressMock).toHaveBeenCalledOnce();
|
|
const output = writtenOutput();
|
|
expect(output).toContain('Welcome to OpenSpec');
|
|
expect(output).not.toMatch(/\x1b\[\d+A/);
|
|
});
|
|
|
|
it.runIf(process.platform === 'darwin' || process.platform === 'linux')(
|
|
'renders statically when the OS prefers reduced motion',
|
|
async () => {
|
|
const { showWelcomeScreen } = await import('../../src/ui/welcome-screen.js');
|
|
execFileSyncMock.mockImplementation((file: string) =>
|
|
file === 'defaults' ? '1\n' : 'false\n'
|
|
);
|
|
|
|
await showWelcomeScreen(CORE_WORKFLOWS);
|
|
|
|
expect(useKeypressMock).toHaveBeenCalledOnce();
|
|
expect(writtenOutput()).toContain('Welcome to OpenSpec');
|
|
}
|
|
);
|
|
});
|
|
|
|
describe('prefersReducedMotion', () => {
|
|
beforeEach(() => {
|
|
execFileSyncMock.mockReset();
|
|
});
|
|
|
|
it('detects macOS Reduce Motion', async () => {
|
|
const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js');
|
|
execFileSyncMock.mockReturnValue('1\n');
|
|
|
|
expect(prefersReducedMotion('darwin')).toBe(true);
|
|
expect(execFileSyncMock).toHaveBeenCalledWith(
|
|
'defaults',
|
|
['read', 'com.apple.universalaccess', 'reduceMotion'],
|
|
expect.objectContaining({ timeout: 500 })
|
|
);
|
|
});
|
|
|
|
it('treats a disabled or unset macOS preference as no preference', async () => {
|
|
const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js');
|
|
|
|
execFileSyncMock.mockReturnValue('0\n');
|
|
expect(prefersReducedMotion('darwin')).toBe(false);
|
|
|
|
// `defaults read` exits non-zero while the key has never been toggled.
|
|
execFileSyncMock.mockImplementation(() => {
|
|
throw new Error('The domain/default pair does not exist');
|
|
});
|
|
expect(prefersReducedMotion('darwin')).toBe(false);
|
|
});
|
|
|
|
it('detects GNOME reduced motion via disabled animations', async () => {
|
|
const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js');
|
|
|
|
execFileSyncMock.mockReturnValue('false\n');
|
|
expect(prefersReducedMotion('linux')).toBe(true);
|
|
|
|
execFileSyncMock.mockReturnValue('true\n');
|
|
expect(prefersReducedMotion('linux')).toBe(false);
|
|
});
|
|
|
|
it('returns false without spawning anything on other platforms', async () => {
|
|
const { prefersReducedMotion } = await import('../../src/ui/welcome-screen.js');
|
|
|
|
expect(prefersReducedMotion('win32')).toBe(false);
|
|
expect(execFileSyncMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|