Skip to content
Open
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
55 changes: 54 additions & 1 deletion packages/javascript/cypress.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import { defineConfig } from 'cypress';
import z from 'zod';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const sharp = require('sharp');

import { rename } from 'node:fs/promises';

export default defineConfig({
defaultBrowser: 'electron',
component: {
setupNodeEvents(on) {
on('after:screenshot', flattenScreenshots);
on('task', {
'get-pixel-value': getPixelValue,
});
},
devServer: {
framework: 'react',
bundler: 'vite',
Expand All @@ -14,8 +25,10 @@ export default defineConfig({

e2e: {
setupNodeEvents(on) {
// This is required to log progress to the terminal whilst generating frames
on('after:screenshot', flattenScreenshots);
on('task', {
'get-pixel-value': getPixelValue,
// This is required to log progress to the terminal whilst generating frames
log(message) {
console.log(message);
return null;
Expand All @@ -24,3 +37,43 @@ export default defineConfig({
},
},
});

const getPixelValueArgs = z.object({
id: z.string(),
x: z.number().gte(0),
y: z.number().gte(0),
});
async function getPixelValue(args: unknown) {
try {
console.debug('getPixelValue()', args);
const { id, x, y } = getPixelValueArgs.parse(args);
const path = `cypress/screenshots/${id}.png`;

// Approach derived from: https://github.com/lovell/sharp/issues/934#issuecomment-327181099
const metadata = await sharp(path).metadata();
const { channels, width } = metadata;
const pixelOffset = channels * (width * y + x);

const data = await sharp(path).raw().toBuffer();
const r = data[pixelOffset + 0];
const g = data[pixelOffset + 1];
const b = data[pixelOffset + 2];

return { r, g, b };
} catch (e) {
console.error(e);
return e;
}
}

// If cypress is in interactive mode screenshots are saved in /screenshots/{name}.png
// If cypress is in run mode, screenshots are saved in /screenshots/{test-name}/{name}.png
const CYPRESS_SCREENSHOTS = 'cypress/screenshots';
async function flattenScreenshots(details: { path: string; specName: string }) {
if (details.specName.length) {
const newPath = details.path.replace(`${CYPRESS_SCREENSHOTS}/${details.specName}`, CYPRESS_SCREENSHOTS);
await rename(details.path, newPath);
return { path: newPath };
}
return details;
}
61 changes: 61 additions & 0 deletions packages/javascript/cypress/component/SurfaceLayers.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { SurfaceManager } from '../../src/state-based/SurfaceManager';
const INDIAN_RED = { r: 191, g: 99, b: 96 };
const ROYAL_BLUE = { r: 75, g: 104, b: 218 };

describe('Surface layer tests', () => {
it('can take a known screenshot', () => {
const now = Date.now();
const manager = new SurfaceManager({
red: {
file: 'cypress/fixtures/indianred@2560x1440.png',
type: 'image',
fit: 'cover',
keyframes: [[now, { set: { opacity: 1 } }]],
},
});
cy.mount(manager);

// Wait for image to load (naturalWidth is set once loaded)
cy.get('img')
.should('be.visible')
.and(($img) => {
expect($img[0].naturalWidth).to.be.greaterThan(0);
});

cy.assertPixelAt(100, 100, INDIAN_RED);
});

it('respects z-index', () => {
const now = Date.now();
const manager = new SurfaceManager({
red: {
file: 'cypress/fixtures/indianred@2560x1440.png',
type: 'image',
fit: 'cover',
keyframes: [
[now, { set: { zIndex: 100 } }],
[now + 2000, { set: { zIndex: 300 } }],
],
},
blue: {
file: 'cypress/fixtures/royalblue@2560x1440.png',
type: 'image',
fit: 'cover',
keyframes: [[now, { set: { zIndex: 200 } }]],
},
});
cy.mount(manager);

// Wait for images to load (naturalWidth is set once loaded)
cy.get('img')
.should('be.visible')
.and(($img) => {
expect($img[0].naturalWidth).to.be.greaterThan(0);
expect($img[1].naturalWidth).to.be.greaterThan(0);
});

cy.assertPixelAt(100, 100, ROYAL_BLUE);
cy.wait(2000);
cy.assertPixelAt(100, 100, INDIAN_RED);
});
});
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
75 changes: 48 additions & 27 deletions packages/javascript/cypress/support/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,51 @@
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
//
// declare global {
// namespace Cypress {
// interface Chainable {
// login(email: string, password: string): Chainable<void>
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }

import { z } from 'zod';
type Color = z.infer<typeof rgb>;
const rgb = z.object({
r: z.int().gte(0).lte(255),
g: z.int().gte(0).lte(255),
b: z.int().gte(0).lte(255),
});

Cypress.Commands.add('getPixelAt', (x: number, y: number) => {
const id = Math.random().toString(16).slice(2);
cy.screenshot(id, { overwrite: true, capture: 'viewport' });
cy.task('get-pixel-value', { id, x, y }).then(($response) => {
try {
return rgb.parse($response);
} catch (e) {
console.error($response);
console.error(e);
}
});
});

// If all r,g,b are within ε of the expected value the color is expected
// An identical web page will be rendered differently depending on the color profile.
// This will be different on different machines, and displays
const RGB_ε = 15;
Cypress.Commands.add('assertPixelAt', (x: number, y: number, expected: Color) => {
cy.getPixelAt(x, y).then(($actual) => {
const expectedColor = `{r:${expected.r},g:${expected.g},b:${expected.b}}`;
const actualColor = `{r:${$actual.r},g:${$actual.g},b:${$actual.b}}`;
expect($actual.r).to.be.closeTo(expected.r, RGB_ε, `Expected within ${RGB_ε} of color ${expectedColor}, but got ${actualColor}`);
expect($actual.g).to.be.closeTo(expected.g, RGB_ε, `Expected within ${RGB_ε} of color ${expectedColor}, but got ${actualColor}`);
expect($actual.b).to.be.closeTo(expected.b, RGB_ε, `Expected within ${RGB_ε} of color ${expectedColor}, but got ${actualColor}`);
});
});

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
getPixelAt(x: number, y: number): Chainable<Color>;
assertPixelAt(x: number, y: number, color: Color): Chainable<void>;
}
}
}

// Required to declare global
export {};
3 changes: 2 additions & 1 deletion packages/javascript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@
"prettier": "^3.4.2",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"sharp": "^0.34.5",
"typedoc": "^0.27.5",
"typescript": "~5.7.2",
"typescript-eslint": "^8.18.1",
"vite": "^7.1.12",
"vitest": "^4.0.6"
}
}
}
9 changes: 8 additions & 1 deletion packages/javascript/src/state-based/ImageManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ImageState } from '../types/MediaSchema';
import { defaultImageOptions, ImageState } from '../types/MediaSchema';
import { getStateAtTime } from '../utils/getStateAtTime';
import { ClipManager } from './ClipManager';

Expand Down Expand Up @@ -39,6 +39,13 @@ export class ImageManager extends ClipManager<ImageState> {
if (this.imageElement.style.objectFit !== this._state.fit) {
this.imageElement.style.objectFit = this._state.fit;
}
if (parseFloat(this.imageElement.style.opacity) !== currentState.opacity) {
this.imageElement.style.opacity = String(currentState.opacity ?? defaultImageOptions.opacity);
}
const z = Math.round(currentState.zIndex ?? defaultImageOptions.zIndex);
if (parseInt(this.imageElement.style.zIndex) !== z) {
this.imageElement.style.zIndex = String(z);
}

const { opacity } = currentState;
if (typeof opacity === 'string' && opacity !== this.imageElement.style.opacity) {
Expand Down
7 changes: 7 additions & 0 deletions packages/javascript/src/state-based/VideoManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ export class VideoManager extends ClipManager<VideoState> {
if (this.videoElement.style.objectFit !== this._state.fit) {
this.videoElement.style.objectFit = this._state.fit;
}
if (parseFloat(this.videoElement.style.opacity) !== currentState.opacity) {
this.videoElement.style.opacity = String(currentState.opacity ?? defaultVideoOptions.opacity);
}
const z = Math.round(currentState.zIndex ?? defaultVideoOptions.zIndex);
if (parseInt(this.videoElement.style.zIndex) !== z) {
this.videoElement.style.zIndex = String(z);
}
if (this.videoElement.volume !== volume) {
this.videoElement.volume = volume;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/javascript/src/types/MediaSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const TemporalProperties = z.object({
export type VisualProperties = z.infer<typeof VisualProperties>;
const VisualProperties = z.object({
opacity: z.number().gte(0).lte(1),
zIndex: z.number(),
});
export type AudialProperties = z.infer<typeof AudialProperties>;
const AudialProperties = z.object({
Expand Down Expand Up @@ -213,6 +214,7 @@ true satisfies UnionsEqual<MediaSurfaceState, z.infer<typeof MediaSurfaceStateSc

export const defaultImageOptions: ImageOptions = {
opacity: 1,
zIndex: 0,
};
export const defaultAudioOptions: AudioOptions = {
t: 0,
Expand All @@ -224,4 +226,5 @@ export const defaultVideoOptions: VideoOptions = {
rate: 1,
volume: 1,
opacity: 1,
zIndex: 0,
};
Loading
Loading