Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/init-upgrade-parity-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
---

test: add init/upgrade/doctor parity test suite

7 tests verifying that init and upgrade produce equivalent scaffolding.
Guards against drift where new init features (like casting) are not
mirrored in upgrade.
240 changes: 240 additions & 0 deletions test/cli/init-upgrade-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/**
* Init / Upgrade / Doctor Parity Tests
*
* Ensures init and upgrade produce equivalent scaffolding.
* When casting was added to init, upgrade was not updated -- these tests
* guard against that class of drift.
*
* @see https://github.com/bradygaster/squad/issues/822
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdir, rm, readFile, writeFile } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
import { randomBytes } from 'crypto';
import { runInit } from '@bradygaster/squad-cli/core/init';
import {
runUpgrade,
ensureGitattributes,
ensureDirectories,
} from '@bradygaster/squad-cli/core/upgrade';

const TEST_ROOT = join(
process.cwd(),
`.test-init-upgrade-parity-${randomBytes(4).toString('hex')}`,
);

/**
* Directories that upgrade's ENSURE_DIRECTORIES must cover.
* Derived from what init scaffolds (SDK initSquad).
*/
const INIT_INFRASTRUCTURE_DIRS = [
'.squad/identity',
'.squad/orchestration-log',
'.squad/log',
'.squad/sessions',
'.squad/decisions/inbox',
'.squad/casting',
'.squad/agents',

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

INIT_INFRASTRUCTURE_DIRS claims to be derived from what runInit scaffolds, but it omits at least .squad/plugins/ (see existing init integration tests). If parity is meant to prevent init/upgrade drift, include the full set of init-created infrastructure dirs here (or derive them programmatically) so missing upgrade coverage is actually detected.

Suggested change
'.squad/agents',
'.squad/agents',
'.squad/plugins',

Copilot uses AI. Check for mistakes.
'.copilot/skills',
];

/**
* .gitattributes merge=union rules both paths must install.
*/
const EXPECTED_GITATTRIBUTES_RULES = [
'.squad/decisions.md merge=union',
'.squad/agents/*/history.md merge=union',
'.squad/log/** merge=union',
'.squad/orchestration-log/** merge=union',
];

/**
* Casting default files both paths should scaffold.
*/
const CASTING_FILES = [
'registry.json',
'policy.json',
'history.json',
];

// ---------------------------------------------------------------------------
// Test Suite
// ---------------------------------------------------------------------------

