diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3e6717d..fd8c978 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,12 +6,14 @@ - [ ] `./gradlew test` passes -- [ ] `npm test` in `web/` passes, if you touched the browser version +- [ ] `npm test` and `npm run test:browser` in `web/` pass, if you touched the + browser version - [ ] Changing one engine and not the other? The equivalence fixtures will say so — see `web/README.md` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15666e8..52f74c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,9 @@ jobs: - uses: gradle/actions/setup-gradle@v6 - # No display is needed: the engine and the tree layout hold no JavaFX - # reference, and nothing here starts the toolkit. - - run: ./gradlew test --no-daemon + # The view tests build real controls, and JavaFX needs a display for + # that even when no window is shown. + - run: xvfb-run -a ./gradlew test --no-daemon - if: failure() uses: actions/upload-artifact@v7 @@ -54,3 +54,31 @@ jobs: # one implementation and not the other fails here. - run: npm test working-directory: web + + browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '22' + cache: npm + cache-dependency-path: web/package-lock.json + + - run: npm ci + working-directory: web + - run: npx playwright install --with-deps chromium + working-directory: web + + # The demo in a real browser, against the page as it is published. This + # is the only thing here that can see layout and colour: the unit tests + # run against a simulated DOM, which has neither. + - run: npm run test:browser + working-directory: web + + - if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: web/playwright-report diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5d4d441..7dbe591 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -44,6 +44,10 @@ jobs: working-directory: web - run: npm test working-directory: web + - run: npx playwright install --with-deps chromium + working-directory: web + - run: npm run test:browser + working-directory: web - run: npm run build working-directory: web diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32d0f58..9dca354 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -50,7 +50,8 @@ jobs: cache-dependency-path: web/package-lock.json # Nothing is released that does not pass, in either implementation. - - run: ./gradlew test --no-daemon + # The view tests build real controls, which needs a display. + - run: xvfb-run -a ./gradlew test --no-daemon - run: npm ci working-directory: web - run: npm test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 270bedd..9eba924 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,12 +63,28 @@ When you add a test, check it can fail. Break the thing it covers on purpose and watch it go red. A test that passes the moment you write it has told you nothing — that is how the fixture problem above went unnoticed. -## What has no automated coverage +## Testing what is drawn -The JavaFX views. Nothing checks them, and a button once went missing in a -rebuild and stayed missing until someone read the README. If you change the -desktop UI, run it and attach a screenshot. The browser version does have view -tests, in `web/test/render.test.ts`. +Both front ends are covered, in the only ways each can be. + +The JavaFX views are built for real in `src/test/java/esu/algorithm/ui/`, on +the JavaFX thread and with the stylesheets applied, but without showing a +window. Those tests check what gets built and how it is styled: that every +control exists, that a tree box carries exactly one state, that every word can +be read against what is behind it. `FxTest` has the harness. + +The demo is checked twice. `web/test/` runs it against a simulated DOM, which +is fast but has no layout engine and no stylesheets. `web/browser/` runs the +published page in real Chromium, which is the only thing here that can see +size, position and colour. + +What none of this can tell you is whether the result looks *good*. A screenshot +still answers that, and it is worth attaching one to a pull request that +changes anything visual. + +Every visual bug this project has shipped was a code path whose only consumer +was a picture, so when you add one, break it on purpose and watch the test go +red before you trust it. ## Style diff --git a/README.md b/README.md index f93eb29..8ad767c 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,11 @@ Two other things are worth testing and are: that a step reached by replay is identical to one reached by stepping, and that the tree layout puts parents over their children without overlapping anything. +Both front ends are covered too. The JavaFX views are built for real, styled, +and inspected without showing a window. The demo is run in Chromium against the +published page, which is the only thing here that can see size, position and +colour. + ## License MIT — see [LICENSE](LICENSE). diff --git a/src/test/java/esu/algorithm/ui/EsuAppTest.java b/src/test/java/esu/algorithm/ui/EsuAppTest.java new file mode 100644 index 0000000..2eb03a9 --- /dev/null +++ b/src/test/java/esu/algorithm/ui/EsuAppTest.java @@ -0,0 +1,138 @@ +package esu.algorithm.ui; + +import java.util.ArrayList; +import java.util.List; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.ButtonBase; +import javafx.scene.control.Menu; +import javafx.scene.control.MenuBar; +import javafx.scene.control.MenuItem; +import javafx.stage.Stage; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * That the window is built with everything it is supposed to have. + * + * Saving the results once disappeared during a rebuild and stayed gone until + * someone happened to read the README: nothing referred to it, so nothing + * broke. A control that quietly stops being built is invisible to every test + * that does not look at the window itself. + */ +public class EsuAppTest extends FxTest { + + /** Build the real window, off screen, and hand back its scene. */ + private Scene window() { + return onFxThread(() -> { + Stage stage = new Stage(); + new EsuApp().start(stage); + Scene scene = stage.getScene(); + // Built and styled is all this needs; showing it is not. + scene.getRoot().applyCss(); + scene.getRoot().layout(); + stage.close(); + return scene; + }); + } + + private static void collect(Node node, List found) { + found.add(node); + if (node instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + collect(child, found); + } + } + } + + private List buttonLabels(Scene scene) { + List all = new ArrayList<>(); + onFxThread(() -> { + collect(scene.getRoot(), all); + return null; + }); + List labels = new ArrayList<>(); + for (Node node : all) { + if (node instanceof ButtonBase button && button.getText() != null + && !button.getText().isBlank()) { + labels.add(button.getText()); + } + } + return labels; + } + + private List menuItems(Scene scene) { + List all = new ArrayList<>(); + onFxThread(() -> { + collect(scene.getRoot(), all); + return null; + }); + List items = new ArrayList<>(); + for (Node node : all) { + if (node instanceof MenuBar bar) { + for (Menu menu : bar.getMenus()) { + for (MenuItem item : menu.getItems()) { + if (item.getText() != null) { + items.add(item.getText()); + } + } + } + } + } + return items; + } + + @Test + public void offersEveryControlTheReadmeSaysItHas() { + List buttons = buttonLabels(window()); + + for (String expected : List.of("Open graph", "Random", "Save results", + "Fit", "Play")) { + assertTrue(buttons.contains(expected), + "no control labelled '" + expected + "'; found " + buttons); + } + } + + @Test + public void offersAPillForEverySubgraphSizeItSupports() { + List buttons = buttonLabels(window()); + + for (int size = EsuApp.MIN_SUBGRAPH_SIZE; + size <= EsuApp.MAX_SUBGRAPH_SIZE; size++) { + assertTrue(buttons.contains(String.valueOf(size)), + "no pill for subgraph size " + size); + } + } + + @Test + public void offersTheMenusAndTheirShortcuts() { + Scene scene = window(); + + List items = menuItems(scene); + for (String expected : List.of("Show step history", "Show shapes found", + "Play or pause", "Next step", "Previous step")) { + assertTrue(items.contains(expected), + "no menu item '" + expected + "'; found " + items); + } + } + + @Test + public void startsWithNothingToActOnDisabled() { + Scene scene = window(); + List all = new ArrayList<>(); + onFxThread(() -> { + collect(scene.getRoot(), all); + return null; + }); + + // Until a graph is open, stepping through a search makes no sense. + for (Node node : all) { + if (node instanceof ButtonBase button + && "Play".equals(button.getText())) { + assertTrue(button.isDisabled(), + "Play is live before a graph is loaded"); + } + } + } +} diff --git a/src/test/java/esu/algorithm/ui/FxTest.java b/src/test/java/esu/algorithm/ui/FxTest.java new file mode 100644 index 0000000..cb04525 --- /dev/null +++ b/src/test/java/esu/algorithm/ui/FxTest.java @@ -0,0 +1,107 @@ +package esu.algorithm.ui; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import javafx.application.Platform; +import javafx.scene.Parent; +import javafx.scene.Scene; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Class FxTest + * + * Enough of JavaFX to build real controls and look at them, without showing a + * window or pulling in a UI-driving framework. + * + * The views had no automated coverage at all, and every visual bug this + * project shipped reached a person before it reached a test. What those bugs + * had in common was not a mishandled click: it was the wrong nodes being + * built, or built with the wrong style. That is what this can see. + * + * What it cannot see is whether the result looks good. Screenshots still + * answer that. + */ +public abstract class FxTest { + + private static boolean started; + + /** Starts the toolkit once for the whole run. */ + protected static synchronized void startToolkit() { + if (started) { + return; + } + CountDownLatch ready = new CountDownLatch(1); + try { + Platform.startup(ready::countDown); + } catch (IllegalStateException alreadyRunning) { + ready.countDown(); + } + try { + assertTrue(ready.await(30, TimeUnit.SECONDS), + "the JavaFX toolkit did not start"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + fail("interrupted waiting for the toolkit"); + } + Platform.setImplicitExit(false); + started = true; + } + + /** + * Run something on the JavaFX thread and hand back what it made. + * + * @param what is being built + * @param build builds it, on the right thread + * @return the result + */ + protected static T onFxThread(Supplier build) { + startToolkit(); + AtomicReference result = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + CountDownLatch done = new CountDownLatch(1); + + Platform.runLater(() -> { + try { + result.set(build.get()); + } catch (Throwable thrown) { + failure.set(thrown); + } finally { + done.countDown(); + } + }); + try { + assertTrue(done.await(30, TimeUnit.SECONDS), + "nothing came back from the JavaFX thread"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + fail("interrupted waiting for the JavaFX thread"); + } + if (failure.get() != null) { + throw new AssertionError("failed on the JavaFX thread", failure.get()); + } + return result.get(); + } + + /** + * Put a control in a scene and resolve its styles, so that what the tests + * read is what a user would have seen. + * + * @param root the control under test + * @param dark true to layer the dark stylesheet over the base one + * @return the same control, styled and laid out + */ + protected static Parent style(Parent root, boolean dark) { + Scene scene = new Scene(root, 900, 700); + scene.getStylesheets().add( + FxTest.class.getResource("esu.css").toExternalForm()); + if (dark) { + scene.getStylesheets().add( + FxTest.class.getResource("dark.css").toExternalForm()); + } + root.applyCss(); + root.layout(); + return root; + } +} diff --git a/src/test/java/esu/algorithm/ui/ReadableTest.java b/src/test/java/esu/algorithm/ui/ReadableTest.java new file mode 100644 index 0000000..83c7e2c --- /dev/null +++ b/src/test/java/esu/algorithm/ui/ReadableTest.java @@ -0,0 +1,156 @@ +package esu.algorithm.ui; + +import esu.algorithm.EsuSession; +import esu.algorithm.UndirectedGraph; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.control.Label; +import javafx.scene.control.ListView; +import javafx.scene.layout.Region; +import javafx.scene.paint.Color; +import javafx.scene.text.Text; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Whether the words can be read against what is behind them. + * + * ThemeTest compares the two stylesheets as text, which catches a rule the + * dark one forgets. This asks the question the other way round, of the styles + * JavaFX actually resolves: whatever the sheets say, is this label a different + * colour from its background? + * + * The step log once rendered dark text on a dark ground for exactly the + * reason no rule caught: the class that lost its colour was on a label inside + * a cell, not on anything either sheet named directly. + */ +public class ReadableTest extends FxTest { + + /** Below this, text and ground are too close to tell apart. */ + private static final double LEAST_DIFFERENCE = 0.18; + + /** Perceived brightness, so that dark-on-dark and light-on-light both fail. */ + private static double brightness(Color colour) { + return 0.299 * colour.getRed() + + 0.587 * colour.getGreen() + + 0.114 * colour.getBlue(); + } + + /** The nearest ancestor that actually paints something. */ + private static Color groundBehind(Node node) { + for (Parent at = node.getParent(); at != null; at = at.getParent()) { + if (at instanceof Region region && region.getBackground() != null + && !region.getBackground().getFills().isEmpty()) { + var fill = region.getBackground().getFills().get(0).getFill(); + if (fill instanceof Color colour && colour.getOpacity() > 0.9) { + return colour; + } + } + } + return null; + } + + private static void collectText(Node node, List found) { + if (node instanceof Label || node instanceof Text) { + found.add(node); + } + if (node instanceof Parent parent) { + for (Node child : parent.getChildrenUnmodifiable()) { + collectText(child, found); + } + } + } + + /** A panel with a search loaded, styled for one appearance. */ + private Parent shapesPanel(boolean dark) { + return onFxThread(() -> { + EsuSession session = new EsuSession( + UndirectedGraph.fromFile(new File("samples/cluster.txt")), 4); + ShapesPanel panel = new ShapesPanel(shape -> { }, vertices -> { }); + panel.setGraph(session.getGraph()); + panel.setShapes(session.getShapes()); + panel.showInstances( + session.subgraphsOfShape(session.getShapes().get(0).shape())); + return style(panel, dark); + }); + } + + @ParameterizedTest(name = "dark={0}") + @ValueSource(booleans = {false, true}) + public void everyWordStandsOutFromWhatIsBehindIt(boolean dark) { + Parent panel = shapesPanel(dark); + + List words = new ArrayList<>(); + onFxThread(() -> { + collectText(panel, words); + return null; + }); + assertFalse(words.isEmpty(), "found nothing to read"); + + for (Node word : words) { + Color ink = inkOf(word); + Color ground = groundBehind(word); + if (ink == null || ground == null) { + continue; + } + assertTrue(Math.abs(brightness(ink) - brightness(ground)) + > LEAST_DIFFERENCE, + text(word) + ": " + ink + " on " + ground); + } + } + + @ParameterizedTest(name = "dark={0}") + @ValueSource(booleans = {false, true}) + public void theStepLogCanBeRead(boolean dark) { + // The list cell's label is the thing that went wrong, so build the + // list the app builds rather than a bare label. + Parent panel = onFxThread(() -> { + ListView log = new ListView<>(); + log.getItems().add("2 is less than or equal to this branch's first step."); + Label line = new Label(log.getItems().get(0)); + line.getStyleClass().add("log-line"); + javafx.scene.layout.VBox host = new javafx.scene.layout.VBox(line); + host.getStyleClass().add("side-panel"); + return style(host, dark); + }); + + List words = new ArrayList<>(); + onFxThread(() -> { + collectText(panel, words); + return null; + }); + + for (Node word : words) { + Color ink = inkOf(word); + Color ground = groundBehind(word); + if (ink == null) { + continue; + } + assertNotNull(ground, "nothing paints behind the log line"); + assertTrue(Math.abs(brightness(ink) - brightness(ground)) + > LEAST_DIFFERENCE, + "log line " + ink + " on " + ground); + } + } + + /** + * The colour a piece of text is drawn in. + * + * A Label's skin puts a LabeledText inside it, so walking the scene finds + * both the Label and the Text it renders through; either can answer. + */ + private static Color inkOf(Node word) { + javafx.scene.paint.Paint paint = word instanceof Label label + ? label.getTextFill() : ((Text) word).getFill(); + return paint instanceof Color colour ? colour : null; + } + + private static String text(Node node) { + return node instanceof Label label ? label.getText() : ((Text) node).getText(); + } +} diff --git a/src/test/java/esu/algorithm/ui/TreeRendererTest.java b/src/test/java/esu/algorithm/ui/TreeRendererTest.java new file mode 100644 index 0000000..2da7fbc --- /dev/null +++ b/src/test/java/esu/algorithm/ui/TreeRendererTest.java @@ -0,0 +1,154 @@ +package esu.algorithm.ui; + +import esu.algorithm.EsuNode; +import esu.algorithm.EsuSession; +import esu.algorithm.EsuTree; +import esu.algorithm.UndirectedGraph; +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import javafx.scene.Node; +import javafx.scene.shape.Rectangle; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** + * What the tree renderer actually builds. + * + * Nothing checked this before, and the shapes it draws are the whole point of + * the app: a box in the wrong state, or no box at all, is invisible to every + * other test in the project. + */ +public class TreeRendererTest extends FxTest { + + private EsuSession session(String file, int size) { + return new EsuSession( + UndirectedGraph.fromFile(new File("samples/" + file)), size); + } + + /** The rectangles the renderer produces for a finished search. */ + private List boxesOf(EsuSession session, Set picked) { + return onFxThread(() -> { + TreeLayout layout = TreeLayout.of(session.getFinalTree()); + List drawn = TreeRenderer.render(layout, + presentIn(session.getCurrentTree()), deadEndsIn(session), + session.getSubgraphSize(), null, Set.of(), picked); + List boxes = new ArrayList<>(); + for (Node node : drawn) { + if (node instanceof Rectangle rectangle) { + boxes.add(rectangle); + } + } + return boxes; + }); + } + + private Set presentIn(EsuTree tree) { + Set present = new HashSet<>(); + ArrayList[] levels = tree.getNodesByLevel(); + for (int level = 1; level < levels.length; level++) { + for (EsuNode node : levels[level]) { + present.add(node.getSubgraphAsString()); + } + } + return present; + } + + private Set deadEndsIn(EsuSession session) { + Set dead = new HashSet<>(); + ArrayList[] levels = session.getFinalTree().getNodesByLevel(); + for (int level = 1; level < levels.length - 1; level++) { + for (EsuNode node : levels[level]) { + if (node.getChildren().isEmpty()) { + dead.add(node.getSubgraphAsString()); + } + } + } + return dead; + } + + private long countWithClass(List boxes, String styleClass) { + return boxes.stream() + .filter(box -> box.getStyleClass().contains(styleClass)) + .count(); + } + + @Test + public void drawsABoxForTheRootAndEveryNodeReached() { + EsuSession session = session("bowtie.txt", 3); + session.goToStep(session.getTotalSteps()); + + List boxes = boxesOf(session, Set.of()); + + // Every node of the finished tree, plus the root. + int nodes = 1; + for (ArrayList level : session.getFinalTree().getNodesByLevel()) { + nodes += level.size(); + } + assertEquals(nodes - 1, boxes.size()); + assertEquals(1, countWithClass(boxes, "t-root")); + } + + @Test + public void drawsOnlyWhatTheSearchHasReached() { + EsuSession session = session("bowtie.txt", 3); + + session.goToStep(0); + + // Before the first step there is nothing but the beginning. + assertEquals(1, boxesOf(session, Set.of()).size()); + } + + @Test + public void marksCompletedSubgraphsAsFound() { + EsuSession session = session("bowtie.txt", 3); + session.goToStep(session.getTotalSteps()); + + List boxes = boxesOf(session, Set.of()); + + assertEquals(session.getSubgraphCount(), + countWithClass(boxes, "t-complete")); + } + + @Test + public void marksBranchesThatDiedAsDeadEnds() { + EsuSession session = session("bowtie.txt", 4); + session.goToStep(session.getTotalSteps()); + + List boxes = boxesOf(session, Set.of()); + + assertEquals(deadEndsIn(session).size(), countWithClass(boxes, "t-dead")); + assertTrue(countWithClass(boxes, "t-dead") > 0, + "this graph should have branches that die"); + } + + @Test + public void picksOutTheSubgraphsItIsAskedTo() { + EsuSession session = session("cluster.txt", 4); + session.goToStep(session.getTotalSteps()); + Set picked = + session.subgraphsWithShape(session.getShapes().get(0).shape()); + + List boxes = boxesOf(session, picked); + + assertTrue(picked.size() > 1, "pick more than one, or this proves little"); + assertEquals(picked.size(), countWithClass(boxes, "t-picked")); + } + + @Test + public void givesEveryBoxExactlyOneState() { + EsuSession session = session("cluster.txt", 4); + session.goToStep(session.getTotalSteps()); + + for (Rectangle box : boxesOf(session, Set.of())) { + long states = box.getStyleClass().stream() + .filter(name -> List.of("t-root", "t-pending", "t-active", + "t-complete", "t-dead").contains(name)) + .count(); + assertEquals(1, states, + "a box carried " + states + " states: " + box.getStyleClass()); + } + } +} diff --git a/web/.gitignore b/web/.gitignore index b947077..f0a232b 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -1,2 +1,4 @@ node_modules/ dist/ +test-results/ +playwright-report/ diff --git a/web/README.md b/web/README.md index 4765b89..ceb3ba2 100644 --- a/web/README.md +++ b/web/README.md @@ -6,9 +6,10 @@ tree. The desktop app remains the reference implementation. ```bash npm install -npm test # 68 tests +npm test # 94 unit tests +npm run test:browser # 8 browser tests, real Chromium npm run typecheck -npm run build # -> dist/demo.js, dist/demo.css +npm run build # -> ../docs/demo/ ``` The built bundle is about 21 KB, 7 KB gzipped, with no runtime dependencies. @@ -33,10 +34,24 @@ implementation detail. suite: enumerate every subset of the right size, keep the connected ones, and require ESU to return exactly that set with no duplicates. -`render.test.ts` drives the demo in a real DOM and checks it draws the graph, -grows the tree, highlights the current subgraph, and builds every control. The -desktop app has no equivalent, which is how a button went missing during a -rebuild and stayed missing until someone read the README. +`render.test.ts` drives the demo in a simulated DOM and checks it draws the +graph, grows the tree, highlights the current subgraph, and builds every +control. + +`browser/` runs the published page in real Chromium, and exists for what the +simulated DOM cannot see: it has no layout engine and no stylesheets, so it can +say a node exists but never that it is visible, the right size, or a readable +colour against what is behind it. Every visual bug this project has shipped was +of that kind. + +The difference is measurable. Make the step log's text the same colour as its +background, or clip the shape list so its contents scroll out of sight, and all +94 unit tests still pass while the browser tests fail. Both of those are bugs +that actually shipped. + +Visual regression by screenshot comparison is deliberately not used: fonts +render differently on macOS and on the Linux runner, so pixel diffs would fail +for reasons that have nothing to do with the change. ## Regenerating the fixtures diff --git a/web/browser/demo.spec.ts b/web/browser/demo.spec.ts new file mode 100644 index 0000000..b38c89f --- /dev/null +++ b/web/browser/demo.spec.ts @@ -0,0 +1,133 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** + * The demo in a real browser. + * + * The unit tests run against a simulated DOM: no layout, no real stylesheets, + * so they can say a node exists but never that it is visible, the right size, + * or a readable colour against what is behind it. Every visual bug this + * project has shipped was of that kind. + */ + +/** Wait for the demo to replace the fallback clip. */ +async function open(page: Page): Promise { + await page.goto('/'); + await expect(page.locator('#demo-mount')).toBeVisible(); + await expect(page.locator('.demo-tree')).toBeVisible(); +} + +/** Perceived brightness, for deciding whether text can be read on a ground. */ +function luminance(colour: string): number { + const [red, green, blue] = (colour.match(/[\d.]+/g) ?? ['0', '0', '0']) + .slice(0, 3) + .map(Number) as [number, number, number]; + return (0.299 * red + 0.587 * green + 0.114 * blue) / 255; +} + +test('replaces the clip with the running demo', async ({ page }) => { + await open(page); + + await expect(page.locator('#demo-fallback')).toBeHidden(); + await expect(page.locator('.demo-graph .g-vertex').first()).toBeVisible(); +}); + +test('draws the tree inside the panel it is given', async ({ page }) => { + await open(page); + + // The tree opened part way into empty canvas once, and nothing caught it. + const panel = await page.locator('.demo-treewrap').boundingBox(); + const tree = await page.locator('.demo-tree').boundingBox(); + + expect(tree).not.toBeNull(); + expect(panel).not.toBeNull(); + expect(tree!.width).toBeGreaterThan(100); + expect(tree!.x).toBeGreaterThanOrEqual(panel!.x - 1); + expect(tree!.x + tree!.width).toBeLessThanOrEqual(panel!.x + panel!.width + 1); +}); + +test('keeps every step-log line readable against its background', async ({ page }) => { + await open(page); + await page.locator('.demo-next').click(); + + // A stylesheet once left this text dark on a dark ground, which no + // assertion about the DOM could have seen. + const log = page.locator('.demo-log'); + const background = await log.evaluate((el) => getComputedStyle(el).backgroundColor); + const lines = log.locator('li'); + await expect(lines.first()).toBeVisible(); + + for (const line of await lines.all()) { + const colour = await line.evaluate((el) => getComputedStyle(el).color); + expect(Math.abs(luminance(colour) - luminance(background)), + `text ${colour} on ${background}`).toBeGreaterThan(0.2); + } +}); + +test('fits the shapes it found without scrolling them out of sight', async ({ page }) => { + await open(page); + + // The desktop panel was sized for three shapes and cramped five. + const shapes = page.locator('.demo-shapes'); + await expect(shapes.locator('.demo-shape').first()).toBeVisible(); + + const overflow = await shapes.evaluate( + (el) => el.scrollHeight - el.clientHeight); + expect(overflow).toBeLessThanOrEqual(1); +}); + +test('draws each shape with the edges it claims', async ({ page }) => { + await open(page); + await page.locator('.demo-graphs .demo-pill', { hasText: 'Cluster' }).click(); + + const edgesOf: Record = { + path: 3, star: 3, cycle: 4, 'triangle and tail': 4, diamond: 5, clique: 6, + }; + + const chips = await page.locator('.demo-shape').all(); + expect(chips.length).toBeGreaterThan(1); + + for (const chip of chips) { + const name = ((await chip.getAttribute('title')) ?? '').split(' — ')[0]!; + await expect(chip.locator('.shape-drawing line')).toHaveCount(edgesOf[name]!); + } +}); + +test('shows a chosen subgraph in the input graph', async ({ page }) => { + await open(page); + await page.locator('.demo-graphs .demo-pill', { hasText: 'Cluster' }).click(); + await page.locator('.demo-shape').first().click(); + + const tile = page.locator('.demo-instance').first(); + await expect(tile).toBeVisible(); + const vertices = (await tile.locator('.demo-instance-label').innerText()) + .trim().split(/\s+/).length; + + await tile.click(); + + await expect(page.locator('.demo-graph .g-chosen')).toHaveCount(vertices); +}); + +test('plays through the search when told to', async ({ page }) => { + await open(page); + const status = page.locator('.demo-status'); + await page.locator('.demo-restart').click(); + await expect(status).toContainText('Step 0 of'); + + await page.locator('.demo-play').click(); + + // A real click on a real button, advancing on a real timer. + await expect(status).not.toContainText('Step 0 of', { timeout: 5000 }); + await page.locator('.demo-play').click(); + await expect(page.locator('.demo-play')).toHaveText('Play'); +}); + +test('stays usable on a narrow window', async ({ page }) => { + await page.setViewportSize({ width: 420, height: 900 }); + await open(page); + + // Nothing may spill sideways off the page. + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + await expect(page.locator('.demo-play')).toBeVisible(); +}); diff --git a/web/package-lock.json b/web/package-lock.json index 16974b7..bb5d036 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -6,7 +6,9 @@ "": { "name": "esu-web", "devDependencies": { + "@playwright/test": "^1.63.0", "happy-dom": "^20.14.0", + "http-server": "^14.1.1", "typescript": "^5.6.3", "vite": "^5.4.10", "vitest": "^2.1.4" @@ -427,6 +429,22 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", @@ -924,6 +942,22 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -934,6 +968,26 @@ "node": ">=12" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/buffer-image-size": { "version": "0.6.4", "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", @@ -957,6 +1011,37 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -974,6 +1059,23 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -984,6 +1086,36 @@ "node": ">= 16" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/corser": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz", + "integrity": "sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1012,6 +1144,21 @@ "node": ">=6" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1025,6 +1172,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1032,6 +1199,19 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1081,6 +1261,13 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "dev": true, + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -1091,6 +1278,27 @@ "node": ">=12.0.0" } }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1106,6 +1314,68 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/happy-dom": { "version": "20.14.0", "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.0.tgz", @@ -1125,6 +1395,121 @@ "node": ">=20.0.0" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-server": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", + "integrity": "sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-auth": "^2.0.1", + "chalk": "^4.1.2", + "corser": "^2.0.1", + "he": "^1.2.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy": "^1.18.1", + "mime": "^1.6.0", + "minimist": "^1.2.6", + "opener": "^1.5.1", + "portfinder": "^1.0.28", + "secure-compare": "3.0.1", + "union": "~0.5.0", + "url-join": "^4.0.1" + }, + "bin": { + "http-server": "bin/http-server" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -1142,6 +1527,39 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1168,6 +1586,29 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "dev": true, + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", @@ -1192,6 +1633,49 @@ "dev": true, "license": "ISC" }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/portfinder": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.38.tgz", + "integrity": "sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async": "^3.2.6", + "debug": "^4.3.6" + }, + "engines": { + "node": ">= 10.12" + } + }, "node_modules/postcss": { "version": "8.5.28", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", @@ -1221,6 +1705,30 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.63.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", @@ -1267,6 +1775,103 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/secure-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/secure-compare/-/secure-compare-3.0.1.tgz", + "integrity": "sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==", + "dev": true, + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1298,6 +1903,19 @@ "dev": true, "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -1363,6 +1981,25 @@ "dev": true, "license": "MIT" }, + "node_modules/union": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/union/-/union-0.5.0.tgz", + "integrity": "sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==", + "dev": true, + "dependencies": { + "qs": "^6.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -1512,6 +2149,20 @@ } } }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", diff --git a/web/package.json b/web/package.json index c9395a1..c95b1a1 100644 --- a/web/package.json +++ b/web/package.json @@ -7,10 +7,14 @@ "test": "vitest run", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "build": "vite build" + "build": "vite build", + "test:browser": "playwright test", + "test:all": "vitest run && playwright test" }, "devDependencies": { + "@playwright/test": "^1.63.0", "happy-dom": "^20.14.0", + "http-server": "^14.1.1", "typescript": "^5.6.3", "vite": "^5.4.10", "vitest": "^2.1.4" diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..baa0d7d --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Browser tests for the demo, against the site as it is published. + * + * These exist for what the unit tests cannot see. Those run against a + * simulated DOM with no layout engine and no real stylesheets, so a panel can + * be the wrong size, text can be invisible against its background, and the + * tree can sit off screen without a single assertion noticing. Every one of + * those has happened. + */ +export default defineConfig({ + testDir: 'browser', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? 'github' : 'list', + + use: { + baseURL: 'http://127.0.0.1:4173', + trace: 'on-first-retry', + }, + + // The real page, built and served exactly as it is deployed. + webServer: { + command: 'npm run build && npx http-server ../docs -p 4173 --silent', + url: 'http://127.0.0.1:4173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], +}); diff --git a/web/vitest.config.ts b/web/vitest.config.ts index a2e66c0..8607470 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -5,5 +5,8 @@ export default defineConfig({ // The engine tests need no DOM; the rendering test does, and a real DOM // is what catches a control that quietly stopped being built. environment: 'happy-dom', + // browser/ belongs to Playwright. Vitest's default pattern matches + // .spec.ts anywhere, and it picked those up and failed on them. + include: ['test/**/*.test.ts'], }, });