Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .beads/issues.jsonl

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test test-coverage lint lint-fix fmt fmt-check vet vuln ci clean docs-validate graph-html graph-screenshot graph-preview graph-serve
.PHONY: build test test-coverage lint lint-fix fmt fmt-check vet vuln ci clean docs-validate graph-html graph-screenshot graph-preview graph-serve graph-test

VERSION ?= dev
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
Expand Down Expand Up @@ -72,6 +72,10 @@ graph-preview: graph-screenshot
@echo "Preview: build/graph/graph.html (open in browser)"
@echo "Screenshot: build/graph/graph.png"

graph-test:
@command -v node >/dev/null 2>&1 || (echo "node required (install Node.js)" && exit 1)
bash scripts/tests/run-all.sh

graph-serve:
GOWORK=off go build -o ./floop ./cmd/floop
./floop graph --format html --serve
125 changes: 125 additions & 0 deletions scripts/tests/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Shared helpers for Playwright visual tests.
// Usage: const { makeCounter, launchBrowser, waitForGraph, waitForElectric, startServer, SCREENSHOT_DIR } = require("./helpers");

const { chromium } = require("playwright");
const { spawn } = require("child_process");
const path = require("path");

const SCREENSHOT_DIR = path.resolve(__dirname, "../../build/playwright");

/** Returns { assert(cond, msg), summary() } with pass/fail tracking. */
function makeCounter() {
let passed = 0;
let failed = 0;

function assert(condition, msg) {
if (condition) {
console.log(` ok: ${msg}`);
passed++;
} else {
console.log(` FAIL: ${msg}`);
failed++;
}
}

function summary() {
console.log(`\n${passed + failed} assertions, ${failed} failures`);
console.log(failed === 0 ? "\nPASS" : "\nFAIL");
return failed === 0 ? 0 : 1;
}

return { assert, summary };
}

/** Launch headless Chromium, return { browser, page } with viewport set. */
async function launchBrowser(width = 1920, height = 1080) {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.setViewportSize({ width, height });
return { browser, page };
}

/** Wait for window.__graph to be defined, then wait for physics simulation to settle. */
async function waitForGraph(page, timeout = 15000) {
await page.waitForFunction(() => window.__graph, { timeout });
// Wait for force simulation to cool (alpha < 0.05) or 4s max
await page.waitForFunction(
() => {
const sim = window.__graph.d3Force && window.__graph.d3Force("link");
// If we can read alpha, wait for it to drop; otherwise just wait
if (window.__graph.d3Force) {
try {
// Access the internal simulation — alpha below 0.05 means settled
const alpha = window.__graph.d3Force("link")?.simulation?.alpha?.();
if (alpha !== undefined) return alpha < 0.05;
} catch {}
}
return false;
},
{ timeout: 4000 }
).catch(() => {
// If alpha check isn't available, fall back to a fixed wait
});
// Extra settle time for coordinate accuracy
await new Promise((r) => setTimeout(r, 1000));
}

/** Wait for both window.__graph and window.__electricSim. */
async function waitForElectric(page, timeout = 15000) {
await page.waitForFunction(
() => window.__graph && window.__electricSim,
{ timeout }
);
}

/**
* Spawn `floop graph --format html --serve --no-open`, parse URL, poll until ready.
* Returns { proc, url }. Caller must kill proc in a finally block.
*/
async function startServer(floopBin) {
const proc = spawn(floopBin, ["graph", "--format", "html", "--serve", "--no-open"], {
env: { ...process.env, GOWORK: "off" },
stdio: ["ignore", "pipe", "pipe"],
});

// Parse URL from stdout/stderr
const url = await new Promise((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Server did not emit URL within 8s")),
8000
);
const handler = (data) => {
const match = data.toString().match(/http:\/\/[^\s]+/);
if (match) {
clearTimeout(timeout);
resolve(match[0]);
}
};
proc.stdout.on("data", handler);
proc.stderr.on("data", handler);
proc.on("error", (err) => {
clearTimeout(timeout);
reject(err);
});
});

// Poll until server is ready
for (let i = 0; i < 25; i++) {
try {
const resp = await fetch(url);
if (resp.ok) return { proc, url };
} catch {}
await new Promise((r) => setTimeout(r, 200));
}
proc.kill();
throw new Error(`Server at ${url} not ready after 5s of polling`);
}

module.exports = {
makeCounter,
launchBrowser,
waitForGraph,
waitForElectric,
startServer,
SCREENSHOT_DIR,
};
70 changes: 70 additions & 0 deletions scripts/tests/run-all.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Run the full Playwright visual test suite.
# Usage: bash scripts/tests/run-all.sh
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$REPO_ROOT"

FLOOP_BIN="$REPO_ROOT/floop"
GRAPH_HTML="$REPO_ROOT/build/graph/graph.html"
SCREENSHOT_DIR="$REPO_ROOT/build/playwright"

# --- Step 1: Ensure playwright is installed ---
if [ ! -d build/node_modules/playwright ]; then
echo "==> Installing playwright..."
npm install --prefix build playwright
npx --prefix build playwright install chromium
fi

# --- Step 2: Build floop binary ---
echo "==> Building floop..."
go build -o "$FLOOP_BIN" ./cmd/floop

# --- Step 3: Generate static HTML ---
echo "==> Generating graph HTML..."
mkdir -p build/graph
"$FLOOP_BIN" graph --format html -o "$GRAPH_HTML" --no-open

# --- Step 4: Create screenshot directory ---
mkdir -p "$SCREENSHOT_DIR"

# --- Step 5: Run tests ---
total=0
pass=0
fail=0
failed_tests=""

