chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
rmSync,
|
||||
cpSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { createConfigService } from '../../src/config/config-service.js';
|
||||
import { runWizard } from '../../src/wizard.js';
|
||||
|
||||
describe('Full Wizard (headless)', () => {
|
||||
let tmpDir: string;
|
||||
const repoRoot = join(import.meta.dirname, '..', '..');
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
|
||||
// Copy templates to tmp dir — templates live under framework/ after monorepo migration
|
||||
const candidates = [join(repoRoot, 'framework', 'templates'), join(repoRoot, 'templates')];
|
||||
for (const templatesDir of candidates) {
|
||||
if (existsSync(templatesDir)) {
|
||||
cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
it('quick start produces valid SOUL.md', async () => {
|
||||
// The headless path reads agent name from MOSAIC_AGENT_NAME env var
|
||||
// (via agentIntentStage) rather than prompting interactively.
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
});
|
||||
|
||||
const soulPath = join(tmpDir, 'SOUL.md');
|
||||
expect(existsSync(soulPath)).toBe(true);
|
||||
|
||||
const soul = readFileSync(soulPath, 'utf-8');
|
||||
expect(soul).toContain('You are **TestBot**');
|
||||
expect(soul).toContain('Be direct, concise, and concrete');
|
||||
expect(soul).toContain('execution partner and visibility engine');
|
||||
});
|
||||
|
||||
it('quick start produces valid USER.md', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'He/Him',
|
||||
'Your timezone': 'America/Chicago',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
});
|
||||
|
||||
const userPath = join(tmpDir, 'USER.md');
|
||||
expect(existsSync(userPath)).toBe(true);
|
||||
|
||||
const user = readFileSync(userPath, 'utf-8');
|
||||
expect(user).toContain('**Name:** Tester');
|
||||
expect(user).toContain('**Pronouns:** He/Him');
|
||||
expect(user).toContain('**Timezone:** America/Chicago');
|
||||
});
|
||||
|
||||
it('applies CLI overrides', async () => {
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'Your name': 'FromPrompt',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
cliOverrides: {
|
||||
soul: {
|
||||
agentName: 'FromCLI',
|
||||
communicationStyle: 'formal',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const soul = readFileSync(join(tmpDir, 'SOUL.md'), 'utf-8');
|
||||
expect(soul).toContain('You are **FromCLI**');
|
||||
expect(soul).toContain('Use professional, structured language');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Unified wizard integration test — exercises the `skipGateway: false` code
|
||||
* path so that wiring between `runWizard` and the two gateway stages is
|
||||
* covered. The gateway stages themselves are mocked (they require a real
|
||||
* daemon + network) but the dynamic imports and option plumbing are real.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync, cpSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { createConfigService } from '../../src/config/config-service.js';
|
||||
import type { SelectOption } from '../../src/prompter/interface.js';
|
||||
import type { MenuSection, WizardState } from '../../src/types.js';
|
||||
|
||||
const gatewayConfigMock = vi.fn();
|
||||
const gatewayBootstrapMock = vi.fn();
|
||||
const providerSetupMock = vi.fn();
|
||||
const skillsSelectMock = vi.fn();
|
||||
|
||||
class SequencedMenuPrompter extends HeadlessPrompter {
|
||||
constructor(
|
||||
answers: Record<string, string | boolean | string[]>,
|
||||
private readonly menuChoices: string[],
|
||||
) {
|
||||
super(answers);
|
||||
}
|
||||
|
||||
override async select<T>(opts: {
|
||||
message: string;
|
||||
options: SelectOption<T>[];
|
||||
initialValue?: T;
|
||||
}): Promise<T> {
|
||||
if (opts.message === 'What would you like to configure?') {
|
||||
const next = this.menuChoices.shift();
|
||||
if (!next) throw new Error('No queued menu choice left');
|
||||
const match = opts.options.find((o) => String(o.value) === next);
|
||||
if (!match) throw new Error(`Queued menu choice not available: ${next}`);
|
||||
return match.value;
|
||||
}
|
||||
return super.select(opts);
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('../../src/stages/gateway-config.js', () => ({
|
||||
gatewayConfigStage: (...args: unknown[]) => gatewayConfigMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/stages/gateway-bootstrap.js', () => ({
|
||||
gatewayBootstrapStage: (...args: unknown[]) => gatewayBootstrapMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/stages/provider-setup.js', () => ({
|
||||
providerSetupStage: (...args: unknown[]) => providerSetupMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/stages/skills-select.js', () => ({
|
||||
skillsSelectStage: (...args: unknown[]) => skillsSelectMock(...args),
|
||||
}));
|
||||
|
||||
// Import AFTER the mocks so runWizard picks up the mocked stage modules.
|
||||
import { runWizard } from '../../src/wizard.js';
|
||||
|
||||
describe('Unified wizard (runWizard with default skipGateway)', () => {
|
||||
let tmpDir: string;
|
||||
const repoRoot = join(import.meta.dirname, '..', '..');
|
||||
|
||||
const originalIsTTY = process.stdin.isTTY;
|
||||
const originalAssumeYes = process.env['MOSAIC_ASSUME_YES'];
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-unified-wizard-'));
|
||||
const candidates = [join(repoRoot, 'framework', 'templates'), join(repoRoot, 'templates')];
|
||||
for (const templatesDir of candidates) {
|
||||
if (existsSync(templatesDir)) {
|
||||
cpSync(templatesDir, join(tmpDir, 'templates'), { recursive: true });
|
||||
break;
|
||||
}
|
||||
}
|
||||
gatewayConfigMock.mockReset();
|
||||
gatewayBootstrapMock.mockReset();
|
||||
providerSetupMock.mockReset();
|
||||
skillsSelectMock.mockReset();
|
||||
providerSetupMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
|
||||
state.providerType = 'none';
|
||||
state.completedSections?.add('providers' satisfies MenuSection);
|
||||
});
|
||||
skillsSelectMock.mockImplementation(async (_p: HeadlessPrompter, state: WizardState) => {
|
||||
state.selectedSkills = [];
|
||||
state.completedSections?.add('skills' satisfies MenuSection);
|
||||
});
|
||||
// Pretend we're on an interactive TTY so the wizard's headless-abort
|
||||
// branch does not call `process.exit(1)` during these tests.
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
value: originalIsTTY,
|
||||
configurable: true,
|
||||
});
|
||||
if (originalAssumeYes === undefined) {
|
||||
delete process.env['MOSAIC_ASSUME_YES'];
|
||||
} else {
|
||||
process.env['MOSAIC_ASSUME_YES'] = originalAssumeYes;
|
||||
}
|
||||
});
|
||||
|
||||
it('invokes the gateway config + bootstrap stages by default', async () => {
|
||||
gatewayConfigMock.mockResolvedValue({ ready: true, host: 'localhost', port: 14242 });
|
||||
gatewayBootstrapMock.mockResolvedValue({ completed: true });
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
gatewayHost: 'localhost',
|
||||
gatewayPort: 14242,
|
||||
skipGatewayNpmInstall: true,
|
||||
});
|
||||
|
||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayBootstrapMock).toHaveBeenCalledTimes(1);
|
||||
const configCall = gatewayConfigMock.mock.calls[0];
|
||||
expect(configCall[2]).toMatchObject({
|
||||
host: 'localhost',
|
||||
defaultPort: 14242,
|
||||
skipInstall: true,
|
||||
});
|
||||
const bootstrapCall = gatewayBootstrapMock.mock.calls[0];
|
||||
expect(bootstrapCall[2]).toMatchObject({ host: 'localhost', port: 14242 });
|
||||
});
|
||||
|
||||
it('prints the success summary only after gateway health succeeds', async () => {
|
||||
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
||||
p.log('Gateway is healthy.');
|
||||
return { ready: true, host: 'localhost', port: 14242 };
|
||||
});
|
||||
gatewayBootstrapMock.mockResolvedValue({ completed: true });
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGatewayNpmInstall: true,
|
||||
});
|
||||
|
||||
const logs = prompter.getLogs();
|
||||
const healthIndex = logs.findIndex((line) => line.includes('Gateway is healthy.'));
|
||||
const summaryIndex = logs.findIndex((line) => line.includes('Installation Summary'));
|
||||
const readyIndex = logs.findIndex((line) => line.includes('Mosaic is ready.'));
|
||||
|
||||
expect(healthIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(summaryIndex).toBeGreaterThan(healthIndex);
|
||||
expect(readyIndex).toBeGreaterThan(summaryIndex);
|
||||
});
|
||||
|
||||
it('does not claim success when gateway health reports not ready', async () => {
|
||||
gatewayConfigMock.mockImplementation(async (p: HeadlessPrompter) => {
|
||||
p.warn('Gateway did not become healthy within 30 seconds.');
|
||||
return { ready: false };
|
||||
});
|
||||
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
});
|
||||
|
||||
await expect(
|
||||
runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGatewayNpmInstall: true,
|
||||
}),
|
||||
).rejects.toThrow('Gateway configuration failed');
|
||||
|
||||
const logs = prompter.getLogs();
|
||||
expect(logs.some((line) => line.includes('Gateway did not become healthy'))).toBe(true);
|
||||
expect(logs.some((line) => line.includes('Gateway configuration failed'))).toBe(true);
|
||||
expect(logs.some((line) => line.includes('Installation Summary'))).toBe(false);
|
||||
expect(logs.some((line) => line.includes('Mosaic is ready.'))).toBe(false);
|
||||
expect(gatewayConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('respects skipGateway: true', async () => {
|
||||
const prompter = new HeadlessPrompter({
|
||||
'Installation mode': 'quick',
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
});
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
});
|
||||
|
||||
expect(gatewayConfigMock).not.toHaveBeenCalled();
|
||||
expect(gatewayBootstrapMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-run completed provider or skills menu steps', async () => {
|
||||
const prompter = new SequencedMenuPrompter(
|
||||
{
|
||||
'What name should agents use?': 'TestBot',
|
||||
'Communication style': 'direct',
|
||||
'Your name': 'Tester',
|
||||
'Your pronouns': 'They/Them',
|
||||
'Your timezone': 'UTC',
|
||||
},
|
||||
['providers', 'providers', 'skills', 'skills', 'finish'],
|
||||
);
|
||||
|
||||
await runWizard({
|
||||
mosaicHome: tmpDir,
|
||||
sourceDir: tmpDir,
|
||||
prompter,
|
||||
configService: createConfigService(tmpDir, tmpDir),
|
||||
skipGateway: true,
|
||||
});
|
||||
|
||||
expect(providerSetupMock).toHaveBeenCalledTimes(1);
|
||||
expect(skillsSelectMock).toHaveBeenCalledTimes(1);
|
||||
expect(prompter.getLogs()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Providers [done] is already complete; skipping.'),
|
||||
expect.stringContaining('Skills [done] is already complete; skipping.'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// homedir/platform are read at call time, so they can be stubbed per case.
|
||||
vi.mock('node:os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:os')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => '/home/tester',
|
||||
platform: () => mockPlatform,
|
||||
};
|
||||
});
|
||||
|
||||
let mockPlatform: NodeJS.Platform = 'linux';
|
||||
|
||||
const { getShellProfilePath, detectShell } = await import('../../src/platform/detect.js');
|
||||
|
||||
describe('getShellProfilePath', () => {
|
||||
const originalShell = process.env['SHELL'];
|
||||
const originalZdotdir = process.env['ZDOTDIR'];
|
||||
|
||||
beforeEach(() => {
|
||||
mockPlatform = 'linux';
|
||||
delete process.env['ZDOTDIR'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalShell === undefined) delete process.env['SHELL'];
|
||||
else process.env['SHELL'] = originalShell;
|
||||
if (originalZdotdir === undefined) delete process.env['ZDOTDIR'];
|
||||
else process.env['ZDOTDIR'] = originalZdotdir;
|
||||
});
|
||||
|
||||
// The regression this guards: setupPath() in stages/finalize.ts appends the
|
||||
// PATH export to whatever this returns. A line written to ~/.bashrc is
|
||||
// unreachable to `bash -lc`, systemd units and agent seats, because Debian's
|
||||
// default .bashrc returns early for non-interactive shells — so an install
|
||||
// reported success and left `mosaic: command not found`. Same for .zshrc,
|
||||
// which zsh only reads for interactive shells.
|
||||
it('never targets an interactive-only rc file', () => {
|
||||
for (const shell of ['/bin/bash', '/usr/bin/zsh']) {
|
||||
process.env['SHELL'] = shell;
|
||||
const profile = getShellProfilePath();
|
||||
expect(profile).not.toMatch(/\.bashrc$/);
|
||||
expect(profile).not.toMatch(/\.zshrc$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses ~/.profile for bash', () => {
|
||||
process.env['SHELL'] = '/bin/bash';
|
||||
expect(getShellProfilePath()).toBe('/home/tester/.profile');
|
||||
});
|
||||
|
||||
it('uses ~/.zshenv for zsh', () => {
|
||||
process.env['SHELL'] = '/usr/bin/zsh';
|
||||
expect(getShellProfilePath()).toBe('/home/tester/.zshenv');
|
||||
});
|
||||
|
||||
it('honours ZDOTDIR for zsh', () => {
|
||||
process.env['SHELL'] = '/usr/bin/zsh';
|
||||
process.env['ZDOTDIR'] = '/custom/zdot';
|
||||
expect(getShellProfilePath()).toBe('/custom/zdot/.zshenv');
|
||||
});
|
||||
|
||||
it('falls back to ~/.profile for an unknown shell', () => {
|
||||
process.env['SHELL'] = '/bin/somethingelse';
|
||||
expect(detectShell()).toBe('unknown');
|
||||
expect(getShellProfilePath()).toBe('/home/tester/.profile');
|
||||
});
|
||||
|
||||
it('still routes fish to its own config', () => {
|
||||
process.env['SHELL'] = '/usr/bin/fish';
|
||||
expect(getShellProfilePath()).toBe('/home/tester/.config/fish/config.fish');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
existsSync,
|
||||
chmodSync,
|
||||
rmSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { syncDirectory } from '../../src/platform/file-ops.js';
|
||||
|
||||
describe('syncDirectory', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-file-ops-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('is a no-op when source and target are the same path', () => {
|
||||
const dir = join(tmpDir, 'same');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'file.txt'), 'hello');
|
||||
// Should not throw even with read-only files
|
||||
const gitDir = join(dir, '.git', 'objects', 'pack');
|
||||
mkdirSync(gitDir, { recursive: true });
|
||||
const packFile = join(gitDir, 'pack-abc.idx');
|
||||
writeFileSync(packFile, 'data');
|
||||
chmodSync(packFile, 0o444);
|
||||
|
||||
expect(() => syncDirectory(dir, dir)).not.toThrow();
|
||||
});
|
||||
|
||||
it('skips nested .git directories when excludeGit is true', () => {
|
||||
const src = join(tmpDir, 'src');
|
||||
const dest = join(tmpDir, 'dest');
|
||||
|
||||
// Create source with a nested .git
|
||||
mkdirSync(join(src, 'sources', 'skills', '.git', 'objects'), { recursive: true });
|
||||
writeFileSync(join(src, 'sources', 'skills', '.git', 'objects', 'pack.idx'), 'git-data');
|
||||
writeFileSync(join(src, 'sources', 'skills', 'SKILL.md'), 'skill content');
|
||||
writeFileSync(join(src, 'README.md'), 'readme');
|
||||
|
||||
syncDirectory(src, dest, { excludeGit: true });
|
||||
|
||||
// .git contents should NOT be copied
|
||||
expect(existsSync(join(dest, 'sources', 'skills', '.git'))).toBe(false);
|
||||
// Normal files should be copied
|
||||
expect(readFileSync(join(dest, 'sources', 'skills', 'SKILL.md'), 'utf-8')).toBe(
|
||||
'skill content',
|
||||
);
|
||||
expect(readFileSync(join(dest, 'README.md'), 'utf-8')).toBe('readme');
|
||||
});
|
||||
|
||||
it('copies nested .git directories when excludeGit is false', () => {
|
||||
const src = join(tmpDir, 'src');
|
||||
const dest = join(tmpDir, 'dest');
|
||||
|
||||
mkdirSync(join(src, 'sub', '.git'), { recursive: true });
|
||||
writeFileSync(join(src, 'sub', '.git', 'HEAD'), 'ref: refs/heads/main');
|
||||
|
||||
syncDirectory(src, dest, { excludeGit: false });
|
||||
|
||||
expect(readFileSync(join(dest, 'sub', '.git', 'HEAD'), 'utf-8')).toBe('ref: refs/heads/main');
|
||||
});
|
||||
|
||||
it('respects preserve option', () => {
|
||||
const src = join(tmpDir, 'src');
|
||||
const dest = join(tmpDir, 'dest');
|
||||
|
||||
mkdirSync(src, { recursive: true });
|
||||
mkdirSync(dest, { recursive: true });
|
||||
writeFileSync(join(src, 'SOUL.md'), 'new soul');
|
||||
writeFileSync(join(dest, 'SOUL.md'), 'old soul');
|
||||
writeFileSync(join(src, 'README.md'), 'new readme');
|
||||
|
||||
syncDirectory(src, dest, { preserve: ['SOUL.md'] });
|
||||
|
||||
expect(readFileSync(join(dest, 'SOUL.md'), 'utf-8')).toBe('old soul');
|
||||
expect(readFileSync(join(dest, 'README.md'), 'utf-8')).toBe('new readme');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { detectInstallStage } from '../../src/stages/detect-install.js';
|
||||
import type { WizardState } from '../../src/types.js';
|
||||
import type { ConfigService } from '../../src/config/config-service.js';
|
||||
|
||||
function createState(mosaicHome: string): WizardState {
|
||||
return {
|
||||
mosaicHome,
|
||||
sourceDir: mosaicHome,
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
};
|
||||
}
|
||||
|
||||
const mockConfig: ConfigService = {
|
||||
readSoul: async () => ({ agentName: 'TestAgent' }),
|
||||
readUser: async () => ({ userName: 'TestUser' }),
|
||||
readTools: async () => ({}),
|
||||
writeSoul: async () => {},
|
||||
writeUser: async () => {},
|
||||
writeTools: async () => {},
|
||||
syncFramework: async () => {},
|
||||
};
|
||||
|
||||
describe('detectInstallStage', () => {
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('sets fresh for empty directory', async () => {
|
||||
const p = new HeadlessPrompter({});
|
||||
const state = createState(join(tmpDir, 'nonexistent'));
|
||||
await detectInstallStage(p, state, mockConfig);
|
||||
|
||||
expect(state.installAction).toBe('fresh');
|
||||
});
|
||||
|
||||
it('detects existing install and offers choices', async () => {
|
||||
// Create a mock existing install
|
||||
mkdirSync(join(tmpDir, 'bin'), { recursive: true });
|
||||
writeFileSync(join(tmpDir, 'AGENTS.md'), '# Test');
|
||||
writeFileSync(join(tmpDir, 'SOUL.md'), 'You are **Jarvis** in this session.');
|
||||
|
||||
const p = new HeadlessPrompter({
|
||||
'What would you like to do?': 'keep',
|
||||
});
|
||||
const state = createState(tmpDir);
|
||||
await detectInstallStage(p, state, mockConfig);
|
||||
|
||||
expect(state.installAction).toBe('keep');
|
||||
expect(state.soul.agentName).toBe('TestAgent');
|
||||
});
|
||||
|
||||
it('pre-populates state when reconfiguring', async () => {
|
||||
mkdirSync(join(tmpDir, 'bin'), { recursive: true });
|
||||
writeFileSync(join(tmpDir, 'SOUL.md'), 'You are **Jarvis** in this session.');
|
||||
writeFileSync(join(tmpDir, 'USER.md'), '**Name:** TestUser');
|
||||
|
||||
const p = new HeadlessPrompter({
|
||||
'What would you like to do?': 'reconfigure',
|
||||
});
|
||||
const state = createState(tmpDir);
|
||||
await detectInstallStage(p, state, mockConfig);
|
||||
|
||||
expect(state.installAction).toBe('reconfigure');
|
||||
// Existing values loaded as defaults for reconfiguration
|
||||
expect(state.soul.agentName).toBe('TestAgent');
|
||||
expect(state.user.userName).toBe('TestUser');
|
||||
});
|
||||
|
||||
it('does not pre-populate state on fresh reset', async () => {
|
||||
mkdirSync(join(tmpDir, 'bin'), { recursive: true });
|
||||
writeFileSync(join(tmpDir, 'SOUL.md'), 'You are **Jarvis** in this session.');
|
||||
|
||||
const p = new HeadlessPrompter({
|
||||
'What would you like to do?': 'reset',
|
||||
});
|
||||
const state = createState(tmpDir);
|
||||
await detectInstallStage(p, state, mockConfig);
|
||||
|
||||
expect(state.installAction).toBe('reset');
|
||||
// Reset should NOT load existing values
|
||||
expect(state.soul.agentName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { soulSetupStage } from '../../src/stages/soul-setup.js';
|
||||
import type { WizardState } from '../../src/types.js';
|
||||
|
||||
function createState(overrides: Partial<WizardState> = {}): WizardState {
|
||||
return {
|
||||
mosaicHome: '/tmp/test-mosaic',
|
||||
sourceDir: '/tmp/test-mosaic',
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: {},
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('soulSetupStage', () => {
|
||||
it('sets agent name and style in quick mode', async () => {
|
||||
const p = new HeadlessPrompter({
|
||||
'What name should agents use?': 'Jarvis',
|
||||
'Communication style': 'friendly',
|
||||
});
|
||||
|
||||
const state = createState({ mode: 'quick' });
|
||||
await soulSetupStage(p, state);
|
||||
|
||||
expect(state.soul.agentName).toBe('Jarvis');
|
||||
expect(state.soul.communicationStyle).toBe('friendly');
|
||||
expect(state.soul.roleDescription).toBe('execution partner and visibility engine');
|
||||
});
|
||||
|
||||
it('uses defaults in quick mode with no answers', async () => {
|
||||
const p = new HeadlessPrompter({});
|
||||
const state = createState({ mode: 'quick' });
|
||||
await soulSetupStage(p, state);
|
||||
|
||||
expect(state.soul.agentName).toBe('Assistant');
|
||||
expect(state.soul.communicationStyle).toBe('direct');
|
||||
});
|
||||
|
||||
it('skips when install action is keep', async () => {
|
||||
const p = new HeadlessPrompter({});
|
||||
const state = createState({ installAction: 'keep' });
|
||||
state.soul.agentName = 'Existing';
|
||||
await soulSetupStage(p, state);
|
||||
|
||||
expect(state.soul.agentName).toBe('Existing');
|
||||
});
|
||||
|
||||
it('asks for all fields in advanced mode', async () => {
|
||||
const p = new HeadlessPrompter({
|
||||
'What name should agents use?': 'Atlas',
|
||||
'Agent role description': 'memory keeper',
|
||||
'Communication style': 'formal',
|
||||
'Accessibility preferences': 'ADHD-friendly',
|
||||
'Custom guardrails (optional)': 'Never push to main',
|
||||
});
|
||||
|
||||
const state = createState({ mode: 'advanced' });
|
||||
await soulSetupStage(p, state);
|
||||
|
||||
expect(state.soul.agentName).toBe('Atlas');
|
||||
expect(state.soul.roleDescription).toBe('memory keeper');
|
||||
expect(state.soul.communicationStyle).toBe('formal');
|
||||
expect(state.soul.accessibility).toBe('ADHD-friendly');
|
||||
expect(state.soul.customGuardrails).toBe('Never push to main');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HeadlessPrompter } from '../../src/prompter/headless-prompter.js';
|
||||
import { userSetupStage } from '../../src/stages/user-setup.js';
|
||||
import type { WizardState } from '../../src/types.js';
|
||||
|
||||
function createState(overrides: Partial<WizardState> = {}): WizardState {
|
||||
return {
|
||||
mosaicHome: '/tmp/test-mosaic',
|
||||
sourceDir: '/tmp/test-mosaic',
|
||||
mode: 'quick',
|
||||
installAction: 'fresh',
|
||||
soul: { communicationStyle: 'direct' },
|
||||
user: {},
|
||||
tools: {},
|
||||
runtimes: { detected: [], mcpConfigured: false },
|
||||
selectedSkills: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('userSetupStage', () => {
|
||||
it('collects basic info in quick mode', async () => {
|
||||
const p = new HeadlessPrompter({
|
||||
'Your name': 'Jason',
|
||||
'Your pronouns': 'He/Him',
|
||||
'Your timezone': 'America/Chicago',
|
||||
});
|
||||
|
||||
const state = createState({ mode: 'quick' });
|
||||
await userSetupStage(p, state);
|
||||
|
||||
expect(state.user.userName).toBe('Jason');
|
||||
expect(state.user.pronouns).toBe('He/Him');
|
||||
expect(state.user.timezone).toBe('America/Chicago');
|
||||
expect(state.user.communicationPrefs).toContain('Direct and concise');
|
||||
});
|
||||
|
||||
it('skips when install action is keep', async () => {
|
||||
const p = new HeadlessPrompter({});
|
||||
const state = createState({ installAction: 'keep' });
|
||||
state.user.userName = 'Existing';
|
||||
await userSetupStage(p, state);
|
||||
|
||||
expect(state.user.userName).toBe('Existing');
|
||||
});
|
||||
|
||||
it('derives communication prefs from soul style', async () => {
|
||||
const p = new HeadlessPrompter({
|
||||
'Your name': 'Test',
|
||||
});
|
||||
|
||||
const state = createState({
|
||||
mode: 'quick',
|
||||
soul: { communicationStyle: 'friendly' },
|
||||
});
|
||||
await userSetupStage(p, state);
|
||||
|
||||
expect(state.user.communicationPrefs).toContain('Warm and conversational');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildSoulTemplateVars,
|
||||
buildUserTemplateVars,
|
||||
buildToolsTemplateVars,
|
||||
} from '../../src/template/builders.js';
|
||||
|
||||
describe('buildSoulTemplateVars', () => {
|
||||
it('builds direct style correctly', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
agentName: 'Jarvis',
|
||||
communicationStyle: 'direct',
|
||||
});
|
||||
expect(vars.AGENT_NAME).toBe('Jarvis');
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Clarity over performance theater');
|
||||
expect(vars.COMMUNICATION_STYLE).toContain('Be direct, concise, and concrete');
|
||||
});
|
||||
|
||||
it('builds friendly style correctly', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
communicationStyle: 'friendly',
|
||||
});
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Be helpful and approachable');
|
||||
expect(vars.COMMUNICATION_STYLE).toContain('Be warm and conversational');
|
||||
});
|
||||
|
||||
it('builds formal style correctly', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
communicationStyle: 'formal',
|
||||
});
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('Maintain professional, structured');
|
||||
expect(vars.COMMUNICATION_STYLE).toContain('Use professional, structured language');
|
||||
});
|
||||
|
||||
it('appends accessibility to principles', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
communicationStyle: 'direct',
|
||||
accessibility: 'ADHD-friendly chunking',
|
||||
});
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).toContain('6. ADHD-friendly chunking.');
|
||||
});
|
||||
|
||||
it('does not append accessibility when "none"', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
communicationStyle: 'direct',
|
||||
accessibility: 'none',
|
||||
});
|
||||
expect(vars.BEHAVIORAL_PRINCIPLES).not.toContain('6.');
|
||||
});
|
||||
|
||||
it('formats custom guardrails', () => {
|
||||
const vars = buildSoulTemplateVars({
|
||||
customGuardrails: 'Never auto-commit',
|
||||
});
|
||||
expect(vars.CUSTOM_GUARDRAILS).toBe('- Never auto-commit');
|
||||
});
|
||||
|
||||
it('uses defaults when config is empty', () => {
|
||||
const vars = buildSoulTemplateVars({});
|
||||
expect(vars.AGENT_NAME).toBe('Assistant');
|
||||
expect(vars.ROLE_DESCRIPTION).toBe('execution partner and visibility engine');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUserTemplateVars', () => {
|
||||
it('maps all fields', () => {
|
||||
const vars = buildUserTemplateVars({
|
||||
userName: 'Jason',
|
||||
pronouns: 'He/Him',
|
||||
timezone: 'America/Chicago',
|
||||
});
|
||||
expect(vars.USER_NAME).toBe('Jason');
|
||||
expect(vars.PRONOUNS).toBe('He/Him');
|
||||
expect(vars.TIMEZONE).toBe('America/Chicago');
|
||||
});
|
||||
|
||||
it('uses defaults for missing fields', () => {
|
||||
const vars = buildUserTemplateVars({});
|
||||
expect(vars.PRONOUNS).toBe('They/Them');
|
||||
expect(vars.TIMEZONE).toBe('UTC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildToolsTemplateVars', () => {
|
||||
it('builds git providers table', () => {
|
||||
const vars = buildToolsTemplateVars({
|
||||
gitProviders: [{ name: 'GitHub', url: 'https://github.com', cli: 'gh', purpose: 'OSS' }],
|
||||
});
|
||||
expect(vars.GIT_PROVIDERS_TABLE).toContain('| GitHub |');
|
||||
expect(vars.GIT_PROVIDERS_TABLE).toContain('`gh`');
|
||||
});
|
||||
|
||||
it('uses default table when no providers', () => {
|
||||
const vars = buildToolsTemplateVars({});
|
||||
expect(vars.GIT_PROVIDERS_TABLE).toContain('add your git providers here');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderTemplate } from '../../src/template/engine.js';
|
||||
|
||||
describe('renderTemplate', () => {
|
||||
it('replaces all placeholders', () => {
|
||||
const template = 'You are **{{AGENT_NAME}}**, role: {{ROLE_DESCRIPTION}}';
|
||||
const result = renderTemplate(template, {
|
||||
AGENT_NAME: 'Jarvis',
|
||||
ROLE_DESCRIPTION: 'steward',
|
||||
});
|
||||
expect(result).toBe('You are **Jarvis**, role: steward');
|
||||
});
|
||||
|
||||
it('preserves ${ENV_VAR} references', () => {
|
||||
const template = 'Path: ${HOME}/.config, Agent: {{AGENT_NAME}}';
|
||||
const result = renderTemplate(template, { AGENT_NAME: 'Test' });
|
||||
expect(result).toBe('Path: ${HOME}/.config, Agent: Test');
|
||||
});
|
||||
|
||||
it('handles multi-line values', () => {
|
||||
const template = '{{PRINCIPLES}}';
|
||||
const result = renderTemplate(template, {
|
||||
PRINCIPLES: '1. First\n2. Second\n3. Third',
|
||||
});
|
||||
expect(result).toBe('1. First\n2. Second\n3. Third');
|
||||
});
|
||||
|
||||
it('replaces unset vars with empty string by default', () => {
|
||||
const template = 'Before {{MISSING}} After';
|
||||
const result = renderTemplate(template, {});
|
||||
expect(result).toBe('Before After');
|
||||
});
|
||||
|
||||
it('throws in strict mode for missing vars', () => {
|
||||
const template = '{{MISSING}}';
|
||||
expect(() => renderTemplate(template, {}, { strict: true })).toThrow(
|
||||
'Template variable not provided: {{MISSING}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles multiple occurrences of same placeholder', () => {
|
||||
const template = '{{NAME}} says hello, {{NAME}}!';
|
||||
const result = renderTemplate(template, { NAME: 'Jarvis' });
|
||||
expect(result).toBe('Jarvis says hello, Jarvis!');
|
||||
});
|
||||
|
||||
it('preserves non-placeholder curly braces', () => {
|
||||
const template = 'const x = { foo: {{VALUE}} }';
|
||||
const result = renderTemplate(template, { VALUE: '"bar"' });
|
||||
expect(result).toBe('const x = { foo: "bar" }');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { semverLt, formatUpdateNotice } from '../src/runtime/update-checker.js';
|
||||
import type { UpdateCheckResult } from '../src/runtime/update-checker.js';
|
||||
|
||||
const { execSyncMock, cacheFiles } = vi.hoisted(() => ({
|
||||
execSyncMock: vi.fn(),
|
||||
cacheFiles: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock('node:child_process', () => ({
|
||||
execSync: execSyncMock,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn((path: string) => cacheFiles.has(path)),
|
||||
mkdirSync: vi.fn(),
|
||||
readFileSync: vi.fn((path: string) => {
|
||||
const value = cacheFiles.get(path);
|
||||
if (value === undefined) {
|
||||
throw new Error(`ENOENT: ${path}`);
|
||||
}
|
||||
return value;
|
||||
}),
|
||||
writeFileSync: vi.fn((path: string, content: string) => {
|
||||
cacheFiles.set(path, content);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('node:os', () => ({
|
||||
homedir: vi.fn(() => '/mock-home'),
|
||||
}));
|
||||
|
||||
async function importUpdateChecker() {
|
||||
vi.resetModules();
|
||||
return import('../src/runtime/update-checker.js');
|
||||
}
|
||||
|
||||
describe('semverLt', () => {
|
||||
it('returns true when a < b', () => {
|
||||
expect(semverLt('0.0.1', '0.0.2')).toBe(true);
|
||||
expect(semverLt('0.1.0', '0.2.0')).toBe(true);
|
||||
expect(semverLt('1.0.0', '2.0.0')).toBe(true);
|
||||
expect(semverLt('0.0.1-alpha.1', '0.0.1-alpha.2')).toBe(true);
|
||||
expect(semverLt('0.0.1-alpha.1', '0.0.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when a >= b', () => {
|
||||
expect(semverLt('0.0.2', '0.0.1')).toBe(false);
|
||||
expect(semverLt('1.0.0', '1.0.0')).toBe(false);
|
||||
expect(semverLt('2.0.0', '1.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty strings', () => {
|
||||
expect(semverLt('', '1.0.0')).toBe(false);
|
||||
expect(semverLt('1.0.0', '')).toBe(false);
|
||||
expect(semverLt('', '')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUpdateNotice', () => {
|
||||
beforeEach(() => {
|
||||
execSyncMock.mockReset();
|
||||
cacheFiles.clear();
|
||||
});
|
||||
|
||||
it('returns empty string when up to date', () => {
|
||||
const result: UpdateCheckResult = {
|
||||
current: '1.0.0',
|
||||
latest: '1.0.0',
|
||||
updateAvailable: false,
|
||||
checkedAt: new Date().toISOString(),
|
||||
registry: 'https://example.com',
|
||||
};
|
||||
expect(formatUpdateNotice(result)).toBe('');
|
||||
});
|
||||
|
||||
it('returns a notice when update is available', () => {
|
||||
const result: UpdateCheckResult = {
|
||||
current: '0.0.1',
|
||||
latest: '0.1.0',
|
||||
updateAvailable: true,
|
||||
checkedAt: new Date().toISOString(),
|
||||
registry: 'https://example.com',
|
||||
};
|
||||
const notice = formatUpdateNotice(result);
|
||||
expect(notice).toContain('0.0.1');
|
||||
expect(notice).toContain('0.1.0');
|
||||
expect(notice).toContain('Update available');
|
||||
});
|
||||
|
||||
it('uses @mosaicstack/mosaic for installs', async () => {
|
||||
execSyncMock.mockImplementation((command: string) => {
|
||||
if (command.includes('ls -g --depth=0 --json')) {
|
||||
return JSON.stringify({
|
||||
dependencies: {
|
||||
'@mosaicstack/mosaic': { version: '0.0.19' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (command.includes('view @mosaicstack/mosaic version')) {
|
||||
return '0.0.20';
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const { checkForUpdate } = await importUpdateChecker();
|
||||
const result = checkForUpdate({ skipCache: true });
|
||||
const notice = formatUpdateNotice(result);
|
||||
|
||||
expect(result.current).toBe('0.0.19');
|
||||
expect(result.latest).toBe('0.0.20');
|
||||
expect(result.currentPackage).toBe('@mosaicstack/mosaic');
|
||||
expect(result.targetPackage).toBe('@mosaicstack/mosaic');
|
||||
expect(notice).toContain('@mosaicstack/mosaic@latest');
|
||||
});
|
||||
|
||||
it('does not query legacy @mosaicstack/cli package', async () => {
|
||||
execSyncMock.mockImplementation((command: string) => {
|
||||
if (command.includes('view @mosaicstack/cli')) {
|
||||
throw new Error('Should not query @mosaicstack/cli');
|
||||
}
|
||||
|
||||
if (command.includes('ls -g --depth=0 --json')) {
|
||||
return JSON.stringify({
|
||||
dependencies: {
|
||||
'@mosaicstack/mosaic': { version: '0.0.19' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (command.includes('view @mosaicstack/mosaic version')) {
|
||||
return '0.0.20';
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const { checkForUpdate } = await importUpdateChecker();
|
||||
const result = checkForUpdate({ skipCache: true });
|
||||
|
||||
expect(result.targetPackage).toBe('@mosaicstack/mosaic');
|
||||
expect(result.latest).toBe('0.0.20');
|
||||
// Verify no @mosaicstack/cli queries were made
|
||||
const calls = execSyncMock.mock.calls.map((c: any[]) => c[0] as string);
|
||||
expect(calls.some((c) => c.includes('@mosaicstack/cli'))).toBe(false);
|
||||
});
|
||||
|
||||
it('returns empty result when package is not installed', async () => {
|
||||
execSyncMock.mockImplementation((command: string) => {
|
||||
if (command.includes('ls -g --depth=0 --json')) {
|
||||
return JSON.stringify({ dependencies: {} });
|
||||
}
|
||||
|
||||
if (command.includes('view @mosaicstack/mosaic version')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
const { checkForUpdate } = await importUpdateChecker();
|
||||
const result = checkForUpdate({ skipCache: true });
|
||||
|
||||
expect(result.current).toBe('');
|
||||
expect(result.updateAvailable).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user