Skip to content

Commit

Permalink
Merge branch 'main' into norbert/cpc
Browse files Browse the repository at this point in the history
  • Loading branch information
ghengeveld authored Sep 6, 2024
2 parents 58cc32f + 6b99afb commit 08cb8f6
Show file tree
Hide file tree
Showing 11 changed files with 299 additions and 73 deletions.
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
10 changes: 5 additions & 5 deletions src/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
IS_OFFLINE,
IS_OUTDATED,
LOCAL_BUILD_PROGRESS,
PANEL_ID,
REMOVE_ADDON,
TELEMETRY,
} from "./constants";
Expand All @@ -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";
Expand Down Expand Up @@ -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) => (
<Provider key={PANEL_ID} value={client}>
<GraphQLClientProvider value={createClient({ fetch })}>
<TelemetryProvider value={trackEvent}>
<AuthProvider value={{ accessToken, setAccessToken }}>
<UninstallProvider
Expand All @@ -111,7 +112,7 @@ export const Panel = ({ active, api }: PanelProps) => {
</UninstallProvider>
</AuthProvider>
</TelemetryProvider>
</Provider>
</GraphQLClientProvider>
);

if (!active) {
Expand All @@ -134,7 +135,6 @@ export const Panel = ({ active, api }: PanelProps) => {
if (!accessToken) {
return withProviders(
<Authentication
key={PANEL_ID}
setAccessToken={setAccessToken}
setCreatedProjectId={setCreatedProjectId}
hasProjectId={!!projectId}
Expand Down
4 changes: 4 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export const ENABLE_FILTER = `${ADDON_ID}/enableFilter`;
export const REMOVE_ADDON = `${ADDON_ID}/removeAddon`;
export const PARAM_KEY = "chromatic";

export const FETCH_ABORTED = `${ADDON_ID}/ChannelFetch/aborted`;
export const FETCH_REQUEST = `${ADDON_ID}ChannelFetch/request`;
export const FETCH_RESPONSE = `${ADDON_ID}ChannelFetch/response`;

export const CONFIG_OVERRIDES = {
// Local changes should never be auto-accepted
autoAcceptChanges: false,
Expand Down
4 changes: 4 additions & 0 deletions src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
LocalBuildProgress,
ProjectInfoPayload,
} from "./types";
import { ChannelFetch } from "./utils/ChannelFetch";
import { SharedState } from "./utils/SharedState";
import { updateChromaticConfig } from "./utils/updateChromaticConfig";

Expand Down Expand Up @@ -159,6 +160,9 @@ const watchConfigFile = async (
async function serverChannel(channel: Channel, options: Options & { configFile?: string }) {
const { configFile, presets } = options;

// Handle relayed fetch requests from the client
ChannelFetch.subscribe(ADDON_ID, channel);

// Lazy load these APIs since we don't need them right away
const apiPromise = presets.apply<any>("experimental_serverAPI");
const corePromise = presets.apply("core");
Expand Down
96 changes: 96 additions & 0 deletions src/utils/ChannelFetch.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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<void>((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<Response>(() => {}));
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);
});
});
});
47 changes: 47 additions & 0 deletions src/utils/ChannelFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Channel } from "@storybook/channels";

import { FETCH_ABORTED, FETCH_REQUEST, FETCH_RESPONSE } from "../constants";

type ChannelLike = Pick<Channel, "emit" | "on" | "off">;

const instances = new Map<string, ChannelFetch>();

export class ChannelFetch {
channel: ChannelLike;

abortControllers: Map<string, AbortController>;

constructor(channel: ChannelLike, _fetch = fetch) {
this.channel = channel;
this.abortControllers = new Map<string, AbortController>();

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;
}
}
16 changes: 16 additions & 0 deletions src/utils/MockChannel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export class MockChannel {
private listeners: Record<string, ((...args: any[]) => 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)));
}
}
18 changes: 1 addition & 17 deletions src/utils/SharedState.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,8 @@
import { beforeEach, describe, expect, it } from "vitest";

import { MockChannel } from "./MockChannel";
import { SharedState } from "./SharedState";

class MockChannel {
private listeners: Record<string, ((...args: any[]) => 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", () => {
Expand Down
Loading

0 comments on commit 08cb8f6

Please sign in to comment.