package fun import ( "os" "os/exec" "path/filepath" "strings" "testing" ) func TestTypeScriptClientBehavior(t *testing.T) { node, err := exec.LookPath("node") if err != nil { t.Skip("node is not installed") } probe := exec.Command(node, "--experimental-strip-types", "--input-type=module", "-e", `if (typeof fetch !== "function" || typeof ReadableStream !== "function") process.exit(1)`) if err := probe.Run(); err != nil { t.Skip("node does not support TypeScript stripping and web streams") } dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "client.ts"), []byte(templateTs{}.genClientTemplate()), 0o644); err != nil { t.Fatal(err) } const script = ` import assert from "node:assert/strict"; import { Client } from "./client.ts"; const json = (value, status = 200) => new Response(JSON.stringify(value), { status, headers: { "Content-Type": "application/json" }, }); const ndjson = body => new Response(body, { headers: { "Content-Type": "application/x-ndjson; charset=utf-8" }, }); const client = new Client("http://example.test///"); const seen = []; let cleanupCheck; client.addResponseInterceptor((_service, _method, result) => { if (cleanupCheck) { assert.equal(cleanupCheck(), true); cleanupCheck = undefined; } seen.push(result.status); }); const expectSeen = status => assert.deepEqual(seen.splice(0), [status]); let fetchInit; globalThis.fetch = async (url, init) => { fetchInit = { url, init }; return json({ status: 0, data: "ok" }); }; let result = await client.request("Svc", "fetchShape", undefined, { signal: new AbortController().signal }); assert.equal(result.status, 0); assert.equal(fetchInit.url, "http://example.test/cell"); assert.equal(fetchInit.init.method, "POST"); assert.equal(fetchInit.init.headers["Content-Type"], "application/json"); assert.ok(fetchInit.init.signal); assert.deepEqual(JSON.parse(fetchInit.init.body), { serviceName: "Svc", methodName: "fetchShape" }); expectSeen(0); globalThis.fetch = async () => json({ status: 2, code: 4001, msg: "business" }, 503); result = await client.request("Svc", "business"); assert.deepEqual(result, { status: 2, code: 4001, msg: "business" }); expectSeen(2); globalThis.fetch = async () => json({ status: 1, msg: "framework" }, 500); result = await client.request("Svc", "framework"); assert.deepEqual(result, { status: 1, msg: "framework" }); expectSeen(1); globalThis.fetch = async () => new Response("gateway timeout", { status: 504 }); result = await client.request("Svc", "gatewayTimeout"); assert.equal(result.status, 5); assert.match(result.msg, /504/); expectSeen(5); globalThis.fetch = async () => new Response("request timeout", { status: 408 }); result = await client.request("Svc", "gateway408"); assert.equal(result.status, 5); expectSeen(5); globalThis.fetch = async () => json({ status: 5, msg: "server timeout" }); result = await client.request("Svc", "serverTimeout"); assert.equal(result.status, 5); assert.equal(result.msg, "server timeout"); expectSeen(5); globalThis.fetch = async () => new Response("bad gateway", { status: 502 }); result = await client.request("Svc", "gateway"); assert.equal(result.status, 4); expectSeen(4); globalThis.fetch = async () => new Response("", { status: 200 }); result = await client.request("Svc", "empty"); assert.equal(result.status, 1); assert.match(result.msg, /Empty/); expectSeen(1); globalThis.fetch = async () => new Response("not-json", { status: 200 }); result = await client.request("Svc", "invalidJson"); assert.equal(result.status, 1); assert.match(result.msg, /Invalid JSON/); expectSeen(1); globalThis.fetch = async () => new Response("login", { status: 200, headers: { "Content-Type": "text/html" }, }); result = await client.request("Svc", "html"); assert.equal(result.status, 1); assert.match(result.msg, /HTML/); expectSeen(1); globalThis.fetch = async () => json({ data: 1 }); result = await client.request("Svc", "invalidResult"); assert.equal(result.status, 1); expectSeen(1); globalThis.fetch = async () => { throw new TypeError("DNS failed"); }; result = await client.request("Svc", "network"); assert.equal(result.status, 4); assert.match(result.msg, /DNS failed/); expectSeen(4); globalThis.fetch = async () => new Response(new ReadableStream({ pull(controller) { controller.error(new Error("body read failed")); }, })); result = await client.request("Svc", "bodyRead"); assert.equal(result.status, 4); assert.match(result.msg, /body read failed/); expectSeen(4); const manualAbort = new AbortController(); manualAbort.abort(); globalThis.fetch = async () => { throw new DOMException("aborted", "AbortError"); }; result = await client.request("Svc", "abort", undefined, { signal: manualAbort.signal }); assert.equal(result.status, 4); assert.match(result.msg, /aborted/i); expectSeen(4); const timeoutAbort = new AbortController(); timeoutAbort.abort(new DOMException("timed out", "TimeoutError")); result = await client.request("Svc", "timeoutSignal", undefined, { signal: timeoutAbort.signal }); assert.equal(result.status, 5); expectSeen(5); globalThis.fetch = async () => { throw new DOMException("timed out", "TimeoutError"); }; result = await client.request("Svc", "timeoutError"); assert.equal(result.status, 5); expectSeen(5); const requestInterceptorClient = new Client("http://example.test"); let requestInterceptorSeen; requestInterceptorClient.addRequestInterceptor((_service, _method, state, dto) => { state.token = "abc"; requestInterceptorSeen = dto; }); requestInterceptorClient.addResponseInterceptor((_service, _method, value) => ({ ...value, msg: "intercepted" })); globalThis.fetch = async (_url, init) => { const payload = JSON.parse(init.body); assert.deepEqual(payload.state, { token: "abc" }); return json({ status: 0 }); }; result = await requestInterceptorClient.request("Svc", "interceptors", { value: 1 }); assert.deepEqual(requestInterceptorSeen, { value: 1 }); assert.equal(result.msg, "intercepted"); const stateClient = new Client("http://example.test"); stateClient.setState({ shared: "global", globalOnly: "yes" }); const stateContexts = new Map(); const nativeResponses = new Map(); const payloads = new Map(); let releaseFirst; const firstMayContinue = new Promise(resolve => { releaseFirst = resolve; }); let firstInterceptorEntered; const firstDidEnter = new Promise(resolve => { firstInterceptorEntered = resolve; }); let firstStateReference; stateClient.addRequestInterceptor(async (_service, method, state, dto) => { state.interceptor = dto.id; if (method === "first") { firstStateReference = state; firstInterceptorEntered(); await firstMayContinue; } state.completed = dto.id; }); stateClient.addResponseInterceptor((_service, method, _value, context) => { stateContexts.set(method, context); }); globalThis.fetch = async (_url, init) => { const payload = JSON.parse(init.body); payloads.set(payload.methodName, payload); const response = json({ status: 0, data: payload.methodName }); nativeResponses.set(payload.methodName, response); return response; }; const firstOverride = { shared: "first", requestOnly: "one" }; const firstRequest = stateClient.request("Svc", "first", { id: "one" }, { state: firstOverride }); await firstDidEnter; firstOverride.shared = "mutated outside"; const secondRequest = stateClient.request("Svc", "second", { id: "two" }, { state: { shared: "second", requestOnly: "two" }, }); assert.equal((await secondRequest).status, 0); releaseFirst(); assert.equal((await firstRequest).status, 0); assert.deepEqual(payloads.get("first").state, { shared: "first", globalOnly: "yes", requestOnly: "one", interceptor: "one", completed: "one", }); assert.deepEqual(payloads.get("second").state, { shared: "second", globalOnly: "yes", requestOnly: "two", interceptor: "two", completed: "two", }); firstStateReference.shared = "mutated after snapshot"; for (const method of ["first", "second"]) { const context = stateContexts.get(method); assert.deepEqual(context.requestState, payloads.get(method).state); assert.equal(Object.isFrozen(context.requestState), true); assert.equal(Object.isFrozen(context), true); assert.equal(context.response, nativeResponses.get(method)); assert.ok(context.response instanceof Response); } assert.equal(stateContexts.get("first").requestState.shared, "first"); const throwingState = Object.defineProperty({}, "token", { enumerable: true, get() { throw new Error("state read failed"); }, }); const stateFailureClient = new Client("http://example.test"); let stateFailureSeen = []; stateFailureClient.addResponseInterceptor((_service, _method, value, context) => { stateFailureSeen.push([value.status, context.requestState]); }); globalThis.fetch = async () => assert.fail("fetch must not run for state failure"); result = await stateFailureClient.request("Svc", "requestStateFailure", undefined, { state: throwingState }); assert.equal(result.status, 1); assert.match(result.msg, /prepare request state/i); result = await stateFailureClient.stream("Svc", "streamStateFailure", undefined, () => {}, { state: throwingState }); assert.equal(result.status, 1); assert.match(result.msg, /prepare request state/i); assert.deepEqual(stateFailureSeen, [[1, {}], [1, {}]]); const snapshotFailureClient = new Client("http://example.test"); snapshotFailureClient.addRequestInterceptor((_service, _method, state) => { Object.defineProperty(state, "broken", { enumerable: true, get() { throw new Error("snapshot read failed"); }, }); }); globalThis.fetch = async () => assert.fail("fetch must not run for snapshot failure"); result = await snapshotFailureClient.request("Svc", "requestSnapshotFailure"); assert.equal(result.status, 1); assert.match(result.msg, /snapshot request state/i); result = await snapshotFailureClient.stream("Svc", "streamSnapshotFailure", undefined, () => {}); assert.equal(result.status, 1); assert.match(result.msg, /snapshot request state/i); const contextClient = new Client("http://example.test"); contextClient.setState({ base: "global" }); const outcomeContexts = new Map(); contextClient.addResponseInterceptor((_service, method, _value, context) => { outcomeContexts.set(method, context); }); contextClient.addRequestInterceptor((_service, method, state) => { state.method = method; if (method === "requestHookFailure") throw new Error("request context hook"); }); globalThis.fetch = async () => assert.fail("fetch must not run before serialization"); const contextCyclic = {}; contextCyclic.self = contextCyclic; result = await contextClient.request("Svc", "serializeContext", contextCyclic, { state: { base: "request" } }); assert.equal(result.status, 1); assert.deepEqual(outcomeContexts.get("serializeContext").requestState, { base: "request", method: "serializeContext", }); assert.equal(outcomeContexts.get("serializeContext").response, undefined); result = await contextClient.request("Svc", "requestHookFailure", undefined, { state: { request: "hook" } }); assert.equal(result.status, 1); assert.deepEqual(outcomeContexts.get("requestHookFailure").requestState, { base: "global", request: "hook", method: "requestHookFailure", }); assert.equal(outcomeContexts.get("requestHookFailure").response, undefined); globalThis.fetch = async () => { throw new TypeError("offline"); }; result = await contextClient.request("Svc", "networkContext", undefined, { state: { request: "network" } }); assert.equal(result.status, 4); assert.deepEqual(outcomeContexts.get("networkContext").requestState, { base: "global", request: "network", method: "networkContext", }); assert.equal(outcomeContexts.get("networkContext").response, undefined); const bodyReadResponse = new Response(new ReadableStream({ pull(controller) { controller.error(new Error("context body read failed")); }, })); globalThis.fetch = async () => bodyReadResponse; result = await contextClient.request("Svc", "bodyReadContext", undefined, { state: { request: "body" } }); assert.equal(result.status, 4); assert.deepEqual(outcomeContexts.get("bodyReadContext").requestState, { base: "global", request: "body", method: "bodyReadContext", }); assert.equal(outcomeContexts.get("bodyReadContext").response, bodyReadResponse); const failingRequestInterceptor = new Client("http://example.test"); let normalized = []; failingRequestInterceptor.addRequestInterceptor(() => { throw new Error("request hook"); }); failingRequestInterceptor.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); }); result = await failingRequestInterceptor.request("Svc", "hook"); assert.equal(result.status, 1); assert.match(result.msg, /request hook/); assert.deepEqual(normalized, [1]); const failingResponseInterceptor = new Client("http://example.test"); normalized = []; failingResponseInterceptor.addResponseInterceptor(() => { throw new Error("response hook"); }); failingResponseInterceptor.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); }); globalThis.fetch = async () => json({ status: 2, code: 4003, msg: "original" }); result = await failingResponseInterceptor.request("Svc", "responseHook"); assert.equal(result.status, 1); assert.match(result.msg, /response hook/); assert.deepEqual(normalized, [1]); const cyclic = {}; cyclic.self = cyclic; globalThis.fetch = async () => assert.fail("fetch must not run for serialization failure"); result = await client.request("Svc", "serialize", cyclic); assert.equal(result.status, 1); assert.match(result.msg, /serialize/i); expectSeen(1); globalThis.fetch = async () => json({ status: 2, code: 4002, msg: "stream business" }, 422); result = await client.stream("Svc", "streamBusiness", undefined, () => {}); assert.deepEqual(result, { status: 2, code: 4002, msg: "stream business" }); expectSeen(2); globalThis.fetch = async () => json({ status: 0 }); result = await client.stream("Svc", "wrongMedia", undefined, () => {}); assert.equal(result.status, 1); assert.match(result.msg, /x-ndjson/); expectSeen(1); const bytes = new TextEncoder().encode('{"text":"你好"}\r\n\n{"n":2}'); const split = bytes.indexOf(0xe5) + 1; globalThis.fetch = async () => new Response(new ReadableStream({ start(controller) { controller.enqueue(bytes.slice(0, split)); controller.enqueue(bytes.slice(split)); controller.close(); }, }), { headers: { "Content-Type": "application/x-ndjson; charset=utf-8" } }); const messages = []; result = await client.stream("Svc", "valid", undefined, value => messages.push(value)); assert.equal(result.status, 0); assert.deepEqual(messages, [{ text: "你好" }, { n: 2 }]); expectSeen(0); let malformedCancelled = false; globalThis.fetch = async () => new Response(new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode("bad\n")); }, cancel() { malformedCancelled = true; }, }), { headers: { "Content-Type": "application/x-ndjson" } }); cleanupCheck = () => malformedCancelled; result = await client.stream("Svc", "malformed", undefined, () => {}); assert.equal(result.status, 1); assert.match(result.msg, /line 1/); assert.equal(malformedCancelled, true); expectSeen(1); globalThis.fetch = async () => ndjson('{"n":1}\n'); result = await client.stream("Svc", "callback", undefined, () => { throw new Error("callback boom"); }); assert.equal(result.status, 1); assert.match(result.msg, /callback boom/); expectSeen(1); globalThis.fetch = async () => new Response(new ReadableStream({ pull(controller) { controller.error(new Error("read boom")); }, }), { headers: { "Content-Type": "application/x-ndjson" } }); result = await client.stream("Svc", "read", undefined, () => {}); assert.equal(result.status, 4); assert.match(result.msg, /read boom/); expectSeen(4); globalThis.fetch = async () => ndjson(new Uint8Array([0xff, 0x0a])); result = await client.stream("Svc", "utf8", undefined, () => {}); assert.equal(result.status, 1); assert.match(result.msg, /UTF-8/); expectSeen(1); globalThis.fetch = async () => ndjson(""); result = await client.stream("Svc", "emptyStream", undefined, () => assert.fail("empty stream callback")); assert.deepEqual(result, { status: 0 }); expectSeen(0); globalThis.fetch = async () => new Response(null, { headers: { "Content-Type": "application/x-ndjson" }, }); result = await client.stream("Svc", "nullBody", undefined, () => assert.fail("null stream callback")); assert.deepEqual(result, { status: 0 }); expectSeen(0); const streamAbort = new AbortController(); streamAbort.abort(); globalThis.fetch = async () => { throw new DOMException("aborted", "AbortError"); }; result = await client.stream("Svc", "abortStream", undefined, () => {}, { signal: streamAbort.signal }); assert.equal(result.status, 4); expectSeen(4); const readAbort = new AbortController(); globalThis.fetch = async () => new Response(new ReadableStream({ start(controller) { readAbort.signal.addEventListener("abort", () => controller.error(new DOMException("aborted", "AbortError")), { once: true }); readAbort.abort(); }, }), { headers: { "Content-Type": "application/x-ndjson" } }); result = await client.stream("Svc", "readAbort", undefined, () => {}, { signal: readAbort.signal }); assert.equal(result.status, 4); assert.match(result.msg, /aborted/i); expectSeen(4); const readTimeout = new AbortController(); readTimeout.abort(new DOMException("timed out", "TimeoutError")); globalThis.fetch = async () => new Response(new ReadableStream({ pull(controller) { controller.error(new DOMException("aborted", "AbortError")); }, }), { headers: { "Content-Type": "application/x-ndjson" } }); result = await client.stream("Svc", "timeoutStream", undefined, () => {}, { signal: readTimeout.signal }); assert.equal(result.status, 5); expectSeen(5); const streamHookClient = new Client("http://example.test"); normalized = []; streamHookClient.addRequestInterceptor(() => { throw new Error("stream hook"); }); streamHookClient.addResponseInterceptor((_s, _m, value) => { normalized.push(value.status); }); result = await streamHookClient.stream("Svc", "hook", undefined, () => {}); assert.equal(result.status, 1); assert.deepEqual(normalized, [1]); const streamStateClient = new Client("http://example.test"); streamStateClient.setState({ shared: "global", globalOnly: "stream" }); const streamContexts = new Map(); const streamResponses = new Map(); const streamPayloads = new Map(); streamStateClient.addRequestInterceptor((_service, method, state) => { state.interceptor = method; if (method === "streamHookFailure") throw new Error("stream context hook"); }); streamStateClient.addResponseInterceptor((_service, method, _value, context) => { streamContexts.set(method, context); }); globalThis.fetch = async (_url, init) => { const payload = JSON.parse(init.body); streamPayloads.set(payload.methodName, payload); if (payload.methodName === "streamNetwork") throw new TypeError("stream offline"); const response = payload.methodName === "streamWrongMedia" ? json({ status: 0 }) : payload.methodName === "streamReadFailure" ? new Response(new ReadableStream({ pull(controller) { controller.error(new Error("stream context read failed")); }, }), { headers: { "Content-Type": "application/x-ndjson" } }) : ndjson('{"n":1}\n'); streamResponses.set(payload.methodName, response); return response; }; result = await streamStateClient.stream("Svc", "streamSuccess", undefined, () => {}, { state: { shared: "request", requestOnly: "success" }, }); assert.equal(result.status, 0); result = await streamStateClient.stream("Svc", "streamWrongMedia", undefined, () => {}, { state: { shared: "wrong-media" }, }); assert.equal(result.status, 1); result = await streamStateClient.stream("Svc", "streamNetwork", undefined, () => {}, { state: { shared: "network" }, }); assert.equal(result.status, 4); result = await streamStateClient.stream("Svc", "streamReadFailure", undefined, () => {}, { state: { shared: "read" }, }); assert.equal(result.status, 4); const streamCyclic = {}; streamCyclic.self = streamCyclic; result = await streamStateClient.stream("Svc", "streamSerialize", streamCyclic, () => {}, { state: { shared: "serialize" }, }); assert.equal(result.status, 1); result = await streamStateClient.stream("Svc", "streamHookFailure", undefined, () => {}, { state: { shared: "hook" }, }); assert.equal(result.status, 1); for (const method of ["streamSuccess", "streamWrongMedia", "streamNetwork", "streamReadFailure"]) { const context = streamContexts.get(method); assert.deepEqual(context.requestState, streamPayloads.get(method).state); assert.equal(Object.isFrozen(context.requestState), true); assert.equal(Object.isFrozen(context), true); } assert.equal(streamContexts.get("streamSuccess").response, streamResponses.get("streamSuccess")); assert.equal(streamContexts.get("streamWrongMedia").response, streamResponses.get("streamWrongMedia")); assert.equal(streamContexts.get("streamReadFailure").response, streamResponses.get("streamReadFailure")); assert.equal(streamContexts.get("streamNetwork").response, undefined); assert.deepEqual(streamContexts.get("streamSerialize").requestState, { shared: "serialize", globalOnly: "stream", interceptor: "streamSerialize", }); assert.equal(streamContexts.get("streamSerialize").response, undefined); assert.deepEqual(streamContexts.get("streamHookFailure").requestState, { shared: "hook", globalOnly: "stream", interceptor: "streamHookFailure", }); assert.equal(streamContexts.get("streamHookFailure").response, undefined); ` scriptPath := filepath.Join(dir, "behavior.mjs") if err := os.WriteFile(scriptPath, []byte(script), 0o644); err != nil { t.Fatal(err) } command := exec.Command(node, "--experimental-strip-types", scriptPath) if output, err := command.CombinedOutput(); err != nil { t.Fatalf("TypeScript client behavior failed: %v\n%s", err, output) } } func TestTypeScriptClientStrictTypecheck(t *testing.T) { tsc, err := exec.LookPath("tsc") if err != nil { t.Skip("tsc is not installed") } dir := t.TempDir() path := filepath.Join(dir, "client.ts") if err := os.WriteFile(path, []byte(templateTs{}.genClientTemplate()), 0o644); err != nil { t.Fatal(err) } usagePath := filepath.Join(dir, "usage.ts") const usage = `import { Client, type ContextResponseInterceptor, type RequestOptions, type ResponseContext, type ResponseInterceptor, type StreamOptions } from "./client"; const requestOptions: RequestOptions = { state: { token: "request" } }; const streamOptions: StreamOptions = { state: { token: "stream" } }; const legacy: ResponseInterceptor = (_service, _method, result) => result; const legacyConsumer: (service: string, method: string, result: import("./client").result) => unknown = legacy; const current: ContextResponseInterceptor = (_service, _method, result, context) => { const token: string | undefined = context.requestState.token; const response: Response | undefined = context.response; void token; void response; return result; }; void legacyConsumer; const context = {} as ResponseContext; // @ts-expect-error requestState is readonly context.requestState.token = "changed"; const client = new Client("/"); client.addResponseInterceptor(legacy); client.addResponseInterceptor(current); void client.request("Svc", "method", undefined, requestOptions); void client.stream("Svc", "method", undefined, () => {}, streamOptions); ` if err := os.WriteFile(usagePath, []byte(usage), 0o644); err != nil { t.Fatal(err) } command := exec.Command(tsc, "--strict", "--noEmit", "--target", "ES2022", "--module", "ESNext", "--lib", "ES2022,DOM", path, usagePath) if output, err := command.CombinedOutput(); err != nil { t.Fatalf("strict TypeScript check failed: %v\n%s", err, output) } } func TestTypeScriptClientSimplicityGate(t *testing.T) { source := templateTs{}.genClientTemplate() + templateTs{}.genServiceTemplate() for _, forbidden := range []string{"RpcError", "httpStatus", "result.aborted", "ClientErrorCode", "setTimeout"} { if strings.Contains(source, forbidden) { t.Errorf("generated TypeScript templates contain forbidden %q", forbidden) } } if !strings.Contains(source, "Promise>") { t.Fatal("stream methods must resolve result") } }