feat(mobile): phone Attach button and camera-roll video uploads

A phone-only toolbar Attach button hands the agent a photo or a video with the
keyboard down. It owns its own file input, positioned over the button so iOS
anchors the picker popover to it; the picked paths land on the prompt as unsent
text, so Compose adopts them together with anything already typed and Use
terminal keyboard hands a single-line draft back the other way.

Videos take a second path on the same route: ?kind=video streams the file to
disk under MAX_PASTE_VIDEO_BYTES (1GB, env CODEMAN_MAX_PASTE_VIDEO_BYTES)
instead of buffering it, sniffs the container from the first bytes as they
arrive, drains a mislabeled body unwritten and unlinks on any failure. It draws
on its own 3/min per-(IP, session) rate bucket beside the image path's 30/min:
at 1GB each, 30/min would be 30GB of disk a minute. Without the flag the route
is byte-identical to the image-only version, same 50MB cap and same bucket.

One client classifier, _promptAttachKind (MIME first, then the name's
extension), drives every pick and paste surface: the Attach button, terminal
drop, the tile grid's drop, the Compose and paste-dialog pickers and the
clipboard handlers. A pick that lands while the tile grid owns the terminal
goes to the PTY rather than the parked main-terminal overlay.
This commit is contained in:
JD committed 2026-10-10 13:40:04 -04:00
1 parent aa3730aace
commit 34c903da41
24 files changed
+1418 -118

No files matched your search

