@meltstudio/meltctl 4.28.0 → 4.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/audit.test.d.ts +1 -0
- package/dist/commands/audit.test.js +250 -0
- package/dist/commands/coins.test.d.ts +1 -0
- package/dist/commands/coins.test.js +133 -0
- package/dist/commands/feedback.test.d.ts +1 -0
- package/dist/commands/feedback.test.js +242 -0
- package/dist/commands/init.test.d.ts +1 -0
- package/dist/commands/init.test.js +476 -0
- package/dist/commands/login.test.d.ts +1 -0
- package/dist/commands/login.test.js +174 -0
- package/dist/commands/logout.test.d.ts +1 -0
- package/dist/commands/logout.test.js +59 -0
- package/dist/commands/plan.test.d.ts +1 -0
- package/dist/commands/plan.test.js +283 -0
- package/dist/commands/standup.test.d.ts +1 -0
- package/dist/commands/standup.test.js +252 -0
- package/dist/commands/templates.test.d.ts +1 -0
- package/dist/commands/templates.test.js +89 -0
- package/dist/commands/version.test.d.ts +1 -0
- package/dist/commands/version.test.js +86 -0
- package/dist/utils/api.test.d.ts +1 -0
- package/dist/utils/api.test.js +96 -0
- package/dist/utils/auth.test.d.ts +1 -0
- package/dist/utils/auth.test.js +165 -0
- package/dist/utils/banner.test.d.ts +1 -0
- package/dist/utils/banner.test.js +34 -0
- package/dist/utils/git.test.d.ts +1 -0
- package/dist/utils/git.test.js +184 -0
- package/dist/utils/package-manager.test.d.ts +1 -0
- package/dist/utils/package-manager.test.js +76 -0
- package/dist/utils/templates.test.d.ts +1 -0
- package/dist/utils/templates.test.js +50 -0
- package/dist/utils/version-check.test.d.ts +1 -0
- package/dist/utils/version-check.test.js +135 -0
- package/package.json +2 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('fs-extra', () => ({
|
|
3
|
+
default: {
|
|
4
|
+
ensureDir: vi.fn(),
|
|
5
|
+
writeFile: vi.fn(),
|
|
6
|
+
},
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('../utils/auth.js', () => ({
|
|
9
|
+
isAuthenticated: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock('../utils/templates.js', () => ({
|
|
12
|
+
fetchTemplates: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
import fs from 'fs-extra';
|
|
15
|
+
import { isAuthenticated } from '../utils/auth.js';
|
|
16
|
+
import { fetchTemplates } from '../utils/templates.js';
|
|
17
|
+
import { templatesCommand } from './templates.js';
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
21
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
22
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
23
|
+
throw new Error(`process.exit(${code})`);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('templatesCommand', () => {
|
|
27
|
+
it('exits when not authenticated', async () => {
|
|
28
|
+
;
|
|
29
|
+
isAuthenticated.mockResolvedValue(false);
|
|
30
|
+
await expect(templatesCommand()).rejects.toThrow('process.exit(1)');
|
|
31
|
+
expect(console.error).toHaveBeenCalled();
|
|
32
|
+
expect(fetchTemplates).not.toHaveBeenCalled();
|
|
33
|
+
});
|
|
34
|
+
it('fetches and writes templates to temp dir', async () => {
|
|
35
|
+
;
|
|
36
|
+
isAuthenticated.mockResolvedValue(true);
|
|
37
|
+
fetchTemplates.mockResolvedValue({
|
|
38
|
+
'AGENTS.md': '# Agents\nContent here',
|
|
39
|
+
'.claude/skills/setup.md': '# Setup skill',
|
|
40
|
+
});
|
|
41
|
+
await templatesCommand();
|
|
42
|
+
// Should have called ensureDir for the temp dir and subdirectories
|
|
43
|
+
expect(fs.ensureDir).toHaveBeenCalled();
|
|
44
|
+
// Should have written both files
|
|
45
|
+
expect(fs.writeFile).toHaveBeenCalledTimes(2);
|
|
46
|
+
// First file
|
|
47
|
+
const writeFileCalls = fs.writeFile.mock.calls;
|
|
48
|
+
const filePaths = writeFileCalls.map((c) => c[0]);
|
|
49
|
+
expect(filePaths.some((p) => p.endsWith('AGENTS.md'))).toBe(true);
|
|
50
|
+
expect(filePaths.some((p) => p.endsWith('setup.md'))).toBe(true);
|
|
51
|
+
// Verify content
|
|
52
|
+
const agentsCall = writeFileCalls.find((c) => c[0].endsWith('AGENTS.md'));
|
|
53
|
+
expect(agentsCall[1]).toBe('# Agents\nContent here');
|
|
54
|
+
// Should print the temp dir path
|
|
55
|
+
expect(console.log).toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
it('exits with error when session expired', async () => {
|
|
58
|
+
;
|
|
59
|
+
isAuthenticated.mockResolvedValue(true);
|
|
60
|
+
fetchTemplates.mockRejectedValue(new Error('Session expired'));
|
|
61
|
+
await expect(templatesCommand()).rejects.toThrow('process.exit(1)');
|
|
62
|
+
const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
|
|
63
|
+
expect(errorCalls.some((msg) => msg.includes('expired'))).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
it('exits with error on fetch/network error', async () => {
|
|
66
|
+
;
|
|
67
|
+
isAuthenticated.mockResolvedValue(true);
|
|
68
|
+
fetchTemplates.mockRejectedValue(new Error('fetch failed'));
|
|
69
|
+
await expect(templatesCommand()).rejects.toThrow('process.exit(1)');
|
|
70
|
+
const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
|
|
71
|
+
expect(errorCalls.some((msg) => msg.includes('connection'))).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
it('exits with generic error for unknown failures', async () => {
|
|
74
|
+
;
|
|
75
|
+
isAuthenticated.mockResolvedValue(true);
|
|
76
|
+
fetchTemplates.mockRejectedValue(new Error('Something weird'));
|
|
77
|
+
await expect(templatesCommand()).rejects.toThrow('process.exit(1)');
|
|
78
|
+
const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
|
|
79
|
+
expect(errorCalls.some((msg) => msg.includes('Something weird'))).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
it('exits with generic message for non-Error thrown values', async () => {
|
|
82
|
+
;
|
|
83
|
+
isAuthenticated.mockResolvedValue(true);
|
|
84
|
+
fetchTemplates.mockRejectedValue('string error');
|
|
85
|
+
await expect(templatesCommand()).rejects.toThrow('process.exit(1)');
|
|
86
|
+
const errorCalls = console.error.mock.calls.map((c) => String(c[0]));
|
|
87
|
+
expect(errorCalls.some((msg) => msg.includes('Unknown error'))).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('../utils/version-check.js', () => ({
|
|
3
|
+
getCurrentCliVersion: vi.fn(),
|
|
4
|
+
getLatestCliVersion: vi.fn(),
|
|
5
|
+
compareVersions: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
vi.mock('../utils/package-manager.js', () => ({
|
|
8
|
+
getUpdateInstructions: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
import { getCurrentCliVersion, getLatestCliVersion, compareVersions, } from '../utils/version-check.js';
|
|
11
|
+
import { getUpdateInstructions } from '../utils/package-manager.js';
|
|
12
|
+
import { versionCheckCommand } from './version.js';
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
16
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
17
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
18
|
+
throw new Error(`process.exit(${code})`);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('versionCheckCommand', () => {
|
|
22
|
+
it('displays up-to-date message when versions match', async () => {
|
|
23
|
+
;
|
|
24
|
+
getCurrentCliVersion.mockResolvedValue('4.26.0');
|
|
25
|
+
getLatestCliVersion.mockResolvedValue('4.26.0');
|
|
26
|
+
compareVersions.mockReturnValue(false);
|
|
27
|
+
await versionCheckCommand();
|
|
28
|
+
const logCalls = console.log.mock.calls.map((c) => c[0]);
|
|
29
|
+
expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('up to date'))).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it('displays update available when newer version exists', async () => {
|
|
32
|
+
;
|
|
33
|
+
getCurrentCliVersion.mockResolvedValue('4.25.0');
|
|
34
|
+
getLatestCliVersion.mockResolvedValue('4.26.0');
|
|
35
|
+
compareVersions.mockReturnValue(true);
|
|
36
|
+
getUpdateInstructions.mockReturnValue([
|
|
37
|
+
'npm install -g @meltstudio/meltctl@latest',
|
|
38
|
+
'',
|
|
39
|
+
'Or with yarn:',
|
|
40
|
+
' yarn global add @meltstudio/meltctl@latest',
|
|
41
|
+
]);
|
|
42
|
+
await versionCheckCommand();
|
|
43
|
+
const logCalls = console.log.mock.calls.map((c) => c[0]);
|
|
44
|
+
expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('Update available'))).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
it('shows update instructions when update is available', async () => {
|
|
47
|
+
;
|
|
48
|
+
getCurrentCliVersion.mockResolvedValue('4.25.0');
|
|
49
|
+
getLatestCliVersion.mockResolvedValue('4.26.0');
|
|
50
|
+
compareVersions.mockReturnValue(true);
|
|
51
|
+
getUpdateInstructions.mockReturnValue(['npm install -g @meltstudio/meltctl@latest']);
|
|
52
|
+
await versionCheckCommand();
|
|
53
|
+
expect(getUpdateInstructions).toHaveBeenCalled();
|
|
54
|
+
const logCalls = console.log.mock.calls.map((c) => c[0]);
|
|
55
|
+
expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('To update, run'))).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
it('shows warning when unable to check for updates (network error)', async () => {
|
|
58
|
+
;
|
|
59
|
+
getCurrentCliVersion.mockResolvedValue('4.26.0');
|
|
60
|
+
getLatestCliVersion.mockResolvedValue(null);
|
|
61
|
+
await versionCheckCommand();
|
|
62
|
+
const logCalls = console.log.mock.calls.map((c) => c[0]);
|
|
63
|
+
expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('Unable to check for updates'))).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
it('shows current version when network check fails', async () => {
|
|
66
|
+
;
|
|
67
|
+
getCurrentCliVersion.mockResolvedValue('4.26.0');
|
|
68
|
+
getLatestCliVersion.mockResolvedValue(null);
|
|
69
|
+
await versionCheckCommand();
|
|
70
|
+
const logCalls = console.log.mock.calls.map((c) => c[0]);
|
|
71
|
+
expect(logCalls.some((msg) => typeof msg === 'string' && msg.includes('4.26.0'))).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
it('exits with error when getCurrentCliVersion throws', async () => {
|
|
74
|
+
;
|
|
75
|
+
getCurrentCliVersion.mockRejectedValue(new Error('Cannot read package.json'));
|
|
76
|
+
await expect(versionCheckCommand()).rejects.toThrow('process.exit(1)');
|
|
77
|
+
expect(console.error).toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
it('exits with error when getLatestCliVersion throws', async () => {
|
|
80
|
+
;
|
|
81
|
+
getCurrentCliVersion.mockResolvedValue('4.26.0');
|
|
82
|
+
getLatestCliVersion.mockRejectedValue(new Error('Unexpected error'));
|
|
83
|
+
await expect(versionCheckCommand()).rejects.toThrow('process.exit(1)');
|
|
84
|
+
expect(console.error).toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('./auth.js', () => ({
|
|
3
|
+
getStoredAuth: vi.fn(),
|
|
4
|
+
API_BASE: 'https://test-api.example.com',
|
|
5
|
+
}));
|
|
6
|
+
import { getStoredAuth, API_BASE } from './auth.js';
|
|
7
|
+
import { getToken, tokenFetch } from './api.js';
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.clearAllMocks();
|
|
10
|
+
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
11
|
+
vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
12
|
+
throw new Error(`process.exit(${code})`);
|
|
13
|
+
});
|
|
14
|
+
delete process.env['MELTCTL_TOKEN'];
|
|
15
|
+
});
|
|
16
|
+
describe('getToken', () => {
|
|
17
|
+
it('returns env token when MELTCTL_TOKEN is set', async () => {
|
|
18
|
+
process.env['MELTCTL_TOKEN'] = 'env-token-123';
|
|
19
|
+
const token = await getToken();
|
|
20
|
+
expect(token).toBe('env-token-123');
|
|
21
|
+
expect(getStoredAuth).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
it('returns stored auth token when valid', async () => {
|
|
24
|
+
;
|
|
25
|
+
getStoredAuth.mockResolvedValue({
|
|
26
|
+
token: 'stored-token',
|
|
27
|
+
email: 'dev@meltstudio.co',
|
|
28
|
+
expiresAt: '2099-12-31T00:00:00Z',
|
|
29
|
+
});
|
|
30
|
+
const token = await getToken();
|
|
31
|
+
expect(token).toBe('stored-token');
|
|
32
|
+
});
|
|
33
|
+
it('exits when no auth is available', async () => {
|
|
34
|
+
;
|
|
35
|
+
getStoredAuth.mockResolvedValue(undefined);
|
|
36
|
+
await expect(getToken()).rejects.toThrow('process.exit(1)');
|
|
37
|
+
expect(console.error).toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
it('exits when stored token is expired', async () => {
|
|
40
|
+
;
|
|
41
|
+
getStoredAuth.mockResolvedValue({
|
|
42
|
+
token: 'expired-token',
|
|
43
|
+
email: 'dev@meltstudio.co',
|
|
44
|
+
expiresAt: '2020-01-01T00:00:00Z',
|
|
45
|
+
});
|
|
46
|
+
await expect(getToken()).rejects.toThrow('process.exit(1)');
|
|
47
|
+
expect(console.error).toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe('tokenFetch', () => {
|
|
51
|
+
it('makes request with bearer token', async () => {
|
|
52
|
+
const mockResponse = { status: 200, ok: true };
|
|
53
|
+
const fetchMock = vi.fn().mockResolvedValue(mockResponse);
|
|
54
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
55
|
+
const result = await tokenFetch('my-token', '/audits');
|
|
56
|
+
expect(result).toBe(mockResponse);
|
|
57
|
+
expect(fetchMock).toHaveBeenCalledWith(`${API_BASE}/audits`, expect.objectContaining({
|
|
58
|
+
headers: expect.objectContaining({
|
|
59
|
+
Authorization: 'Bearer my-token',
|
|
60
|
+
}),
|
|
61
|
+
}));
|
|
62
|
+
vi.unstubAllGlobals();
|
|
63
|
+
});
|
|
64
|
+
it('merges custom options and headers', async () => {
|
|
65
|
+
const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true });
|
|
66
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
67
|
+
await tokenFetch('tok', '/test', {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
body: '{}',
|
|
71
|
+
});
|
|
72
|
+
expect(fetchMock).toHaveBeenCalledWith(`${API_BASE}/test`, expect.objectContaining({
|
|
73
|
+
method: 'POST',
|
|
74
|
+
body: '{}',
|
|
75
|
+
headers: expect.objectContaining({
|
|
76
|
+
Authorization: 'Bearer tok',
|
|
77
|
+
'Content-Type': 'application/json',
|
|
78
|
+
}),
|
|
79
|
+
}));
|
|
80
|
+
vi.unstubAllGlobals();
|
|
81
|
+
});
|
|
82
|
+
it('throws when API returns 401', async () => {
|
|
83
|
+
const fetchMock = vi.fn().mockResolvedValue({ status: 401, ok: false });
|
|
84
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
85
|
+
await expect(tokenFetch('bad-token', '/test')).rejects.toThrow('Authentication failed');
|
|
86
|
+
vi.unstubAllGlobals();
|
|
87
|
+
});
|
|
88
|
+
it('returns response for non-401 errors without throwing', async () => {
|
|
89
|
+
const mockResponse = { status: 500, ok: false, statusText: 'Internal Server Error' };
|
|
90
|
+
const fetchMock = vi.fn().mockResolvedValue(mockResponse);
|
|
91
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
92
|
+
const result = await tokenFetch('tok', '/test');
|
|
93
|
+
expect(result.status).toBe(500);
|
|
94
|
+
vi.unstubAllGlobals();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('fs-extra', () => ({
|
|
3
|
+
default: {
|
|
4
|
+
pathExists: vi.fn(),
|
|
5
|
+
readJson: vi.fn(),
|
|
6
|
+
ensureDir: vi.fn(),
|
|
7
|
+
writeJson: vi.fn(),
|
|
8
|
+
remove: vi.fn(),
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
import fs from 'fs-extra';
|
|
12
|
+
import { getStoredAuth, storeAuth, clearAuth, isAuthenticated, authenticatedFetch, API_BASE, } from './auth.js';
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
});
|
|
16
|
+
describe('API_BASE', () => {
|
|
17
|
+
it('has a default value', () => {
|
|
18
|
+
expect(API_BASE).toBeTruthy();
|
|
19
|
+
expect(typeof API_BASE).toBe('string');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('getStoredAuth', () => {
|
|
23
|
+
it('returns undefined when auth file does not exist', async () => {
|
|
24
|
+
;
|
|
25
|
+
fs.pathExists.mockResolvedValue(false);
|
|
26
|
+
const result = await getStoredAuth();
|
|
27
|
+
expect(result).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
it('returns stored auth data when file exists', async () => {
|
|
30
|
+
const mockAuth = { token: 'abc', email: 'dev@meltstudio.co', expiresAt: '2026-12-31' };
|
|
31
|
+
fs.pathExists.mockResolvedValue(true);
|
|
32
|
+
fs.readJson.mockResolvedValue(mockAuth);
|
|
33
|
+
const result = await getStoredAuth();
|
|
34
|
+
expect(result).toEqual(mockAuth);
|
|
35
|
+
});
|
|
36
|
+
it('returns undefined when readJson throws', async () => {
|
|
37
|
+
;
|
|
38
|
+
fs.pathExists.mockResolvedValue(true);
|
|
39
|
+
fs.readJson.mockRejectedValue(new Error('parse error'));
|
|
40
|
+
const result = await getStoredAuth();
|
|
41
|
+
expect(result).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe('storeAuth', () => {
|
|
45
|
+
it('creates directory and writes auth file', async () => {
|
|
46
|
+
const auth = { token: 'tok', email: 'a@meltstudio.co', expiresAt: '2026-12-31' };
|
|
47
|
+
await storeAuth(auth);
|
|
48
|
+
expect(fs.ensureDir).toHaveBeenCalled();
|
|
49
|
+
expect(fs.writeJson).toHaveBeenCalledWith(expect.stringContaining('auth.json'), auth, {
|
|
50
|
+
spaces: 2,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe('clearAuth', () => {
|
|
55
|
+
it('removes auth file when it exists', async () => {
|
|
56
|
+
;
|
|
57
|
+
fs.pathExists.mockResolvedValue(true);
|
|
58
|
+
await clearAuth();
|
|
59
|
+
expect(fs.remove).toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
it('does nothing when auth file does not exist', async () => {
|
|
62
|
+
;
|
|
63
|
+
fs.pathExists.mockResolvedValue(false);
|
|
64
|
+
await clearAuth();
|
|
65
|
+
expect(fs.remove).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe('isAuthenticated', () => {
|
|
69
|
+
it('returns false when no stored auth', async () => {
|
|
70
|
+
;
|
|
71
|
+
fs.pathExists.mockResolvedValue(false);
|
|
72
|
+
expect(await isAuthenticated()).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
it('returns false when token is expired', async () => {
|
|
75
|
+
;
|
|
76
|
+
fs.pathExists.mockResolvedValue(true);
|
|
77
|
+
fs.readJson.mockResolvedValue({
|
|
78
|
+
token: 'tok',
|
|
79
|
+
email: 'a@meltstudio.co',
|
|
80
|
+
expiresAt: '2020-01-01T00:00:00Z',
|
|
81
|
+
});
|
|
82
|
+
expect(await isAuthenticated()).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
it('returns true when token is valid and not expired', async () => {
|
|
85
|
+
;
|
|
86
|
+
fs.pathExists.mockResolvedValue(true);
|
|
87
|
+
fs.readJson.mockResolvedValue({
|
|
88
|
+
token: 'tok',
|
|
89
|
+
email: 'a@meltstudio.co',
|
|
90
|
+
expiresAt: '2099-12-31T00:00:00Z',
|
|
91
|
+
});
|
|
92
|
+
expect(await isAuthenticated()).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('authenticatedFetch', () => {
|
|
96
|
+
it('throws when not authenticated', async () => {
|
|
97
|
+
;
|
|
98
|
+
fs.pathExists.mockResolvedValue(false);
|
|
99
|
+
await expect(authenticatedFetch('/test')).rejects.toThrow('Not authenticated');
|
|
100
|
+
});
|
|
101
|
+
it('throws when session expired', async () => {
|
|
102
|
+
;
|
|
103
|
+
fs.pathExists.mockResolvedValue(true);
|
|
104
|
+
fs.readJson.mockResolvedValue({
|
|
105
|
+
token: 'tok',
|
|
106
|
+
email: 'a@meltstudio.co',
|
|
107
|
+
expiresAt: '2020-01-01T00:00:00Z',
|
|
108
|
+
});
|
|
109
|
+
await expect(authenticatedFetch('/test')).rejects.toThrow('expired');
|
|
110
|
+
});
|
|
111
|
+
it('makes authenticated request with bearer token', async () => {
|
|
112
|
+
;
|
|
113
|
+
fs.pathExists.mockResolvedValue(true);
|
|
114
|
+
fs.readJson.mockResolvedValue({
|
|
115
|
+
token: 'my-jwt',
|
|
116
|
+
email: 'a@meltstudio.co',
|
|
117
|
+
expiresAt: '2099-12-31T00:00:00Z',
|
|
118
|
+
});
|
|
119
|
+
const mockResponse = { status: 200, ok: true };
|
|
120
|
+
const fetchMock = vi.fn().mockResolvedValue(mockResponse);
|
|
121
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
122
|
+
const result = await authenticatedFetch('/templates');
|
|
123
|
+
expect(result).toBe(mockResponse);
|
|
124
|
+
expect(fetchMock).toHaveBeenCalledWith(`${API_BASE}/templates`, expect.objectContaining({
|
|
125
|
+
headers: expect.objectContaining({
|
|
126
|
+
Authorization: 'Bearer my-jwt',
|
|
127
|
+
}),
|
|
128
|
+
}));
|
|
129
|
+
vi.unstubAllGlobals();
|
|
130
|
+
});
|
|
131
|
+
it('throws when API returns 401', async () => {
|
|
132
|
+
;
|
|
133
|
+
fs.pathExists.mockResolvedValue(true);
|
|
134
|
+
fs.readJson.mockResolvedValue({
|
|
135
|
+
token: 'expired-jwt',
|
|
136
|
+
email: 'a@meltstudio.co',
|
|
137
|
+
expiresAt: '2099-12-31T00:00:00Z',
|
|
138
|
+
});
|
|
139
|
+
const fetchMock = vi.fn().mockResolvedValue({ status: 401, ok: false });
|
|
140
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
141
|
+
await expect(authenticatedFetch('/test')).rejects.toThrow('expired');
|
|
142
|
+
vi.unstubAllGlobals();
|
|
143
|
+
});
|
|
144
|
+
it('merges custom headers with auth header', async () => {
|
|
145
|
+
;
|
|
146
|
+
fs.pathExists.mockResolvedValue(true);
|
|
147
|
+
fs.readJson.mockResolvedValue({
|
|
148
|
+
token: 'jwt',
|
|
149
|
+
email: 'a@meltstudio.co',
|
|
150
|
+
expiresAt: '2099-12-31T00:00:00Z',
|
|
151
|
+
});
|
|
152
|
+
const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true });
|
|
153
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
154
|
+
await authenticatedFetch('/test', {
|
|
155
|
+
headers: { 'Content-Type': 'application/json' },
|
|
156
|
+
});
|
|
157
|
+
expect(fetchMock).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
|
158
|
+
headers: expect.objectContaining({
|
|
159
|
+
Authorization: 'Bearer jwt',
|
|
160
|
+
'Content-Type': 'application/json',
|
|
161
|
+
}),
|
|
162
|
+
}));
|
|
163
|
+
vi.unstubAllGlobals();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('fs', () => ({
|
|
3
|
+
readFileSync: vi.fn().mockReturnValue(JSON.stringify({ version: '1.2.3' })),
|
|
4
|
+
}));
|
|
5
|
+
vi.mock('gradient-string', () => ({
|
|
6
|
+
default: vi.fn().mockReturnValue(vi.fn((str) => str)),
|
|
7
|
+
}));
|
|
8
|
+
import { printBanner } from './banner.js';
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.clearAllMocks();
|
|
11
|
+
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
12
|
+
});
|
|
13
|
+
describe('printBanner', () => {
|
|
14
|
+
it('does not throw', () => {
|
|
15
|
+
expect(() => printBanner()).not.toThrow();
|
|
16
|
+
});
|
|
17
|
+
it('calls console.log at least once', () => {
|
|
18
|
+
printBanner();
|
|
19
|
+
expect(console.log).toHaveBeenCalled();
|
|
20
|
+
});
|
|
21
|
+
it('prints output containing MELTCTL logo text', () => {
|
|
22
|
+
printBanner();
|
|
23
|
+
const logCalls = console.log.mock.calls.map((c) => c[0]);
|
|
24
|
+
const allOutput = logCalls.filter(Boolean).join('\n');
|
|
25
|
+
// The logo contains block characters for MELTCTL
|
|
26
|
+
expect(allOutput).toContain('███');
|
|
27
|
+
});
|
|
28
|
+
it('prints version info', () => {
|
|
29
|
+
printBanner();
|
|
30
|
+
const logCalls = console.log.mock.calls.map((c) => String(c[0] ?? ''));
|
|
31
|
+
const hasVersion = logCalls.some((msg) => msg.includes('v1.2.3') || msg.includes('1.2.3'));
|
|
32
|
+
expect(hasVersion).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('child_process', () => ({
|
|
3
|
+
execSync: vi.fn(),
|
|
4
|
+
}));
|
|
5
|
+
vi.mock('fs-extra', () => ({
|
|
6
|
+
default: {
|
|
7
|
+
pathExistsSync: vi.fn(),
|
|
8
|
+
readJsonSync: vi.fn(),
|
|
9
|
+
pathExists: vi.fn(),
|
|
10
|
+
readdir: vi.fn(),
|
|
11
|
+
stat: vi.fn(),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
import { execSync } from 'child_process';
|
|
15
|
+
import fs from 'fs-extra';
|
|
16
|
+
import { getGitBranch, getGitCommit, getGitRepository, getProjectName, extractTicketId, findMdFiles, } from './git.js';
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
vi.clearAllMocks();
|
|
19
|
+
});
|
|
20
|
+
describe('getGitBranch', () => {
|
|
21
|
+
it('returns trimmed branch name', () => {
|
|
22
|
+
;
|
|
23
|
+
execSync.mockReturnValue(' feature/my-branch\n');
|
|
24
|
+
expect(getGitBranch()).toBe('feature/my-branch');
|
|
25
|
+
});
|
|
26
|
+
it('returns "unknown" when git command fails', () => {
|
|
27
|
+
;
|
|
28
|
+
execSync.mockImplementation(() => {
|
|
29
|
+
throw new Error('not a git repo');
|
|
30
|
+
});
|
|
31
|
+
expect(getGitBranch()).toBe('unknown');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('getGitCommit', () => {
|
|
35
|
+
it('returns trimmed commit hash', () => {
|
|
36
|
+
;
|
|
37
|
+
execSync.mockReturnValue('abc1234\n');
|
|
38
|
+
expect(getGitCommit()).toBe('abc1234');
|
|
39
|
+
});
|
|
40
|
+
it('returns "unknown" when git command fails', () => {
|
|
41
|
+
;
|
|
42
|
+
execSync.mockImplementation(() => {
|
|
43
|
+
throw new Error('not a git repo');
|
|
44
|
+
});
|
|
45
|
+
expect(getGitCommit()).toBe('unknown');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
describe('getGitRepository', () => {
|
|
49
|
+
it('parses SSH URL format', () => {
|
|
50
|
+
;
|
|
51
|
+
execSync.mockReturnValue('git@github.com:Owner/Repo.git\n');
|
|
52
|
+
const result = getGitRepository();
|
|
53
|
+
expect(result).toEqual({
|
|
54
|
+
slug: 'Owner/Repo',
|
|
55
|
+
url: 'git@github.com:Owner/Repo.git',
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
it('parses HTTPS URL format', () => {
|
|
59
|
+
;
|
|
60
|
+
execSync.mockReturnValue('https://github.com/Owner/Repo.git\n');
|
|
61
|
+
const result = getGitRepository();
|
|
62
|
+
expect(result).toEqual({
|
|
63
|
+
slug: 'Owner/Repo',
|
|
64
|
+
url: 'https://github.com/Owner/Repo.git',
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
it('parses HTTPS URL without .git suffix', () => {
|
|
68
|
+
;
|
|
69
|
+
execSync.mockReturnValue('https://github.com/Owner/Repo\n');
|
|
70
|
+
const result = getGitRepository();
|
|
71
|
+
expect(result).toEqual({
|
|
72
|
+
slug: 'Owner/Repo',
|
|
73
|
+
url: 'https://github.com/Owner/Repo',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
it('parses GitLab SSH URL', () => {
|
|
77
|
+
;
|
|
78
|
+
execSync.mockReturnValue('git@gitlab.com:My-Org/My-Project.git\n');
|
|
79
|
+
const result = getGitRepository();
|
|
80
|
+
expect(result).toEqual({
|
|
81
|
+
slug: 'My-Org/My-Project',
|
|
82
|
+
url: 'git@gitlab.com:My-Org/My-Project.git',
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
it('returns null when git command fails', () => {
|
|
86
|
+
;
|
|
87
|
+
execSync.mockImplementation(() => {
|
|
88
|
+
throw new Error('no remote');
|
|
89
|
+
});
|
|
90
|
+
expect(getGitRepository()).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
it('falls back to raw URL when regex does not match', () => {
|
|
93
|
+
;
|
|
94
|
+
execSync.mockReturnValue('some-weird-url\n');
|
|
95
|
+
const result = getGitRepository();
|
|
96
|
+
expect(result).toEqual({
|
|
97
|
+
slug: 'some-weird-url',
|
|
98
|
+
url: 'some-weird-url',
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
describe('getProjectName', () => {
|
|
103
|
+
it('returns name from package.json when available', () => {
|
|
104
|
+
;
|
|
105
|
+
fs.pathExistsSync.mockReturnValue(true);
|
|
106
|
+
fs.readJsonSync.mockReturnValue({ name: '@meltstudio/meltctl' });
|
|
107
|
+
expect(getProjectName()).toBe('@meltstudio/meltctl');
|
|
108
|
+
});
|
|
109
|
+
it('returns directory basename when package.json has no name', () => {
|
|
110
|
+
;
|
|
111
|
+
fs.pathExistsSync.mockReturnValue(true);
|
|
112
|
+
fs.readJsonSync.mockReturnValue({});
|
|
113
|
+
const result = getProjectName();
|
|
114
|
+
// Should be the basename of cwd
|
|
115
|
+
expect(typeof result).toBe('string');
|
|
116
|
+
expect(result.length).toBeGreaterThan(0);
|
|
117
|
+
});
|
|
118
|
+
it('returns directory basename when package.json does not exist', () => {
|
|
119
|
+
;
|
|
120
|
+
fs.pathExistsSync.mockReturnValue(false);
|
|
121
|
+
const result = getProjectName();
|
|
122
|
+
expect(typeof result).toBe('string');
|
|
123
|
+
expect(result.length).toBeGreaterThan(0);
|
|
124
|
+
});
|
|
125
|
+
it('returns directory basename when readJsonSync throws', () => {
|
|
126
|
+
;
|
|
127
|
+
fs.pathExistsSync.mockReturnValue(true);
|
|
128
|
+
fs.readJsonSync.mockImplementation(() => {
|
|
129
|
+
throw new Error('parse error');
|
|
130
|
+
});
|
|
131
|
+
const result = getProjectName();
|
|
132
|
+
expect(typeof result).toBe('string');
|
|
133
|
+
expect(result.length).toBeGreaterThan(0);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
describe('extractTicketId', () => {
|
|
137
|
+
it('extracts ticket ID from branch name', () => {
|
|
138
|
+
expect(extractTicketId('feature/PROJ-123-add-login')).toBe('PROJ-123');
|
|
139
|
+
});
|
|
140
|
+
it('extracts ticket ID case-insensitively', () => {
|
|
141
|
+
expect(extractTicketId('fix/proj-456')).toBe('proj-456');
|
|
142
|
+
});
|
|
143
|
+
it('returns null when no ticket ID found', () => {
|
|
144
|
+
expect(extractTicketId('main')).toBeNull();
|
|
145
|
+
expect(extractTicketId('feature/add-login')).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
describe('findMdFiles', () => {
|
|
149
|
+
it('returns empty array when directory does not exist', async () => {
|
|
150
|
+
;
|
|
151
|
+
fs.pathExists.mockResolvedValue(false);
|
|
152
|
+
const result = await findMdFiles('/nonexistent');
|
|
153
|
+
expect(result).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
it('finds and sorts .md files by mtime descending', async () => {
|
|
156
|
+
;
|
|
157
|
+
fs.pathExists.mockResolvedValue(true);
|
|
158
|
+
fs.readdir.mockResolvedValue([
|
|
159
|
+
{ name: 'old.md', isDirectory: () => false, isFile: () => true },
|
|
160
|
+
{ name: 'new.md', isDirectory: () => false, isFile: () => true },
|
|
161
|
+
{ name: 'not-md.txt', isDirectory: () => false, isFile: () => true },
|
|
162
|
+
]);
|
|
163
|
+
fs.stat
|
|
164
|
+
.mockResolvedValueOnce({ mtimeMs: 1000 }) // old.md
|
|
165
|
+
.mockResolvedValueOnce({ mtimeMs: 2000 }); // new.md
|
|
166
|
+
const result = await findMdFiles('/test-dir');
|
|
167
|
+
expect(result).toEqual(['/test-dir/new.md', '/test-dir/old.md']);
|
|
168
|
+
});
|
|
169
|
+
it('recurses into subdirectories', async () => {
|
|
170
|
+
;
|
|
171
|
+
fs.pathExists.mockResolvedValue(true);
|
|
172
|
+
fs.readdir
|
|
173
|
+
.mockResolvedValueOnce([
|
|
174
|
+
{ name: 'sub', isDirectory: () => true, isFile: () => false },
|
|
175
|
+
{ name: 'root.md', isDirectory: () => false, isFile: () => true },
|
|
176
|
+
])
|
|
177
|
+
.mockResolvedValueOnce([{ name: 'nested.md', isDirectory: () => false, isFile: () => true }]);
|
|
178
|
+
fs.stat
|
|
179
|
+
.mockResolvedValueOnce({ mtimeMs: 4000 }) // sub/nested.md
|
|
180
|
+
.mockResolvedValueOnce({ mtimeMs: 3000 }); // root.md
|
|
181
|
+
const result = await findMdFiles('/project');
|
|
182
|
+
expect(result).toEqual(['/project/sub/nested.md', '/project/root.md']);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|