Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .changeset/social-worms-enjoy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@chromatic-com/playwright': patch
'@chromatic-com/cypress': patch
'@chromatic-com/shared-e2e': patch
'@chromatic-com/vitest': patch
---

Feat: add support for setting `colorScheme`
3 changes: 2 additions & 1 deletion packages/cypress/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*
* @param {string} name - Use to apply a custom name to the snapshot (optional)
*/
takeSnapshot(name?: string): Chainable<any>;

Check warning on line 14 in packages/cypress/src/commands.ts

View workflow job for this annotation

GitHub Actions / test / test

Unexpected any. Specify a different type

Check warning on line 14 in packages/cypress/src/commands.ts

View workflow job for this annotation

GitHub Actions / test / test

Unexpected any. Specify a different type
}
}
}
Expand All @@ -24,11 +24,12 @@

cy.window().then((win) => {
const viewport = { width: win.innerWidth, height: win.innerHeight };
const colorScheme = win.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';

cy.document().then((doc) => {
// here, handle the source map

cy.wrap(takeChromaticSnapshot(doc, viewport, true)).then(
cy.wrap(takeChromaticSnapshot(doc, viewport, colorScheme, true)).then(
(manualSnapshot: CypressSnapshot) => {
// reassign manualSnapshots so it includes this new snapshot
cy.get('@manualSnapshots')
Expand Down
4 changes: 2 additions & 2 deletions packages/cypress/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@
}: WriteArchivesParams) => {
const allSnapshots = Object.fromEntries(
// manual snapshots can be given a name; otherwise, just use the snapshot's place in line as the name
domSnapshots.map(({ name, snapshot, viewport, pseudoClassIds }, index) => [
domSnapshots.map(({ name, snapshot, viewport, colorScheme, pseudoClassIds }, index) => [
name ?? `Snapshot #${index + 1}`,
{ snapshot: Buffer.from(JSON.stringify(snapshot)), viewport, pseudoClassIds },
{ snapshot: Buffer.from(JSON.stringify(snapshot)), viewport, colorScheme, pseudoClassIds },
])
);

Expand Down Expand Up @@ -99,7 +99,7 @@

interface TaskParams {
action: 'setup-network-listener' | 'save-archives';
payload?: any;

Check warning on line 102 in packages/cypress/src/index.ts

View workflow job for this annotation

GitHub Actions / test / test

Unexpected any. Specify a different type

Check warning on line 102 in packages/cypress/src/index.ts

View workflow job for this annotation

GitHub Actions / test / test

Unexpected any. Specify a different type
}

// Handles all server-side tasks, dispatching each to its proper handler.
Expand Down
50 changes: 27 additions & 23 deletions packages/cypress/src/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,33 +51,37 @@ afterEach(() => {
}
cy.window().then((win) => {
const viewport = { width: win.innerWidth, height: win.innerHeight };
const colorScheme = win.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';

// can we be sure this always fires after all the requests are back?
cy.document().then((doc) => {
cy.wrap(takeSnapshot(doc, viewport)).then((automaticSnapshot: CypressSnapshot) => {
// @ts-expect-error will fix when Cypress has its own package
cy.get('@manualSnapshots').then((manualSnapshots = []) => {
cy.url().then((url) => {
// pass the snapshot to the server to write to disk
cy.task('prepareArchives', {
action: 'save-archives',
payload: {
testTitlePath: [
// @ts-expect-error relativeToCommonRoot is on spec (but undocumented)
Cypress.spec.relativeToCommonRoot,
...Cypress.currentTest.titlePath,
],
domSnapshots: [
...manualSnapshots,
...(automaticSnapshot ? [automaticSnapshot] : []),
],
chromaticStorybookParams: buildChromaticParams(Cypress.env),
pageUrl: url,
outputDir: Cypress.config('downloadsFolder'),
},
cy.wrap(takeSnapshot(doc, viewport, colorScheme)).then(
(automaticSnapshot: CypressSnapshot) => {
// @ts-expect-error will fix when Cypress has its own package
cy.get('@manualSnapshots').then((manualSnapshots = []) => {
cy.url().then((url) => {
// pass the snapshot to the server to write to disk
cy.task('prepareArchives', {
action: 'save-archives',
payload: {
testTitlePath: [
// @ts-expect-error relativeToCommonRoot is on spec (but undocumented)
Cypress.spec.relativeToCommonRoot,
...Cypress.currentTest.titlePath,
],
domSnapshots: [
...manualSnapshots,
...(automaticSnapshot ? [automaticSnapshot] : []),
],
chromaticStorybookParams: buildChromaticParams(Cypress.env),
pageUrl: url,
outputDir: Cypress.config('downloadsFolder'),
},
});
});
});
});
});
}
);
});
});
});
5 changes: 3 additions & 2 deletions packages/cypress/src/takeSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { serializedNodeWithId } from '@rrweb/types';
export const takeSnapshot = (
doc: Document,
viewport: { width: number; height: number },
colorScheme: 'light' | 'dark',
isManualSnapshot?: boolean
): Promise<CypressSnapshot | null> => {
return new Promise((resolve) => {
Expand All @@ -17,7 +18,7 @@ export const takeSnapshot = (

const pseudoClassIds: CypressSnapshot['pseudoClassIds'] = {};

for (const className of [':hover', ':focus', ':focus-visible', ':active']) {
for (const className of [':hover', ':focus', ':focus-visible', ':active'] as const) {
const elements = doc.querySelectorAll(className);
const ids = Array.from(elements, (el) => mirror.getId(el)).filter((id) => id !== -1);
pseudoClassIds[className] = ids;
Expand Down Expand Up @@ -54,7 +55,7 @@ export const takeSnapshot = (
};

replaceBlobUrls(domSnapshot).then(() => {
resolve({ snapshot: domSnapshot, viewport, pseudoClassIds });
resolve({ snapshot: domSnapshot, viewport, colorScheme, pseudoClassIds });
});
});
};
1 change: 1 addition & 0 deletions packages/cypress/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export interface CypressSnapshot {
// the DOM snapshot
snapshot: serializedNodeWithId;
viewport: DOMSnapshots[string]['viewport'];
colorScheme: DOMSnapshots[string]['colorScheme'];
pseudoClassIds: DOMSnapshots[string]['pseudoClassIds'];
}
51 changes: 51 additions & 0 deletions packages/cypress/tests/cypress/e2e/color-scheme.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
afterEach(() => {
// Reset the emulation so it doesn't leak into other spec files
cy.wrap(null).then(() =>
Cypress.automation('remote:debugger:protocol', {
command: 'Emulation.setEmulatedMedia',
params: { features: [] },
})
);
});

describe('light color scheme', () => {
it('renders the light-only content', () => {
cy.wrap(null).then(() => setColorScheme('light'));
cy.visit('/color-scheme');

cy.contains('Only visible in LIGHT mode').should('be.visible');
cy.contains('Only visible in DARK mode').should('not.be.visible');
});
});

describe('dark color scheme', () => {
it('renders the dark-only content', () => {
cy.wrap(null).then(() => setColorScheme('dark'));
cy.visit('/color-scheme');

cy.contains('Only visible in DARK mode').should('be.visible');
cy.contains('Only visible in LIGHT mode').should('not.be.visible');
});
});

it(
'captures both color schemes inside a single test case',
{ env: { disableAutoSnapshot: true } },
() => {
cy.wrap(null).then(() => setColorScheme('dark'));
cy.visit('/color-scheme');
cy.contains('Only visible in DARK mode').should('be.visible');
cy.takeSnapshot('dark');

cy.wrap(null).then(() => setColorScheme('light'));
cy.contains('Only visible in LIGHT mode').should('be.visible');
cy.takeSnapshot('light');
}
);

function setColorScheme(value: 'light' | 'dark') {
return Cypress.automation('remote:debugger:protocol', {
command: 'Emulation.setEmulatedMedia',
params: { features: [{ name: 'prefers-color-scheme', value }] },
});
}
4 changes: 3 additions & 1 deletion packages/playwright/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
const mirror = createMirror();
const domSnapshot = snapshot(document, { recordCanvas: true, mirror });

const colorScheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';

const pseudoClassIds: DOMSnapshots[string]['pseudoClassIds'] = {};

for (const className of [':hover', ':focus', ':focus-visible', ':active'] as const) {
Expand All @@ -18,7 +20,7 @@

await replaceBlobUrls(domSnapshot);

return { domSnapshot, pseudoClassIds };
return { domSnapshot, pseudoClassIds, colorScheme } as const;
}

async function replaceBlobUrls(node: serializedNodeWithId) {
Expand Down Expand Up @@ -59,4 +61,4 @@
}

// This is never used, but needed to mark takeSnapshot as used to avoid tree-shaking dropping it
(window as any).__chromatic_takeSnapshot = takeSnapshot;

Check warning on line 64 in packages/playwright/src/browser.ts

View workflow job for this annotation

GitHub Actions / test / test

Unexpected any. Specify a different type

Check warning on line 64 in packages/playwright/src/browser.ts

View workflow job for this annotation

GitHub Actions / test / test

Unexpected any. Specify a different type
3 changes: 2 additions & 1 deletion packages/playwright/src/takeSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ async function takeSnapshot(
});

// Serialize and capture the DOM
const { domSnapshot, pseudoClassIds } = await executeSnapshotScript(page);
const { domSnapshot, pseudoClassIds, colorScheme } = await executeSnapshotScript(page);

// First iframe is the main document, skip it.
// This returns all iframes, even the nested ones.
Expand Down Expand Up @@ -78,6 +78,7 @@ async function takeSnapshot(
chromaticSnapshots.get(testId).set(name, {
snapshot: bufferedSnapshot,
viewport: page.viewportSize() || { width: 1280, height: 720 },
colorScheme,
pseudoClassIds,
});
}
Expand Down
34 changes: 34 additions & 0 deletions packages/playwright/tests/color-scheme.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { expect, takeSnapshot, test } from '../src';

test.describe('light color scheme', () => {
test.use({ colorScheme: 'light' });

test('renders the light-only content', async ({ page }) => {
await page.goto('/color-scheme');

await expect(page.getByText('Only visible in LIGHT mode')).toBeVisible();
await expect(page.getByText('Only visible in DARK mode')).toBeHidden();
});
});

test.describe('dark color scheme', () => {
test.use({ colorScheme: 'dark' });

test('renders the dark-only content', async ({ page }) => {
await page.goto('/color-scheme');

await expect(page.getByText('Only visible in DARK mode')).toBeVisible();
await expect(page.getByText('Only visible in LIGHT mode')).toBeHidden();
});
});

test('captures both color schemes inside a single test case', async ({ page }, testInfo) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/color-scheme');
await expect(page.getByText('Only visible in DARK mode')).toBeVisible();
await takeSnapshot(page, 'dark', testInfo);

await page.emulateMedia({ colorScheme: 'light' });
await expect(page.getByText('Only visible in LIGHT mode')).toBeVisible();
await takeSnapshot(page, 'light', testInfo);
});
3 changes: 3 additions & 0 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export type DOMSnapshots = Record<
/** Viewport dimensions from the exact time the snapshot was taken */
viewport: Viewport;

/** Color scheme from the exact time the snapshot was taken */
colorScheme: 'light' | 'dark';

/** Mapping of pseudo-class names to their corresponding rrweb-snapshot element IDs */
pseudoClassIds: Partial<
Record<':active' | ':focus' | ':focus-visible' | ':hover', serializedNodeWithId['id'][]>
Expand Down
5 changes: 4 additions & 1 deletion packages/shared/src/write-archive/stories-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ export function createStories(

return {
title,
stories: Object.entries(domSnapshots).map(([snapshotName, { viewport, parameters }]) => {
stories: Object.entries(domSnapshots).map(([snapshotName, snapshotOptions]) => {
const name = collapseNewlines(snapshotName);

const { parameters, viewport, colorScheme } = snapshotOptions;
const { chromatic: chromaticParams, ...restParameters } = parameters || {};

return {
Expand All @@ -53,6 +55,7 @@ export function createStories(
chromatic: {
...chromaticStorybookParams,
...chromaticParams,
colorScheme,
modes: buildStoryModesConfig([viewport]),
},
viewport: {
Expand Down
19 changes: 13 additions & 6 deletions packages/vitest/src/node/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export function createCommands(options: ResolvedOptions) {
{
snapshot: serializedNodeWithId;
viewport: DOMSnapshots[string]['viewport'];
colorScheme: DOMSnapshots[string]['colorScheme'];
pseudoClassIds: DOMSnapshots[string]['pseudoClassIds'];
}
>
Expand Down Expand Up @@ -77,12 +78,17 @@ export function createCommands(options: ResolvedOptions) {
name ||= `Snapshot #${sessionSnapshots.size + 1}`;

const frame = await context.frame();
const viewport = await frame.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
}));
const { viewport, colorScheme } = await frame.evaluate(
() =>
({
viewport: { width: window.innerWidth, height: window.innerHeight },
colorScheme: window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light',
}) as const
);

sessionSnapshots.set(name, { snapshot, viewport, pseudoClassIds });
sessionSnapshots.set(name, { snapshot, viewport, colorScheme, pseudoClassIds });

ChromaticReporter.onSnapshot(context.project.vitest, entity);
},
Expand Down Expand Up @@ -153,7 +159,7 @@ export function createCommands(options: ResolvedOptions) {
const snapshotBuffers: DOMSnapshots = {};
const titlePath = testOptions.title ? [testOptions.title] : getTitle(entity);

for (const [name, { snapshot, viewport, pseudoClassIds }] of sessionSnapshots) {
for (const [name, { snapshot, viewport, colorScheme, pseudoClassIds }] of sessionSnapshots) {
const names = generateUniqueSnapshotName({
snapshotName: getSnapshotPrefix(entity).concat(name),
titlePath,
Expand All @@ -162,6 +168,7 @@ export function createCommands(options: ResolvedOptions) {
snapshotBuffers[names] = {
snapshot: Buffer.from(JSON.stringify(snapshot)),
viewport,
colorScheme,
pseudoClassIds,
parameters: {
chromatic: {
Expand Down
44 changes: 44 additions & 0 deletions packages/vitest/test/color-scheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { afterEach, describe, expect } from 'vitest';
import { commands, page } from 'vitest/browser';
import { test } from './utils/browser';
import { takeSnapshot } from '../src';

afterEach(async () => {
// Reset the emulation so it doesn't leak into other tests
await emulateColorScheme('no-preference');
});

describe('light color scheme', () => {
test('renders the light-only content', async ({ goTo }) => {
await emulateColorScheme('light');
await goTo('/color-scheme');

await expect.element(page.getByText('Only visible in LIGHT mode')).toBeVisible();
await expect.element(page.getByText('Only visible in DARK mode')).not.toBeVisible();
});
});

describe('dark color scheme', () => {
test('renders the dark-only content', async ({ goTo }) => {
await emulateColorScheme('dark');
await goTo('/color-scheme');

await expect.element(page.getByText('Only visible in DARK mode')).toBeVisible();
await expect.element(page.getByText('Only visible in LIGHT mode')).not.toBeVisible();
});
});

test('captures both color schemes inside a single test case', async ({ goTo }) => {
await emulateColorScheme('dark');
await goTo('/color-scheme');
await expect.element(page.getByText('Only visible in DARK mode')).toBeVisible();
await takeSnapshot('dark');

await emulateColorScheme('light');
await expect.element(page.getByText('Only visible in LIGHT mode')).toBeVisible();
await takeSnapshot('light');
});

const emulateColorScheme = (commands as any).emulateColorScheme as (
colorScheme: 'light' | 'dark' | 'no-preference'
) => Promise<void>;
5 changes: 5 additions & 0 deletions packages/vitest/test/vitest.config.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export default defineConfig({
viewport: { width: 1280, height: 720 },

commands: {
async emulateColorScheme(context, colorScheme: 'light' | 'dark' | 'no-preference') {
await context.page.emulateMedia({ colorScheme });
},

async mousedown(context, selector: string) {
const frame = await context.frame();
const box = await frame.locator(selector).boundingBox();
Expand Down Expand Up @@ -83,6 +87,7 @@ function testServerProxy() {
'canvas',
'amd',
'css-pseudo-states',
'color-scheme',
'@fz',
'embeds',
];
Expand Down
Loading
Loading