Skip to content

Commit 4f8b618

Browse files
committed
For issue #20, vibe-write some unit tests for handleClose().
1 parent b357b78 commit 4f8b618

File tree

6 files changed

+292
-20
lines changed

6 files changed

+292
-20
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [1.1.7] - 2025-??-??
6+
7+
### Changes
8+
9+
- Fixed [issue #20](https://github.com/codedread/spaces/issues/20): Close browser windows from the Spaces window.
10+
- Increased unit test coverage from 10.75% to 11.41%.
11+
12+
513
## [1.1.6] - 2025-09-17
614

715
### Changes

js/background/background.js

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -330,16 +330,16 @@ async function processMessage(request, sender) {
330330

331331
case 'closeWindow':
332332
windowId = cleanParameter(request.windowId);
333-
if(windowId) {
334-
try {
335-
const window = await chrome.windows.get(windowId);
336-
if (window) {
337-
await chrome.windows.remove(windowId);
338-
return true;
339-
}
340-
} catch (error) {
341-
console.error("Error closing window:", error);
342-
}
333+
if (!windowId) {
334+
return false;
335+
}
336+
337+
try {
338+
await chrome.windows.get(windowId);
339+
await chrome.windows.remove(windowId);
340+
return true;
341+
} catch (error) {
342+
console.error("Error closing window:", error);
343343
return false;
344344
}
345345

js/spaces.js

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,16 @@ async function handleDelete() {
369369
}
370370
}
371371

372-
async function handleClose() {
372+
/**
373+
* Closes the currently selected space's window after user confirmation (if unnamed).
374+
* The arguments are only for testing purposes to allow dependency injection.
375+
* @param {Function} updateSpacesListFn Function to refresh the spaces list after closing
376+
* @param {Function} renderSpaceDetailFn Function to render space details (called with false, false to clear)
377+
* @returns {Promise<void>}
378+
*/
379+
async function handleClose(
380+
updateSpacesListFn = updateSpacesList,
381+
renderSpaceDetailFn = renderSpaceDetail) {
373382
if (!globalSelectedSpace || !globalSelectedSpace.windowId) {
374383
console.error("No opened window is currently selected.");
375384
return;
@@ -378,18 +387,18 @@ async function handleClose() {
378387

379388
// Only show confirm if the space is unnamed
380389
if (!sessionId) {
381-
const confirm = window.confirm(
382-
"Are you sure you want to close this window?"
383-
);
390+
const confirm = window.confirm("Are you sure you want to close this window?");
384391
if (!confirm) return;
385392
}
386393

387-
chrome.runtime.sendMessage({ action: 'closeWindow', windowId });
388-
await updateSpacesList();
394+
const success = await chrome.runtime.sendMessage({ action: 'closeWindow', windowId });
395+
if (!success) {
396+
console.warn("Failed to close window - it may have already been closed");
397+
}
389398

390-
// Clear the detail view since the closed window was selected
399+
await updateSpacesListFn();
391400
globalSelectedSpace = null;
392-
renderSpaceDetail(false, false);
401+
renderSpaceDetailFn(false, false);
393402
}
394403

395404
// import accepts either a newline separated list of urls or a json backup object
@@ -816,4 +825,13 @@ async function getSpacesForBackup() {
816825
}
817826

818827
// Export for testing
819-
export { addDuplicateMetadata, getSpacesForBackup, normaliseTabUrl };
828+
export {
829+
addDuplicateMetadata,
830+
getSpacesForBackup,
831+
handleClose,
832+
normaliseTabUrl,
833+
};
834+
835+
// Export globalSelectedSpace for testing (mutable reference)
836+
export function setGlobalSelectedSpace(space) { globalSelectedSpace = space; }
837+
export function getGlobalSelectedSpace() { return globalSelectedSpace; }

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "Spaces",
33
"description": "Intuitive tab management",
4-
"version": "1.1.6",
4+
"version": "1.1.7.0",
55
"permissions": [
66
"contextMenus",
77
"favicon",

tests/handleClose.test.js

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
/**
2+
* Unit tests for handleClose function in spaces.js
3+
* Tests all code paths and edge cases for closing windows
4+
*/
5+
6+
import { jest, setupTestMocks, mockConsole } from './helpers.js';
7+
import { handleClose, setGlobalSelectedSpace, getGlobalSelectedSpace } from '../js/spaces.js';
8+
9+
// Setup all test mocks (includes window.confirm)
10+
setupTestMocks();
11+
12+
const WINDOW_ID = 123;
13+
const SESSION_ID = 'test-session';
14+
const TEST_SPACES = {
15+
NO_WINDOW: { sessionId: SESSION_ID },
16+
UNNAMED: { windowId: WINDOW_ID },
17+
NAMED: { windowId: WINDOW_ID, sessionId: SESSION_ID }
18+
};
19+
20+
describe('handleClose Function', () => {
21+
let consoleErrorSpy;
22+
let consoleWarnSpy;
23+
let mockUpdateSpacesList;
24+
let mockRenderSpaceDetail;
25+
26+
beforeEach(() => {
27+
jest.clearAllMocks();
28+
29+
// Create simple mock functions for UI updates
30+
mockUpdateSpacesList = jest.fn().mockResolvedValue();
31+
mockRenderSpaceDetail = jest.fn();
32+
33+
// Reset mocks
34+
chrome.runtime.sendMessage.mockClear();
35+
window.confirm.mockClear();
36+
37+
// Mock the Chrome API responses
38+
chrome.runtime.sendMessage.mockImplementation(async (request) => {
39+
if (request.action === 'closeWindow') {
40+
return true; // Default to success for closeWindow
41+
}
42+
return undefined;
43+
});
44+
45+
// Setup console spies
46+
consoleErrorSpy = mockConsole('error');
47+
consoleWarnSpy = mockConsole('warn');
48+
49+
// Clear globalSelectedSpace
50+
setGlobalSelectedSpace(null);
51+
});
52+
53+
afterEach(() => {
54+
consoleErrorSpy.restore();
55+
consoleWarnSpy.restore();
56+
});
57+
58+
test('should return early and log error when no space is selected', async () => {
59+
// Setup: No selected space
60+
setGlobalSelectedSpace(null);
61+
62+
// Execute
63+
await handleClose(mockUpdateSpacesList, mockRenderSpaceDetail);
64+
65+
// Verify
66+
expect(consoleErrorSpy.called).toBe(true);
67+
expect(consoleErrorSpy.args[0]).toBe('No opened window is currently selected.');
68+
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
69+
expect(mockUpdateSpacesList).not.toHaveBeenCalled();
70+
expect(mockRenderSpaceDetail).not.toHaveBeenCalled();
71+
expect(getGlobalSelectedSpace()).toBe(null); // Should remain null
72+
});
73+
74+
test('should return early and log error when selected space has no windowId', async () => {
75+
// Setup: Selected space without windowId
76+
setGlobalSelectedSpace(TEST_SPACES.NO_WINDOW);
77+
78+
// Execute
79+
await handleClose(mockUpdateSpacesList, mockRenderSpaceDetail);
80+
81+
// Verify
82+
expect(consoleErrorSpy.called).toBe(true);
83+
expect(consoleErrorSpy.args[0]).toBe('No opened window is currently selected.');
84+
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
85+
expect(mockUpdateSpacesList).not.toHaveBeenCalled();
86+
expect(mockRenderSpaceDetail).not.toHaveBeenCalled();
87+
// globalSelectedSpace should remain unchanged since function returns early
88+
expect(getGlobalSelectedSpace()).toEqual(TEST_SPACES.NO_WINDOW);
89+
});
90+
91+
test('should show confirmation dialog for unnamed space and return early when user cancels', async () => {
92+
// Setup: Unnamed space (no sessionId) and user cancels
93+
setGlobalSelectedSpace(TEST_SPACES.UNNAMED);
94+
window.confirm.mockReturnValue(false); // User cancels
95+
96+
// Execute
97+
await handleClose(mockUpdateSpacesList, mockRenderSpaceDetail);
98+
99+
// Verify
100+
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to close this window?');
101+
expect(chrome.runtime.sendMessage).not.toHaveBeenCalled();
102+
expect(mockUpdateSpacesList).not.toHaveBeenCalled();
103+
expect(mockRenderSpaceDetail).not.toHaveBeenCalled();
104+
// globalSelectedSpace should remain unchanged since function returns early
105+
expect(getGlobalSelectedSpace()).toEqual(TEST_SPACES.UNNAMED);
106+
});
107+
108+
test('should skip confirmation for named space and proceed with close', async () => {
109+
// Setup: Named space (has sessionId)
110+
setGlobalSelectedSpace(TEST_SPACES.NAMED);
111+
112+
// Execute
113+
await handleClose(mockUpdateSpacesList, mockRenderSpaceDetail);
114+
115+
// Verify
116+
expect(window.confirm).not.toHaveBeenCalled(); // No confirmation for named spaces
117+
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
118+
action: 'closeWindow',
119+
windowId: WINDOW_ID
120+
});
121+
expect(mockUpdateSpacesList).toHaveBeenCalled();
122+
expect(mockRenderSpaceDetail).toHaveBeenCalledWith(false, false);
123+
expect(getGlobalSelectedSpace()).toBe(null); // Selection cleared
124+
});
125+
126+
test('should show confirmation for unnamed space and proceed when user confirms', async () => {
127+
// Setup: Unnamed space and user confirms
128+
setGlobalSelectedSpace(TEST_SPACES.UNNAMED);
129+
window.confirm.mockReturnValue(true); // User confirms
130+
131+
// Execute
132+
await handleClose(mockUpdateSpacesList, mockRenderSpaceDetail);
133+
134+
// Verify
135+
expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to close this window?');
136+
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
137+
action: 'closeWindow',
138+
windowId: WINDOW_ID
139+
});
140+
expect(mockUpdateSpacesList).toHaveBeenCalled();
141+
expect(mockRenderSpaceDetail).toHaveBeenCalledWith(false, false);
142+
expect(getGlobalSelectedSpace()).toBe(null); // Selection cleared
143+
});
144+
145+
test('should handle successful window close without warning', async () => {
146+
// Setup: Successful close
147+
setGlobalSelectedSpace(TEST_SPACES.NAMED);
148+
149+
// Execute
150+
await handleClose(mockUpdateSpacesList, mockRenderSpaceDetail);
151+
152+
// Verify
153+
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
154+
action: 'closeWindow',
155+
windowId: WINDOW_ID
156+
});
157+
expect(consoleWarnSpy.called).toBe(false); // No warning for success
158+
expect(mockUpdateSpacesList).toHaveBeenCalled();
159+
expect(mockRenderSpaceDetail).toHaveBeenCalledWith(false, false);
160+
expect(getGlobalSelectedSpace()).toBe(null);
161+
});
162+
163+
test('should handle failed window close with warning and still update UI', async () => {
164+
// Setup: Failed close - override the mock for this specific test
165+
setGlobalSelectedSpace(TEST_SPACES.NAMED);
166+
167+
chrome.runtime.sendMessage.mockImplementation(async (request) => {
168+
if (request.action === 'closeWindow') {
169+
return false; // Return false for this test case
170+
}
171+
return undefined;
172+
});
173+
174+
// Execute
175+
await handleClose(mockUpdateSpacesList, mockRenderSpaceDetail);
176+
177+
// Verify
178+
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
179+
action: 'closeWindow',
180+
windowId: WINDOW_ID
181+
});
182+
expect(consoleWarnSpy.called).toBe(true);
183+
expect(consoleWarnSpy.args[0]).toBe('Failed to close window - it may have already been closed');
184+
expect(mockUpdateSpacesList).toHaveBeenCalled();
185+
expect(mockRenderSpaceDetail).toHaveBeenCalledWith(false, false);
186+
expect(getGlobalSelectedSpace()).toBe(null); // Still clears selection
187+
});
188+
189+
test('should handle chrome.runtime.sendMessage rejection and still update UI', async () => {
190+
// Setup: Message sending throws error
191+
setGlobalSelectedSpace(TEST_SPACES.NAMED);
192+
const mockError = new Error('Chrome API error');
193+
194+
chrome.runtime.sendMessage.mockImplementation(async (request) => {
195+
if (request.action === 'closeWindow') {
196+
throw mockError; // Throw error for closeWindow
197+
}
198+
return undefined;
199+
});
200+
201+
// Execute - this should throw since handleClose doesn't catch promise rejections
202+
// Execute and verify it throws
203+
await expect(handleClose(mockUpdateSpacesList, mockRenderSpaceDetail)).rejects.toThrow('Chrome API error');
204+
205+
// Verify the message was attempted
206+
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
207+
action: 'closeWindow',
208+
windowId: WINDOW_ID
209+
});
210+
// The UI functions should not be called since function threw before reaching them
211+
expect(mockUpdateSpacesList).not.toHaveBeenCalled();
212+
expect(mockRenderSpaceDetail).not.toHaveBeenCalled();
213+
// The globalSelectedSpace should remain unchanged since function threw before clearing it
214+
expect(getGlobalSelectedSpace()).toEqual(TEST_SPACES.NAMED);
215+
});
216+
217+
test('should preserve original globalSelectedSpace values during execution', async () => {
218+
// Setup: Verify that windowId and sessionId are extracted before any async operations
219+
setGlobalSelectedSpace(TEST_SPACES.NAMED);
220+
221+
chrome.runtime.sendMessage.mockImplementation(async (request) => {
222+
if (request.action === 'closeWindow') {
223+
// Simulate the space being modified during the async operation
224+
const currentSpace = getGlobalSelectedSpace();
225+
if (currentSpace) {
226+
currentSpace.windowId = 'MODIFIED';
227+
}
228+
return true;
229+
}
230+
return undefined;
231+
});
232+
233+
// Execute
234+
await handleClose(mockUpdateSpacesList, mockRenderSpaceDetail);
235+
236+
// Verify that the original windowId was used in the message
237+
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
238+
action: 'closeWindow',
239+
windowId: WINDOW_ID // Original value, not 'MODIFIED'
240+
});
241+
expect(mockUpdateSpacesList).toHaveBeenCalled();
242+
expect(mockRenderSpaceDetail).toHaveBeenCalledWith(false, false);
243+
expect(getGlobalSelectedSpace()).toBe(null); // Still cleared at end
244+
});
245+
});

tests/helpers.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export const setupDOMMocks = () => {
6060
reload: jest.fn(),
6161
},
6262
close: jest.fn(),
63+
confirm: jest.fn(),
6364
};
6465
};
6566

0 commit comments

Comments
 (0)