mirror of
https://github.com/penpot/penpot.git
synced 2026-08-15 01:08:45 +00:00
* ✨ Add plugin with composable test framework and component tests The plugin provides a framework for writing composable tests against the Plugin API, and applies it to systematic end-to-end testing of component semantics. The framework's core ideas: a test is written once as a composition of operations over a starting configuration; choice points among the operations (optional steps, alternatives) expand the composition into a full sweep of test variants, so a single case definition yields broad combinatorial coverage; and the operations drive the real Plugin API with real change propagation, testing the full production implementation. The initial application is a suite of component test cases covering synchronization, overrides, swap slots and variants — the TypeScript/e2e continuation of the ClojureScript composable test suite (frontend_tests.composable_tests). Several cases originate from reproducing real defects (e.g. #10109 and the swap-slot corruptions). Tests run from an interactive panel in Penpot: cases are listed with plain-language descriptions, tests can be run selectively, results stream in live, and every checkbox carries a stable DOM id — so the panel can equally be driven programmatically (the basis for running the suite in CI), as documented in the plugin's README. Lives at plugins/apps/composable-test-suite as a regular member of the plugins workspace (init script, start:plugin:composable-test-suite, shared dev port 4202, covered by build:plugins via the new ./apps/*-test-suite filter). Related to #10584. AI-assisted-by: claude-fable-5 * ✨ Run the composable test suite headlessly in CI Adds a headless run mode for the composable test suite, following the plugin-api-test-suite's CI architecture, and a workflow that runs it as a per-PR gate. An in-sandbox entry (src/ci/headless.ts) runs the suite without the panel UI — the framework's runner was UI-free by construction, so no refactoring was needed — and streams each result through console markers, addressed by the same composite identifiers the panel uses (e.g. MainEditSyncs-2), with durations and, on failure, the error and the applied-steps transcript. It is built as a single self-executing bundle and evaluated directly inside a real Penpot plugin sandbox by the driver (ci/run-ci.ts), so no plugin dev server or port is involved. The driver needs no backend and no login: it serves the prebuilt frontend bundle via the frontend e2e static server and intercepts every backend RPC with Playwright fixtures. The mocked backend is not a limitation for this suite — everything it asserts is frontend store logic executed in memory — which the full run confirms: all 48 tests behave identically to the interactive panel, including variants and swap slots, with the single (currently expected) failure of MainEditSyncs-2 reproducing bug #10109 under the mock. TEST_FILTER selects tests by identifier substring; CI_TIMEOUT_MS bounds the run. The mock harness mirrors the frontend e2e harness (see the provenance note in the driver). Related to #10584. AI-assisted-by: claude-fable-5 * 📚 Restructure the composable-tests memory around both suites Present the composable component tests top-down: the shared framework principles upfront, then the two implementations — the ClojureScript suite in the frontend test tree and the TypeScript suite in the plugin, which tests fully end-to-end with a slightly more elaborate set of abstractions — and the plugin's headless CI run, pointing to the plugin's README for operational details. Also records this session's additions (geometry operations, case N, the CI harness). AI-assisted-by: claude-fable-5 * 📎 Refine the PR-description conventions in the creating-prs memory Encourage digestible descriptions: bullet items over prose (grouped by area with bold lead-ins for larger PRs) and no manual line wraps, since the rendered markdown adapts to the viewport. Also drop the outdated 'MCP' from the standard Note line. AI-assisted-by: claude-fable-5 * 🔧 Set Prettier endOfLine to auto in plugins workspace Prettier defaults to endOfLine "lf", which is incompatible with checkouts on Windows that use core.autocrlf=true * 🐛 Fix problems with suite --------- Co-authored-by: alonso.torres <alonso.torres@kaleidos.net>
96 lines
5.3 KiB
TypeScript
96 lines
5.3 KiB
TypeScript
import { TestCase } from "../test-suite/TestCase.ts";
|
|
import { Situation } from "../core/Situation";
|
|
import { Color } from "../model/Color";
|
|
import { ShapePropFillColor } from "../model/ShapeProp.ts";
|
|
import { OpAssert } from "../operations/OpAssert";
|
|
import { OpSequence } from "../operations/OpSequence.ts";
|
|
import { OpOptional } from "../operations/OpOptional.ts";
|
|
import { OpCreateVariantContainer } from "../operations/OpCreateVariantContainer";
|
|
import { OpCreateNestableComponent } from "../operations/OpCreateNestableComponent";
|
|
import { OpSwitchVariant } from "../operations/OpSwitchVariant";
|
|
import { ContentCreationStrategyRectangle } from "../content-creation/ContentCreationStrategyRectangle.ts";
|
|
|
|
/**
|
|
* Case M — a variant switch propagates like a swap (the variant-switch flavour of
|
|
* the swap sweep).
|
|
*
|
|
* Build a variant SET of peer members (member i is a rectangle of colour
|
|
* COLORS[i]) and nest the base member at EVERY level, so each level has a variant
|
|
* head. Then OPTIONALLY switch the head at each level to a differently-coloured
|
|
* sibling (a switch at level i selects member i+1, colour SWAP_COLORS[i]) and
|
|
* assert the colour that surfaces at every level. A switch at level i propagates
|
|
* OUTWARD to level i and every outer level until a switch at a higher level
|
|
* overrides it — so the colour at level i is the switch applied at the HIGHEST
|
|
* index j ≤ i, else the base member's colour.
|
|
*/
|
|
export function createTestCaseVariantSwitchPropagates(): TestCase {
|
|
const fillColor = new ShapePropFillColor();
|
|
// the variant members' colours: index 0 is the base member, 1..3 the siblings a
|
|
// switch can select. swapColors[i] is the colour a switch at level i selects.
|
|
const baseColor = new Color("#aaaaaa");
|
|
const swapColors = [new Color("#ff0000"), new Color("#00ff00"), new Color("#0000ff")];
|
|
const variantColors = [baseColor, ...swapColors]; // variant i has colour COLORS[i]
|
|
const nestingLevels = 3;
|
|
|
|
// the variant set: one rectangle member per colour, addressed by index value
|
|
const opCreateVariantContainer = new OpCreateVariantContainer(
|
|
...variantColors.map((color) => new ContentCreationStrategyRectangle(color))
|
|
);
|
|
|
|
// nest the base member (variant 0) at every level: its instance is the head at
|
|
// each nesting level, exactly the target a per-level switch acts on
|
|
const opCreateComponent = new OpCreateNestableComponent(opCreateVariantContainer.createContentCreationStrategy(0));
|
|
|
|
// switch[i] switches level i's variant instance to member i+1 (colour SWAP_COLORS[i]).
|
|
// The nestable op yields the structural nested head; the variant container finds
|
|
// the variant instance within it — keeping variant knowledge out of the nestable op.
|
|
const opVariantSwitches = Array.from(
|
|
{ length: nestingLevels },
|
|
(_unused, i) =>
|
|
new OpSwitchVariant(
|
|
(s: Situation) =>
|
|
opCreateVariantContainer.getVariantInstance(opCreateComponent.getNestedInstance(s, i)),
|
|
opCreateVariantContainer.valueForVariant(i + 1)
|
|
)
|
|
);
|
|
|
|
// the colour expected at level i: the switch applied at the highest j ≤ i, else base
|
|
const expectedAtLevel = (s: Situation, level: number): Color => {
|
|
for (let j = level; j >= 0; j--) {
|
|
if (s.wasApplied(opVariantSwitches[j])) return swapColors[j];
|
|
}
|
|
return baseColor;
|
|
};
|
|
|
|
return new TestCase(
|
|
"VariantSwitchPropagates",
|
|
"A variant set with four differently coloured rectangle members is created. Its base " +
|
|
"member is placed inside a component, from which a copy is instantiated and then " +
|
|
"wrapped in further components, one nesting level at a time — so for each level there " +
|
|
"is a copy carrying the variant instance at that depth. At each level, that variant " +
|
|
"instance is optionally switched to a differently coloured member. Each level's " +
|
|
"rectangle must show the colour selected by the switch applied at the highest level " +
|
|
"at or below it: a variant switch must propagate through nesting exactly like a " +
|
|
"component swap.",
|
|
new OpSequence(
|
|
// foundations: create the variants, a component with an instance
|
|
// and nest the instance several times
|
|
opCreateVariantContainer,
|
|
opCreateComponent, // creates component with a variant instance
|
|
opCreateComponent.createOpInstantiate(), // level 0
|
|
opCreateComponent.createOpMakeNested(), // level 1
|
|
opCreateComponent.createOpMakeNested(), // level 2
|
|
// optionally switch the variant to a unique colour at each nesting level
|
|
...opVariantSwitches.map((sw) => new OpOptional(sw)),
|
|
// check the colour that surfaces at every level
|
|
new OpAssert("each level shows the highest-precedence applied switch", (s) => {
|
|
for (let level = 0; level < nestingLevels; level++) {
|
|
const nestedInstance = opCreateComponent.getNestedInstance(s, level);
|
|
const rect = ContentCreationStrategyRectangle.findRectangle(nestedInstance);
|
|
fillColor.assertEqual(fillColor.read(rect), expectedAtLevel(s, level));
|
|
}
|
|
})
|
|
)
|
|
);
|
|
}
|