Files
Codeman/test/mobile/file-browser.test.ts
T

181 lines
8.0 KiB
TypeScript
Raw Permalink Normal View History

// Issue #759: opt-in phone access, exercised against the real app and filesystem.
// Run with npm run test:mobile -- test/mobile/file-browser.test.ts (not the CI runner).
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdir, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import type { BrowserContext, Locator, Page } from 'playwright';
import type { WebServer } from '../../src/web/server.js';
import { assertSandboxedHome, safeRmHomeTree } from '../mocks/test-helpers.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { assertTouchTarget } from './helpers/assertions.js';
import { STORAGE_KEYS } from './helpers/constants.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
describe('File browser on a phone (issue #759)', () => {
let server: WebServer;
let baseUrl: string;
let sessionId: string;
let workspace: string;
let context: BrowserContext;
let page: Page;
beforeAll(async () => {
assertSandboxedHome('Phone file browser fixture');
workspace = join(homedir(), 'file-browser-phone');
await mkdir(join(workspace, 'src'), { recursive: true });
await writeFile(join(workspace, 'README.md'), 'Phone browser fixture');
await writeFile(join(workspace, '.hidden.txt'), 'Hidden fixture');
await writeFile(join(workspace, 'src', 'note.txt'), 'Nested fixture');
server = await createTestServer();
baseUrl = `http://localhost:${server.boundPort}`;
const response = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'phone-files', mode: 'shell', workingDir: workspace }),
});
const body = await response.json();
expect(response.ok, JSON.stringify(body)).toBe(true);
expect(body.success, JSON.stringify(body)).toBe(true);
expect(body.data?.session?.id).toBeTypeOf('string');
sessionId = body.data.session.id;
});
beforeEach(async () => {
({ context, page } = await createDevicePage(
{
...REPRESENTATIVE_DEVICES['standard-phone'],
viewport: { width: 390, height: 844 },
deviceScaleFactor: 1,
},
baseUrl,
'chromium'
));
await page.waitForFunction((id) => (window as any).app?.sessions.has(id), sessionId);
await page.evaluate(async (id) => {
await (window as any).app.selectSession(id);
}, sessionId);
});
afterEach(async () => {
await context?.close();
});
afterAll(async () => {
await closeAllBrowsers();
if (sessionId) await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
if (server) await stopTestServer(server);
if (workspace) safeRmHomeTree(workspace);
});
async function saveSetting(id: string, checked: boolean) {
await page.locator('.btn-settings-mobile').tap();
const control = page.locator(`#${id}`);
// The chip's native checkbox is visually hidden; the label is the touch target.
if ((await control.isChecked()) !== checked) {
await page.locator(`label:has(#${id})`).tap();
}
await page
.locator('#appSettingsModal .set-head-save')
.filter({ hasText: /^Save$/ })
.tap();
await expect.poll(() => page.locator('#appSettingsModal').isVisible()).toBe(false);
}
async function expectReachable(control: Locator) {
expect(await control.isVisible()).toBe(true);
const box = await control.boundingBox();
expect(box).not.toBeNull();
const viewport = page.viewportSize()!;
// Allow subpixel rounding at the fixed header's border.
expect(box!.x).toBeGreaterThanOrEqual(-1);
expect(box!.y).toBeGreaterThanOrEqual(-1);
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width + 1);
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport.height + 1);
// A Settings-saved toast can briefly cover the panel after opening it.
// Wait for normal feedback to clear, but reject persistent occlusion.
await expect
.poll(
() =>
control.evaluate((element) => {
const rect = element.getBoundingClientRect();
const target = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
return { reachable: target !== null && element.contains(target), target: target?.outerHTML.slice(0, 200) };
}),
{ timeout: 5000 }
)
.toMatchObject({ reachable: true });
}
it('keeps the phone header button off by default', async () => {
expect(await page.evaluate(() => (window as any).app.getDefaultSettings().showFileViewerButton)).toBe(false);
expect(await page.locator('.btn-file-viewer').isVisible()).toBe(false);
});
it('honors enable/save/reload, opens and closes from the header, and honors disabling', async () => {
await saveSetting('appSettingsShowFileViewerButton', true);
const button = page.locator('.btn-file-viewer');
await expect.poll(() => button.isVisible()).toBe(true);
expect(
await page.evaluate(
(key) => JSON.parse(localStorage.getItem(key)!).showFileViewerButton,
STORAGE_KEYS.SETTINGS_MOBILE
)
).toBe(true);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction((id) => (window as any).app?.sessions.has(id), sessionId);
await expect.poll(() => button.isVisible(), { timeout: 3000 }).toBe(true);
await expectReachable(button);
await assertTouchTarget(button);
const panel = page.locator('#fileBrowserPanel');
await button.tap();
await expect.poll(() => panel.isVisible()).toBe(true);
await page.locator('#fileBrowserTree [data-path="README.md"]').waitFor({ state: 'visible' });
await button.tap();
await expect.poll(() => panel.isVisible()).toBe(false);
await button.tap();
await expect.poll(() => panel.isVisible()).toBe(true);
await panel.locator('button[title="Close"]').tap();
await expect.poll(() => panel.isVisible()).toBe(false);
await saveSetting('appSettingsShowFileViewerButton', false);
expect(await button.isVisible()).toBe(false);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction((id) => (window as any).app?.sessions.has(id), sessionId);
expect(await button.isVisible()).toBe(false);
});
it('keeps the panel and essential controls usable at 390px through the existing Settings entry', async () => {
// Exercise panel usability independently of the missing header entry point.
await saveSetting('appSettingsShowFileBrowser', true);
const panel = page.locator('#fileBrowserPanel');
await expect.poll(() => panel.isVisible()).toBe(true);
const tree = panel.locator('#fileBrowserTree');
await tree.locator('[data-path="README.md"]').waitFor({ state: 'visible' });
for (const control of [
panel,
panel.locator('#fileBrowserSearch'),
panel.locator('#fileBrowserHiddenBtn'),
panel.locator('button[title="Refresh"]'),
panel.locator('#fileBrowserExpandBtn'),
panel.locator('button[title="Close"]'),
]) {
await expectReachable(control);
}
await panel.locator('#fileBrowserExpandBtn').tap();
await tree.locator('[data-path="src/note.txt"]').waitFor({ state: 'visible' });
await panel.locator('#fileBrowserHiddenBtn').tap();
await tree.locator('[data-path=".hidden.txt"]').waitFor({ state: 'visible' });
expect(await tree.locator('[data-path="README.md"]').isVisible()).toBe(true);
await panel.locator('#fileBrowserSearch').fill('note.txt');
await expect.poll(() => tree.locator('[data-path="README.md"]').count()).toBe(0);
await tree.locator('[data-path="src/note.txt"]').waitFor({ state: 'visible' });
await panel.locator('button[title="Refresh"]').tap();
await tree.locator('[data-path="README.md"]').waitFor({ state: 'visible' });
await panel.locator('button[title="Close"]').tap();
await expect.poll(() => panel.isVisible()).toBe(false);
});
});