Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow archiving from specified external domains #37

Merged
merged 21 commits into from
Nov 17, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/cypress-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ interface ArchiveParams {
domSnapshots: elementNode[];
resourceArchive: ResourceArchive;
chromaticStorybookParams: ChromaticStorybookParameters;
pageUrl: string;
}

const doArchive = async ({
testTitle,
domSnapshots,
resourceArchive,
chromaticStorybookParams,
pageUrl,
}: ArchiveParams) => {
const bufferedArchiveList = Object.entries(resourceArchive).map(([key, value]) => {
return [
Expand Down Expand Up @@ -43,7 +45,8 @@ const doArchive = async ({
},
allSnapshots,
Object.fromEntries(bufferedArchiveList),
{ ...chromaticStorybookParams, viewport: { width: 500, height: 500 } }
{ ...chromaticStorybookParams, viewport: { width: 500, height: 500 } },
pageUrl
);
};

Expand Down
19 changes: 11 additions & 8 deletions src/cypress-api/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,17 @@ const completeArchive = () => {
const snap = snapshot(doc, { noAbsolute: true });
// @ts-expect-error will fix when Cypress has its own package
cy.get('@manualSnapshots').then((manualSnapshots = []) => {
// pass the snapshot to the server to write to disk
cy.task('archiveCypress', {
testTitle: Cypress.currentTest.title,
domSnapshots: [...manualSnapshots, snap],
resourceArchive: archive,
chromaticStorybookParams: {
diffThreshold: Cypress.env('diffThreshold'),
},
cy.url().then((url) => {
// pass the snapshot to the server to write to disk
cy.task('archiveCypress', {
testTitle: Cypress.currentTest.title,
domSnapshots: [...manualSnapshots, snap],
resourceArchive: archive,
chromaticStorybookParams: {
diffThreshold: Cypress.env('diffThreshold'),
},
pageUrl: url,
});
});
});
});
Expand Down
18 changes: 15 additions & 3 deletions src/playwright-api/makeTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
PlaywrightWorkerArgs,
PlaywrightWorkerOptions,
} from '@playwright/test';
import type { ChromaticConfig, ChromaticStorybookParameters } from '../types';
import type { ChromaticConfig } from '../types';
import { createResourceArchive } from '../resource-archive';
import { writeTestResult } from '../write-archive';
import { contentType, takeArchive } from './takeArchive';
Expand Down Expand Up @@ -33,6 +33,7 @@ export const makeTest = (
pauseAnimationAtEnd: [undefined, { option: true }],
prefersReducedMotion: [undefined, { option: true }],
resourceArchiveTimeout: [DEFAULT_GLOBAL_RESOURCE_ARCHIVE_TIMEOUT_MS, { option: true }],
allowedExternalDomains: [[], { option: true }],

save: [
async (
Expand All @@ -46,6 +47,7 @@ export const makeTest = (
pauseAnimationAtEnd,
prefersReducedMotion,
resourceArchiveTimeout,
allowedExternalDomains,
},
use,
testInfo
Expand All @@ -61,7 +63,11 @@ export const makeTest = (
return;
}

const completeArchive = await createResourceArchive(page, resourceArchiveTimeout);
const completeArchive = await createResourceArchive({
page,
networkTimeout: resourceArchiveTimeout,
allowedExternalDomains,
});
await use();

if (!disableAutoCapture) {
Expand All @@ -86,7 +92,13 @@ export const makeTest = (
viewports: [page.viewportSize().width],
};

await writeTestResult(testInfo, snapshots, resourceArchive, chromaticStorybookParams);
await writeTestResult(
testInfo,
snapshots,
resourceArchive,
chromaticStorybookParams,
page.url()
skitterm marked this conversation as resolved.
Show resolved Hide resolved
);

trackComplete();
},
Expand Down
118 changes: 105 additions & 13 deletions src/resource-archive/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ import express, { type Request } from 'express';
import { Server } from 'http';
import { Browser, chromium, Page } from 'playwright';

import { createResourceArchive, type ResourceArchive } from './index';
import { expectArchiveContains } from '../utils/testUtils';
import { createResourceArchive } from './index';
import { logger } from '../utils/logger';

const { TEST_PORT = 13337 } = process.env;
const TEST_PORT = 13337;

const baseUrl = `http://localhost:${TEST_PORT}`;

Expand All @@ -31,6 +30,10 @@ const styleCss = dedent`
const imgPng =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';

// stubbed external imgs so we're not relying on a placeholder
const externalImgPng = 'iamexternal';
const anotherExternalImg = 'anotherone';

const pathToResponseInfo = {
'/': {
content: ({ query: { inject = '' } }: Request) =>
Expand Down Expand Up @@ -75,17 +78,30 @@ describe('new', () => {
const mockWarn = jest.spyOn(logger, 'warn').mockImplementation(() => {});

beforeEach(async () => {
// create a bare-bones Playwright test launch (https://playwright.dev/docs/library)
browser = await chromium.launch();
page = await browser.newPage();

// mock external image requests
await page.route('https://i-ama.fake/external/domain/image.png', async (route) => {
await route.fulfill({ body: Buffer.from(externalImgPng, 'base64') });
});

await page.route('https://another-domain.com/picture.png', async (route) => {
await route.fulfill({ body: Buffer.from(anotherExternalImg, 'base64') });
});

await page.route('https://unwanted-domain.com/img.png', async (route) => {
await route.fulfill({ body: Buffer.from(anotherExternalImg, 'base64') });
});
});

afterEach(async () => {
await browser.close();
});

// eslint-disable-next-line jest/expect-expect
it('should log if the network times out waiting for requests', async () => {
const complete = await createResourceArchive(page, 1);
const complete = await createResourceArchive({ page, networkTimeout: 1 });

await page.goto(baseUrl);

Expand All @@ -95,29 +111,105 @@ describe('new', () => {
expect(mockWarn).toBeCalledWith(`Global timeout of 1ms reached`);
});

// eslint-disable-next-line jest/expect-expect
it('gathers basic resources used by the page', async () => {
const complete = await createResourceArchive(page);
const complete = await createResourceArchive({ page });

await page.goto(baseUrl);

const archive = await complete();

expectArchiveContains(archive, ['/img.png', '/style.css'], pathToResponseInfo, baseUrl);
expect(archive).toEqual({
'http://localhost:13337/style.css': {
statusCode: 200,
statusText: 'OK',
body: Buffer.from(styleCss),
contentType: 'text/css; charset=utf-8',
},
'http://localhost:13337/img.png': {
statusCode: 200,
statusText: 'OK',
body: Buffer.from(imgPng, 'base64'),
contentType: undefined,
},
});
skitterm marked this conversation as resolved.
Show resolved Hide resolved
});

// eslint-disable-next-line jest/expect-expect
it('ignores remote resources', async () => {
const externalUrl =
'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_272x92dp.png';
const externalUrl = 'https://i-ama.fake/external/domain/image.png';
const indexPath = `/?inject=${encodeURIComponent(`<img src="${externalUrl}">`)}`;

const complete = await createResourceArchive(page);
const complete = await createResourceArchive({ page });

await page.goto(new URL(indexPath, baseUrl).toString());

const archive = await complete();

expect(archive).toEqual({
'http://localhost:13337/style.css': {
statusCode: 200,
statusText: 'OK',
body: Buffer.from(styleCss),
contentType: 'text/css; charset=utf-8',
},
'http://localhost:13337/img.png': {
statusCode: 200,
statusText: 'OK',
body: Buffer.from(imgPng, 'base64'),
contentType: undefined,
},
});
});

it('includes remote resource when told to', async () => {
const externalUrls = [
'https://i-ama.fake/external/domain/image.png',
'https://another-domain.com/picture.png',
// this image won't be in allow-list
'https://unwanted-domain.com/img.png',
];
const indexPath = `/?inject=${encodeURIComponent(
externalUrls.map((url) => `<img src="${url}">`).join()
)}`;

const complete = await createResourceArchive({
page,
allowedExternalDomains: [
// external origins we allow-list
'https://i-ama.fake',
'https://another-domain.com',
],
});

await page.goto(new URL(indexPath, baseUrl).toString());

const archive = await complete();

expectArchiveContains(archive, ['/img.png', '/style.css'], pathToResponseInfo, baseUrl);
expect(archive).toEqual({
'http://localhost:13337/style.css': {
statusCode: 200,
statusText: 'OK',
body: Buffer.from(styleCss),
contentType: 'text/css; charset=utf-8',
},
'http://localhost:13337/img.png': {
statusCode: 200,
statusText: 'OK',
body: Buffer.from(imgPng, 'base64'),
contentType: undefined,
},
// includes cross-origin images
'https://i-ama.fake/external/domain/image.png': {
statusCode: 200,
statusText: 'OK',
body: Buffer.from(externalImgPng, 'base64'),
contentType: undefined,
},
'https://another-domain.com/picture.png': {
statusCode: 200,
statusText: 'OK',
body: Buffer.from(anotherExternalImg, 'base64'),
contentType: undefined,
},
});
});
});
43 changes: 31 additions & 12 deletions src/resource-archive/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ class Watcher {

private globalNetworkTimeoutMs;

/**
Specifies which domains (origins) we should archive resources for (by default we only archive same-origin resources).
Useful in situations where the environment running the archived storybook (e.g. in CI) may be restricted to an intranet or other domain restrictions
*/
private allowedExternalDomains: string[];
skitterm marked this conversation as resolved.
Show resolved Hide resolved

/**
* We assume the first URL loaded after @watch is called is the base URL of the
* page and we only save resources that are loaded from the same protocol/host/port combination.
Expand All @@ -39,8 +45,13 @@ class Watcher {

private globalNetworkResolver: () => void;

constructor(private page: Page, networkTimeoutMs = DEFAULT_GLOBAL_RESOURCE_ARCHIVE_TIMEOUT_MS) {
constructor(
private page: Page,
networkTimeoutMs = DEFAULT_GLOBAL_RESOURCE_ARCHIVE_TIMEOUT_MS,
allowedDomains?: string[]
) {
this.globalNetworkTimeoutMs = networkTimeoutMs;
this.allowedExternalDomains = allowedDomains || [];
}

async watch() {
Expand Down Expand Up @@ -128,17 +139,16 @@ class Watcher {

this.firstUrl ??= requestUrl;

const isLocalRequest =
requestUrl.protocol === this.firstUrl.protocol &&
requestUrl.host === this.firstUrl.host &&
requestUrl.port === this.firstUrl.port;
const isRequestFromAllowedDomain =
skitterm marked this conversation as resolved.
Show resolved Hide resolved
requestUrl.origin === this.firstUrl.origin ||
this.allowedExternalDomains.includes(requestUrl.origin);

logger.log(
'requestPaused',
requestUrl.toString(),
responseStatusCode || responseErrorReason ? 'response' : 'request',
this.firstUrl.toString(),
isLocalRequest
isRequestFromAllowedDomain
);

if (this.closed) {
Expand Down Expand Up @@ -178,7 +188,7 @@ class Watcher {

// No need to capture the response of the top level page request
const isFirstRequest = requestUrl.toString() === this.firstUrl.toString();
if (isLocalRequest && !isFirstRequest) {
if (isRequestFromAllowedDomain && !isFirstRequest) {
skitterm marked this conversation as resolved.
Show resolved Hide resolved
this.archive[request.url] = {
statusCode: responseStatusCode,
statusText: responseStatusText,
Expand Down Expand Up @@ -216,11 +226,20 @@ class Watcher {
}
}

export async function createResourceArchive(
page: Page,
networkTimeout = DEFAULT_GLOBAL_RESOURCE_ARCHIVE_TIMEOUT_MS
): Promise<() => Promise<ResourceArchive>> {
const watcher = new Watcher(page, networkTimeout);
export async function createResourceArchive({
page,
networkTimeout,
allowedExternalDomains,
}: {
page: Page;
networkTimeout?: number;
allowedExternalDomains?: string[];
}): Promise<() => Promise<ResourceArchive>> {
const watcher = new Watcher(
page,
networkTimeout ?? DEFAULT_GLOBAL_RESOURCE_ARCHIVE_TIMEOUT_MS,
allowedExternalDomains
);
await watcher.watch();

return async () => {
Expand Down
5 changes: 5 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export interface ChromaticConfig {
// Specify a network timeout, in milliseconds. This is the maximum amount of time that
// each test will wait for the network to be idle while archiving resources.
resourceArchiveTimeout?: number;

// domains (besides localhost) that assets should be archived from
// (needed when, for example, CI environment can't access the archives later on)
// ex: https://www.some-domain.com
allowedExternalDomains?: string[];
skitterm marked this conversation as resolved.
Show resolved Hide resolved
}

export interface ChromaticStorybookParameters extends Omit<ChromaticConfig, 'disableAutoCapture'> {
Expand Down
Loading