Skip to content
Merged
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
40 changes: 40 additions & 0 deletions .github/workflows/_test-integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Integration Tests

on:
workflow_call:

permissions:
contents: read

jobs:
cypress:
name: Run Cypress integration tests against the built library
runs-on: ubuntu-latest

steps:
- name: Check out Git repository
uses: actions/checkout@v7

- name: Set up pnpm
uses: pnpm/action-setup@v6

- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version-file: .nvmrc
cache: pnpm

- name: Cypress run
uses: cypress-io/github-action@c541a95ea92d9239347a2355f2e895cd4b3f8060 # v7.4.6
with:
install-command: pnpm install --frozen-lockfile
build: pnpm build
command: pnpm cy:run:integration

- name: Upload screenshots
if: failure()
uses: actions/upload-artifact@v7
with:
name: cypress-integration-screenshots
path: cypress/screenshots
if-no-files-found: ignore
4 changes: 4 additions & 0 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ jobs:
uses: ./.github/workflows/_test-components.yml
needs: static_analysis
secrets: inherit
test_integration:
uses: ./.github/workflows/_test-integration.yml
needs: static_analysis
secrets: inherit
13 changes: 12 additions & 1 deletion cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,24 @@ export default defineConfig({

return config;
},
specPattern: 'src/**/*.spec.{js,ts,jsx,tsx}',
specPattern: [
'src/**/*.spec.{js,ts,jsx,tsx}',
'cypress/integration/**/*.spec.{ts,tsx}',
],
devServer: {
framework: 'react',
bundler: 'vite',
viteConfig: defineViteConfig({
assetsInclude: ['**/*.tiff', '**/*.heic'],
resolve: { tsconfigPaths: true },
optimizeDeps: {
include: [
'react',
'react-dom',
'react-dom/client',
'react/jsx-runtime',
],
},
}),
},
},
Expand Down
55 changes: 55 additions & 0 deletions cypress/integration/api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { loadDist } from './helpers';

/**
* Guards the public runtime surface of the package. A missing or renamed
* export is a breaking change for consumers and must be deliberate.
*/
describe('built library public API', () => {
const expectedFunctions = [
'AnnotationLens',
'AnnotationViewer',
'dataURItoBlob',
'drawLayer',
'drawShape',
'drawShapes',
'getImagesFromPDF',
'getPDFPageCount',
'getZoomScale',
'setShapeConfig',
'toBase64',
];

it('exposes exactly the documented runtime exports, all of them functions', () => {
loadDist().then((lib) => {
expect(Object.keys(lib).sort()).to.deep.equal(expectedFunctions);
expectedFunctions.forEach((name) => {
expect(lib[name as keyof typeof lib]).to.be.a('function');
});
});
});

it('builds data URLs with toBase64, defaulting to image/jpeg', () => {
loadDist().then(({ toBase64 }) => {
expect(toBase64('AAAA')).to.equal('data:image/jpeg;base64,AAAA');
expect(toBase64('AAAA', 'application/pdf')).to.equal(
'data:application/pdf;base64,AAAA',
);
});
});

it('round-trips a data URL through dataURItoBlob', () => {
loadDist().then(({ dataURItoBlob, toBase64 }) => {
const payload = btoa('hello');
const blob = dataURItoBlob(toBase64(payload, 'image/png'));
expect(blob.type).to.equal('image/png');
expect(blob.size).to.equal(5);
cy.wrap(blob.text()).should('equal', 'hello');
});
});

it('rejects malformed input in dataURItoBlob instead of producing an empty blob', () => {
loadDist().then(({ dataURItoBlob }) => {
expect(() => dataURItoBlob('not-a-data-url')).to.throw();
});
});
});
79 changes: 79 additions & 0 deletions cypress/integration/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type * as Library from '@/index';

export type DistLibrary = typeof Library;

/**
* Path of the built entry point, relative to this file. It is kept in a
* variable so that neither tsc nor Vite resolve it ahead of time: the
* integration suite must stay type-checkable without a prior build, and the
* browser must receive the production chunks exactly as Rolldown emitted them.
*/
const distEntry = '../../dist/index.js';

export const CONTAINER_HEIGHT = 800;
export const CONTAINER_WIDTH = 700;
export const CONTAINER_STYLE = {
height: CONTAINER_HEIGHT,
width: CONTAINER_WIDTH,
};

/**
* Imports the built library (dist/) at runtime, as a consumer would, so that
* production-only behaviour such as lazy chunks and the inlined pdf.js worker
* is exercised rather than the TypeScript sources.
*
* Cypress keeps the same window for every test of a spec file, so module-level
* state inside dist/index.js (such as the memoised pdf.js worker) survives
* between tests. Pass `fresh: true` to cache-bust the entry point and get a
* brand new module instance when a test asserts on first-use behaviour.
*/
export const loadDist = (
options: { fresh?: boolean } = {},
): Cypress.Chainable<DistLibrary> => {
const specifier =
options.fresh === true
? `${distEntry}?fresh=${String(Date.now())}`
: distEntry;
return cy.wrap(
(import(/* @vite-ignore */ specifier) as Promise<DistLibrary>).catch(
Comment thread
sebastianMindee marked this conversation as resolved.
(error: unknown) => {
throw new Error(
`Could not load dist/index.js, run "pnpm build" first. ${String(error)}`,
);
},
),
);
};

