/** * @fileoverview Detach Tiles (per-device `tileDetachEnabled`, default OFF): a * tile leaves the grid for a window of its own, and comes back into any * Codeman window's grid, by dragging. * * - OFF (the default) changes nothing: a lone tile's header does not drag, a * header let go outside the window opens nothing, and a tile dragged from * another window is not taken. * - A header let go OUTSIDE the window (dragend with no drop, its point off the * viewport, or the page's own record of the pointer leaving when the * browser reports no point) opens the session as a pop-out after a short * settle, the tile's size, its header under the drop point. Let go inside * on nothing, or taken by a drop target (`dropEffect: 'move'`), nothing * opens. A lone or zoomed tile drags too (out only). * - Another window docking the tile ('tile-adopted' on the window channel) * takes it out of THIS grid only when this window dragged it (still * settling, or ended a moment ago); the keyboard stays put, and the last * tile leaves for the welcome screen, never for that session. * - A tile from another window dropped on a tile or an empty cell docks there * through the target's own drop and says so; a popped-out session is taken * back first and its window asked to close; its stale 'detached' answers * are ignored for a moment. Unknown ids dock nowhere. * - The pop-out window: its features carry the placement (the default stays * 960x680, unplaced); a released pop-out stops answering roll-calls. * - The gesture overlay's helpers ask the same rules: what is under a point, * what a drop there would do, and the pop-out at a hand's point. * - A tile's ⋯ menu offers "Open in a new window" with Detach Tiles on (the * keyboard's way to the drag), placed over the tile. * - The setting is per-device: a display key, stripped from the settings PUT, * absent from the .strict() schema, OFF unless switched on, its row shown on * desktop only. * * Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A. */ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import vm from 'node:vm'; import { JSDOM } from 'jsdom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FakeEl, FakeTile, documentAddEventListener, documentRemoveEventListener, localStore, makeGridApp, pointHits, resetGridHarness, section, type GridApp, tileEl, windowStub, } from './mocks/tile-grid-vm.js'; const TILE_TYPE = 'application/x-codeman-tile'; const FOUR = ['s-a', 's-b', 's-c', 's-d']; const THREE = ['s-a', 's-b', 's-c']; // Five tiles lay out 3x2 here: one empty cell, the last. const FIVE = ['s-a', 's-b', 's-c', 's-d', 's-e']; const headerOf = (id: string) => tileEl(id).children[0]; const slots = () => section.children.filter((el) => el.className.split(' ').includes('tile-slot')); const settle = () => new Promise((r) => setTimeout(r, 200)); function dragEvent(extra: Record = {}, transfer: Record = {}) { return { preventDefault: vi.fn(), stopPropagation: vi.fn(), clientX: 100, clientY: 10, ...extra, dataTransfer: { effectAllowed: '', dropEffect: 'none', setData: vi.fn(), ...transfer }, }; } /** A drag from another window: none of this page's own, the tile type carried, its id readable on drop. */ function foreign(id: string) { return dragEvent({}, { types: [TILE_TYPE], getData: (t: string) => (t === TILE_TYPE ? id : '') }); } function openGrid(ids: string[], { detach = true, focusedId = ids[0] } = {}): GridApp { const app = makeGridApp(ids); app.loadAppSettingsFromStorage = () => ({ tileDetachEnabled: detach }); app._postWindowMessage = vi.fn(); app._wsTabNonce = 'c-here'; app.openTileGrid(ids, { focusedId }); return app; } /** Press the header and start its native drag (a press at (40, 10) inside the tile). */ function startDrag(id: string) { headerOf(id).dispatch('pointerdown', { target: headerOf(id), button: 0 }); const start = dragEvent({ clientX: 40, clientY: 10 }); headerOf(id).dispatch('dragstart', start); return start; } const endDrag = (id: string, extra: Record, effect = 'none') => headerOf(id).dispatch('dragend', dragEvent(extra, { dropEffect: effect })); /** The page-level drag listeners the tear-off installed (capture phase). */ const docListener = (type: string) => documentAddEventListener.mock.calls.filter((c) => c[0] === type).at(-1)?.[1] as (e: unknown) => void; beforeEach(() => { resetGridHarness(); documentAddEventListener.mockClear(); }); afterEach(() => { delete windowStub.screenX; delete windowStub.screenY; delete windowStub.outerHeight; delete windowStub.screen; delete windowStub.open; }); describe('Detach Tiles OFF (the default) changes nothing', () => { it('a lone tile does not drag, and a header let go outside the window opens nothing', async () => { const app = openGrid(['s-a'], { detach: false }); app.detachSession = vi.fn(); expect(headerOf('s-a').getAttribute('draggable')).toBe('false'); expect(startDrag('s-a').preventDefault).toHaveBeenCalled(); const four = openGrid(FOUR, { detach: false }); four.detachSession = vi.fn(); startDrag('s-b'); endDrag('s-b', { clientX: -50, clientY: 300, screenX: 20, screenY: 400 }); await settle(); expect(four.detachSession).not.toHaveBeenCalled(); expect(four._tileGrid.ids).toEqual(FOUR); }); it('a tile dragged from another window is not taken', () => { const app = openGrid(THREE, { detach: false }); const over = foreign('s-other'); tileEl('s-b').dispatch('dragover', over); expect(over.preventDefault).not.toHaveBeenCalled(); tileEl('s-b').dispatch('drop', foreign('s-other')); expect(app._tileGrid.ids).toEqual(THREE); expect(app._postWindowMessage).not.toHaveBeenCalled(); }); it('the header tooltip keeps its old wording', () => { openGrid(THREE, { detach: false }); expect(headerOf('s-a').title).toContain('Drag to move the tile'); expect(headerOf('s-a').title).not.toContain('window'); }); }); describe('dragging a header out of the window', () => { it('opens the session as a pop-out after a short settle, the tile size, its header under the drop point', async () => { const app = openGrid(FOUR); app.detachSession = vi.fn(); startDrag('s-b'); endDrag('s-b', { clientX: -50, clientY: 300, screenX: 900, screenY: 500 }); expect(app.detachSession).not.toHaveBeenCalled(); await settle(); expect(app.detachSession).toHaveBeenCalledTimes(1); const [id, placement] = app.detachSession.mock.calls[0]; expect(id).toBe('s-b'); // The harness's tiles measure 2400x1200; the press was at (40, 10). expect(placement).toEqual({ width: 2400, height: 1200, left: 860, top: 430 }); }); it('a lone tile, or a zoomed one, drags out too (no target inside takes it)', async () => { const app = openGrid(['s-a']); app.detachSession = vi.fn(); expect(headerOf('s-a').getAttribute('draggable')).toBe('true'); expect(headerOf('s-a').title).toContain('Drag out of the window to open the tile on its own'); expect(startDrag('s-a').preventDefault).not.toHaveBeenCalled(); endDrag('s-a', { clientX: 5000, clientY: 300, screenX: 5000, screenY: 300 }); await settle(); expect(app.detachSession).toHaveBeenCalledWith('s-a', expect.any(Object)); const zoomed = openGrid(FOUR); zoomed.zoomTile('s-c'); expect(headerOf('s-c').getAttribute('draggable')).toBe('true'); }); it('let go inside the window on nothing, or taken by a drop target: nothing opens', async () => { const app = openGrid(FOUR); app.detachSession = vi.fn(); startDrag('s-b'); endDrag('s-b', { clientX: 300, clientY: 300, screenX: 300, screenY: 400 }); startDrag('s-c'); endDrag('s-c', { clientX: -50, clientY: 300, screenX: 20, screenY: 400 }, 'move'); await settle(); expect(app.detachSession).not.toHaveBeenCalled(); expect(app._tileGrid.ids).toEqual(FOUR); }); it('no point reported: the page record of the pointer leaving at the edge decides, a dragover inside undoes it', async () => { const app = openGrid(FOUR); app.detachSession = vi.fn(); startDrag('s-b'); docListener('dragleave')({ relatedTarget: null, clientX: 0, clientY: 500 }); endDrag('s-b', { clientX: 0, clientY: 0, screenX: 0, screenY: 0 }); await settle(); expect(app.detachSession).toHaveBeenCalledTimes(1); // No screen point: placed over the tile instead (no window position known here: size only). expect(app.detachSession.mock.calls[0][1]).toEqual({ width: 2400, height: 1200 }); app.detachSession.mockClear(); startDrag('s-c'); docListener('dragleave')({ relatedTarget: null, clientX: 0, clientY: 500 }); docListener('dragover')({}); endDrag('s-c', { clientX: 0, clientY: 0, screenX: 0, screenY: 0 }); await settle(); expect(app.detachSession).not.toHaveBeenCalled(); }); it('the page drag listeners go when the drag ends, and when the grid closes mid-drag', () => { const app = openGrid(FOUR); startDrag('s-b'); const over = docListener('dragover'); const leave = docListener('dragleave'); documentRemoveEventListener.mockClear(); endDrag('s-b', { clientX: 300, clientY: 300 }); expect(documentRemoveEventListener).toHaveBeenCalledWith('dragover', over, true); expect(documentRemoveEventListener).toHaveBeenCalledWith('dragleave', leave, true); startDrag('s-c'); const over2 = docListener('dragover'); documentRemoveEventListener.mockClear(); app.closeTileGrid({ reselect: false }); expect(documentRemoveEventListener).toHaveBeenCalledWith('dragover', over2, true); expect(app._tileTearOff).toBeNull(); }); it('the real pop-out takes the tile out of the grid (as the tab pop-out does)', async () => { const app = openGrid(FOUR); const win = { closed: false, focus: vi.fn() }; windowStub.open = vi.fn(() => win); app.detachedWindows = new Map(); app._detachWatchTimers = new Map(); app.hasHostWindows = () => false; // No tab strip in this harness. app.$ = () => null; startDrag('s-b'); endDrag('s-b', { clientX: -50, clientY: 300, screenX: 900, screenY: 500 }); await settle(); expect(windowStub.open).toHaveBeenCalledWith( '/session/s-b', 'codeman-session-s-b', 'width=2400,height=1200,left=860,top=430,menubar=no,toolbar=no,location=no,status=no' ); expect(app.detachedSessions.has('s-b')).toBe(true); expect(app._tileGrid.ids).toEqual(['s-a', 's-c', 's-d']); }); }); describe('another window docking the tile this one dragged', () => { it('a drop target in another window took it: the tile goes, the keyboard stays put', () => { const app = openGrid(FOUR, { focusedId: 's-b' }); startDrag('s-b'); endDrag('s-b', { clientX: -50, clientY: 300 }, 'move'); const focusBefore = FakeTile.all.map((t) => t.terminal.focus.mock.calls.length); app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-there' }); expect(app._tileGrid.ids).toEqual(['s-a', 's-c', 's-d']); expect(app._tileGrid.focusedId).not.toBe('s-b'); expect(FakeTile.all.map((t) => t.terminal.focus.mock.calls.length)).toEqual(focusBefore); }); it('a claim that arrives while the pop-out is settling cancels the pop-out', async () => { const app = openGrid(FOUR); app.detachSession = vi.fn(); startDrag('s-c'); endDrag('s-c', { clientX: -50, clientY: 300, screenX: 20, screenY: 400 }); app._onWindowMessage({ type: 'tile-adopted', id: 's-c', by: 'c-there' }); await settle(); expect(app.detachSession).not.toHaveBeenCalled(); expect(app._tileGrid.ids).toEqual(['s-a', 's-b', 's-d']); }); it('the last tile leaves for the welcome screen, never for that session', () => { const app = openGrid(['s-a']); startDrag('s-a'); endDrag('s-a', { clientX: -50, clientY: 300 }, 'move'); app._onWindowMessage({ type: 'tile-adopted', id: 's-a', by: 'c-there' }); expect(app._tileGrid.open).toBe(false); expect(app.activeSessionId).toBeNull(); expect(app.showWelcome).toHaveBeenCalled(); expect(app.terminal.clear).toHaveBeenCalled(); }); // One stored grid per browser: the window that docked the tile wrote it a // moment before this one hears the claim. Losing the tile here is not a // layout of this window's to store over that one, nor a reason to forget it. const docked = JSON.stringify({ v: 1, open: true, ids: ['s-x', 's-b'], count: 2, focused: 's-b', zoomed: null, colFr: [1, 1], rowFr: [1], }); it("the stored grid stays the docking window's when this one keeps other tiles", () => { const app = openGrid(FOUR); startDrag('s-b'); endDrag('s-b', { clientX: -50, clientY: 300 }, 'move'); localStore.set('codeman:tile-grid', docked); app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-there' }); expect(app._tileGrid.ids).toEqual(['s-a', 's-c', 's-d']); expect(localStore.get('codeman:tile-grid')).toBe(docked); }); it("the stored grid stays the docking window's when this one's last tile leaves", () => { const app = openGrid(['s-b']); startDrag('s-b'); endDrag('s-b', { clientX: -50, clientY: 300 }, 'move'); localStore.set('codeman:tile-grid', docked); app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-there' }); expect(app._tileGrid.open).toBe(false); expect(localStore.get('codeman:tile-grid')).toBe(docked); }); it('a claim that lands mid-drag lets the tile go, with no tear-off here (Detach Tiles off)', () => { // The tile's id rides every header drag, so a window with Detach Tiles on can // dock it while this one has the setting off; its claim can beat the dragend. const app = openGrid(FOUR, { detach: false }); startDrag('s-b'); expect(app._tileTearOff ?? null).toBeNull(); app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-there' }); expect(app._tileGrid.ids).toEqual(['s-a', 's-c', 's-d']); }); it('a claim for a tile this window did not drag, or one it dragged long ago, is ignored', () => { const app = openGrid(FOUR); app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-there' }); expect(app._tileGrid.ids).toEqual(FOUR); startDrag('s-b'); endDrag('s-b', { clientX: 300, clientY: 300 }, 'move'); app._lastTileDrag.at -= 60_000; app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-there' }); expect(app._tileGrid.ids).toEqual(FOUR); }); it("this window's own claim (same nonce) is never acted on", () => { const app = openGrid(FOUR); startDrag('s-b'); endDrag('s-b', { clientX: 300, clientY: 300 }, 'move'); app._onWindowMessage({ type: 'tile-adopted', id: 's-b', by: 'c-here' }); expect(app._tileGrid.ids).toEqual(FOUR); }); }); describe('a tile from another window dropped here', () => { it('on a tile: it takes that tile’s place, focused, and every window hears it was docked', () => { const app = openGrid(THREE); const over = foreign('s-other'); tileEl('s-b').dispatch('dragover', over); expect(over.preventDefault).toHaveBeenCalled(); expect(over.stopPropagation).toHaveBeenCalled(); expect(over.dataTransfer.dropEffect).toBe('move'); expect(tileEl('s-b').classList.contains('tile--drop-target')).toBe(true); tileEl('s-b').dispatch('drop', foreign('s-other')); expect(app._tileGrid.ids).toEqual(['s-a', 's-other', 's-c']); expect(app._tileGrid.focusedId).toBe('s-other'); expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'tile-adopted', id: 's-other', by: 'c-here' }); expect(tileEl('s-other').classList.contains('tile--drop-target')).toBe(false); }); it('on an empty cell: it joins there', () => { const app = openGrid(FIVE); expect(slots()).toHaveLength(1); slots()[0].dispatch('drop', foreign('s-other')); expect(app._tileGrid.cells).toEqual([...FIVE, 's-other']); expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'tile-adopted', id: 's-other', by: 'c-here' }); }); it('a popped-out session is taken back first and its window asked to close; its stale answers are ignored', () => { const app = openGrid(THREE); app.detachedSessions.add('s-other'); app._redock = vi.fn((id: string) => app.detachedSessions.delete(id)); app._markDetached = vi.fn(); tileEl('s-c').dispatch('drop', foreign('s-other')); expect(app._redock).toHaveBeenCalledWith('s-other'); expect(app._tileGrid.ids).toContain('s-other'); expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'close-request', id: 's-other' }); expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'tile-adopted', id: 's-other', by: 'c-here' }); // The closing pop-out answering a roll-call on its way out. app._onWindowMessage({ type: 'detached', id: 's-other' }); expect(app._markDetached).not.toHaveBeenCalled(); expect(app._tileGrid.ids).toContain('s-other'); }); it("asks the session's pop-out to close even when this window did not know of one", () => { // Popped out from another dashboard: this window's detachedSessions never had it. const app = openGrid(THREE); tileEl('s-c').dispatch('drop', foreign('s-other')); expect(app._tileGrid.ids).toContain('s-other'); expect(app._postWindowMessage).toHaveBeenCalledWith({ type: 'close-request', id: 's-other' }); }); it('an id this page does not know docks nowhere', () => { const app = openGrid(THREE); tileEl('s-b').dispatch('drop', foreign('s-gone')); tileEl('s-b').dispatch('drop', foreign('')); expect(app._tileGrid.ids).toEqual(THREE); expect(app._postWindowMessage).not.toHaveBeenCalled(); }); it("a drag of this page's own is never read as a foreign one", () => { const app = openGrid(FOUR); startDrag('s-a'); const over = foreign('s-other'); over.dataTransfer.types = [TILE_TYPE]; tileEl('s-d').dispatch('drop', over); // The own drag wins: s-a and s-d trade places, nothing docks. expect(app._tileGrid.ids).toEqual(['s-d', 's-b', 's-c', 's-a']); expect(app._postWindowMessage).not.toHaveBeenCalled(); }); }); describe('the pop-out window', () => { it('features: the placement when given, the old default otherwise', () => { const app = makeGridApp(); expect(app._detachWindowFeatures(null)).toBe('width=960,height=680,menubar=no,toolbar=no,location=no,status=no'); expect(app._detachWindowFeatures({ width: 700.4, height: 500, left: -1200, top: 40 })).toBe( 'width=700,height=500,left=-1200,top=40,menubar=no,toolbar=no,location=no,status=no' ); // A position needs both coordinates; non-numbers never reach the string. expect(app._detachWindowFeatures({ left: 10, top: Number.NaN, width: 'x' })).toBe( 'width=960,height=680,menubar=no,toolbar=no,location=no,status=no' ); }); it('from the ⋯ menu (no point): over the tile, at least the minimum size, within the screen', () => { const app = openGrid(FOUR); app.detachSession = vi.fn(); windowStub.screenX = 100; windowStub.screenY = 50; windowStub.outerHeight = 1300; windowStub.screen = { availWidth: 1920, availHeight: 1080 }; app.detachTile('s-b'); expect(app.detachSession).toHaveBeenCalledWith('s-b', { width: 1920, height: 1080, left: 100, top: 90 }); }); it('never with Detach Tiles off, nor for a session that is not a tile', () => { const off = openGrid(FOUR, { detach: false }); off.detachSession = vi.fn(); expect(off.detachTile('s-b')).toBe(false); const on = openGrid(FOUR); on.detachSession = vi.fn(); expect(on.detachTile('s-other')).toBe(false); expect(off.detachSession).not.toHaveBeenCalled(); expect(on.detachSession).not.toHaveBeenCalled(); }); it('a released pop-out says so, then stops answering roll-calls', () => { const app = makeGridApp(); app.isSoloWindow = true; app.soloSessionId = 's-a'; app._postWindowMessage = vi.fn(); app._closeSoloWindow = vi.fn(); app._onWindowMessage({ type: 'roll-call' }); expect(app._postWindowMessage).toHaveBeenLastCalledWith({ type: 'detached', id: 's-a' }); app._onWindowMessage({ type: 'close-request', id: 's-a' }); expect(app._postWindowMessage).toHaveBeenLastCalledWith({ type: 'redocked', id: 's-a' }); expect(app._closeSoloWindow).toHaveBeenCalled(); app._postWindowMessage.mockClear(); app._onWindowMessage({ type: 'roll-call' }); expect(app._postWindowMessage).not.toHaveBeenCalled(); }); }); describe('the gesture overlay helpers', () => { it('what is under a point: the tile, and whether a hand may pick it up', () => { const app = openGrid(FOUR); const inner = new FakeEl(); tileEl('s-c').children[1].appendChild(inner); pointHits.at = () => inner; expect(app.tileAtPoint(10, 10)).toBe('s-c'); expect(app.canGrabTile('s-c')).toBe(true); pointHits.at = () => null; expect(app.tileAtPoint(10, 10)).toBeNull(); // Alone with Detach Tiles off it can neither move nor leave. const lone = openGrid(['s-a'], { detach: false }); expect(lone.canGrabTile('s-a')).toBe(false); const loneOn = openGrid(['s-a']); expect(loneOn.canGrabTile('s-a')).toBe(true); }); it("what a drop there would do, by the mouse drag's rules, and doing it", () => { const app = openGrid(FIVE); pointHits.at = () => tileEl('s-c'); expect(app.tileDropTargetAt(1, 1, 's-a')).toEqual({ kind: 'tile', id: 's-c', el: tileEl('s-c') }); // Its own tile is no target; a popped-out or unknown session has none. expect(app.tileDropTargetAt(1, 1, 's-c')).toBeNull(); app.detachedSessions.add('s-other'); expect(app.tileDropTargetAt(1, 1, 's-other')).toBeNull(); app.detachedSessions.delete('s-other'); expect(app.tileDropTargetAt(1, 1, 's-nope')).toBeNull(); expect(app.dropOnTileTarget('s-a', app.tileDropTargetAt(1, 1, 's-a'))).toBe(true); expect(app._tileGrid.ids).toEqual(['s-c', 's-b', 's-a', 's-d', 's-e']); pointHits.at = () => slots()[0]; const cell = app.tileDropTargetAt(1, 1, 's-other'); expect(cell).toEqual({ kind: 'cell', cell: 5, el: slots()[0] }); expect(app.dropOnTileTarget('s-other', cell)).toBe(true); expect(app._tileGrid.cells).toEqual(['s-c', 's-b', 's-a', 's-d', 's-e', 's-other']); }); it('a tiled session has no target while a tile is zoomed (moving is off)', () => { const app = openGrid(FOUR); app.zoomTile('s-a'); pointHits.at = () => tileEl('s-a'); expect(app.tileDropTargetAt(1, 1, 's-b')).toBeNull(); }); it('a hand let go outside the grid: the pop-out at that point, the page chrome counted', () => { const app = openGrid(FOUR); app.detachSession = vi.fn(); windowStub.screenX = 0; windowStub.screenY = 0; windowStub.outerHeight = 1300; app.detachTileAtPoint('s-d', 500, 20, { offsetX: 100, offsetY: 10 }); expect(app.detachSession).toHaveBeenCalledWith('s-d', { width: 2400, height: 1200, left: 400, top: 50 }); }); it('whether a point is over the grid', () => { const app = openGrid(FOUR); expect(app.isOverTileGrid(10, 10)).toBe(true); expect(app.isOverTileGrid(10, 5000)).toBe(false); app.closeTileGrid({ reselect: false }); expect(app.isOverTileGrid(10, 10)).toBe(false); }); }); const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); const read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8'); describe("a tile's ⋯ menu", () => { function menuFor(sessionId: string, { tiled = true, detach = true, tabPopOut = false } = {}) { const dom = new JSDOM('', { runScripts: 'outside-only', url: 'http://localhost/', }); const ctx = dom.getInternalVMContext(); vm.runInContext('globalThis.CodemanApp = class CodemanApp {};', ctx); vm.runInContext(read('tab-rail-resize.js'), ctx, { filename: 'tab-rail-resize.js' }); const App = (dom.window as unknown as { CodemanApp: { prototype: object } }).CodemanApp; const app = Object.assign(Object.create(App.prototype), { loadAppSettingsFromStorage: () => ({ showTabDetachButton: tabPopOut }), tabDetachButtonEnabled: (s: { showTabDetachButton?: boolean }) => s.showTabDetachButton === true, detachedSessions: new Set(), _tileGrid: { has: () => tiled }, _tileDetachAllowed: () => detach, detachTile: vi.fn(), detachSession: vi.fn(), openSessionOptions: vi.fn(), requestCloseSession: vi.fn(), }) as Record & ((...a: unknown[]) => unknown)>; const doc = dom.window.document; app.openTabRailActionMenu( { preventDefault() {}, stopPropagation() {}, currentTarget: doc.getElementById('t') }, sessionId ); const items = [...doc.querySelectorAll('.tab-rail-action-menu [role="menuitem"]')] as HTMLButtonElement[]; return { app, labels: items.map((b) => b.textContent), click: (label: string) => items.find((b) => b.textContent === label)!.click(), }; } it('with Detach Tiles on: "Open in a new window" pops the tile out, placed over it', () => { const { app, labels, click } = menuFor('s-b'); expect(labels).toEqual(['Session options', 'Open in a new window', 'Close session']); click('Open in a new window'); expect(app.detachTile).toHaveBeenCalledWith('s-b'); expect(app.detachSession).not.toHaveBeenCalled(); }); it('with it off: the menu is what it was (the tab pop-out setting still adds its own entry)', () => { expect(menuFor('s-b', { detach: false }).labels).toEqual(['Session options', 'Close session']); const tabbed = menuFor('s-b', { detach: false, tabPopOut: true }); expect(tabbed.labels).toEqual(['Session options', 'Open in a new window', 'Close session']); tabbed.click('Open in a new window'); expect(tabbed.app.detachSession).toHaveBeenCalledWith('s-b'); // A tab (not a tile) with Detach Tiles on: only the tab setting decides. expect(menuFor('s-x', { tiled: false }).labels).toEqual(['Session options', 'Close session']); }); }); describe('tileDetachEnabled stays per-device and OFF by default', () => { const settingsUi = read('settings-ui.js'); const schemas = readFileSync(resolve(import.meta.dirname, '../src/web/schemas.ts'), 'utf8'); const index = read('index.html'); it('is a display key, stripped from the settings PUT, and absent from the .strict() schema', () => { const start = settingsUi.indexOf('const displayKeys = new Set(['); expect(settingsUi.slice(start, settingsUi.indexOf('])', start))).toContain("'tileDetachEnabled'"); expect(settingsUi).toContain('tileDetachEnabled: _tde,'); expect(schemas).not.toContain('tileDetachEnabled'); }); it('loads OFF unless switched on, saves from its switch, and its row shows on desktop only', () => { expect(settingsUi).toContain( "document.getElementById('appSettingsTileDetach').checked = settings.tileDetachEnabled === true;" ); expect(settingsUi).toContain("tileDetachEnabled: document.getElementById('appSettingsTileDetach').checked,"); expect(settingsUi).toMatch(/tileDetachItem\.style\.display = MobileDetection\.getDeviceType\(\) === 'desktop'/); expect(index).toMatch(/id="appSettingsTileDetachItem"[\s\S]*?/); // Not `checked` in the markup: OFF is the default. expect(index).not.toMatch(/id="appSettingsTileDetach" checked/); }); it('a saved change repaints the tile handles (draggable, tooltip)', () => { expect(settingsUi).toContain('this._renderTileChrome?.();'); }); });