describe('Init / Upgrade parity', () => {
beforeEach(async () => {
if (existsSync(TEST_ROOT)) {
await rm(TEST_ROOT, { recursive: true, force: true });
}
await mkdir(TEST_ROOT, { recursive: true });
});

afterEach(async () => {
if (existsSync(TEST_ROOT)) {
await rm(TEST_ROOT, { recursive: true, force: true });
}
});
// -------------------------------------------------------------------------
// 1. ENSURE_DIRECTORIES covers every infrastructure dir init creates
// -------------------------------------------------------------------------
it('ENSURE_DIRECTORIES matches init infrastructure directories', async () => {
await runInit(TEST_ROOT);

// Any dirs init missed should be filled in by ensureDirectories
const created = ensureDirectories(TEST_ROOT);

// After both init + ensureDirectories, every dir must exist
for (const dir of INIT_INFRASTRUCTURE_DIRS) {
expect(
existsSync(join(TEST_ROOT, dir)),
).toBe(true);
}

// Track which dirs init missed so drift is visible in test output.
// Ideally created.length === 0 (init scaffolds everything upgrade expects).
if (created.length > 0) {
console.warn(
`[parity drift] init missed ${created.length} dirs that upgrade ensures: ${created.join(', ')}`,
);
}
Comment on lines +95 to +101

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test logs parity drift via console.warn but never fails when drift is detected (created.length > 0). This means init/upgrade directory drift will not be caught by CI. Convert this into an assertion (e.g., expect created to be empty) so the contract is enforced.

Suggested change
// Track which dirs init missed so drift is visible in test output.
// Ideally created.length === 0 (init scaffolds everything upgrade expects).
if (created.length > 0) {
console.warn(
`[parity drift] init missed ${created.length} dirs that upgrade ensures: ${created.join(', ')}`,
);
}
// Enforce parity: init should already scaffold everything upgrade ensures.
expect(
created,
`[parity drift] init missed ${created.length} dirs that upgrade ensures: ${created.join(', ')}`,
).toEqual([]);

Copilot uses AI. Check for mistakes.
});

// -------------------------------------------------------------------------
// 2. Upgrade scaffolds casting defaults
// -------------------------------------------------------------------------
it('upgrade scaffolds casting directory and init creates casting files', async () => {
await runInit(TEST_ROOT);

const castingDir = join(TEST_ROOT, '.squad', 'casting');
expect(existsSync(castingDir)).toBe(true);

// Init should have created the three casting JSON files
for (const file of CASTING_FILES) {
const filePath = join(castingDir, file);
expect(existsSync(filePath)).toBe(true);

const content = await readFile(filePath, 'utf-8');
expect(() => JSON.parse(content)).not.toThrow();
}

// After upgrade on an already-init'd directory, casting files survive
await runUpgrade(TEST_ROOT);

for (const file of CASTING_FILES) {
expect(existsSync(join(castingDir, file))).toBe(true);
}
});
Comment on lines +107 to +128

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name/comment says it verifies upgrade scaffolds casting defaults, but it only checks that init created the files and that upgrade preserves them. To validate upgrade scaffolding behavior, delete .squad/casting/ (or the JSON files) after init, run runUpgrade, and assert the directory/files are recreated with valid JSON.

Copilot uses AI. Check for mistakes.

// -------------------------------------------------------------------------
// 3. Upgrade doesn't overwrite existing files (force: false)
// -------------------------------------------------------------------------
it('upgrade does not overwrite existing user state files', async () => {
await runInit(TEST_ROOT);

// Write sentinel values into user state files
const teamPath = join(TEST_ROOT, '.squad', 'team.md');
const sentinel = '<!-- USER_SENTINEL -->\n';
if (existsSync(teamPath)) {
await writeFile(teamPath, sentinel);
}

// Write a sentinel casting file
const registryPath = join(TEST_ROOT, '.squad', 'casting', 'registry.json');
const customRegistry = JSON.stringify({ agents: { sentinel: true } });
if (existsSync(registryPath)) {
await writeFile(registryPath, customRegistry);
}

await runUpgrade(TEST_ROOT);

// team.md should still have our sentinel
if (existsSync(teamPath)) {
const teamContent = await readFile(teamPath, 'utf-8');
expect(teamContent).toContain('USER_SENTINEL');
}

// casting/registry.json should still have our custom content
if (existsSync(registryPath)) {
const regContent = await readFile(registryPath, 'utf-8');
expect(regContent).toContain('sentinel');
}
Comment on lines +138 to +162

Copilot AI Apr 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertions in this test are conditional on team.md and casting/registry.json already existing, so the test can pass without actually verifying upgrade's non-overwrite behavior. Since runInit is expected to create these, assert they exist after init (and fail if they don't) before writing/validating sentinel content.

Suggested change
const sentinel = '<!-- USER_SENTINEL -->\n';
if (existsSync(teamPath)) {
await writeFile(teamPath, sentinel);
}
// Write a sentinel casting file
const registryPath = join(TEST_ROOT, '.squad', 'casting', 'registry.json');
const customRegistry = JSON.stringify({ agents: { sentinel: true } });
if (existsSync(registryPath)) {
await writeFile(registryPath, customRegistry);
}
await runUpgrade(TEST_ROOT);
// team.md should still have our sentinel
if (existsSync(teamPath)) {
const teamContent = await readFile(teamPath, 'utf-8');
expect(teamContent).toContain('USER_SENTINEL');
}
// casting/registry.json should still have our custom content
if (existsSync(registryPath)) {
const regContent = await readFile(registryPath, 'utf-8');
expect(regContent).toContain('sentinel');
}
const registryPath = join(TEST_ROOT, '.squad', 'casting', 'registry.json');
const sentinel = '<!-- USER_SENTINEL -->\n';
const customRegistry = JSON.stringify({ agents: { sentinel: true } });
expect(existsSync(teamPath)).toBe(true);
expect(existsSync(registryPath)).toBe(true);
await writeFile(teamPath, sentinel);
// Write a sentinel casting file
await writeFile(registryPath, customRegistry);
await runUpgrade(TEST_ROOT);
// team.md should still have our sentinel
const teamContent = await readFile(teamPath, 'utf-8');
expect(teamContent).toContain('USER_SENTINEL');
// casting/registry.json should still have our custom content
const regContent = await readFile(registryPath, 'utf-8');
expect(regContent).toContain('sentinel');

Copilot uses AI. Check for mistakes.
});
// -------------------------------------------------------------------------
// 4. Both paths create .squad/agents/
// -------------------------------------------------------------------------
it('both init and upgrade ensure .squad/agents/ exists', async () => {
// init creates .squad/agents/
await runInit(TEST_ROOT);
expect(existsSync(join(TEST_ROOT, '.squad', 'agents'))).toBe(true);

// ENSURE_DIRECTORIES includes .squad/agents, so upgrade also covers it.
// Verify by checking that the full list of INIT_INFRASTRUCTURE_DIRS
// is present after init + ensureDirectories.
ensureDirectories(TEST_ROOT);
for (const dir of INIT_INFRASTRUCTURE_DIRS) {
expect(existsSync(join(TEST_ROOT, dir))).toBe(true);
}
});

// -------------------------------------------------------------------------
// 5. Both paths create .gitattributes with merge=union rules
// -------------------------------------------------------------------------
it('both init and upgrade create .gitattributes merge=union rules', async () => {
// After init
await runInit(TEST_ROOT);
const attrPath = join(TEST_ROOT, '.gitattributes');
expect(existsSync(attrPath)).toBe(true);

const afterInit = await readFile(attrPath, 'utf-8');
for (const rule of EXPECTED_GITATTRIBUTES_RULES) {
expect(afterInit).toContain(rule);
}

// Wipe .gitattributes and run upgrade -- should recreate rules
await writeFile(attrPath, '');
await runUpgrade(TEST_ROOT);

const afterUpgrade = await readFile(attrPath, 'utf-8');
for (const rule of EXPECTED_GITATTRIBUTES_RULES) {
expect(afterUpgrade).toContain(rule);
}
});

// -------------------------------------------------------------------------
// 6. ensureDirectories is idempotent on second call
// -------------------------------------------------------------------------
it('ensureDirectories is idempotent on second call', async () => {
await runInit(TEST_ROOT);

// First call fills any gaps init left
ensureDirectories(TEST_ROOT);

// Second call should return empty (everything now exists)
const second = ensureDirectories(TEST_ROOT);
expect(second).toEqual([]);
});

// -------------------------------------------------------------------------
// 7. ensureGitattributes does not duplicate rules
// -------------------------------------------------------------------------
it('ensureGitattributes does not duplicate rules', async () => {
await runInit(TEST_ROOT);

// Run twice
ensureGitattributes(TEST_ROOT);
ensureGitattributes(TEST_ROOT);

const content = await readFile(
join(TEST_ROOT, '.gitattributes'),
'utf-8',
);

// Each rule should appear exactly once
for (const rule of EXPECTED_GITATTRIBUTES_RULES) {
const count = content.split(rule).length - 1;
expect(count).toBe(1);
}
});
});
Loading