This is a fix to pnpm deployment which needs testing as the final part of origin/master release No functional changes * fix(docs): remove stray tool-call tags breaking the MDX build platform/docs/docs/migration-guide/3p12-to-3p13/build-tooling.md ended with two orphan closing tags (leftover tool-call serialization artifacts): </content> </invoke> Docusaurus compiles Markdown as MDX (JSX-aware), so the orphan closing tag failed the docs build: MDX compilation failed ... Unexpected closing slash in tag, expected an open tag first (build-tooling.md line 402) This was the remaining blocker for build-and-deploy-docs once the --no-frozen-lockfile change let the install step succeed. A scan of the docs tree found no other such artifacts. Verified locally: docusaurus build now generates static files with no MDX errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Update lockfile and avoid freshness check on every command * fix(release): keep workspace:* in the repo, concretize only at publish The release flow rewrote internal @ohif/* dependency specifiers to the concrete version and committed them, so pnpm-lock.yaml (which records workspace links) drifted from the manifests on every version bump. The resulting ERR_PNPM_OUTDATED_LOCKFILE broke every frozen install: Netlify (viewer-dev), the docs deploy, pnpm's pre-run deps check, and post-merge installs. Keep workspace:* everywhere in the committed repo and move the concrete-version substitution to publish time only: - publish-version.mjs: bump each package's own `version` field only; stop rewriting @ohif/* dependency/peerDependency specifiers. - publish-package.mjs: publish with `pnpm publish --no-git-checks` instead of `npm publish`. pnpm rewrites workspace:* to the exact version in the published tarball; npm would publish the literal "workspace:*", which npm/yarn consumers cannot resolve. - One-time: revert the 25 workspace manifests' @ohif/* specifiers to workspace:* (version fields untouched) and regenerate pnpm-lock.yaml to match. Because internal deps are workspace:* (links, not versions in the lockfile), version bumps no longer change pnpm-lock.yaml, so it stays in sync and frozen installs keep working. Verified: `pnpm install --frozen-lockfile` passes, and `pnpm pack` of @ohif/core emits a tarball whose @ohif/ui dependency is the exact version (3.13.0-beta.92), not workspace:*. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(ci): correct build-docs install comment for workspace:* release flow publish-version.mjs no longer rewrites @ohif/* deps to concrete versions, so the old comment was stale. Internal deps stay workspace:* and the lockfile stays consistent; pnpm publish concretizes only the published tarball. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(docs): use --frozen-lockfile now that the lockfile no longer drifts With internal deps as workspace:* the lockfile stays in sync across version bumps, so the docs deploy can install frozen -- failing fast on genuine lockfile drift instead of silently reconciling. The --no-frozen-lockfile workaround is no longer needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci: use --frozen-lockfile in CI install steps now that the lockfile is stable Internal @ohif/* deps are workspace:* so pnpm-lock.yaml no longer drifts; the UNIT_TESTS/BUILD/NPM_PUBLISH installs can run frozen and fail fast on genuine drift. Kept --no-frozen-lockfile only where it is still required: the Dockerfile (platform/docs is excluded from the build context) and the playwright CS3D-version step (mutates @cornerstonejs versions before installing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: stability of seg load mpr test * Better drag fix for crosshairs --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
import { Locator, Page } from '@playwright/test';
|
|
import {
|
|
simulateClicksOnElement,
|
|
simulateDoubleClickOnElement,
|
|
simulateNormalizedClicksOnElement,
|
|
simulateNormalizedDragOnElement,
|
|
simulateNormalizedPathDragOnElement,
|
|
} from '../utils';
|
|
import { DataOverlayPageObject } from './DataOverlayPageObject';
|
|
import { DOMOverlayPageObject } from './DOMOverlayPageObject';
|
|
import { MagnifyGlassPageObject } from './MagnifyGlassPageObject';
|
|
|
|
export type SvgInnerElement = 'circle' | 'path' | 'line' | 'g';
|
|
|
|
type NormalizedDragParams = {
|
|
start: { x: number; y: number };
|
|
end: { x: number; y: number };
|
|
config?: { button?: 'left' | 'right' | 'middle'; delay?: number; steps?: number };
|
|
};
|
|
|
|
type NormalizedPathDragParams = {
|
|
path: { x: number; y: number }[];
|
|
config?: { button?: 'left' | 'right' | 'middle'; delay?: number; steps?: number };
|
|
};
|
|
|
|
export interface IOverlayText {
|
|
get windowLevel(): Locator;
|
|
get instanceNumber(): Locator;
|
|
}
|
|
function overlayTextFactory(viewport: Locator, id: string): IOverlayText {
|
|
const locator = viewport.getByTestId(id);
|
|
return {
|
|
get windowLevel() {
|
|
return locator.getByTitle('Window Level');
|
|
},
|
|
get instanceNumber() {
|
|
return locator.getByTitle('Instance Number');
|
|
},
|
|
};
|
|
}
|
|
|
|
export interface IViewportPageObject {
|
|
nthAnnotation(nth: number): {
|
|
locator: Locator;
|
|
click: () => Promise<void>;
|
|
contextMenu: {
|
|
open: () => Promise<void>;
|
|
};
|
|
text: {
|
|
locator: Locator;
|
|
click: () => Promise<void>;
|
|
};
|
|
};
|
|
clickAt: (
|
|
points: { x: number; y: number }[],
|
|
button?: 'left' | 'right' | 'middle'
|
|
) => Promise<void>;
|
|
doubleClickAt: (point: { x: number; y: number }) => Promise<void>;
|
|
normalizedClickAt: (
|
|
normalizedPoints: { x: number; y: number }[],
|
|
button?: 'left' | 'right' | 'middle'
|
|
) => Promise<void>;
|
|
normalizedDragAt: (params: NormalizedDragParams) => Promise<void>;
|
|
normalizedPathDragAt: (params: NormalizedPathDragParams) => Promise<void>;
|
|
orientationMarkers: {
|
|
topMid: Locator;
|
|
leftMid: Locator;
|
|
rightMid: Locator;
|
|
bottomMid: Locator;
|
|
};
|
|
overlayText: {
|
|
topLeft: IOverlayText;
|
|
topRight: IOverlayText;
|
|
bottomLeft: IOverlayText;
|
|
bottomRight: IOverlayText;
|
|
};
|
|
overlayMenu: {
|
|
dataOverlay: DataOverlayPageObject;
|
|
orientation: {
|
|
button: Locator;
|
|
click: () => Promise<void>;
|
|
};
|
|
windowLevel: {
|
|
button: Locator;
|
|
click: () => Promise<void>;
|
|
};
|
|
};
|
|
pane: Locator;
|
|
svg: (innerElement?: SvgInnerElement) => Locator;
|
|
getSvgAnnotationStatTextLines: (uid: string) => Locator;
|
|
navigationArrows: {
|
|
locator: Locator;
|
|
prev: {
|
|
button: Locator;
|
|
click: () => Promise<void>;
|
|
};
|
|
next: {
|
|
button: Locator;
|
|
click: () => Promise<void>;
|
|
};
|
|
};
|
|
sliceNavigation: {
|
|
toSlice: (sliceIndex: number) => Promise<void>;
|
|
toFirstSlice: () => Promise<void>;
|
|
toLastSlice: () => Promise<void>;
|
|
scrollBy: (delta: number) => Promise<void>;
|
|
};
|
|
magnifyGlass: MagnifyGlassPageObject;
|
|
}
|
|
|
|
export class ViewportPageObject {
|
|
readonly page: Page;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
}
|
|
|
|
private getAnnotation(viewport: Locator, nth: number) {
|
|
const page = this.page;
|
|
const domOverlayPageObject = new DOMOverlayPageObject(page);
|
|
const annotation = viewport.locator('g[data-annotation-uid]').nth(nth);
|
|
const textLocator = annotation.locator('text').first();
|
|
|
|
return {
|
|
locator: annotation,
|
|
click: async () => {
|
|
await annotation.click();
|
|
},
|
|
contextMenu: {
|
|
open: async () => {
|
|
await domOverlayPageObject.viewport.annotationContextMenu.open(annotation);
|
|
},
|
|
},
|
|
text: {
|
|
locator: textLocator,
|
|
click: async () => {
|
|
await textLocator.click({ force: true });
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
private async getViewportId(viewport: Locator): Promise<string> {
|
|
const id = await viewport.locator('[data-viewportid]').getAttribute('data-viewportid');
|
|
if (id === null) {
|
|
throw new Error('Could not resolve data-viewportid from viewport locator');
|
|
}
|
|
return id;
|
|
}
|
|
|
|
private getOrientationMarkers(viewport: Locator) {
|
|
return {
|
|
topMid: viewport.locator('.top-mid.orientation-marker'),
|
|
leftMid: viewport.locator('.left-mid.orientation-marker'),
|
|
rightMid: viewport.locator('.right-mid.orientation-marker'),
|
|
bottomMid: viewport.locator('.bottom-mid.orientation-marker'),
|
|
};
|
|
}
|
|
|
|
private getOverlayText(viewport: Locator) {
|
|
return {
|
|
topLeft: overlayTextFactory(viewport, 'viewport-overlay-top-left'),
|
|
topRight: overlayTextFactory(viewport, 'viewport-overlay-top-right'),
|
|
bottomLeft: overlayTextFactory(viewport, 'viewport-overlay-bottom-left'),
|
|
bottomRight: overlayTextFactory(viewport, 'viewport-overlay-bottom-right'),
|
|
};
|
|
}
|
|
|
|
private async getOverlayMenu(viewport: Locator) {
|
|
return {
|
|
dataOverlay: new DataOverlayPageObject(this.page, await this.getViewportId(viewport)),
|
|
get orientation() {
|
|
const button = viewport.locator('[data-cy^="orientationMenu"]');
|
|
return {
|
|
button,
|
|
async click() {
|
|
await button.click();
|
|
},
|
|
};
|
|
},
|
|
get windowLevel() {
|
|
const button = viewport.locator('[data-cy^="windowLevelMenu"]');
|
|
return {
|
|
button,
|
|
async click() {
|
|
await button.click();
|
|
},
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
private getSvg(viewport: Locator, innerElement?: SvgInnerElement) {
|
|
return viewport.locator(`svg.svg-layer${innerElement ? ` ${innerElement}` : ''}`);
|
|
}
|
|
|
|
private getNavigationArrows(viewport: Locator) {
|
|
const container = viewport.getByTestId('viewport-action-arrows');
|
|
const prevButton = viewport.getByTestId('viewport-action-arrows-left');
|
|
const nextButton = viewport.getByTestId('viewport-action-arrows-right');
|
|
return {
|
|
locator: container,
|
|
prev: {
|
|
button: prevButton,
|
|
click: async () => {
|
|
await prevButton.click();
|
|
},
|
|
},
|
|
next: {
|
|
button: nextButton,
|
|
click: async () => {
|
|
await nextButton.click();
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Note: awaiting the returned methods (toSlice, toFirstSlice, toLastSlice, scrollBy)
|
|
* does not guarantee the viewport has finished rendering. Follow up with
|
|
* `waitForViewportsRendered` if you need pixel-stable state.
|
|
*/
|
|
private getSliceNavigation(viewport: Locator) {
|
|
const page = this.page;
|
|
|
|
const jumpToImage = async (imageIndex: number) => {
|
|
const viewportId = await this.getViewportId(viewport);
|
|
await page.evaluate(
|
|
({ commandsManager, viewportId, imageIndex }) => {
|
|
return commandsManager.runCommand('jumpToImage', {
|
|
imageIndex,
|
|
viewport: { id: viewportId },
|
|
});
|
|
},
|
|
{
|
|
viewportId,
|
|
imageIndex,
|
|
commandsManager: await page.evaluateHandle('window.commandsManager'),
|
|
}
|
|
);
|
|
};
|
|
|
|
const scrollBy = async (delta: number) => {
|
|
const viewportId = await this.getViewportId(viewport);
|
|
await page.evaluate(
|
|
({ services, viewportId, delta }) => {
|
|
const cornerstoneViewport = (
|
|
services as any
|
|
).cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
|
if (!cornerstoneViewport) {
|
|
return;
|
|
}
|
|
return cornerstoneViewport.scroll(delta);
|
|
},
|
|
{
|
|
viewportId,
|
|
delta,
|
|
services: await page.evaluateHandle('window.services'),
|
|
}
|
|
);
|
|
};
|
|
|
|
return {
|
|
toSlice: jumpToImage,
|
|
toFirstSlice: () => jumpToImage(0),
|
|
toLastSlice: () => jumpToImage(-1),
|
|
scrollBy,
|
|
};
|
|
}
|
|
|
|
private async viewportPageObjectFactory(viewport: Locator): Promise<IViewportPageObject> {
|
|
return {
|
|
nthAnnotation: (nth: number) => this.getAnnotation(viewport, nth),
|
|
doubleClickAt: async (point: { x: number; y: number }) => {
|
|
await simulateDoubleClickOnElement({
|
|
locator: viewport,
|
|
point,
|
|
});
|
|
},
|
|
clickAt: async (points: { x: number; y: number }[], button?: 'left' | 'right' | 'middle') => {
|
|
await simulateClicksOnElement({
|
|
locator: viewport,
|
|
points,
|
|
button,
|
|
});
|
|
},
|
|
normalizedClickAt: async (
|
|
normalizedPoints: { x: number; y: number }[],
|
|
button?: 'left' | 'right' | 'middle'
|
|
) => {
|
|
await simulateNormalizedClicksOnElement({
|
|
locator: viewport,
|
|
normalizedPoints,
|
|
button,
|
|
});
|
|
},
|
|
normalizedDragAt: async (params: NormalizedDragParams) => {
|
|
await simulateNormalizedDragOnElement({
|
|
locator: viewport,
|
|
start: params.start,
|
|
end: params.end,
|
|
button: params.config?.button,
|
|
delay: params.config?.delay,
|
|
steps: params.config?.steps,
|
|
});
|
|
},
|
|
normalizedPathDragAt: async (params: NormalizedPathDragParams) => {
|
|
await simulateNormalizedPathDragOnElement({
|
|
locator: viewport,
|
|
path: params.path,
|
|
button: params.config?.button,
|
|
delay: params.config?.delay,
|
|
steps: params.config?.steps,
|
|
});
|
|
},
|
|
orientationMarkers: this.getOrientationMarkers(viewport),
|
|
overlayText: this.getOverlayText(viewport),
|
|
overlayMenu: await this.getOverlayMenu(viewport),
|
|
pane: viewport,
|
|
svg: (innerElement?: SvgInnerElement) => {
|
|
return this.getSvg(viewport, innerElement);
|
|
},
|
|
getSvgAnnotationStatTextLines: (uid: string) => {
|
|
return this.getSvg(viewport)
|
|
.locator(`g[data-annotation-uid="${uid}"]`)
|
|
.locator('tspan');
|
|
},
|
|
navigationArrows: this.getNavigationArrows(viewport),
|
|
sliceNavigation: this.getSliceNavigation(viewport),
|
|
magnifyGlass: new MagnifyGlassPageObject(this.page, viewport),
|
|
};
|
|
}
|
|
|
|
get active(): Promise<IViewportPageObject> {
|
|
const viewport = this.page.locator('[data-cy="viewport-pane"][data-is-active="true"]');
|
|
return this.viewportPageObjectFactory(viewport);
|
|
}
|
|
|
|
get crosshairs() {
|
|
const page = this.page;
|
|
|
|
const crosshairHoverTimeout = 20000;
|
|
|
|
async function getSlabHandleLocator(locator: Locator) {
|
|
const startTime = Date.now();
|
|
const rectLocator = locator.locator('rect').first();
|
|
const circleLocator = locator.locator('circle').first();
|
|
|
|
while (Date.now() - startTime < crosshairHoverTimeout) {
|
|
if ((await rectLocator.count()) > 0) {
|
|
return rectLocator;
|
|
}
|
|
|
|
if ((await circleLocator.count()) > 0) {
|
|
return circleLocator;
|
|
}
|
|
|
|
await page.waitForTimeout(250);
|
|
}
|
|
|
|
throw new Error('Could not find slab thickness handle for crosshairs interaction');
|
|
}
|
|
|
|
// Drive the drag from the handle's own bounding-box center rather than the
|
|
// async window.mouseX/Y tracker: the tracker can lag behind the hover, which
|
|
// makes the drag start from a stale point and rotate/resize nothing. Stepped
|
|
// moves emit intermediate mousemove events so cornerstone registers a real
|
|
// drag instead of a single teleport (which can be dropped or mis-deltad).
|
|
const DRAG_DISTANCE = 100;
|
|
const DRAG_STEPS = 10;
|
|
|
|
async function dragHandleFromCenter(handle: Locator, dx: number, dy: number) {
|
|
const box = await handle.boundingBox();
|
|
if (!box) {
|
|
throw new Error('Could not resolve crosshairs handle bounding box for drag');
|
|
}
|
|
const cx = box.x + box.width / 2;
|
|
const cy = box.y + box.height / 2;
|
|
|
|
await page.mouse.move(cx, cy);
|
|
await page.mouse.down();
|
|
await page.mouse.move(cx + dx, cy + dy, { steps: DRAG_STEPS });
|
|
await page.mouse.up();
|
|
}
|
|
|
|
async function increaseSlabThickness(locator: Locator, lineNumber: number, axis: string) {
|
|
const lineLocator = locator.locator('line').nth(lineNumber);
|
|
await lineLocator.click({ force: true });
|
|
await lineLocator.hover({ force: true, timeout: crosshairHoverTimeout });
|
|
|
|
const slabHandleLocator = await getSlabHandleLocator(locator);
|
|
await slabHandleLocator.hover({ force: true, timeout: crosshairHoverTimeout });
|
|
|
|
const dx = axis === 'x' ? DRAG_DISTANCE : 0;
|
|
const dy = axis === 'y' ? DRAG_DISTANCE : 0;
|
|
await dragHandleFromCenter(slabHandleLocator, dx, dy);
|
|
}
|
|
|
|
async function rotateCrosshairs(locator: Locator, lineNumber: number) {
|
|
const lineLocator = locator.locator('line').nth(lineNumber);
|
|
await lineLocator.click({ force: true });
|
|
await lineLocator.hover({ force: true, timeout: crosshairHoverTimeout });
|
|
|
|
const circleLocator = locator.locator('circle').nth(1);
|
|
await circleLocator.waitFor({ state: 'attached', timeout: crosshairHoverTimeout });
|
|
await circleLocator.hover({ force: true, timeout: crosshairHoverTimeout });
|
|
|
|
await dragHandleFromCenter(circleLocator, 0, DRAG_DISTANCE);
|
|
}
|
|
|
|
function crosshairsFactory(
|
|
locator: Locator,
|
|
increaseLineNumber: number,
|
|
increaseAxis: 'x' | 'y',
|
|
rotateLineNumber: number
|
|
) {
|
|
return {
|
|
increase: async () => {
|
|
await increaseSlabThickness(locator, increaseLineNumber, increaseAxis);
|
|
},
|
|
locator,
|
|
rotate: () => {
|
|
return rotateCrosshairs(locator, rotateLineNumber);
|
|
},
|
|
};
|
|
}
|
|
|
|
return {
|
|
axial: crosshairsFactory(page.locator('#svg-layer-mpr-axial'), 0, 'x', 3),
|
|
sagittal: crosshairsFactory(page.locator('#svg-layer-mpr-sagittal'), 2, 'x', 0),
|
|
coronal: crosshairsFactory(page.locator('#svg-layer-mpr-coronal'), 0, 'y', 0),
|
|
};
|
|
}
|
|
|
|
async getAll(): Promise<IViewportPageObject[]> {
|
|
const viewports = await this.page.getByTestId('viewport-pane').all();
|
|
return await Promise.all(viewports.map(viewport => this.viewportPageObjectFactory(viewport)));
|
|
}
|
|
|
|
getNth(index: number): Promise<IViewportPageObject> {
|
|
const viewport = this.getNthLocator(index);
|
|
return this.viewportPageObjectFactory(viewport);
|
|
}
|
|
|
|
getNthLocator(index: number): Locator {
|
|
return this.page.getByTestId('viewport-pane').nth(index);
|
|
}
|
|
|
|
getById(viewportId: string): Promise<IViewportPageObject> {
|
|
const viewport = this.page.locator(
|
|
`[data-cy="viewport-pane"]:has(div[data-viewportid="${viewportId}"])`
|
|
);
|
|
return this.viewportPageObjectFactory(viewport);
|
|
}
|
|
|
|
get grid() {
|
|
return this.page.getByTestId('viewport-grid');
|
|
}
|
|
}
|