run_test() {
local name="$1"
shift
total=$((total + 1))
echo ""
echo "========================================"
echo " Running: $name"
echo "========================================"
if NODE_PATH=build/node_modules "$@"; then
pass=$((pass + 1))
echo " >> $name: PASS"
else
fail=$((fail + 1))
failed_tests="$failed_tests $name"
echo " >> $name: FAIL"
fi
}

run_test "test-focus" node scripts/tests/test-focus.js "$GRAPH_HTML"
run_test "test-drag" node scripts/tests/test-drag.js "$GRAPH_HTML"
run_test "test-electric" node scripts/tests/test-electric.js "$FLOOP_BIN"

# --- Step 6: Summary ---
echo ""
echo "========================================"
echo " Suite Summary: $pass/$total passed, $fail failed"
if [ -n "$failed_tests" ]; then
echo " Failed:$failed_tests"
fi
echo " Screenshots: $SCREENSHOT_DIR/"
echo "========================================"

exit "$fail"
40 changes: 17 additions & 23 deletions scripts/test-drag.js → scripts/tests/test-drag.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,28 @@
#!/usr/bin/env node
// Diagnostic test: captures detailed state before/during/after mouse interaction.
// Diagnostic test: captures detailed state before/during/after mouse drag interaction.
// Usage: NODE_PATH=build/node_modules node scripts/tests/test-drag.js <input.html>

const { chromium } = require("playwright");
const path = require("path");
const { makeCounter, launchBrowser, waitForGraph, SCREENSHOT_DIR } = require("./helpers");

const input = process.argv[2];
if (!input) {
console.error("Usage: node scripts/test-drag.js <input.html>");
console.error("Usage: node scripts/tests/test-drag.js <input.html>");
process.exit(1);
}
const inputPath = path.resolve(input);

async function testDrag() {
const browser = await chromium.launch({ headless: true });
try {
const page = await browser.newPage();
await page.setViewportSize({ width: 1920, height: 1080 });
const { browser, page } = await launchBrowser();
const { assert, summary } = makeCounter();

// Capture all console output
try {
const logs = [];
page.on("console", (msg) => logs.push(`[${msg.type()}] ${msg.text()}`));
page.on("pageerror", (err) => logs.push(`[ERROR] ${err.message}`));

await page.goto(`file://${inputPath}`, { waitUntil: "networkidle" });
await new Promise((r) => setTimeout(r, 4000));
await waitForGraph(page);

// Diagnostic: check graph state
const state = await page.evaluate(() => {
Expand All @@ -33,15 +32,13 @@ async function testDrag() {
const data = g.graphData();
const nodes = data.nodes || [];

// Sample first 5 nodes' positions
const samples = nodes.slice(0, 5).map((n) => ({
id: n.id.substring(0, 30),
x: n.x, y: n.y,
fx: n.fx, fy: n.fy,
vx: n.vx, vy: n.vy,
}));

// Check for NaN/Infinity
const badNodes = nodes.filter(
(n) => !isFinite(n.x) || !isFinite(n.y)
).length;
Expand Down Expand Up @@ -83,7 +80,7 @@ async function testDrag() {

console.log(`\nTarget: screen=(${target.sx.toFixed(0)}, ${target.sy.toFixed(0)}) graph=(${target.gx.toFixed(1)}, ${target.gy.toFixed(1)})`);

// Inject drag event listener to check if drag fires
// Inject drag event listener
await page.evaluate(() => {
window.__dragEvents = [];
const canvas = document.querySelector("canvas");
Expand All @@ -98,15 +95,13 @@ async function testDrag() {
});
}

// Hook into force-graph's drag handler (onNodeDrag only — avoid
// overriding onNodeDragEnd which is configured in the production template)
const g = window.__graph;
g.onNodeDrag((node) => {
window.__dragEvents.push({ type: "onNodeDrag", nodeId: node.id, ts: Date.now() });
});
});

await page.screenshot({ path: "/tmp/drag-test-before.png" });
await page.screenshot({ path: `${SCREENSHOT_DIR}/drag-before.png` });

// Perform the drag
const sx = Math.round(target.sx);
Expand All @@ -116,7 +111,6 @@ async function testDrag() {
await page.mouse.move(sx, sy);
await page.mouse.down();

// Move in small steps
for (let i = 1; i <= 15; i++) {
await page.mouse.move(sx + i * 10, sy);
await new Promise((r) => setTimeout(r, 30));
Expand Down Expand Up @@ -183,19 +177,19 @@ async function testDrag() {
console.log(` ${s.id}: (${s.x?.toFixed(1)}, ${s.y?.toFixed(1)})`);
}

await page.screenshot({ path: "/tmp/drag-test-after.png" });
await page.screenshot({ path: `${SCREENSHOT_DIR}/drag-after.png` });

// Assertions
assert(duringState.dragFired, "onNodeDrag event fired during drag");
assert(afterState.badNodes === 0, "no NaN/Infinity nodes after drag");

// Console logs
if (logs.length > 0) {
console.log("\n=== CONSOLE LOGS ===");
logs.forEach((l) => console.log(" " + l));
}

console.log("\nScreenshots: /tmp/drag-test-before.png, /tmp/drag-test-after.png");

const passed = duringState.dragFired && afterState.badNodes === 0;
console.log(passed ? "\nPASS" : "\nFAIL");
process.exit(passed ? 0 : 1);
console.log(`\nScreenshots: ${SCREENSHOT_DIR}/drag-*.png`);
process.exit(summary());
} finally {
await browser.close();
}
Expand Down
Loading