mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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:
1 parent
aa3730aace
commit
34c903da41
24 files changed
+1418
-118
No files matched your search
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user