/**
* Names of the network resources fetched so far by the test window whose URL
* contains the given fragment. Used to prove that a lazy chunk is only
* downloaded once the feature that needs it is used.
*
* Resource entries accumulate for the whole spec file, so the "not yet
* loaded" assertion is only meaningful if no earlier test in the same file
* used the feature.
*/
export const loadedResources = (
win: Cypress.AUTWindow,
fragment: string,
): string[] =>
win.performance
.getEntriesByType('resource')
.map((entry) => entry.name)
.filter((name) => name.includes(fragment));

/** Reads the natural dimensions of a data URL or object URL image. */
export const imageDimensions = async (
source: string,
): Promise<{ width: number; height: number }> =>
await new Promise((resolve, reject) => {
const image = new Image();
image.onload = (): void => {
resolve({ width: image.naturalWidth, height: image.naturalHeight });
};
image.onerror = (): void => {
reject(new Error('Image could not be decoded'));
};
image.src = source;
});
124 changes: 124 additions & 0 deletions cypress/integration/images.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import dummyImageHEIC from 'cypress/assets/demo.heic';
import dummyImage from 'cypress/assets/demo.jpg';
import dummyImageTIFF from 'cypress/assets/demo.tiff';
import multiPage from 'cypress/assets/multi-page.pdf';

import { CONTAINER_STYLE, loadDist, loadedResources } from './helpers';

const containerId = 'annotationViewer';

/**
* Asserts that the viewer ended up with a drawn image by checking that the
* canvas pixels are not fully transparent.
*/
const expectPaintedCanvas = (): void => {
cy.get(`#${containerId} canvas`)
.first()
.should(($canvas) => {
const canvas = $canvas[0] as HTMLCanvasElement;
const context = canvas.getContext('2d');
if (context === null) {
throw new Error('Canvas has no 2d context');
}
const { data } = context.getImageData(0, 0, canvas.width, canvas.height);
let opaquePixels = 0;
for (let index = 3; index < data.length; index += 4) {
if (data[index] !== 0) {
opaquePixels += 1;
}
}
expect(opaquePixels).to.be.greaterThan(0);
});
};

/**
* Format conversion runs through utif (TIFF) and a lazily loaded heic2any
* chunk (HEIC). These paths rely on the production chunking, so they are
* verified against the built viewer.
*/
describe('built library image formats', () => {
it('displays a JPEG image', () => {
loadDist().then((lib) => {
cy.mount(
<lib.AnnotationViewer
id={containerId}
data={{ image: dummyImage }}
style={CONTAINER_STYLE}
/>,
);
expectPaintedCanvas();
});
});

it('displays a PNG data URL produced from a PDF page', () => {
loadDist().then((lib) => {
cy.wrap<Promise<string[]>, string[]>(
lib.getImagesFromPDF(multiPage),
).then((images) => {
cy.mount(
<lib.AnnotationViewer
id={containerId}
data={{ image: images[0] ?? null }}
style={CONTAINER_STYLE}
/>,
);
expectPaintedCanvas();
});
});
});

it('converts and displays a TIFF image', () => {
loadDist().then((lib) => {
cy.mount(
<lib.AnnotationViewer
id={containerId}
data={{ image: dummyImageTIFF }}
style={CONTAINER_STYLE}
/>,
);
expectPaintedCanvas();
});
});

it('converts and displays a HEIC image, downloading the heic2any chunk only then', () => {
loadDist().then((lib) => {
cy.mount(
<lib.AnnotationViewer
id={containerId}
data={{ image: dummyImage }}
style={CONTAINER_STYLE}
/>,
);
expectPaintedCanvas();
cy.window().then((win) => {
expect(loadedResources(win, 'heic2any')).to.have.length(0);
});

cy.mount(
<lib.AnnotationViewer
id={containerId}
data={{ image: dummyImageHEIC }}
style={CONTAINER_STYLE}
/>,
);
expectPaintedCanvas();
cy.window().should((win) => {
expect(loadedResources(win, 'heic2any')).to.have.length(1);
});
});
});

it('renders an empty stage without errors when no image is provided', () => {
loadDist().then((lib) => {
cy.mount(
<lib.AnnotationViewer
id={containerId}
data={{ image: null }}
style={CONTAINER_STYLE}
/>,
);
cy.get(`#${containerId}`).should('be.visible');
cy.get(`#${containerId} canvas`).should('have.length.at.least', 1);
});
});
});
Loading
Loading