From 0ef7bae78c29aba898ac574b141ead5f09407780 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 20 Jun 2026 14:40:41 +0800 Subject: [PATCH] feat(artifact): add HTTP client with body-error parsing Co-Authored-By: glm-5.2 --- .../ArtifactTool/__tests__/client.test.ts | 109 ++++++++++++++++++ .../src/tools/ArtifactTool/client.ts | 59 ++++++++++ 2 files changed, 168 insertions(+) create mode 100644 packages/builtin-tools/src/tools/ArtifactTool/__tests__/client.test.ts create mode 100644 packages/builtin-tools/src/tools/ArtifactTool/client.ts diff --git a/packages/builtin-tools/src/tools/ArtifactTool/__tests__/client.test.ts b/packages/builtin-tools/src/tools/ArtifactTool/__tests__/client.test.ts new file mode 100644 index 000000000..9573742c9 --- /dev/null +++ b/packages/builtin-tools/src/tools/ArtifactTool/__tests__/client.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { uploadArtifact } from '../client.js' + +const originalFetch = globalThis.fetch + +function mockFetch(body: object, status = 200): typeof fetch { + return mock((_url: string | URL | Request, _init?: RequestInit) => + Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) as unknown as typeof fetch +} + +describe('uploadArtifact', () => { + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('returns id/url/expiresAt on successful upload', async () => { + globalThis.fetch = mockFetch({ + id: 'V1StGXR8_Z5jdHi6B', + url: 'https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B.html', + expiresAt: '2026-06-27T10:00:00.000Z', + }) + + const result = await uploadArtifact({ + html: '

hello

', + token: 'test-token', + uploadUrl: 'https://example.test/upload', + }) + + expect(result).toEqual({ + id: 'V1StGXR8_Z5jdHi6B', + url: 'https://cloud-artifacts.claude-code-best.win/7d/V1StGXR8_Z5jdHi6B.html', + expiresAt: '2026-06-27T10:00:00.000Z', + }) + }) + + test('passes hash as query param when provided', async () => { + const fetchMock = mockFetch({ + id: 'my-id', + url: 'https://x/y.html', + expiresAt: '2026-06-27T00:00:00.000Z', + }) + globalThis.fetch = fetchMock + + await uploadArtifact({ + html: '

x

', + token: 't', + uploadUrl: 'https://example.test/upload', + hash: 'my-id', + }) + + const calledUrl = ( + fetchMock as unknown as { mock: { calls: [string | URL | Request][] } } + ).mock.calls[0][0] + expect(calledUrl.toString()).toContain('hash=my-id') + }) + + test('passes ttl=30 query param when provided', async () => { + const fetchMock = mockFetch({ + id: 'x', + url: 'https://x', + expiresAt: '2026-07-20T00:00:00.000Z', + }) + globalThis.fetch = fetchMock + + await uploadArtifact({ + html: '

x

', + token: 't', + uploadUrl: 'https://example.test/upload', + ttl: 30, + }) + + const calledUrl = ( + fetchMock as unknown as { mock: { calls: [string | URL | Request][] } } + ).mock.calls[0][0] + expect(calledUrl.toString()).toContain('ttl=30') + }) + + test('throws with error code when body contains {error} (Deno Deploy flattens status)', async () => { + globalThis.fetch = mockFetch({ error: 'payload_too_large' }, 200) + + await expect( + uploadArtifact({ + html: 'x'.repeat(100), + token: 't', + uploadUrl: 'https://example.test/upload', + }), + ).rejects.toThrow(/payload_too_large/) + }) + + test('throws on non-JSON body', async () => { + globalThis.fetch = mock((_u: string | URL | Request) => + Promise.resolve(new Response('Internal Server Error', { status: 500 })), + ) as unknown as typeof fetch + + await expect( + uploadArtifact({ + html: '

', + token: 't', + uploadUrl: 'https://example.test/upload', + }), + ).rejects.toThrow() + }) +}) diff --git a/packages/builtin-tools/src/tools/ArtifactTool/client.ts b/packages/builtin-tools/src/tools/ArtifactTool/client.ts new file mode 100644 index 000000000..83c7b08a6 --- /dev/null +++ b/packages/builtin-tools/src/tools/ArtifactTool/client.ts @@ -0,0 +1,59 @@ +export type UploadResult = { + id: string + url: string + expiresAt: string +} + +export type UploadParams = { + html: string + token: string + uploadUrl: string + hash?: string + ttl?: 7 | 30 +} + +export async function uploadArtifact( + params: UploadParams, +): Promise { + const url = new URL(params.uploadUrl) + if (params.hash) url.searchParams.set('hash', params.hash) + if (params.ttl) url.searchParams.set('ttl', String(params.ttl)) + + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${params.token}`, + 'Content-Type': 'text/html', + }, + body: params.html, + }) + + // Deno Deploy proxy flattens upstream status to 200; the Worker embeds the + // real error in the body as `{ "error": "" }`. Always parse body first. + const text = await response.text() + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw new Error( + `Artifact upload failed: HTTP ${response.status} (non-JSON body)`, + ) + } + + if (parsed && typeof parsed === 'object' && 'error' in parsed) { + const code = (parsed as { error: unknown }).error + throw new Error(`Artifact upload failed: ${String(code)}`) + } + + const data = parsed as Partial + if ( + typeof data.id !== 'string' || + typeof data.url !== 'string' || + typeof data.expiresAt !== 'string' + ) { + throw new Error( + `Artifact upload returned malformed body: ${text.slice(0, 200)}`, + ) + } + return { id: data.id, url: data.url, expiresAt: data.expiresAt } +}