WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)
1import { beforeEach, describe, expect, it, vi } from 'vitest';
2import {
3 promptForIdentifier,
4 promptForLogin,
5 promptForPassword,
6 promptForRemoteSelection,
7 promptToSaveRemote,
8} from '../../src/utils/prompts.js';
9
10// Mock @inquirer/prompts
11vi.mock('@inquirer/prompts', () => ({
12 confirm: vi.fn(),
13 input: vi.fn(),
14 password: vi.fn(),
15 select: vi.fn(),
16}));
17
18describe('Prompts', () => {
19 let mockInput: ReturnType<typeof vi.fn>;
20 let mockPassword: ReturnType<typeof vi.fn>;
21 let mockSelect: ReturnType<typeof vi.fn>;
22 let mockConfirm: ReturnType<typeof vi.fn>;
23
24 beforeEach(async () => {
25 const inquirer = await import('@inquirer/prompts');
26 mockInput = vi.mocked(inquirer.input);
27 mockPassword = vi.mocked(inquirer.password);
28 mockSelect = vi.mocked(inquirer.select);
29 mockConfirm = vi.mocked(inquirer.confirm);
30 vi.clearAllMocks();
31 });
32
33 describe('promptForIdentifier', () => {
34 it('should prompt for identifier and return valid handle', async () => {
35 mockInput.mockResolvedValue('user.bsky.social');
36
37 const result = await promptForIdentifier();
38
39 expect(result).toBe('user.bsky.social');
40 expect(mockInput).toHaveBeenCalledWith({
41 message: 'Enter your AT Protocol identifier (handle or DID):',
42 validate: expect.any(Function),
43 });
44 });
45
46 it('should validate identifier format', async () => {
47 mockInput.mockResolvedValue('user.bsky.social');
48
49 await promptForIdentifier();
50
51 const validateFn = mockInput.mock.calls[0]?.[0]?.validate;
52 expect(validateFn).toBeDefined();
53
54 if (validateFn) {
55 // Valid handle
56 expect(validateFn('user.bsky.social')).toBe(true);
57
58 // Valid DID
59 expect(validateFn('did:plc:test123')).toBe(true);
60
61 // Empty
62 expect(validateFn('')).toBe('Identifier cannot be empty');
63 expect(validateFn(' ')).toBe('Identifier cannot be empty');
64
65 // Invalid format
66 expect(validateFn('invalid')).toContain('Invalid');
67 }
68 });
69 });
70
71 describe('promptForPassword', () => {
72 it('should prompt for password with masking', async () => {
73 mockPassword.mockResolvedValue('test-password');
74
75 const result = await promptForPassword();
76
77 expect(result).toBe('test-password');
78 expect(mockPassword).toHaveBeenCalledWith({
79 message: 'Enter your app password:',
80 mask: '*',
81 validate: expect.any(Function),
82 });
83 });
84
85 it('should validate password is not empty', async () => {
86 mockPassword.mockResolvedValue('test-password');
87
88 await promptForPassword();
89
90 const validateFn = mockPassword.mock.calls[0]?.[0]?.validate;
91 expect(validateFn).toBeDefined();
92
93 if (validateFn) {
94 // Valid password
95 expect(validateFn('password123')).toBe(true);
96
97 // Empty
98 expect(validateFn('')).toBe('Password cannot be empty');
99 }
100 });
101 });
102
103 describe('promptForLogin', () => {
104 it('should prompt for both identifier and password', async () => {
105 mockInput.mockResolvedValue('user.bsky.social');
106 mockPassword.mockResolvedValue('test-password');
107
108 const result = await promptForLogin();
109
110 expect(result).toEqual({
111 identifier: 'user.bsky.social',
112 password: 'test-password',
113 });
114 expect(mockInput).toHaveBeenCalledOnce();
115 expect(mockPassword).toHaveBeenCalledOnce();
116 });
117 });
118
119 describe('promptForRemoteSelection', () => {
120 it('should prompt user to select from multiple remotes', async () => {
121 const remotes = [
122 { name: 'origin', url: 'git@tangled.org:did:plc:abc123/repo.git' },
123 { name: 'upstream', url: 'git@tangled.org:did:plc:xyz789/repo.git' },
124 ];
125
126 mockSelect.mockResolvedValue('upstream');
127
128 const result = await promptForRemoteSelection(remotes);
129
130 expect(result).toBe('upstream');
131 expect(mockSelect).toHaveBeenCalledWith({
132 message: 'Multiple tangled.org remotes found. Which one would you like to use?',
133 choices: [
134 { name: 'origin (git@tangled.org:did:plc:abc123/repo.git)', value: 'origin' },
135 { name: 'upstream (git@tangled.org:did:plc:xyz789/repo.git)', value: 'upstream' },
136 ],
137 default: 'origin',
138 });
139 });
140
141 it('should default to "origin" if present', async () => {
142 const remotes = [
143 { name: 'upstream', url: 'git@tangled.org:did:plc:abc123/repo.git' },
144 { name: 'origin', url: 'git@tangled.org:did:plc:xyz789/repo.git' },
145 ];
146
147 mockSelect.mockResolvedValue('origin');
148
149 await promptForRemoteSelection(remotes);
150
151 const call = mockSelect.mock.calls[0]?.[0];
152 expect(call?.default).toBe('origin');
153 });
154
155 it('should not have default if "origin" not present', async () => {
156 const remotes = [
157 { name: 'upstream', url: 'git@tangled.org:did:plc:abc123/repo.git' },
158 { name: 'fork', url: 'git@tangled.org:did:plc:xyz789/repo.git' },
159 ];
160
161 mockSelect.mockResolvedValue('upstream');
162
163 await promptForRemoteSelection(remotes);
164
165 const call = mockSelect.mock.calls[0]?.[0];
166 expect(call?.default).toBeUndefined();
167 });
168 });
169
170 describe('promptToSaveRemote', () => {
171 it('should prompt user to save remote selection', async () => {
172 mockConfirm.mockResolvedValue(true);
173
174 const result = await promptToSaveRemote();
175
176 expect(result).toBe(true);
177 expect(mockConfirm).toHaveBeenCalledWith({
178 message: 'Save this remote selection for this repository? (saves to .tangledrc)',
179 default: false,
180 });
181 });
182
183 it('should default to false', async () => {
184 mockConfirm.mockResolvedValue(false);
185
186 const result = await promptToSaveRemote();
187
188 expect(result).toBe(false);
189 const call = mockConfirm.mock.calls[0]?.[0];
190 expect(call?.default).toBe(false);
191 });
192 });
193});