v1.3.2: add request context and generation-only binding
This commit is contained in:
+250
-1
@@ -167,6 +167,153 @@ result = await requestInterceptorClient.request("Svc", "interceptors", { value:
|
||||
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"); });
|
||||
@@ -299,6 +446,81 @@ streamHookClient.addResponseInterceptor((_s, _m, value) => { normalized.push(val
|
||||
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 {
|
||||
@@ -320,8 +542,35 @@ func TestTypeScriptClientStrictTypecheck(t *testing.T) {
|
||||
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<any>) => 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)
|
||||
"--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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user