Skip to content

Commit a7e6e07

Browse files
feat(console): take photos, open attachments and share records through the mobile shell (#29803)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbK3TYRkrRkWybjzukkDSydj)_ Fixes #29735 Clause-②: yes Metadata-protocol: none Claim: comment 6595193307 (branch `claude/project-thread-6oh3sk`); file surface amended in 6595245493 (seat ruling: record share only, never pass the signed URL to the shell), plus `ui/packages/mobile/README.md` on the seat's word. ## 维护者速读 - **改了什么**:在移动端外壳(ObjectStack mobile)里,文件/图片字段多了「拍照 / 从相册选择」(走外壳的 `pickImage()`),记录附件点开时由 Console 用自己的会话取回文件字节交给外壳的 `openFile()` 预览,记录页「分享」走外壳的 `share()` 调起系统分享面板。普通浏览器和旧版外壳行为完全不变。 - **为什么改**:维护者裁决(mobile#147 选项 A)要求 Console 自己取文件、外壳不碰会话;外壳侧 mobile#180 已提供这三个方法,Console 若不调用就是「已声明却无人使用」的能力。 - **风险与回滚**:风险低。三处都先做能力探测(`has()`),不满足就走原来的路径;拍到的照片走字段原有的上传管线(同样的大小校验、同样存 `sys_file` 引用)。附件超过 8 MiB、取字节失败或外壳拒收(`bad_args`)都回到原来的打开方式。签名下载链接是凭证,从不交给外壳。回滚即 revert 本 PR。 - **先看**:① `ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx` 的 `openInNativeShell`(取字节、8 MiB 判断、回退);② `ui/packages/fields/src/widgets/FileField.tsx` / `ImageField.tsx` 里照片如何进入原上传管线;③ `ui/packages/mobile/src/nativeHost.ts` 新增的可选成员与包装函数(公开面扩展,需契约复核)。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/fields/src/widgets/nativeImagePick.shellPhoto.test.tsx packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx packages/mobile/src/__tests__/nativeHost.test.tsx`,预期 4 个文件全绿(135 个用例)。 - **需要你做的**:无,席位落地(`@object-ui/mobile` 公开面扩展的契约复核由席位转总监)。 ## What changes The ObjectStack mobile shell offers three optional bridge methods for files (objectstack-ai/mobile `docs/HYBRID-SHELL.md`, "Files", shell side mobile#180), each feature-detected with `has()`. The Console now calls them. A plain browser and an older shell take exactly today's path. 1. **Bridge (`@object-ui/mobile`, `nativeHost.ts`).** `ObjectStackNative` gains optional `pickImage?`, `openFile?` and `share?` (an older shell lacks them), the method union gains the three names, and the typed wrappers `nativePickImage`, `nativeOpenFile`, `nativeShare` follow the `scan` / `showConnectionSwitcher` precedent (feature-detect through `nativeHostCan`, then `host.x!()`; resolve `unavailable | ok | failed`, never throw). New types `NativePickImageOptions`, `NativeFile`, `NativeShareContent`; the bridge limit `NATIVE_FILE_MAX_BYTES` (8 MiB, the shell's `MAX_FILE_BYTES`); helpers `fileFromNativeFile` (bridge file to an uploadable `File`) and `nativeFileFromBlob` (bytes to padded base64 with no `data:` prefix and a parameter-free MIME type). All exported from the package entry: a public widening, so a contract review is owed. 2. **pickImage (`@object-ui/fields`).** In a shell announcing `pickImage`, `FileField` (when the field accepts images) routes its existing camera button through `pickImage({ camera: true })` and adds "Choose photo" (`pickImage({ camera: false })`); `capture: false` still hides the camera. `ImageField` adds "Take photo" and "Choose photo" under its upload button. The picked image becomes a `File` and enters the SAME pipeline an input pick does: `FileField`'s `processFiles`, and `ImageField`'s input handler body lifted into `uploadPicked`, which both the input and the shell call. No second upload route. Cancel (`null`) uploads nothing; a shell failure shows the error toast with the shell's message (`fields.file.photoFailed`). Internal helper `widgets/nativeImagePick.ts`, not exported. The grid `FileCell` and `AvatarField` are not touched. 3. **openFile (`@object-ui/app-shell`, `RecordAttachmentsPanel`).** The row's Download tap still fetches the signed URL with the Console's own session. In a shell announcing `openFile`, it then fetches the bytes and hands only the bytes to `openFile`. A row over 8 MiB (judged before any byte is fetched, and again on the fetched size), a failed byte fetch (for example a cross-origin storage URL without CORS), or any non-`ok` answer (`bad_args` included) keeps today's `window.open`. 4. **share (`@object-ui/app-shell`, `RecordDetailView` `sys_share`).** In a shell announcing `share`, the record header's Share calls `share({ url: location.href, text: document.title })`. A refusal or failure falls back to today's `navigator.share` or clipboard copy. Only the record URL crosses the bridge. 5. **i18n.** `fields.file.choosePhoto` and `fields.file.photoFailed` in the ten locale packs. 6. **Docs.** `ui/packages/mobile/README.md`: the typed-wrapper line names the three new wrappers, and one entry names `NATIVE_FILE_MAX_BYTES` and the two file helpers (added to the amended file list on the seat's word: doc follows export). ## Tests - `packages/mobile/src/__tests__/nativeHost.test.tsx`: the fake bridge carries the three methods; each wrapper resolves `unavailable` with no bridge, when `has()` says no, and when the method is missing; `pickImage` forwards options and resolves the file or `null`; `openFile` forwards the file and resolves `failed` / `bad_args` on refusal; `share` forwards url and text; the 8 MiB constant; base64 decode and encode helpers. - `packages/fields/src/widgets/nativeImagePick.shellPhoto.test.tsx` (new, 12 tests): buttons only when `has('pickImage')`; camera and library both upload the picked `File` (name, type, size checked) through the field's `UploadProvider` adapter and `onChange` gets the `sys_file` id; cancel uploads nothing; failure toasts and uploads nothing; `capture: false`; a non-image `accept`; the picked image meets the same `maxSize` check. CONTROL: no shell and an older shell draw no shell button. - `packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx`, new top-level describe (6 tests): `openFile` gets exactly `{ base64, name, mime }`, the signed-URL token appears in no shell call, `openExternal` / `share` untouched, no tab; over 8 MiB by row size (no byte fetch) and by fetched size; `bad_args`; failed byte fetch; CONTROL older shell. The plain-browser control is the existing signed-URL download describe, unchanged and green. - `packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx`, new top-level describe (4 tests): Share calls `share` with exactly the record URL and title and copies nothing; refusal falls back to the clipboard; CONTROL older shell and plain browser copy as today. - Ablation per leg, each restored to HEAD by `scripts/ablation-replace.mjs` (blob == HEAD, `git diff HEAD` empty): bridge `share` forwards `{}` (1 red); `FileField` drops the picked files (2 red); `ImageField` drops the picked files (2 red); the panel skips `openInNativeShell` (2 red); Share ignores the shell's `ok` (1 red). ## Acceptance notes - Attachment share is not added (seat ruling A in 6595245493): no attachment share action exists, the contract's Console list names only "sharing a record link", the stable `/storage/files/:id` link needs the Console's Bearer session (dead for a recipient), and the signed URL is a credential. - `plugin-detail`'s `DetailView` has its own `sys_share`; it is not on the Console record page and is unchanged. - The file-field download links (`FileValueAffordance`), the grid `FileCell` and `AvatarField` keep today's behaviour; the contract names attachments and image/attachment fields. - `openFile` passes the row's file name as is; a name the shell refuses (`bad_args`) takes the browser path. - No real shell in the container: every leg is driven through the shell fake. ## Verification Final head `b37e3124` (merged `origin/main` 2545021 cleanly, then rebuilt the `@object-ui/app-shell` closure plus formula, sdui-parser, lint, client, client-react: 66 tasks, exit 0). - type-check at `b37e3124`: `turbo run type-check` for `@object-ui/mobile`, `@object-ui/fields`, `@object-ui/app-shell`, `@object-ui/i18n`: 68/68 tasks, exit 0. - Suites at `b37e3124` (locked, 2 workers): the native-shell set (`git grep -l` of `@object-ui/mobile` or `ObjectStackNative` in tests), every test naming `FileField`, `ImageField` or `RecordAttachmentsPanel`, the four new or touched test files, and `packages/i18n/`: 153 files, 2831 passed, 13 skipped, 0 failed. - The other 99 `RecordDetailView*.test.tsx` at `3d7a69ca` (before the main merge, which touched no line of this diff): 99 files, 619 tests green. - eslint on the 11 touched source and test files: 0 errors. - Derived gates at `b37e3124`: `dispatch-gates --commands` derived 35; all 35 run, all exit 0 (including `pnpm --dir ui exec vitest run scripts/`, `check:nul-bytes`, `check:published-files`, `check:type-check-debt`, `check-sdui-manifest`, `check-changeset-no-major`, `check-adr-0087-registration`, `check-closing-keyword-parity` against this body). `dispatch-gates --ran`: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. - Ablations: listed under Tests, run at `c413ef3c`. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSqiKZMCzGXQK5xzUq)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b758e91 commit a7e6e07

23 files changed

Lines changed: 873 additions & 18 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/mobile': patch
3+
'@object-ui/fields': patch
4+
'@object-ui/app-shell': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
feat(console): photos, attachments and record sharing go through the mobile shell's `pickImage()`, `openFile()` and `share()`
9+
10+
Clause-②: yes
11+
Metadata-protocol: none
12+
13+
The ObjectStack mobile shell offers three optional bridge methods for files, each feature-detected with `has()`. The Console now calls them, and a plain browser or an older shell behaves exactly as before.
14+
15+
- `@object-ui/mobile`: `ObjectStackNative` gains the optional `pickImage?`, `openFile?` and `share?` members, with the typed wrappers `nativePickImage`, `nativeOpenFile` and `nativeShare` (they resolve `unavailable | ok | failed` and never throw), the types `NativePickImageOptions`, `NativeFile` and `NativeShareContent`, the bridge limit `NATIVE_FILE_MAX_BYTES` (8 MiB), and two helpers: `fileFromNativeFile` (a bridge file to an uploadable `File`) and `nativeFileFromBlob` (bytes to a bridge file).
16+
- File and image fields: in a shell with `pickImage`, "Take photo" and "Choose photo" use the shell's camera and photo library. The picked image goes through the field's existing upload pipeline, so the size check and the stored `sys_file` reference are the same as for an `<input type=file>` pick. A cancel uploads nothing; a shell failure shows an error toast. New strings: `fields.file.choosePhoto` and `fields.file.photoFailed`.
17+
- Record attachments: in a shell with `openFile`, tapping an attachment fetches its bytes with the Console's own session and hands the bytes to the shell. A file over 8 MiB, a failed fetch, or a refusal from the shell keeps the browser behaviour. The signed download URL never crosses the bridge.
18+
- Record page Share: in a shell with `share`, it opens the OS share sheet with the record's page link and title. A refusal from the shell keeps today's `navigator.share` or clipboard copy.

‎ui/packages/app-shell/src/views/RecordAttachmentsPanel.tsx‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ import { createAuthenticatedFetch } from '@object-ui/auth';
2424
import { useObjectTranslation, isPermissionError, classifyLoadError } from '@object-ui/react';
2525
import { usePermissions } from '@object-ui/permissions';
2626
import { resolveAffordance, type ConfirmationHandler } from '@object-ui/core';
27+
import {
28+
NATIVE_FILE_MAX_BYTES,
29+
getNativeHost,
30+
nativeFileFromBlob,
31+
nativeHostCan,
32+
nativeOpenFile,
33+
type NativeFile,
34+
} from '@object-ui/mobile';
2735
import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog.js';
2836
import { RECORD_PAGE_ATTACHMENT_ROWS, recordAttachmentsCountField } from './recordPagePayload.js';
2937

@@ -127,6 +135,42 @@ function formatSize(bytes?: number | null): string {
127135
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
128136
}
129137

138+
139+
/**
140+
* Inside the ObjectStack mobile shell, when it announces `openFile`: fetch the
141+
* attachment's bytes from the signed URL this panel already fetched with the
142+
* Console's own session, and hand the BYTES to the shell, which previews or
143+
* opens them with the OS (objectstack-ai/mobile `docs/HYBRID-SHELL.md`,
144+
* "Files"; the shell never downloads a URL or reads the session). Answers
145+
* whether the shell took the file.
146+
*
147+
* `false` keeps the browser path (`window.open` on the signed URL), exactly as
148+
* outside the shell: no shell or an older one, a file over the bridge's
149+
* `NATIVE_FILE_MAX_BYTES` (judged on the row's size before any byte is
150+
* fetched, and on the fetched size), a fetch that fails (a cross-origin
151+
* storage URL without CORS), and the shell refusing (`bad_args`) or failing
152+
* the call. ⛔ The signed URL is a credential: only bytes cross the
153+
* bridge, never the URL.
154+
*/
155+
async function openInNativeShell(target: string, row: AttachmentRow): Promise<boolean> {
156+
if (!nativeHostCan(getNativeHost(), 'openFile')) return false;
157+
if (typeof row.size === 'number' && row.size > NATIVE_FILE_MAX_BYTES) return false;
158+
let file: NativeFile;
159+
try {
160+
const res = await fetch(target);
161+
if (!res.ok) return false;
162+
let blob = await res.blob();
163+
if (blob.size > NATIVE_FILE_MAX_BYTES) return false;
164+
if (!blob.type && row.mime_type) blob = new Blob([blob], { type: row.mime_type });
165+
file = await nativeFileFromBlob(blob, row.file_name || row.file_id);
166+
} catch {
167+
return false;
168+
}
169+
// Anything but `ok` (the shell refusing the file with `bad_args`, or failing
170+
// it) takes the browser path, so a tap is never answered worse than before.
171+
return (await nativeOpenFile(file)).status === 'ok';
172+
}
173+
130174
export const RecordAttachmentsPanel: React.FC<RecordAttachmentsPanelProps> = ({
131175
objectName,
132176
recordId,
@@ -465,6 +509,7 @@ export const RecordAttachmentsPanel: React.FC<RecordAttachmentsPanelProps> = ({
465509
const url: string | undefined = body?.url ?? body?.data?.url;
466510
if (!url) throw new Error('Download URL missing from response');
467511
const target = /^https?:/i.test(url) ? url : `${baseUrl}${url}`;
512+
if (await openInNativeShell(target, row)) return;
468513
window.open(target, '_blank', 'noopener,noreferrer');
469514
} catch (err: any) {
470515
setError(friendlyError(err));

‎ui/packages/app-shell/src/views/RecordDetailView.favoritesEntry-12427.test.tsx‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,3 +210,84 @@ describe('record page: the favorites entry is in the overflow, ahead of Share (o
210210
);
211211
});
212212
});
213+
214+
/**
215+
* The overflow's Share inside the ObjectStack mobile shell, when it announces
216+
* `share` (objectstack-ai/mobile `docs/HYBRID-SHELL.md`, "Files"): the OS share
217+
* sheet through the shell, with the record's page link and its title. Only
218+
* that record URL crosses the bridge. The control cases (a plain browser, an
219+
* older shell without `share`, a shell refusing the call) keep today's
220+
* path: `navigator.share` when present, else the clipboard copy.
221+
*/
222+
describe('record page: Share through the mobile shell', () => {
223+
const GLOBAL = 'ObjectStackNative';
224+
225+
function installShell(opts: { capabilities?: string[]; refuse?: boolean } = {}) {
226+
const capabilities = opts.capabilities ?? ['share', 'openFile'];
227+
const share = vi.fn(async (_content: { url?: string; text?: string }) => {
228+
if (opts.refuse) throw Object.assign(new Error('url must be http(s)'), { code: 'bad_args' });
229+
return null;
230+
});
231+
const host = {
232+
version: 1,
233+
platform: 'ios',
234+
appVersion: '2.2.0',
235+
capabilities,
236+
safeArea: { top: 0, bottom: 0, left: 0, right: 0 },
237+
has: (c: string) => capabilities.includes(c),
238+
share,
239+
openFile: vi.fn(async () => null),
240+
openExternal: vi.fn(async () => null),
241+
ready: vi.fn(),
242+
routeChanged: vi.fn(),
243+
};
244+
(window as unknown as Record<string, unknown>)[GLOBAL] = host;
245+
return host;
246+
}
247+
248+
let writeText: ReturnType<typeof vi.fn>;
249+
beforeEach(() => {
250+
document.title = 'Intro call · CRM';
251+
writeText = vi.fn(async () => {});
252+
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
253+
});
254+
afterEach(() => {
255+
delete (window as unknown as Record<string, unknown>)[GLOBAL];
256+
});
257+
258+
async function chooseShare() {
259+
await mountRecordPage();
260+
await openOverflow();
261+
fireEvent.click(screen.getByRole('menuitem', { name: 'Share' }));
262+
}
263+
264+
it('opens the shell share sheet with the record URL and title, and copies nothing', async () => {
265+
const host = installShell();
266+
await chooseShare();
267+
await waitFor(() => expect(host.share).toHaveBeenCalledTimes(1));
268+
// Exactly the record's page link and its title: nothing else crosses the bridge.
269+
expect(host.share).toHaveBeenCalledWith({ url: window.location.href, text: 'Intro call · CRM' });
270+
expect(host.openFile).not.toHaveBeenCalled();
271+
expect(host.openExternal).not.toHaveBeenCalled();
272+
expect(writeText).not.toHaveBeenCalled();
273+
});
274+
275+
it('a shell refusing the call keeps the browser path', async () => {
276+
const host = installShell({ refuse: true });
277+
await chooseShare();
278+
await waitFor(() => expect(writeText).toHaveBeenCalledWith(window.location.href));
279+
expect(host.share).toHaveBeenCalledTimes(1);
280+
});
281+
282+
it('CONTROL: an older shell without share copies the link as today', async () => {
283+
const host = installShell({ capabilities: ['openFile'] });
284+
await chooseShare();
285+
await waitFor(() => expect(writeText).toHaveBeenCalledWith(window.location.href));
286+
expect(host.share).not.toHaveBeenCalled();
287+
});
288+
289+
it('CONTROL: a plain browser copies the link as today', async () => {
290+
await chooseShare();
291+
await waitFor(() => expect(writeText).toHaveBeenCalledWith(window.location.href));
292+
});
293+
});

‎ui/packages/app-shell/src/views/RecordDetailView.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import {
9292
useWorkspacePreference,
9393
} from '../utils/workspacePreference.js';
9494
import { shortcutKeycaps, useAdvertiseShortcut, type AdvertisedShortcut, type ShortcutChord, type ShortcutLabelTranslate } from '../chrome/advertisedShortcuts.js';
95+
import { nativeShare } from '@object-ui/mobile';
9596
import { useActionModal } from '../hooks/useActionModal.js';
9697
import { resolveCascadeDeleteLabels } from '../hooks/useObjectActions.js';
9798
import { useRecentItems } from '../hooks/useRecentItems.js';
@@ -4482,6 +4483,17 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
44824483
order: 110,
44834484
component: 'action:menu',
44844485
onClick: async () => {
4486+
// Inside the ObjectStack mobile shell, when it announces `share`: the
4487+
// OS share sheet through the shell, with this record's page link and
4488+
// its title (objectstack-ai/mobile `docs/HYBRID-SHELL.md`, "Files").
4489+
// Only the record URL crosses the bridge. A shell that refuses or
4490+
// fails the call, an older shell and a plain browser take the path
4491+
// below, unchanged.
4492+
const shared = await nativeShare({
4493+
url: window.location.href,
4494+
...(document.title ? { text: document.title } : {}),
4495+
});
4496+
if (shared.status === 'ok') return;
44854497
try {
44864498
if ((navigator as any).share) {
44874499
await (navigator as any).share({

‎ui/packages/app-shell/src/views/__tests__/RecordAttachmentsPanel.test.tsx‎

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
44
import { render, screen, waitFor, within } from '@testing-library/react';
55
import userEvent from '@testing-library/user-event';
66
import { RecordAttachmentsPanel } from '../RecordAttachmentsPanel';
@@ -712,3 +712,137 @@ describe('RecordAttachmentsPanel — authenticated signed-URL download (#2970)',
712712
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
713713
});
714714
});
715+
716+
/**
717+
* Inside the ObjectStack mobile shell, when it announces `openFile`
718+
* (objectstack-ai/mobile `docs/HYBRID-SHELL.md`, "Files"): the row tap
719+
* fetches the signed URL with the Console's session as before, then fetches
720+
* the BYTES and hands them to the shell's `openFile`. Over the bridge's
721+
* 8 MiB, a failed byte fetch or a `bad_args` refusal keeps the browser path.
722+
* ⛔ The signed URL is a credential, so it never crosses the bridge.
723+
*/
724+
describe('RecordAttachmentsPanel — mobile shell openFile', () => {
725+
const GLOBAL = 'ObjectStackNative';
726+
const SIGNED = '/api/v1/storage/_local/raw/tok123';
727+
728+
function installShell(opts: { capabilities?: string[]; refuse?: boolean } = {}) {
729+
const capabilities = opts.capabilities ?? ['openFile', 'share'];
730+
const openFile = vi.fn(async (_file: { base64: string; name: string; mime: string }) => {
731+
if (opts.refuse) throw Object.assign(new Error('Files cross the bridge up to 8 MB'), { code: 'bad_args' });
732+
return null;
733+
});
734+
const host = {
735+
version: 1,
736+
platform: 'ios',
737+
appVersion: '2.2.0',
738+
capabilities,
739+
safeArea: { top: 0, bottom: 0, left: 0, right: 0 },
740+
has: (c: string) => capabilities.includes(c),
741+
openFile,
742+
openExternal: vi.fn(async () => null),
743+
share: vi.fn(async () => null),
744+
ready: vi.fn(),
745+
routeChanged: vi.fn(),
746+
};
747+
(window as unknown as Record<string, unknown>)[GLOBAL] = host;
748+
return host;
749+
}
750+
751+
/** `/files/:id/url` answers the signed URL; the signed URL answers `bytes`. */
752+
function mockStorage(bytes: BlobPart = 'hello', type = 'application/pdf', bytesStatus = 200) {
753+
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
754+
const url = String(input instanceof Request ? input.url : input);
755+
if (url.includes('/api/v1/storage/files/f1/url')) {
756+
return new Response(JSON.stringify({ success: true, data: { url: SIGNED } }), {
757+
status: 200,
758+
headers: { 'Content-Type': 'application/json' },
759+
});
760+
}
761+
if (url.endsWith(SIGNED)) return new Response(new Blob([bytes], { type }), { status: bytesStatus });
762+
throw new Error(`unexpected fetch ${url}`);
763+
});
764+
}
765+
766+
async function tapDownload(rows: unknown[] = [ROW]) {
767+
setup(makeDataSource({ find: vi.fn(async () => rows) }));
768+
await waitFor(() => expect(screen.getByText('report.pdf')).toBeInTheDocument());
769+
await userEvent.setup().click(screen.getByRole('button', { name: 'Download' }));
770+
}
771+
772+
afterEach(() => {
773+
delete (window as unknown as Record<string, unknown>)[GLOBAL];
774+
});
775+
776+
it('hands the fetched bytes to openFile, never the URL, and opens no tab', async () => {
777+
const host = installShell();
778+
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
779+
const fetchSpy = mockStorage();
780+
await tapDownload();
781+
782+
await waitFor(() => expect(host.openFile).toHaveBeenCalledTimes(1));
783+
// "hello" in standard padded base64; the bridge file has exactly these keys.
784+
expect(host.openFile.mock.calls[0][0]).toEqual({ base64: 'aGVsbG8=', name: 'report.pdf', mime: 'application/pdf' });
785+
expect(fetchSpy.mock.calls.map((c) => String(c[0]))).toEqual([
786+
expect.stringContaining('/api/v1/storage/files/f1/url'),
787+
expect.stringContaining(SIGNED),
788+
]);
789+
expect(JSON.stringify(host.openFile.mock.calls)).not.toContain('tok123');
790+
expect(host.openExternal).not.toHaveBeenCalled();
791+
expect(host.share).not.toHaveBeenCalled();
792+
expect(openSpy).not.toHaveBeenCalled();
793+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
794+
});
795+
796+
it('a row over 8 MiB keeps the browser path without fetching the bytes', async () => {
797+
const host = installShell();
798+
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
799+
const fetchSpy = mockStorage();
800+
await tapDownload([{ ...ROW, size: 8 * 1024 * 1024 + 1 }]);
801+
802+
await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer'));
803+
expect(fetchSpy).toHaveBeenCalledTimes(1);
804+
expect(host.openFile).not.toHaveBeenCalled();
805+
});
806+
807+
it('fetched bytes over 8 MiB keep the browser path', async () => {
808+
const host = installShell();
809+
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
810+
mockStorage(new Uint8Array(8 * 1024 * 1024 + 1));
811+
await tapDownload([{ ...ROW, size: null }]);
812+
813+
await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer'));
814+
expect(host.openFile).not.toHaveBeenCalled();
815+
});
816+
817+
it('a bad_args refusal keeps the browser path', async () => {
818+
const host = installShell({ refuse: true });
819+
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
820+
mockStorage();
821+
await tapDownload();
822+
823+
await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer'));
824+
expect(host.openFile).toHaveBeenCalledTimes(1);
825+
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
826+
});
827+
828+
it('a failed byte fetch keeps the browser path', async () => {
829+
const host = installShell();
830+
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
831+
mockStorage('nope', 'text/plain', 403);
832+
await tapDownload();
833+
834+
await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer'));
835+
expect(host.openFile).not.toHaveBeenCalled();
836+
});
837+
838+
it('CONTROL: an older shell without openFile fetches no bytes and opens the tab as today', async () => {
839+
const host = installShell({ capabilities: ['share'] });
840+
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
841+
const fetchSpy = mockStorage();
842+
await tapDownload();
843+
844+
await waitFor(() => expect(openSpy).toHaveBeenCalledWith(expect.stringContaining(SIGNED), '_blank', 'noopener,noreferrer'));
845+
expect(fetchSpy).toHaveBeenCalledTimes(1);
846+
expect(host.openFile).not.toHaveBeenCalled();
847+
});
848+
});

0 commit comments

Comments
 (0)