import { Share } from 'react-native'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { HOST_BRIDGE_PROTOCOL, HOST_BRIDGE_VERSION, type HostBridgeRequest, } from '../../../../packages/shared/src/contracts/hostBridge'; import { openShare, resetMobileHostBridgeShareTargetForTest, setMobileHostBridgeShareTarget, } from './share'; vi.mock('react-native', () => ({ Share: { share: vi.fn(), }, })); function request( method: 'share.open' | 'share.setTarget', payload?: unknown, ): HostBridgeRequest { return { bridge: HOST_BRIDGE_PROTOCOL, version: HOST_BRIDGE_VERSION, id: `${method}-request`, method, payload, }; } beforeEach(() => { vi.mocked(Share.share).mockReset(); resetMobileHostBridgeShareTargetForTest(); }); describe('mobile share helpers', () => { test('opens the native share sheet with normalized explicit payload', async () => { const response = await openShare( request('share.open', { title: '测试作品', message: '来玩这个作品', url: 'https://app.genarrative.world/works/detail?work=PZ-1', }), ); expect(response).toEqual({ bridge: HOST_BRIDGE_PROTOCOL, version: HOST_BRIDGE_VERSION, id: 'share.open-request', ok: true, result: true, }); expect(Share.share).toHaveBeenCalledWith({ title: '测试作品', message: '来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-1', url: 'https://app.genarrative.world/works/detail?work=PZ-1', }); }); test('uses cached work target when share.open has no explicit payload', async () => { const targetResponse = setMobileHostBridgeShareTarget( request('share.setTarget', { target: { type: 'genarrative:share-target', payload: { title: '暖灯猫街', message: '来玩这个作品', work: 'PZ-00000001', }, }, }), ); expect(targetResponse).toEqual({ bridge: HOST_BRIDGE_PROTOCOL, version: HOST_BRIDGE_VERSION, id: 'share.setTarget-request', ok: true, result: true, }); await expect(openShare(request('share.open'))).resolves.toMatchObject({ ok: true, result: true, }); expect(Share.share).toHaveBeenCalledWith({ title: '暖灯猫街', message: '来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-00000001', url: 'https://app.genarrative.world/works/detail?work=PZ-00000001', }); }); test('keeps the previous cached target when a new target is missing or invalid', async () => { setMobileHostBridgeShareTarget( request('share.setTarget', { target: { type: 'genarrative:share-target', payload: { title: '暖灯猫街', work: 'PZ-00000001', }, }, }), ); expect(() => setMobileHostBridgeShareTarget(request('share.setTarget', {}))) .toThrowError('target is required'); expect(() => setMobileHostBridgeShareTarget( request('share.setTarget', { target: {}, }), ), ).toThrowError('share target is required'); expect(() => setMobileHostBridgeShareTarget( request('share.setTarget', { target: { title: '危险作品', url: 'https://example.com/works/detail?work=PZ-1', }, }), ), ).toThrowError('share target is invalid'); await expect(openShare(request('share.open'))).resolves.toMatchObject({ ok: true, result: true, }); expect(Share.share).toHaveBeenCalledWith({ title: '暖灯猫街', message: 'https://app.genarrative.world/works/detail?work=PZ-00000001', url: 'https://app.genarrative.world/works/detail?work=PZ-00000001', }); }); test('normalizes same-origin paths into public share URLs', async () => { await expect( openShare( request('share.open', { title: '测试作品', path: '/works/detail?work=PZ-2#play', }), ), ).resolves.toMatchObject({ ok: true, result: true, }); expect(Share.share).toHaveBeenCalledWith({ title: '测试作品', message: 'https://app.genarrative.world/works/detail?work=PZ-2#play', url: 'https://app.genarrative.world/works/detail?work=PZ-2#play', }); }); test('maps native share sheet failures to stable host errors', async () => { vi.mocked(Share.share).mockRejectedValueOnce(new Error('native share failed')); await expect( openShare( request('share.open', { title: '测试作品', url: 'https://app.genarrative.world/works/detail?work=PZ-1', }), ), ).rejects.toMatchObject({ code: 'host_error', message: 'share unavailable', }); }); test.each([ 'https://example.com/works/detail?work=PZ-1', '//example.com/works/detail?work=PZ-1', '//app.genarrative.world/works/detail?work=PZ-1', 'javascript:alert(1)', ])('rejects unsafe explicit share URLs: %s', async (url) => { await expect( openShare( request('share.open', { title: '测试作品', url, }), ), ).rejects.toMatchObject({ code: 'invalid_request', message: 'share target is invalid', }); expect(Share.share).not.toHaveBeenCalled(); }); test('does not fall back to cached target when explicit payload is unsafe', async () => { setMobileHostBridgeShareTarget( request('share.setTarget', { target: { type: 'genarrative:share-target', payload: { title: '暖灯猫街', work: 'PZ-00000001', }, }, }), ); await expect( openShare( request('share.open', { title: '测试作品', url: 'https://example.com/works/detail?work=PZ-1', }), ), ).rejects.toMatchObject({ code: 'invalid_request', message: 'share target is invalid', }); expect(Share.share).not.toHaveBeenCalled(); }); test('rejects empty share requests before opening native share sheet', async () => { await expect(openShare(request('share.open', {}))).rejects.toMatchObject({ code: 'invalid_request', message: 'share target is required', }); expect(Share.share).not.toHaveBeenCalled(); }); });