diff --git a/CHANGELOG.md b/CHANGELOG.md index 140791be..3537b38e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +# v1.9.0 (Fri Sep 06 2024) + +#### 🚀 Enhancement + +- Relay client-side fetch requests to the server using the Storybook channel API [#331](https://github.com/chromaui/addon-visual-tests/pull/331) ([@ghengeveld](https://github.com/ghengeveld)) + +#### Authors: 1 + +- Gert Hengeveld ([@ghengeveld](https://github.com/ghengeveld)) + +--- + # v1.8.0 (Thu Aug 29 2024) #### 🚀 Enhancement diff --git a/package.json b/package.json index e4f28dc9..ce03eda7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@chromatic-com/storybook", - "version": "1.8.0", + "version": "1.9.0", "description": "Catch unexpected visual changes & UI bugs in your stories", "keywords": [ "storybook-addons", diff --git a/src/Panel.tsx b/src/Panel.tsx index 572a479a..30d82a48 100644 --- a/src/Panel.tsx +++ b/src/Panel.tsx @@ -11,7 +11,6 @@ import { IS_OFFLINE, IS_OUTDATED, LOCAL_BUILD_PROGRESS, - PANEL_ID, REMOVE_ADDON, TELEMETRY, } from "./constants"; @@ -28,9 +27,10 @@ import { ControlsProvider } from "./screens/VisualTests/ControlsContext"; import { RunBuildProvider } from "./screens/VisualTests/RunBuildContext"; import { VisualTests } from "./screens/VisualTests/VisualTests"; import { GitInfoPayload, LocalBuildProgress, UpdateStatusFunction } from "./types"; -import { client, Provider, useAccessToken } from "./utils/graphQLClient"; +import { createClient, GraphQLClientProvider, useAccessToken } from "./utils/graphQLClient"; import { TelemetryProvider } from "./utils/TelemetryContext"; import { useBuildEvents } from "./utils/useBuildEvents"; +import { useChannelFetch } from "./utils/useChannelFetch"; import { useProjectId } from "./utils/useProjectId"; import { clearSessionState, useSessionState } from "./utils/useSessionState"; import { useSharedState } from "./utils/useSharedState"; @@ -93,8 +93,9 @@ export const Panel = ({ active, api }: PanelProps) => { const trackEvent = useCallback((data: any) => emit(TELEMETRY, data), [emit]); const { isRunning, startBuild, stopBuild } = useBuildEvents({ localBuildProgress, accessToken }); + const fetch = useChannelFetch(); const withProviders = (children: React.ReactNode) => ( - + { - + ); if (!active) { @@ -134,7 +135,6 @@ export const Panel = ({ active, api }: PanelProps) => { if (!accessToken) { return withProviders( ("experimental_serverAPI"); const corePromise = presets.apply("core"); diff --git a/src/utils/ChannelFetch.test.ts b/src/utils/ChannelFetch.test.ts new file mode 100644 index 00000000..62bf9a46 --- /dev/null +++ b/src/utils/ChannelFetch.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { FETCH_ABORTED, FETCH_REQUEST, FETCH_RESPONSE } from "../constants"; +import { ChannelFetch } from "./ChannelFetch"; +import { MockChannel } from "./MockChannel"; + +const resolveAfter = (ms: number, value: any) => + new Promise((resolve) => setTimeout(resolve, ms, value)); + +const rejectAfter = (ms: number, reason: any) => + new Promise((_, reject) => setTimeout(reject, ms, reason)); + +describe("ChannelFetch", () => { + let channel: MockChannel; + + beforeEach(() => { + channel = new MockChannel(); + }); + + it("should handle fetch requests", async () => { + const fetch = vi.fn(() => resolveAfter(100, { headers: [], text: async () => "data" })); + ChannelFetch.subscribe("req", channel, fetch as any); + + channel.emit(FETCH_REQUEST, { + requestId: "req", + input: "https://example.com", + init: { headers: { foo: "bar" } }, + }); + + await vi.waitFor(() => { + expect(fetch).toHaveBeenCalledWith("https://example.com", { + headers: { foo: "bar" }, + signal: expect.any(AbortSignal), + }); + }); + }); + + it("should send fetch responses", async () => { + const fetch = vi.fn(() => resolveAfter(100, { headers: [], text: async () => "data" })); + const instance = ChannelFetch.subscribe("res", channel, fetch as any); + + const promise = new Promise((resolve) => { + channel.on(FETCH_RESPONSE, ({ response, error }) => { + expect(response.body).toBe("data"); + expect(error).toBeUndefined(); + resolve(); + }); + }); + + channel.emit(FETCH_REQUEST, { requestId: "res", input: "https://example.com" }); + await vi.waitFor(() => { + expect(instance.abortControllers.size).toBe(1); + }); + + await promise; + + expect(instance.abortControllers.size).toBe(0); + }); + + it("should send fetch error responses", async () => { + const fetch = vi.fn(() => rejectAfter(100, new Error("oops"))); + const instance = ChannelFetch.subscribe("err", channel, fetch as any); + + const promise = new Promise((resolve) => { + channel.on(FETCH_RESPONSE, ({ response, error }) => { + expect(response).toBeUndefined(); + expect(error).toMatch(/oops/); + resolve(); + }); + }); + + channel.emit(FETCH_REQUEST, { requestId: "err", input: "https://example.com" }); + await vi.waitFor(() => { + expect(instance.abortControllers.size).toBe(1); + }); + + await promise; + expect(instance.abortControllers.size).toBe(0); + }); + + it("should abort fetch requests", async () => { + const fetch = vi.fn((input, init) => new Promise(() => {})); + const instance = ChannelFetch.subscribe("abort", channel, fetch); + + channel.emit(FETCH_REQUEST, { requestId: "abort", input: "https://example.com" }); + await vi.waitFor(() => { + expect(instance.abortControllers.size).toBe(1); + }); + + channel.emit(FETCH_ABORTED, { requestId: "abort" }); + await vi.waitFor(() => { + expect(fetch.mock.lastCall?.[1].signal.aborted).toBe(true); + expect(instance.abortControllers.size).toBe(0); + }); + }); +}); diff --git a/src/utils/ChannelFetch.ts b/src/utils/ChannelFetch.ts new file mode 100644 index 00000000..d6fb1371 --- /dev/null +++ b/src/utils/ChannelFetch.ts @@ -0,0 +1,47 @@ +import type { Channel } from "@storybook/channels"; + +import { FETCH_ABORTED, FETCH_REQUEST, FETCH_RESPONSE } from "../constants"; + +type ChannelLike = Pick; + +const instances = new Map(); + +export class ChannelFetch { + channel: ChannelLike; + + abortControllers: Map; + + constructor(channel: ChannelLike, _fetch = fetch) { + this.channel = channel; + this.abortControllers = new Map(); + + this.channel.on(FETCH_ABORTED, ({ requestId }) => { + this.abortControllers.get(requestId)?.abort(); + this.abortControllers.delete(requestId); + }); + + this.channel.on(FETCH_REQUEST, async ({ requestId, input, init }) => { + const controller = new AbortController(); + this.abortControllers.set(requestId, controller); + + try { + const res = await _fetch(input as RequestInfo, { ...init, signal: controller.signal }); + const body = await res.text(); + const headers = Array.from(res.headers as any); + const response = { body, headers, status: res.status, statusText: res.statusText }; + this.channel.emit(FETCH_RESPONSE, { requestId, response }); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + this.channel.emit(FETCH_RESPONSE, { requestId, error }); + } finally { + this.abortControllers.delete(requestId); + } + }); + } + + static subscribe(key: string, channel: ChannelLike, _fetch = fetch) { + const instance = instances.get(key) || new ChannelFetch(channel, _fetch); + if (!instances.has(key)) instances.set(key, instance); + return instance; + } +} diff --git a/src/utils/MockChannel.ts b/src/utils/MockChannel.ts new file mode 100644 index 00000000..209e4f39 --- /dev/null +++ b/src/utils/MockChannel.ts @@ -0,0 +1,16 @@ +export class MockChannel { + private listeners: Record void)[]> = {}; + + on(event: string, listener: (...args: any[]) => void) { + this.listeners[event] = [...(this.listeners[event] ?? []), listener]; + } + + off(event: string, listener: (...args: any[]) => void) { + this.listeners[event] = (this.listeners[event] ?? []).filter((l) => l !== listener); + } + + emit(event: string, ...args: any[]) { + // setTimeout is used to simulate the asynchronous nature of the real channel + (this.listeners[event] || []).forEach((listener) => setTimeout(() => listener(...args))); + } +} diff --git a/src/utils/SharedState.test.ts b/src/utils/SharedState.test.ts index 7b2d1ecb..ca98b216 100644 --- a/src/utils/SharedState.test.ts +++ b/src/utils/SharedState.test.ts @@ -1,24 +1,8 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { MockChannel } from "./MockChannel"; import { SharedState } from "./SharedState"; -class MockChannel { - private listeners: Record void)[]> = {}; - - on(event: string, listener: (...args: any[]) => void) { - this.listeners[event] = [...(this.listeners[event] ?? []), listener]; - } - - off(event: string, listener: (...args: any[]) => void) { - this.listeners[event] = (this.listeners[event] ?? []).filter((l) => l !== listener); - } - - emit(event: string, ...args: any[]) { - // setTimeout is used to simulate the asynchronous nature of the real channel - (this.listeners[event] || []).forEach((listener) => setTimeout(() => listener(...args))); - } -} - const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); describe("SharedState", () => { diff --git a/src/utils/graphQLClient.tsx b/src/utils/graphQLClient.tsx index 029c5357..d3b7908e 100644 --- a/src/utils/graphQLClient.tsx +++ b/src/utils/graphQLClient.tsx @@ -1,13 +1,11 @@ import { authExchange } from "@urql/exchange-auth"; import React from "react"; import { useAddonState } from "storybook/internal/manager-api"; -import { Client, fetchExchange, mapExchange, Provider } from "urql"; +import { Client, ClientOptions, fetchExchange, mapExchange, Provider } from "urql"; import { v4 as uuid } from "uuid"; import { ACCESS_TOKEN_KEY, ADDON_ID, CHROMATIC_API_URL } from "../constants"; -export { Provider }; - let currentToken: string | null; let currentTokenExpiration: number | null; const setCurrentToken = (token: string | null) => { @@ -56,56 +54,62 @@ export const getFetchOptions = (token?: string) => ({ }, }); -export const client = new Client({ - url: CHROMATIC_API_URL, - exchanges: [ - // We don't use cacheExchange, because it would inadvertently share data between stories. - mapExchange({ - onResult(result) { - // Not all queries contain the `viewer` field, in which case it will be `undefined`. - // When we do retrieve the field but the token is invalid, it will be `null`. - if (result.data?.viewer === null) setCurrentToken(null); - }, - }), - authExchange(async (utils) => { - return { - addAuthToOperation(operation) { - if (!currentToken) return operation; - return utils.appendHeaders(operation, { Authorization: `Bearer ${currentToken}` }); +export const createClient = (options?: Partial) => + new Client({ + url: CHROMATIC_API_URL, + exchanges: [ + // We don't use cacheExchange, because it would inadvertently share data between stories. + mapExchange({ + onResult(result) { + // Not all queries contain the `viewer` field, in which case it will be `undefined`. + // When we do retrieve the field but the token is invalid, it will be `null`. + if (result.data?.viewer === null) setCurrentToken(null); }, + }), + authExchange(async (utils) => { + return { + addAuthToOperation(operation) { + if (!currentToken) return operation; + return utils.appendHeaders(operation, { Authorization: `Bearer ${currentToken}` }); + }, - // Determine if the current error is an authentication error. - didAuthError: (error) => - error.response.status === 401 || - error.graphQLErrors.some((e) => e.message.includes("Must login")), + // Determine if the current error is an authentication error. + didAuthError: (error) => + error.response.status === 401 || + error.graphQLErrors.some((e) => e.message.includes("Must login")), - // If didAuthError returns true, clear the token. Ideally we should refresh the token here. - // The operation will be retried automatically. - async refreshAuth() { - setCurrentToken(null); - }, + // If didAuthError returns true, clear the token. Ideally we should refresh the token here. + // The operation will be retried automatically. + async refreshAuth() { + setCurrentToken(null); + }, - // Prevent making a request if we know the token is missing, invalid or expired. - // This handler is called repeatedly so we avoid parsing the token each time. - willAuthError() { - if (!currentToken) return true; - try { - if (!currentTokenExpiration) { - const { exp } = JSON.parse(atob(currentToken.split(".")[1])); - currentTokenExpiration = exp; + // Prevent making a request if we know the token is missing, invalid or expired. + // This handler is called repeatedly so we avoid parsing the token each time. + willAuthError() { + if (!currentToken) return true; + try { + if (!currentTokenExpiration) { + const { exp } = JSON.parse(atob(currentToken.split(".")[1])); + currentTokenExpiration = exp; + } + return Date.now() / 1000 > (currentTokenExpiration || 0); + } catch (e) { + return true; } - return Date.now() / 1000 > (currentTokenExpiration || 0); - } catch (e) { - return true; - } - }, - }; - }), - fetchExchange, - ], - fetchOptions: getFetchOptions(), // Auth header (token) is handled by authExchange -}); + }, + }; + }), + fetchExchange, + ], + fetchOptions: getFetchOptions(), // Auth header (token) is handled by authExchange + ...options, + }); -export const GraphQLClientProvider = ({ children }: { children: React.ReactNode }) => { - return {children}; -}; +export const GraphQLClientProvider = ({ + children, + value = createClient(), +}: { + children: React.ReactNode; + value?: Client; +}) => {children}; diff --git a/src/utils/useChannelFetch.ts b/src/utils/useChannelFetch.ts new file mode 100644 index 00000000..8eca1d1e --- /dev/null +++ b/src/utils/useChannelFetch.ts @@ -0,0 +1,59 @@ +import { useChannel } from "@storybook/manager-api"; + +import { FETCH_ABORTED, FETCH_REQUEST, FETCH_RESPONSE } from "../constants"; + +type SerializedResponse = { + status: number; + statusText: string; + headers: [string, string][]; + body: string; +}; + +const pendingRequests = new Map< + string, + { resolve: (value: Response) => void; reject: (reason?: any) => void } +>(); + +export const useChannelFetch: () => typeof fetch = () => { + const emit = useChannel({ + [FETCH_RESPONSE]: ( + data: + | { requestId: string; response: SerializedResponse } + | { requestId: string; error: string } + ) => { + const request = pendingRequests.get(data.requestId); + if (!request) return; + + pendingRequests.delete(data.requestId); + if ("error" in data) { + request.reject(new Error(data.error)); + } else { + const { body, headers, status, statusText } = data.response; + const res = new Response(body, { headers, status, statusText }); + request.resolve(res); + } + }, + }); + + return async (input: string | URL | Request, { signal, ...init }: RequestInit = {}) => { + if (signal?.aborted) { + return Promise.reject(signal.reason); + } + + const requestId = Math.random().toString(36).slice(2); + signal?.addEventListener("abort", () => { + emit(FETCH_ABORTED, { requestId }); + pendingRequests.get(requestId)?.reject(signal.reason); + pendingRequests.delete(requestId); + }); + emit(FETCH_REQUEST, { requestId, input, init }); + + return new Promise((resolve, reject) => { + pendingRequests.set(requestId, { resolve, reject }); + setTimeout(() => { + reject(new Error("Request timed out")); + pendingRequests.delete(requestId); + }, 30000); + }); + }; +};