+3
View File
@@ -139,6 +139,9 @@ describe('terminal link-provider regexes (shipped source)', () => {
// paths at all outside /var and /tmp.
'/Users/arbbot/codeman-cases/report.docx',
'/mnt/d/captures/demo.mp4',
// Camera-roll video uploads the agent echoes back (VIDEO_ATTACHMENT_EXTENSIONS).
'/home/a/.codeman-uploads/paste-1-ab.m4v',
'/home/a/.codeman-uploads/paste-2-cd.ogv',
// Longer extension of a family must win over its prefix (tsx over ts).
'/home/a/src/App.tsx',
];
+176 -4
View File
@@ -26,6 +26,9 @@ function loadComposer(sessionId = 'session-1') {
localEcho.pendingText = '';
}),
suppressBufferDetection: vi.fn(),
appendText: vi.fn((text: string) => {
localEcho.pendingText += text;
}),
};
const app = {
activeSessionId: sessionId,
@@ -42,6 +45,8 @@ function loadComposer(sessionId = 'session-1') {
_predictiveEcho: { clearPredictions: vi.fn() },
_sendInputAsync: vi.fn(),
_uploadAndInsertImages: vi.fn(async () => ['/tmp/image-one.png']),
_promptAttachKind: (file: { type: string }) =>
file.type.startsWith('image/') ? 'image' : file.type.startsWith('video/') ? 'video' : null,
showToast: vi.fn(),
};
const schedule = (callback: () => void, delay = 0) => {
@@ -330,7 +335,7 @@ describe('mobile prompt composer', () => {
expect(app.showToast).not.toHaveBeenCalled();
});
it('preserves the draft and focuses xterm when Use terminal keyboard is chosen', () => {
it('returns the text to the prompt and focuses xterm when Use terminal keyboard is chosen', () => {
const { app, bar, document, localEcho, runTimers } = loadComposer();
const composeButton = mountComposeButton(bar, document);
bar.composePrompt();
@@ -339,11 +344,13 @@ describe('mobile prompt composer', () => {
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
expect(app.terminal.focus).toHaveBeenCalledOnce();
expect(composeButton.classList.contains('has-draft')).toBe(true);
expect(composeButton.title).toBe('Resume saved prompt draft');
// The text is on the prompt now, not parked, so there is no draft to resume.
expect(localEcho.pendingText).toBe('keep this');
expect(composeButton.classList.contains('has-draft')).toBe(false);
runTimers();
localEcho.pendingText = '; then continue';
localEcho.pendingText += '; then continue';
bar.composePrompt();
// Adopted back exactly once: a surviving draft would double the first half.
expect(textarea(document).value).toBe('keep this; then continue');
});
@@ -374,6 +381,138 @@ describe('mobile prompt composer', () => {
expect(app._sendInputAsync).not.toHaveBeenCalled();
});
it('hands a single-line draft back to the terminal prompt and keeps no draft', () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const input = textarea(document);
input.value = 'describe this image';
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
expect(app._localEchoOverlay.appendText).toHaveBeenCalledWith('describe this image');
expect(document.querySelector('.prompt-composer-overlay')).toBeNull();
bar.composePrompt();
expect(textarea(document).value).toBe('describe this image');
});
it('writes the text to the PTY when the session has no buffer overlay, as codex does', () => {
const { app, bar, document } = loadComposer();
// Codex turns the buffer overlay off and echoes write-through instead.
app._localEchoEnabled = false;
bar.composePrompt();
const input = textarea(document);
input.value = 'describe this image';
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', ' describe this image');
expect(app._predictiveEcho.clearPredictions).toHaveBeenCalled();
});
it('writes the handoff to the PTY, not the parked overlay, while the tile grid owns the terminal', () => {
const { app, bar, document } = loadComposer();
// The grid parks and hides the main terminal, and a tile's Enter cannot flush
// its overlay, so the text would strand there (same guard as _attachFromToolbar).
app._tilesOwnTerminal = () => true;
bar.composePrompt();
const input = textarea(document);
input.value = 'describe this image';
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', ' describe this image');
});
it('refuses an oversized handoff with the draft intact rather than losing it from both surfaces', () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const input = textarea(document);
input.value = 'x'.repeat(bar._composerMaxLength + 1);
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
expect(app._sendInputAsync).not.toHaveBeenCalled();
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalled();
// Still open, still holding the text.
expect(document.querySelector('.prompt-composer-overlay')).not.toBeNull();
expect(textarea(document).value.length).toBe(bar._composerMaxLength + 1);
});
it('disables the terminal handoff while an upload is in flight', async () => {
const { app, bar, document } = loadComposer();
let finishUpload!: (paths: string[]) => void;
app._uploadAndInsertImages.mockImplementation(
() => new Promise<string[]>((resolveUpload) => (finishUpload = resolveUpload))
);
bar.composePrompt();
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
// Tapping it mid-upload would close the overlay and let the upload rebuild a
// draft holding only the paths, which the next open would prepend.
expect((document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).disabled).toBe(true);
finishUpload(['/tmp/image-one.png']);
await vi.waitFor(() =>
expect((document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).disabled).toBe(false)
);
});
it('writes to the PTY when the session is in echo passthrough, where Enter skips the overlay', () => {
const { app, bar, document } = loadComposer();
app._echoPassthroughSessions = new Set(['session-1']);
bar.composePrompt();
const input = textarea(document);
input.value = 'describe this image';
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', ' describe this image');
});
// Forward lock, not a check on this diff: the pre-change handoff also preserved
// the draft and never appended, so this passes on either side of the change. It
// exists to pin that multi-line stays parked if the single-line route is widened.
it('parks a multi-line draft instead of mangling it on the single-line prompt', () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const input = textarea(document);
input.value = 'first line\nsecond line';
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
(document.querySelector('.prompt-composer-terminal') as HTMLButtonElement).click();
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
bar.composePrompt();
expect(textarea(document).value).toBe('first line\nsecond line');
});
it('separates a parked draft from text put on the prompt before the next open', () => {
const { bar, document, localEcho, runTimers } = loadComposer();
bar.composePrompt();
const input = textarea(document);
input.value = 'describe this';
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
(document.querySelector('.paste-cancel') as HTMLButtonElement).click(); // parks the draft
runTimers();
// The toolbar Attach put a path on the now-empty prompt with no leading gap.
localEcho.pendingText = '/case/.codeman-uploads/paste-1-a.png ';
bar.composePrompt();
expect(textarea(document).value).toBe('describe this /case/.codeman-uploads/paste-1-a.png ');
});
it('finishes an upload into a reopened composer without restoring a deleted session draft', async () => {
const { app, bar, document } = loadComposer();
let finishUpload!: (paths: string[]) => void;
@@ -436,4 +575,37 @@ describe('mobile prompt composer', () => {
await vi.waitFor(() => expect(input.value).toBe('/tmp/first.png /tmp/second.png'));
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
});
it('offers videos in the Image key picker and filters the pick through the shared classifier', async () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
expect(fileInput.getAttribute('accept')).toBe('image/*,video/*');
const video = new document.defaultView!.File(['clip'], 'clip.mov', { type: 'video/quicktime' });
const pdf = new document.defaultView!.File(['%PDF'], 'notes.pdf', { type: 'application/pdf' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [video, pdf] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([video], { insert: false });
await vi.waitFor(() => expect(textarea(document).value).toContain('/tmp/image-one.png'));
});
it('takes a video pasted into the editor through the shared classifier', () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const input = textarea(document);
const video = new document.defaultView!.File(['clip'], 'clip.mp4', { type: 'video/mp4' });
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: {
items: [
{ type: 'text/plain', getAsFile: () => null },
{ type: 'video/mp4', getAsFile: () => video },
],
},
});
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([video], { insert: false });
});
});
+4
View File
@@ -11,6 +11,10 @@ export const SELECTORS = {
TOOLBAR_RIGHT: '.toolbar-right',
SETTINGS_MOBILE: '.btn-settings-mobile',
CASE_MOBILE: '.btn-case-mobile',
ATTACH_MOBILE: '.btn-attach-mobile',
// The desktop rule hides the WRAPPER, and assertHidden reads an element's own
// computed style (an ancestor's display:none is invisible to it).
ATTACH_MOBILE_WRAP: '.btn-attach-mobile-wrap',
// Tabs
TABS_CONTAINER: '.session-tabs',
+14 -27
View File
@@ -199,13 +199,24 @@ describe('Mobile Layout', () => {
}
});
it('uses phone upload and voice controls without toolbar overlap', async () => {
it('keeps the desktop voice control visible and the phone attach button hidden', async () => {
const { context, page } = await createDevicePage(iPadPro, baseUrl, 'chromium');
try {
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertVisible(page, '#voiceInputBtn');
await assertHidden(page, SELECTORS.ATTACH_MOBILE_WRAP);
} finally {
await context.close();
}
});
it('uses phone attach and voice controls without toolbar overlap', async () => {
const device = REPRESENTATIVE_DEVICES['small-phone'];
const { context, page } = await createDevicePage(device, baseUrl, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
expect(await page.locator('.toolbar-center .btn-upload').isVisible()).toBe(false);
await assertVisible(page, '.btn-upload-mobile');
await assertVisible(page, SELECTORS.ATTACH_MOBILE);
await assertVisible(page, '#voiceInputBtnMobile');
const violations = await page.evaluate(() => {
@@ -264,30 +275,6 @@ describe('Mobile Layout', () => {
await context.close();
}
});
it('keeps desktop upload and voice controls in one centered row', async () => {
const { context, page } = await createDevicePage(iPadPro, baseUrl, 'chromium');
try {
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertVisible(page, '.toolbar-center .btn-upload');
await assertVisible(page, '#voiceInputBtn');
const layout = await page.evaluate(() => {
const upload = document.querySelector('.toolbar-center .btn-upload')!.getBoundingClientRect();
const voice = document.querySelector('#voiceInputBtn')!.getBoundingClientRect();
return {
centerYDifference: Math.abs(upload.top + upload.height / 2 - (voice.top + voice.height / 2)),
centerXDifference: Math.abs(upload.left + upload.width / 2 - (voice.left + voice.width / 2)),
};
});
expect(layout.centerYDifference).toBeLessThanOrEqual(2);
expect(layout.centerXDifference).toBeGreaterThan(20);
} finally {
await context.close();
}
});
});
// ─── Device Classes ───────────────────────────────────────────────────────
+258 -1
View File
@@ -38,6 +38,11 @@ vi.mock('node:child_process', async (orig) => {
return { ...actual, execFile };
});
vi.mock('../../src/web/heic-jpeg-converter.js', () => ({ convertHeicToJpeg: heicConvert }));
// A tiny video cap so the streamed path's truncation branch is reachable in-process.
vi.mock('../../src/config/buffer-limits.js', async (orig) => ({
...(await orig<typeof import('../../src/config/buffer-limits.js')>()),
MAX_PASTE_VIDEO_BYTES: 64,
}));
// In-memory remote store so remote-case tests can inject hosts/cases without real JSON files.
const remoteStore = vi.hoisted(() => ({
@@ -56,7 +61,7 @@ vi.mock('../../src/remote-hosts.js', async (orig) => {
};
});
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { registerSessionRoutes, _resetPasteRateBuckets } from '../../src/web/routes/session-routes.js';
import { RemoteWakeRegistry, REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS } from '../../src/remote-wake.js';
import { resolveTerminalHistoryConfig } from '../../src/config/terminal-history.js';
@@ -250,6 +255,10 @@ describe('session-routes', () => {
// ========== POST /api/sessions/:id/paste-image ==========
describe('POST /api/sessions/:id/paste-image', () => {
// Rate buckets are process-global; clear them so one test's uploads cannot
// exhaust another's budget (the video bucket is only 3/min).
beforeEach(() => _resetPasteRateBuckets());
function imageUploadBody(boundary: string, filename: string, mimetype: string, imageBytes: Buffer): Buffer {
return Buffer.concat([
Buffer.from(
@@ -445,6 +454,254 @@ describe('session-routes', () => {
expect(JSON.parse(res.body).success).toBe(false);
expect(heicConvert).not.toHaveBeenCalled();
});
// `?kind=video`: the phone Attach button's camera-roll videos. Streamed to
// disk under their own cap; without the flag the route stays image-only.
const boundary = 'codeman-test-boundary';
const movHeaders = {
host: 'codeman.test',
origin: 'http://codeman.test',
'content-type': `multipart/form-data; boundary=${boundary}`,
};
// size + ftyp + "qt " brand, as an iPhone writes it, padded past the 12-byte sniff.
const qtMov = Buffer.concat([Buffer.from('0000001466747970717420200000000071742020', 'hex'), Buffer.alloc(20)]);
it('streams a ?kind=video upload to .codeman-uploads under the video extension', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
harness.ctx._session.workingDir = workDir;
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
headers: movHeaders,
payload: imageUploadBody(boundary, 'IMG_0001.MOV', 'video/quicktime', qtMov),
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.path).toMatch(/\/\.codeman-uploads\/paste-\d+-[a-f0-9]{8}\.mov$/);
expect(await readFile(body.data.path)).toEqual(qtMov);
await rm(workDir, { recursive: true });
});
it('accepts the registry video containers across their magic families (m4v, webm, ogv)', async () => {
// The admitted set is VIDEO_ATTACHMENT_EXTENSIONS (mp4/webm/mov/m4v/ogv); mov is
// covered above. These three exercise the three sniff families: ISO BMFF (m4v),
// EBML (webm) and Ogg (ogv). Three per session stays inside the 3/min video budget.
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
harness.ctx._session.workingDir = workDir;
const m4v = Buffer.concat([Buffer.from('00000018667479706d703432', 'hex'), Buffer.alloc(16)]); // ftyp mp42
const webm = Buffer.concat([Buffer.from('1a45dfa3a3428681', 'hex'), Buffer.alloc(16)]); // EBML
const ogv = Buffer.concat([Buffer.from('4f67675300020000', 'hex'), Buffer.alloc(16)]); // "OggS"
for (const [name, mime, bytes, ext] of [
['VID_0001.m4v', 'video/x-m4v', m4v, '.m4v'],
['VID_0002.webm', 'video/webm', webm, '.webm'],
['VID_0003.ogv', '', ogv, '.ogv'], // no MIME type: the name's extension carries it
] as const) {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
headers: movHeaders,
payload: imageUploadBody(boundary, name, mime || 'application/octet-stream', bytes),
});
expect(res.statusCode, name).toBe(200);
expect(JSON.parse(res.body).data.filename.endsWith(ext), name).toBe(true);
}
await rm(workDir, { recursive: true });
});
it('refuses 3gp and mkv, which a phone camera roll does not produce and the viewer cannot play', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
harness.ctx._session.workingDir = workDir;
const threeGp = Buffer.concat([Buffer.from('0000001866747970336770340000000033677034', 'hex'), Buffer.alloc(20)]);
const mkv = Buffer.concat([Buffer.from('1a45dfa3a3428681', 'hex'), Buffer.alloc(24)]);
for (const [name, mime, bytes] of [
['VID.3gp', 'video/3gpp', threeGp],
['VID.mkv', 'video/x-matroska', mkv],
] as const) {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
headers: movHeaders,
payload: imageUploadBody(boundary, name, mime, bytes),
});
expect(res.statusCode, name).toBe(400);
expect(JSON.parse(res.body).error, name).toMatch(/Unsupported video type/);
}
await rm(workDir, { recursive: true });
});
it('rejects a video without the flag, so the image path stays image-only', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
headers: movHeaders,
payload: imageUploadBody(boundary, 'IMG_0001.MOV', 'video/quicktime', qtMov),
});
// The image path falls back to .png for an unknown type and fails its magic check.
expect(res.statusCode).toBe(415);
expect(JSON.parse(res.body).error).toMatch(/do not match declared type/);
});
it('refuses an image declared under ?kind=video, so nothing rides the video cap through memory', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
headers: movHeaders,
payload: imageUploadBody(boundary, 'a.png', 'image/png', qtMov),
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/Unsupported video type/);
});
it('unlinks a ?kind=video upload whose bytes are not a video container', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
harness.ctx._session.workingDir = workDir;
const png = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), Buffer.alloc(24)]);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
headers: movHeaders,
payload: imageUploadBody(boundary, 'fake.mov', 'video/quicktime', png),
});
expect(res.statusCode).toBe(415);
expect(await readdir(join(workDir, '.codeman-uploads'))).toEqual(['.gitignore']); // the folder's own ignore file, no upload
await rm(workDir, { recursive: true });
});
it('leaves no partial file behind when a ?kind=video transfer ends early', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
harness.ctx._session.workingDir = workDir;
// The body stops mid-part: no closing boundary ever arrives.
const cut = imageUploadBody(boundary, 'IMG_0003.MOV', 'video/quicktime', qtMov).subarray(0, -12);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
headers: movHeaders,
payload: cut,
});
expect(res.statusCode).toBeGreaterThanOrEqual(400); // "Premature close" out of the write loop
expect(await readdir(join(workDir, '.codeman-uploads'))).toEqual(['.gitignore']); // the folder's own ignore file, no upload
await rm(workDir, { recursive: true });
});
it('unlinks a ?kind=video upload over the video cap and answers 413', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
harness.ctx._session.workingDir = workDir;
const big = Buffer.concat([qtMov, Buffer.alloc(200)]); // cap is mocked to 64 bytes
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
headers: movHeaders,
payload: imageUploadBody(boundary, 'IMG_0002.MOV', 'video/quicktime', big),
});
expect(res.statusCode).toBe(413);
expect(JSON.parse(res.body).error).toMatch(/too large/);
expect(await readdir(join(workDir, '.codeman-uploads'))).toEqual(['.gitignore']); // the folder's own ignore file, no upload
await rm(workDir, { recursive: true });
});
it('judges a ?kind=video upload on its header, before the body has landed on disk', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
harness.ctx._session.workingDir = workDir;
// Mislabeled AND over the (64-byte) cap: the header verdict comes first, so the
// answer is 415, never the 413 that would mean the whole body was taken in.
const big = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), Buffer.alloc(200)]);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
headers: movHeaders,
payload: imageUploadBody(boundary, 'fake.mov', 'video/quicktime', big),
});
expect(res.statusCode).toBe(415);
expect(await readdir(join(workDir, '.codeman-uploads'))).toEqual(['.gitignore']); // the folder's own ignore file, no upload
await rm(workDir, { recursive: true });
});
it('refuses an upload for a remote session before touching disk, video or image', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-video-'));
harness.ctx._session.workingDir = workDir;
// A remote session's workingDir is the REMOTE path: a file written here is unreadable there.
(harness.ctx._session as unknown as { remote: unknown }).remote = { hostId: 'gpu-box' };
for (const [url, name, mime, bytes] of [
[`/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`, 'IMG_0001.MOV', 'video/quicktime', qtMov],
[
`/api/sessions/${harness.ctx._sessionId}/paste-image`,
'shot.png',
'image/png',
Buffer.from('89504e470d0a1a0a00000000', 'hex'),
],
] as const) {
const res = await harness.app.inject({
method: 'POST',
url,
headers: movHeaders,
payload: imageUploadBody(boundary, name, mime, bytes),
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/remote/);
}
(harness.ctx._session as unknown as { remote: unknown }).remote = undefined;
expect(await readdir(workDir)).toEqual([]);
await rm(workDir, { recursive: true });
});
it('keeps the image path on its own size cap: a valid image past the video cap is accepted without the flag', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
harness.ctx._session.workingDir = workDir;
try {
// A real PNG far larger than the mocked 64-byte video cap but well under the
// harness's 10MB image cap. If the video path's per-call cap override leaked
// onto the image path, @fastify/multipart would truncate this and the magic
// check would fail; without ?kind=video it must be accepted whole.
const png = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), Buffer.alloc(4096)]);
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
headers: movHeaders,
payload: imageUploadBody(boundary, 'shot.png', 'image/png', png),
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.path).toMatch(/\/\.codeman-uploads\/paste-\d+-[a-f0-9]{8}\.png$/);
expect(await readFile(body.data.path)).toEqual(png);
} finally {
await rm(workDir, { recursive: true });
}
});
it('caps ?kind=video at 3 per minute per session, far below the image rate, on its own bucket', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
harness.ctx._session.workingDir = workDir;
try {
for (let i = 0; i < 3; i++) {
const ok = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
headers: movHeaders,
payload: imageUploadBody(boundary, `IMG_000${i}.MOV`, 'video/quicktime', qtMov),
});
expect(ok.statusCode).toBe(200);
}
const limited = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image?kind=video`,
headers: movHeaders,
payload: imageUploadBody(boundary, 'IMG_0004.MOV', 'video/quicktime', qtMov),
});
expect(limited.statusCode).toBe(429);
expect(JSON.parse(limited.body).error).toMatch(/3 video uploads\/min/);
// The image bucket is independent, so an image still goes through.
const png = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), Buffer.alloc(24)]);
const img = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
headers: movHeaders,
payload: imageUploadBody(boundary, 'shot.png', 'image/png', png),
});
expect(img.statusCode).toBe(200);
} finally {
await rm(workDir, { recursive: true });
}
});
});
// ========== GET /api/sessions ==========
+16 -9
View File
@@ -7,9 +7,10 @@
* of Codeman. Now the grid section itself takes every file drag (bubble
* phase): anywhere over it (a tile, an empty cell, a divider, its padding)
* dragover and drop are cancelled, so the page never navigates, and a drop on
* a tile uploads its images to THAT tile's session, the same upload and the
* same "Only image files" toast as the single view. Tab and tile drags are
* the targets' own (_acceptTabDrops, capture phase) and stay untouched.
* a tile uploads its files to THAT tile's session, through the same classifier
* (_promptAttachKind: images, videos and documents) and the same "Unsupported
* file type" toast as the single view. Tab and tile drags are the targets' own
* (_acceptTabDrops, capture phase) and stay untouched.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
*/
@@ -31,6 +32,10 @@ function fileEvent(target: FakeEl, files: Array<{ type: string; name?: string }>
function gridApp(ids = IDS): GridApp {
const app = makeGridApp(ids);
app._uploadAndInsertImages = vi.fn();
// The single view's classifier, by MIME then by the name's extension (image-input.js).
(app as unknown as { _promptAttachKind: (f: { type: string; name?: string }) => string | null })._promptAttachKind = (
f
) => (/^(image|video)\//.test(f.type) ? 'image' : /\.(pdf|docx|md)$/i.test(f.name || '') ? 'document' : null);
app.openTileGrid(ids, { focusedId: 's-a' });
return app;
}
@@ -61,19 +66,21 @@ describe('a file dropped on a tile', () => {
expect(app.activeSessionId).toBe('s-a');
});
it('only the images of a mixed drop are uploaded', () => {
it('only the files the classifier admits are uploaded: a document and a video count, an unnamed text part does not', () => {
const app = gridApp();
section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG]));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG], { sessionId: 's-c' });
const pdf = { type: 'application/pdf', name: 'Q3 report.pdf' };
const mov = { type: 'video/quicktime', name: 'IMG_0001.MOV' };
section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG, pdf, mov]));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG, pdf, mov], { sessionId: 's-c' });
});
it('a drop with no image says so, as the single view does, and uploads nothing', () => {
it('a drop with nothing the classifier admits says so, as the single view does, and uploads nothing', () => {
const app = gridApp();
const drop = fileEvent(tileEl('s-b'), [{ type: 'application/pdf' }]);
const drop = fileEvent(tileEl('s-b'), [{ type: 'application/x-sh', name: 'run.sh' }]);
section.dispatch('drop', drop);
expect(drop.preventDefault).toHaveBeenCalled();
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Only image files are supported', 'error');
expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error');
});
});
+1 -1
View File
@@ -334,7 +334,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
'Could not attach the session',
'This group has no session to show as tiles',
'No sessions to show as tiles',
'Only image files are supported',
'Unsupported file type',
'Split: unavailable while tiles are open',
'Tiles: show several sessions side by side (right-click for how many)',
'Tiles: back to a single session (right-click for how many tiles)',
+386
View File
@@ -0,0 +1,386 @@
/**
* Phone toolbar Attach button (image-input.js): the tap lands on the file input
* that sits over the button, so the BROWSER opens the picker, and a selection
* uploads then puts the paths on the prompt. The Compose dialog is not involved.
*
* The native tap is load-bearing, not a style choice: app.js's keyboard tap shim
* turns every toolbar BUTTON tap into a scripted click while the keyboard is up,
* and iOS refuses to open a file picker from an untrusted click.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const imageInputSource = readFileSync(resolve(import.meta.dirname, '../src/web/public/image-input.js'), 'utf8');
function loadToolbar(sessionId: string | null = 'session-1', localEcho = true, keyboardUp = false) {
const dom = new JSDOM(
'<!DOCTYPE html><html><body><span class="btn-attach-mobile-wrap">' +
'<button type="button" id="attachBtnMobile"></button>' +
'<input type="file" id="attachFileInput" accept="image/*,video/*" multiple>' +
'</span></body></html>'
);
const { window } = dom;
// Stands in for the real bar: the production code reaches for an OPEN composer
// before touching the terminal prompt, and only this shape of it matters here.
const composerBar = {
_composerOverlay: null as null | HTMLElement,
_insertComposerText: vi.fn(),
};
const fetchMock = vi.fn(async () => ({
ok: true,
json: async () => ({ data: { path: '/case/.codeman-uploads/p' } }),
}));
const context = vm.createContext({
window,
document: window.document,
setTimeout,
clearTimeout,
console,
FormData: window.FormData,
fetch: fetchMock,
KeyboardAccessoryBar: composerBar,
});
vm.runInContext('class CodemanApp {}', context);
vm.runInContext(imageInputSource, context, { filename: 'image-input.js' });
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => any;
const app = new CodemanApp();
app.activeSessionId = sessionId;
app.showToast = vi.fn();
app.sendInput = vi.fn(async () => {});
app.terminal = { focus: vi.fn() };
app._isMobileTerminalInputFocused = vi.fn(() => keyboardUp);
app._blurMobileTerminalInput = vi.fn();
app._sendInputAsync = vi.fn();
app._echoPassthroughSessions = new Set<string>();
app.sessions = new Map<string, unknown>([['session-1', { mode: 'claude' }]]);
app._flushedTexts = new Map<string, string>();
app._flushedOffsets = new Map<string, number>();
app._localEchoEnabled = localEcho;
app._localEchoOverlay = localEcho
? {
appendText: vi.fn(),
pendingText: '',
getFlushed: vi.fn(() => ({ count: 0, text: '' })),
detectBufferText: vi.fn(),
}
: null;
app._uploadAndInsertImages = vi.fn(async () => [
'/case/.codeman-uploads/paste-1-a.png',
'/case/.codeman-uploads/paste-2-b.jpg',
]);
app.initImageInput();
const button = window.document.getElementById('attachBtnMobile') as HTMLButtonElement;
const input = window.document.getElementById('attachFileInput') as HTMLInputElement;
const inputClicks: number[] = [];
input.addEventListener('click', () => inputClicks.push(1));
// A real tap lands on the input, never on the button underneath it.
const tap = () => {
const event = new window.MouseEvent('click', { bubbles: true, cancelable: true });
input.dispatchEvent(event);
return event;
};
const select = (files: File[]) => {
tap(); // captures the session, exactly as a real tap does
Object.defineProperty(input, 'files', { configurable: true, value: files });
input.dispatchEvent(new window.Event('change', { bubbles: true }));
};
return { app, window, button, input, inputClicks, tap, select, composerBar, fetchMock };
}
describe('phone toolbar Attach button', () => {
it('lets a native tap on the input open the picker, with the session captured', () => {
const { app, tap } = loadToolbar();
const event = tap();
expect(event.defaultPrevented).toBe(false); // the browser opens the picker
expect(app._attachSessionId).toBe('session-1');
});
// Pins the MECHANISM, not the outcome: whether the keyboard actually stays up is
// only observable on a device, so this locks the cancelled mousedown that stops
// the input taking focus. Same kind of lock as the multi-line composer test.
it('cancels mousedown on the input so focus never leaves the terminal', () => {
const { window, input } = loadToolbar();
const event = new window.MouseEvent('mousedown', { bubbles: true, cancelable: true });
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
});
// The input is aria-hidden and not tabbable, so assistive tech and a hardware
// keyboard reach the button instead. That click is trusted, so it may open the
// picker from script.
it('opens the input from the button for a keyboard or VoiceOver activation', () => {
const { button, inputClicks } = loadToolbar();
button.click();
expect(inputClicks).toHaveLength(1);
});
it('refuses before the picker opens when no session is active', () => {
const { app, tap } = loadToolbar(null);
const event = tap();
expect(event.defaultPrevented).toBe(true); // no picker, rather than a refused pick
expect(app.showToast).toHaveBeenCalledWith('Open a session to attach a file', 'error');
});
it('puts the paths in the local-echo overlay, not the PTY, so Compose can adopt them', async () => {
const { app, window, select } = loadToolbar();
const a = new window.File(['a'], 'a.png', { type: 'image/png' });
const b = new window.File(['b'], 'b.jpg', { type: 'image/jpeg' });
select([a, b]);
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([a, b], { insert: false, sessionId: 'session-1' });
expect(app._localEchoOverlay.appendText).toHaveBeenCalledWith(
'/case/.codeman-uploads/paste-1-a.png /case/.codeman-uploads/paste-2-b.jpg '
);
expect(app._sendInputAsync).not.toHaveBeenCalled();
});
it('falls back to the session-bound PTY write when there is no overlay to buffer into', async () => {
const { app, window, select } = loadToolbar('session-1', false);
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._sendInputAsync).toHaveBeenCalledTimes(1));
expect(app._sendInputAsync).toHaveBeenCalledWith(
'session-1',
' /case/.codeman-uploads/paste-1-a.png /case/.codeman-uploads/paste-2-b.jpg '
);
});
it('writes to the PTY, not the overlay, while the session is in echo passthrough', async () => {
const { app, window, select } = loadToolbar();
// A composer nav key hands the session back to plain PTY echo, and Enter then
// skips the overlay flush entirely, so buffered text would never be submitted.
app._echoPassthroughSessions.add('session-1');
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._sendInputAsync).toHaveBeenCalledTimes(1));
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
});
it('writes to the PTY, not the overlay, while the tile grid owns the terminal', async () => {
const { app, window, select } = loadToolbar();
// Tiles park and hide the main terminal, and a tile's Enter never reaches its
// overlay, so a path appended there would sit unseen and never be submitted.
app._tilesOwnTerminal = () => true;
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._sendInputAsync).toHaveBeenCalledTimes(1));
expect(app._sendInputAsync).toHaveBeenCalledWith(
'session-1',
' /case/.codeman-uploads/paste-1-a.png /case/.codeman-uploads/paste-2-b.jpg '
);
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
});
it('separates the path from a half-typed word already on the prompt', async () => {
const { app, window, select } = loadToolbar();
app._localEchoOverlay.pendingText = 'describe';
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
expect(app._localEchoOverlay.appendText.mock.calls[0][0]).toBe(
' /case/.codeman-uploads/paste-1-a.png /case/.codeman-uploads/paste-2-b.jpg '
);
});
it('separates the path from flushed prompt text when pending is empty', async () => {
const { app, window, select } = loadToolbar();
// After a tab completion or a tab-switch restore the words live in the flushed
// half of the prompt and pending is empty.
app._localEchoOverlay.getFlushed = vi.fn(() => ({ count: 8, text: 'describe' }));
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
expect(app._localEchoOverlay.appendText.mock.calls[0][0].startsWith(' /case/')).toBe(true);
});
it('adds no separator when the prompt already ends in whitespace', async () => {
const { app, window, select } = loadToolbar();
app._localEchoOverlay.pendingText = 'describe ';
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
expect(app._localEchoOverlay.appendText.mock.calls[0][0].startsWith('/case/')).toBe(true);
});
it('sends the path to the session the attach started on after a tab switch', async () => {
const { app, window, select } = loadToolbar('session-1', true, true);
app._uploadAndInsertImages.mockImplementation(async () => {
// The user switches tabs while the upload is in flight.
app.activeSessionId = 'session-2';
return ['/case/.codeman-uploads/paste-1-a.png'];
});
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._sendInputAsync).toHaveBeenCalledTimes(1));
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', ' /case/.codeman-uploads/paste-1-a.png ');
// A background session's prompt is tracked, or a later composer open erases the wrong tail.
expect(app._flushedTexts.get('session-1')).toBe(' /case/.codeman-uploads/paste-1-a.png ');
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
// The keyboard belongs to whatever the user is looking at now, not to us.
expect(app.terminal.focus).not.toHaveBeenCalled();
});
// Nothing in the attach path may move focus. Focus stays on the terminal through
// the whole pick, so the keyboard is never dismissed and never has to be restored
// (a programmatic focus does not raise it on iOS without user activation).
it('never touches the keyboard, with it up or down', async () => {
for (const keyboardUp of [true, false]) {
const { app, window, select } = loadToolbar('session-1', true, keyboardUp);
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
expect(app._blurMobileTerminalInput).not.toHaveBeenCalled();
expect(app.terminal.focus).not.toHaveBeenCalled();
}
});
it('refuses to upload for a session deleted while the picker was open', async () => {
const { app, window, tap, input, fetchMock } = loadToolbar();
// The production upload path, spied: the refusal under test is its own.
app._uploadAndInsertImages = vi.fn(Object.getPrototypeOf(app)._uploadAndInsertImages);
tap();
app.sessions.delete('session-1');
app.activeSessionId = 'session-2';
Object.defineProperty(input, 'files', {
configurable: true,
value: [new window.File(['a'], 'a.png', { type: 'image/png' })],
});
input.dispatchEvent(new window.Event('change', { bubbles: true }));
await vi.waitFor(() => expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1));
expect(app._uploadAndInsertImages.mock.calls[0][1]).toEqual({ insert: false, sessionId: 'session-1' });
await expect(app._uploadAndInsertImages.mock.results[0].value).resolves.toEqual([]);
expect(fetchMock).not.toHaveBeenCalled();
expect(app.showToast).not.toHaveBeenCalled();
expect(app._sendInputAsync).not.toHaveBeenCalled();
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
});
it('records nothing in the flushed maps for a background session in echo passthrough', async () => {
const { app, window, select } = loadToolbar('session-1', true, true);
app._echoPassthroughSessions.add('session-1');
app._uploadAndInsertImages.mockImplementation(async () => {
app.activeSessionId = 'session-2';
return ['/case/.codeman-uploads/paste-1-a.png'];
});
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._sendInputAsync).toHaveBeenCalledTimes(1));
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', ' /case/.codeman-uploads/paste-1-a.png ');
// Nothing tracks a passthrough composer and Enter there never clears the maps,
// so a record would offset the overlay for that session's next prompt.
expect(app._flushedTexts.has('session-1')).toBe(false);
expect(app._flushedOffsets.has('session-1')).toBe(false);
});
it('passes a .gif with no MIME through untouched, on the same extension fallback as the kind check', async () => {
const { app, window } = loadToolbar();
const gif = new window.File(['GIF89a'], 'anim.gif', { type: '' });
await expect(app._normalizeImageForUpload(gif)).resolves.toBe(gif);
});
it('drops the paths when the session was deleted while the upload ran', async () => {
const { app, window, select } = loadToolbar('session-1', true, true);
app._uploadAndInsertImages.mockImplementation(async () => {
app.sessions.delete('session-1');
app.activeSessionId = 'session-2';
return ['/case/.codeman-uploads/paste-1-a.png'];
});
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1));
await new Promise((r) => setTimeout(r, 0));
expect(app._sendInputAsync).not.toHaveBeenCalled();
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
// No bookkeeping for a session whose cleanup already ran.
expect(app._flushedTexts.has('session-1')).toBe(false);
});
it('detects undetected prompt text before measuring the separator', async () => {
const { app, window, select } = loadToolbar();
// Tab completion put text on the prompt the overlay has not adopted yet.
app._localEchoOverlay.detectBufferText = vi.fn(() => {
app._localEchoOverlay.getFlushed = vi.fn(() => ({ count: 8, text: 'describe' }));
return 'describe';
});
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
expect(app._localEchoOverlay.appendText.mock.calls[0][0].startsWith(' /case/')).toBe(true);
});
it('puts the paths into an open composer rather than behind it', async () => {
const { app, window, select, composerBar } = loadToolbar();
const overlay = window.document.createElement('div');
overlay.dataset.sessionId = 'session-1';
const area = window.document.createElement('textarea');
area.className = 'prompt-composer-textarea';
overlay.appendChild(area);
window.document.body.appendChild(overlay);
composerBar._composerOverlay = overlay;
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(composerBar._insertComposerText).toHaveBeenCalledTimes(1));
expect(composerBar._insertComposerText.mock.calls[0][0]).toBe(area);
// Not behind it: the composer already took the terminal's pending text.
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
expect(app._sendInputAsync).not.toHaveBeenCalled();
});
it('ignores a composer open for a different session', async () => {
const { app, window, select, composerBar } = loadToolbar();
const overlay = window.document.createElement('div');
overlay.dataset.sessionId = 'session-2';
const area = window.document.createElement('textarea');
area.className = 'prompt-composer-textarea';
overlay.appendChild(area);
window.document.body.appendChild(overlay);
composerBar._composerOverlay = overlay;
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
expect(composerBar._insertComposerText).not.toHaveBeenCalled();
});
it('rejects a selection with neither image nor video with a toast and no upload', async () => {
const { app, window, select } = loadToolbar();
select([new window.File(['%PDF-'], 'doc.pdf', { type: 'application/pdf' })]);
await Promise.resolve();
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app._sendInputAsync).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Only image or video files are supported', 'error');
});
it('classifies a pick by its name when the file manager supplied no MIME type', () => {
const { app, window } = loadToolbar();
const kind = (name: string, type: string) => app._promptAttachKind(new window.File(['x'], name, { type }));
expect(kind('clip.ogv', '')).toBe('video');
expect(kind('IMG_0001.MOV', '')).toBe('video');
expect(kind('shot.heic', '')).toBe('image');
expect(kind('notes.pdf', '')).toBeNull();
expect(kind('clip.ogv', 'image/jpeg')).toBe('image'); // the MIME type wins when present
});
it('flags the upload as a video by name as well as by MIME type', async () => {
const { app, window, fetchMock } = loadToolbar();
await app._uploadPasteImage('session-1', new window.File(['v'], 'clip.ogv', { type: '' }));
await app._uploadPasteImage('session-1', new window.File(['v'], 'IMG_0001.MOV', { type: 'video/quicktime' }));
await app._uploadPasteImage('session-1', new window.File(['a'], 'a.png', { type: 'image/png' }));
const urls = fetchMock.mock.calls.map((c: unknown[]) => c[0]);
expect(urls).toEqual([
'/api/sessions/session-1/paste-image?kind=video',
'/api/sessions/session-1/paste-image?kind=video',
'/api/sessions/session-1/paste-image',
]);
});
it('uploads a camera-roll video alongside photos', async () => {
const { app, window, select } = loadToolbar();
const mov = new window.File(['v'], 'IMG_0001.MOV', { type: 'video/quicktime' });
const jpg = new window.File(['a'], 'a.jpg', { type: 'image/jpeg' });
select([mov, jpg]);
await vi.waitFor(() => expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1));
expect(app._uploadAndInsertImages.mock.calls[0][0]).toEqual([mov, jpg]);
});
it('types nothing when every upload failed', async () => {
const { app, window, select } = loadToolbar();
app._uploadAndInsertImages.mockResolvedValueOnce([]);
select([new window.File(['a'], 'a.png', { type: 'image/png' })]);
await vi.waitFor(() => expect(app._uploadAndInsertImages).toHaveBeenCalledTimes(1));
await Promise.resolve();
expect(app._sendInputAsync).not.toHaveBeenCalled();
expect(app._localEchoOverlay.appendText).not.toHaveBeenCalled();
});
});