Alonso Torres cb21f6401a
Add waitForLayoutUpdate plugin method (#9898)
*  Add waitForLayoutUpdate plugin method

*  Refactor internal wait for tasks
2026-07-28 14:44:30 +02:00

614 lines
23 KiB
TypeScript

import { expect, expectReject } from '../framework/expect';
import { describe, test } from '../framework/registry';
import type { Board, Font, Group, Shape, Text } from '@penpot/plugin-types';
import type { TestContext } from '../framework/types';
// waitForLayoutUpdate (context-level and per-shape).
// The promise must resolve only once the pending reflow work has been applied,
// so every test asserts the *observable geometry* after awaiting: if the
// promise settles too early the read-back is stale and the test goes red.
// Reflow work is scheduled by three pipelines: flex/grid layout updates
// (buffered ~100ms), wasm text resizes (debounced), and font-change
// re-measurement. The probes below cover each one, including cascaded reflows
// (a child resize that must also re-hug its ancestors) and waits racing the
// font-loading retry loop.
function flexBoard(ctx: TestContext): Board {
const b = ctx.penpot.createBoard();
ctx.board.appendChild(b);
return b;
}
/** Creates a horizontal flex board with two 50x50 rects and a 10px gap. */
function flexRow(ctx: TestContext): {
board: Board;
rects: [Shape, Shape];
} {
const b = flexBoard(ctx);
const flex = b.addFlexLayout();
flex.dir = 'row';
flex.columnGap = 10;
const r1 = ctx.penpot.createRectangle();
r1.resize(50, 50);
flex.appendChild(r1);
const r2 = ctx.penpot.createRectangle();
r2.resize(50, 50);
flex.appendChild(r2);
return { board: b, rects: [r1, r2] };
}
/** Orders the two rects of a flex row by their current x position. */
function byX(rects: [Shape, Shape]): { left: Shape; right: Shape } {
const [a, b] = rects;
return a.x <= b.x ? { left: a, right: b } : { left: b, right: a };
}
/** Font ids already handed out by `unloadedFont`. */
const claimedFonts = new Set<string>();
/**
* Picks an unclaimed font differing from the text's current one, so assigning
* it triggers a real (not-yet-loaded) fetch. A font stays cached for the whole
* run, so reusing one would drain the pending mark near-instantly.
*/
function unloadedFont(ctx: TestContext, t: Text): Font {
const all = ctx.penpot.fonts.all;
for (let i = all.length - 1; i >= 0; i--) {
const f = all[i];
if (f.fontId === t.fontId || f.variants.length === 0) continue;
if (claimedFonts.has(f.fontId)) continue;
claimedFonts.add(f.fontId);
return f;
}
throw new Error('no alternative font available');
}
/** Picks any font other than the text's current one; may already be loaded. */
function otherFont(ctx: TestContext, t: Text): Font {
const font = ctx.penpot.fonts.all.find(
(f) => f.fontId !== t.fontId && f.variants.length > 0,
);
if (!font) throw new Error('no alternative font available');
return font;
}
function autoWidthText(ctx: TestContext, value: string): Text {
const t = ctx.penpot.createText(value);
if (!t) throw new Error('createText returned null');
ctx.board.appendChild(t);
t.growType = 'auto-width';
t.fontSize = '36';
return t;
}
describe('WaitForLayoutUpdate', () => {
describe('Basics', () => {
test('context method returns a promise that resolves', async (ctx) => {
const p = ctx.penpot.waitForLayoutUpdate();
expect(typeof p.then).toBe('function');
const value = await p;
expect(value).toBeUndefined();
});
// For the next two, resolving (not rejecting, not hitting the runner
// timeout) is itself the behaviour under test, so they have no expects.
test('a timeout wait resolves via the fast path when nothing is pending', async (ctx) => {
// On an already drained pending map the settle signal replays
// synchronously, so it beats the deadline and even a 1ms timeout
// resolves. Draining is what has to be retried: a pipeline can schedule
// fresh work in the instant between the drain and the timed call, and
// that is a pending map, not a broken fast path.
let resolved = false;
for (let i = 0; i < 5 && !resolved; i++) {
await ctx.penpot.waitForLayoutUpdate();
resolved = await ctx.penpot
.waitForLayoutUpdate(1)
.then(() => true)
.catch(() => false);
}
expect(resolved).toBe(true);
});
test('shape method resolves for a shape with no pending work', async (ctx) => {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
await r.waitForLayoutUpdate();
});
test('consecutive waits each track their own pending work', async (ctx) => {
// Each call opens a fresh subscription to the pending map, so a wait must
// settle on the work scheduled right before it and never carry state from
// an earlier, already-resolved wait. Two successive resizes to different
// sizes prove it: the second wait has to reflect the second reflow.
const { rects } = flexRow(ctx);
await ctx.penpot.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(130, 0);
left.resize(70, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(80, 0);
});
});
describe('Flex reflow', () => {
test('sibling is repositioned after awaiting a child resize', async (ctx) => {
const { rects } = flexRow(ctx);
await ctx.penpot.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(130, 0);
});
test('board-level wait settles the reflow of its layout', async (ctx) => {
const { board: b, rects } = flexRow(ctx);
await b.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(120, 50);
// The layout pipeline marks the laid-out board (the parent) as pending,
// so the board proxy is the shape-level handle for this reflow.
await b.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(130, 0);
});
test('back-to-back resizes settle to the last value', async (ctx) => {
// Overlapping operations on the same shapes: the wait must not resolve
// after the first one drains while the second is still in flight.
const { rects } = flexRow(ctx);
await ctx.penpot.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(80, 50);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(right.x - left.x).toBeCloseTo(130, 0);
});
test('grid layout reflow settles child positions', async (ctx) => {
const b = flexBoard(ctx);
const grid = b.addGridLayout();
grid.addRow('fixed', 60);
grid.addColumn('fixed', 60);
grid.addColumn('fixed', 60);
const r1 = ctx.penpot.createRectangle();
r1.resize(50, 50);
grid.appendChild(r1, 1, 1);
const r2 = ctx.penpot.createRectangle();
r2.resize(50, 50);
grid.appendChild(r2, 1, 2);
await ctx.penpot.waitForLayoutUpdate();
// Cells are 60px wide, so the second child sits one track further right.
expect(Math.abs(r2.x - r1.x)).toBeCloseTo(60, 0);
});
});
describe('Hug sizing', () => {
test('hug board fits its content after awaiting the initial layout', async (ctx) => {
const { board: b } = flexRow(ctx);
const flex = b.flex;
if (!flex) throw new Error('expected a flex layout on the board');
flex.horizontalSizing = 'auto';
flex.verticalSizing = 'auto';
await ctx.penpot.waitForLayoutUpdate();
// 50 + 10 gap + 50, no padding.
expect(b.width).toBeCloseTo(110, 0);
});
test('hug board grows after awaiting a child resize', async (ctx) => {
const { board: b, rects } = flexRow(ctx);
const flex = b.flex;
if (!flex) throw new Error('expected a flex layout on the board');
flex.horizontalSizing = 'auto';
flex.verticalSizing = 'auto';
await ctx.penpot.waitForLayoutUpdate();
const { left } = byX(rects);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(b.width).toBeCloseTo(180, 0);
});
test('nested hug boards both grow after awaiting a child resize', async (ctx) => {
// Cascade probe: the child resize reflows the inner board, whose new
// size must then reflow the outer board. The wait has to cover the whole
// chain, not just the first reflow round.
const outer = flexBoard(ctx);
const outerFlex = outer.addFlexLayout();
outerFlex.dir = 'row';
outerFlex.horizontalSizing = 'auto';
outerFlex.verticalSizing = 'auto';
const inner = ctx.penpot.createBoard();
outerFlex.appendChild(inner);
const innerFlex = inner.addFlexLayout();
innerFlex.dir = 'row';
innerFlex.horizontalSizing = 'auto';
innerFlex.verticalSizing = 'auto';
const rect = ctx.penpot.createRectangle();
rect.resize(50, 50);
innerFlex.appendChild(rect);
await ctx.penpot.waitForLayoutUpdate();
expect(inner.width).toBeCloseTo(50, 0);
expect(outer.width).toBeCloseTo(50, 0);
rect.resize(200, 50);
await ctx.penpot.waitForLayoutUpdate();
expect(inner.width).toBeCloseTo(200, 0);
expect(outer.width).toBeCloseTo(200, 0);
});
test.skipIfMocked(
'hug board hugs an auto-width text after a characters change',
async (ctx) => {
// Cascade probe across pipelines: the text resize is scheduled by the
// text pipeline and only then does the board reflow to hug it. Skipped
// under a mocked backend: hugging a text depends on real glyph
// measurement (no fonts are served), so the text width never grows.
const b = flexBoard(ctx);
const flex = b.addFlexLayout();
flex.horizontalSizing = 'auto';
flex.verticalSizing = 'auto';
const t = ctx.penpot.createText('hi');
if (!t) throw new Error('createText returned null');
flex.appendChild(t);
t.growType = 'auto-width';
await ctx.penpot.waitForLayoutUpdate();
const w0 = b.width;
t.characters = 'a much much much longer text content than before';
await ctx.penpot.waitForLayoutUpdate();
expect(t.width).toBeGreaterThan(w0);
expect(b.width).toBeCloseTo(t.width, 0);
},
);
});
// Skipped under a mocked backend: every case here asserts that a text is
// re-measured (width changes) after a mutation, which depends on real glyph
// measurement against fonts the backend serves. Mocked mode serves no fonts,
// so the measured width stays put and the assertions can't hold.
describe.skipIfMocked('Text', () => {
test('auto-width text grows after awaiting a fontSize bump', async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
t.fontSize = '72';
await ctx.penpot.waitForLayoutUpdate();
expect(t.width).toBeGreaterThan(w0);
});
test('auto-width text grows after awaiting a characters change', async (ctx) => {
const t = autoWidthText(ctx, 'short');
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
t.characters = 'short plus a considerably longer tail 0123456789';
await ctx.penpot.waitForLayoutUpdate();
expect(t.width).toBeGreaterThan(w0);
});
test('shape-level wait covers the text own resize', async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await t.waitForLayoutUpdate();
const w0 = t.width;
t.fontSize = '72';
await t.waitForLayoutUpdate();
expect(t.width).toBeGreaterThan(w0);
});
test('auto-width text is re-measured after awaiting a font change', async (ctx) => {
// Probe for the font-loading window: right after a font change the new
// font is typically not loaded yet, and the wait must cover the load +
// re-measure, not resolve while the loader is still retrying.
const t = autoWidthText(
ctx,
'The quick brown fox jumps over the lazy dog 0123456789',
);
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
const font = otherFont(ctx, t);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
await ctx.penpot.waitForLayoutUpdate();
// A different family practically never yields the same measured width
// for a 54-char string at 36px.
expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5);
// The wait covers the *final* geometry: a second wait must find the
// text where the first one left it.
const settled = t.width;
await ctx.penpot.waitForLayoutUpdate();
expect(t.width).toBeCloseTo(settled, 0);
});
test('range font change is re-measured after awaiting', async (ctx) => {
// Same probe through the text-range pipeline instead of the shape-level
// font setter.
const t = autoWidthText(
ctx,
'The quick brown fox jumps over the lazy dog 0123456789',
);
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
const font = otherFont(ctx, t);
const range = t.getRange(0, t.characters.length);
range.fontFamily = font.fontFamily;
range.fontId = font.fontId;
await ctx.penpot.waitForLayoutUpdate();
expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5);
});
});
// A font applied to a group reaches its text descendants, so the work is
// pending on the children and never on the group itself. Skipped under a
// mocked backend for the same reason as the Text group: no fonts are served,
// so nothing is ever re-measured.
describe.skipIfMocked('Groups', () => {
/** Groups two auto-width texts and returns the group with its children. */
function textGroup(ctx: TestContext): {
group: Group;
texts: [Text, Text];
} {
const a = autoWidthText(ctx, 'The quick brown fox');
const b = autoWidthText(ctx, 'jumps over the lazy dog');
const group = ctx.penpot.group([a, b]);
if (!group) throw new Error('group returned null');
return { group, texts: [a, b] };
}
/** Groups an auto-width text with a fixed-size one. */
function mixedGrowTypeGroup(ctx: TestContext): {
group: Group;
auto: Text;
fixed: Text;
} {
const auto = autoWidthText(ctx, 'The quick brown fox');
const fixed = autoWidthText(ctx, 'jumps over the lazy dog');
fixed.resize(400, 80);
fixed.growType = 'fixed';
const group = ctx.penpot.group([auto, fixed]);
if (!group) throw new Error('group returned null');
return { group, auto, fixed };
}
/**
* The typed signature of `applyToText` takes a `Text`, but the setter also
* accepts a container and forwards the change to its text descendants, as
* it does for a group selected in the UI.
*/
function applyFontToGroup(font: Font, group: Group): void {
font.applyToText(group as unknown as Text);
}
test('context wait covers a font applied to a group', async (ctx) => {
const { group, texts } = textGroup(ctx);
await ctx.penpot.waitForLayoutUpdate();
const [w0, w1] = [texts[0].width, texts[1].width];
const font = otherFont(ctx, texts[0]);
applyFontToGroup(font, group);
const started = Date.now();
await ctx.penpot.waitForLayoutUpdate();
expect(Math.abs(texts[0].width - w0)).toBeGreaterThan(0.5);
expect(Math.abs(texts[1].width - w1)).toBeGreaterThan(0.5);
// The wait tracks the text children. Tracking the group id would only
// unblock on the internal stuck-timeout, orders of magnitude slower.
expect(Date.now() - started).toBeLessThan(5000);
});
test('group wait covers the reflow of its text children', async (ctx) => {
const { group, texts } = textGroup(ctx);
await group.waitForLayoutUpdate();
const w0 = texts[0].width;
const font = otherFont(ctx, texts[0]);
applyFontToGroup(font, group);
const started = Date.now();
await group.waitForLayoutUpdate();
expect(Math.abs(texts[0].width - w0)).toBeGreaterThan(0.5);
expect(Date.now() - started).toBeLessThan(5000);
});
test('a font on a mixed group re-measures only the auto-width child', async (ctx) => {
// A group can hold both grow types, and only the auto ones have geometry
// to recompute. A fixed text keeps its box whatever the new glyphs
// measure, so the wait settles on the auto child alone.
const { group, auto, fixed } = mixedGrowTypeGroup(ctx);
await ctx.penpot.waitForLayoutUpdate();
const [autoW0, fixedW0] = [auto.width, fixed.width];
const font = otherFont(ctx, auto);
applyFontToGroup(font, group);
const started = Date.now();
await ctx.penpot.waitForLayoutUpdate();
expect(Math.abs(auto.width - autoW0)).toBeGreaterThan(0.5);
expect(fixed.width).toBeCloseTo(fixedW0, 0);
expect(Date.now() - started).toBeLessThan(5000);
});
test('board wait covers a pending descendant', async (ctx) => {
// The shape-level wait spans the subtree, so a board settles only once
// the text nested inside it has been re-measured too.
const b = flexBoard(ctx);
const t = ctx.penpot.createText('The quick brown fox');
if (!t) throw new Error('createText returned null');
b.appendChild(t);
t.growType = 'auto-width';
t.fontSize = '36';
await b.waitForLayoutUpdate();
const w0 = t.width;
const font = otherFont(ctx, t);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
await b.waitForLayoutUpdate();
expect(Math.abs(t.width - w0)).toBeGreaterThan(0.5);
});
});
describe('Arguments', () => {
test('rejects a non-numeric timeout', async (ctx) => {
await expectReject(() =>
ctx.penpot.waitForLayoutUpdate('soon' as unknown as number),
);
});
test('rejects a non-positive timeout', async (ctx) => {
// 0 would mean "reject immediately"; omitting the argument is how a
// caller asks for the default.
await expectReject(() => ctx.penpot.waitForLayoutUpdate(0));
await expectReject(() => ctx.penpot.waitForLayoutUpdate(-1));
});
test('shape method rejects an invalid timeout too', async (ctx) => {
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
await expectReject(() =>
r.waitForLayoutUpdate('soon' as unknown as number),
);
});
test('an invalid timeout comes back as a rejected promise', async (ctx) => {
// `expectReject` accepts a synchronous throw as readily as a rejection,
// so a thunk cannot tell the two apart. The signature is `Promise<void>`:
// the rejection has to travel on a promise, so call it bare and check
// what came back before awaiting it.
const r = ctx.penpot.createRectangle();
ctx.board.appendChild(r);
const fromContext = ctx.penpot.waitForLayoutUpdate(-1);
expect(fromContext instanceof Promise).toBe(true);
await expectReject(fromContext);
const fromShape = r.waitForLayoutUpdate(0);
expect(fromShape instanceof Promise).toBe(true);
await expectReject(fromShape);
});
});
describe('Timeout', () => {
// Pure geometry mutations (resize & co.) are settled synchronously by the
// renderer, so right after them nothing is pending and a short-timeout
// wait legitimately resolves. The deterministic pending source is a font
// change: it marks the text pending until the font is fetched and the
// shape re-measured, which can never complete within 1ms. These tests hit
// the real backend for the font asset, hence skipIfMocked.
test.skipIfMocked(
'rejects with a timeout error while a font is loading',
async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await ctx.penpot.waitForLayoutUpdate();
// A fresh font change leaves work pending, so a 1ms deadline loses and
// the wait rejects. Retried because that work can already have drained
// by the time the deadline is set.
let rejected = false;
for (let i = 0; i < 3 && !rejected; i++) {
const font = unloadedFont(ctx, t);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
rejected = await ctx.penpot
.waitForLayoutUpdate(1)
.then(() => false)
.catch((e: Error) => /timeout/i.test(e.message));
}
expect(rejected).toBe(true);
},
);
test.skipIfMocked(
'shape-level wait also rejects on timeout',
async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await ctx.penpot.waitForLayoutUpdate();
const font = unloadedFont(ctx, t);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
await expectReject(t.waitForLayoutUpdate(1), /timeout/i);
},
);
test('resolves normally when work settles before the timeout', async (ctx) => {
const { rects } = flexRow(ctx);
await ctx.penpot.waitForLayoutUpdate();
const { left, right } = byX(rects);
left.resize(120, 50);
await ctx.penpot.waitForLayoutUpdate(5000);
expect(right.x - left.x).toBeCloseTo(130, 0);
});
test.skipIfMocked(
'a rejected wait leaves later waits working',
async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
await ctx.penpot.waitForLayoutUpdate();
const w0 = t.width;
const loaded = { fontId: t.fontId, fontFamily: t.fontFamily };
// A fresh font change leaves work pending, so a 1ms deadline loses and
// the wait rejects. Retried because that work can already have drained
// by the time the deadline is set.
let rejected = false;
for (let i = 0; i < 3 && !rejected; i++) {
const font = unloadedFont(ctx, t);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
rejected = await ctx.penpot
.waitForLayoutUpdate(1)
.then(() => false)
.catch((e: Error) => /timeout/i.test(e.message));
}
expect(rejected).toBe(true);
// A later wait still tracks work and still reports settled geometry.
// Measured on the loaded font the text started with: one inside its
// block period renders no box, so that text can never grow.
t.fontId = loaded.fontId;
t.fontFamily = loaded.fontFamily;
await ctx.penpot.waitForLayoutUpdate(8000);
t.characters =
'The quick brown fox jumps over the lazy dog 0123456789 and then some';
await ctx.penpot.waitForLayoutUpdate(8000);
expect(t.width).toBeGreaterThan(w0);
},
);
test.skipIfMocked(
'per-shape wait is not blocked by another shape pending work',
async (ctx) => {
const t = autoWidthText(ctx, 'The quick brown fox');
const bystander = ctx.penpot.createRectangle();
ctx.board.appendChild(bystander);
await ctx.penpot.waitForLayoutUpdate();
const font = unloadedFont(ctx, t);
t.fontId = font.fontId;
t.fontFamily = font.fontFamily;
// The text has pending font work but the bystander does not; its wait
// must resolve on the fast path instead of waiting for the font.
const started = Date.now();
await bystander.waitForLayoutUpdate();
expect(Date.now() - started).toBeLessThan(90);
},
);
});
});