diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d99c826..cf75b9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,11 @@ jobs: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable echo "$HOME/.cargo/bin" >> $GITHUB_PATH + - name: Install browser test dependencies + run: | + npm ci + npx playwright install --with-deps chromium + - name: Verify assets are up to date run: | make clean @@ -47,6 +52,7 @@ jobs: run: | cargo test node --test tests/*.test.js + node tests/demo-browser-check.js - name: Verify package contents run: ./scripts/verify-package.sh diff --git a/.gitignore b/.gitignore index c275290..79c89d5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ Thumbs.db *.iml .zed/ +# Node +node_modules/ +__pycache__/ + # Claude .claude/ diff --git a/Makefile b/Makefile index 169c6d5..58ab821 100644 --- a/Makefile +++ b/Makefile @@ -27,8 +27,8 @@ VERSIONED_CSS := static/sf/sf.$(VERSION).css VERSIONED_JS := static/sf/sf.$(VERSION).js # ============== Phony Targets ============== -.PHONY: banner help assets build build-release test test-quick test-doc test-unit test-frontend test-one \ - lint fmt fmt-check clippy ci-local pre-release version package-verify \ +.PHONY: banner help assets build build-release test test-quick test-doc test-unit test-frontend test-browser test-one \ + lint fmt fmt-check clippy ci-local pre-release version package-verify browser-setup \ bump-patch bump-minor bump-major bump-dry demo-serve \ publish-dry publish clean watch @@ -85,6 +85,7 @@ test: banner @printf "$(ARROW) $(BOLD)Running all tests...$(RESET)\n" @cargo test && \ node --test tests/*.test.js && \ + node tests/demo-browser-check.js && \ printf "\n$(GREEN)$(CHECK) All tests passed$(RESET)\n\n" || \ (printf "\n$(RED)$(CROSS) Tests failed$(RESET)\n\n" && exit 1) @@ -104,6 +105,10 @@ test-quick: banner @node --test tests/*.test.js && \ printf "$(GREEN)$(CHECK) Frontend tests passed$(RESET)\n\n" || \ (printf "$(RED)$(CROSS) Frontend tests failed$(RESET)\n\n" && exit 1) + @printf "$(PROGRESS) Running browser demo smoke tests...\n" + @node tests/demo-browser-check.js && \ + printf "$(GREEN)$(CHECK) Browser smoke tests passed$(RESET)\n\n" || \ + (printf "$(RED)$(CROSS) Browser smoke tests failed$(RESET)\n\n" && exit 1) test-doc: @printf "$(PROGRESS) Running doctests...\n" @@ -123,6 +128,18 @@ test-frontend: printf "$(GREEN)$(CHECK) Frontend tests passed$(RESET)\n" || \ (printf "$(RED)$(CROSS) Frontend tests failed$(RESET)\n" && exit 1) +test-browser: + @printf "$(PROGRESS) Running browser demo smoke tests...\n" + @node tests/demo-browser-check.js && \ + printf "$(GREEN)$(CHECK) Browser smoke tests passed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Browser smoke tests failed$(RESET)\n" && exit 1) + +browser-setup: + @printf "$(PROGRESS) Installing browser test dependencies...\n" + @npm ci && npx playwright install --with-deps chromium && \ + printf "$(GREEN)$(CHECK) Browser test dependencies installed$(RESET)\n" || \ + (printf "$(RED)$(CROSS) Browser test dependency setup failed$(RESET)\n" && exit 1) + test-one: @printf "$(PROGRESS) Running test: $(YELLOW)$(TEST)$(RESET)\n" @RUST_LOG=info cargo test $(TEST) -- --nocapture @@ -156,20 +173,22 @@ ci-local: banner @printf "$(CYAN)$(BOLD)║ Local CI Simulation ║$(RESET)\n" @printf "$(CYAN)$(BOLD)╚══════════════════════════════════════════════════════════╝$(RESET)\n\n" @printf "$(ARROW) $(BOLD)Simulating GitHub Actions CI workflow locally...$(RESET)\n\n" - @printf "$(PROGRESS) Step 1/6: Asset freshness check...\n" + @printf "$(PROGRESS) Step 1/8: Asset freshness check...\n" @$(MAKE) assets --no-print-directory - @printf "$(PROGRESS) Step 2/6: Format check...\n" + @printf "$(PROGRESS) Step 2/8: Format check...\n" @$(MAKE) fmt-check --no-print-directory - @printf "$(PROGRESS) Step 3/6: Build...\n" + @printf "$(PROGRESS) Step 3/8: Build...\n" @cargo build --quiet && printf "$(GREEN)$(CHECK) Build passed$(RESET)\n" - @printf "$(PROGRESS) Step 4/6: Clippy...\n" + @printf "$(PROGRESS) Step 4/8: Clippy...\n" @$(MAKE) clippy --no-print-directory - @printf "$(PROGRESS) Step 5/6: Doctests...\n" + @printf "$(PROGRESS) Step 5/8: Doctests...\n" @cargo test --doc --quiet && printf "$(GREEN)$(CHECK) Doctests passed$(RESET)\n" - @printf "$(PROGRESS) Step 6/7: Unit tests...\n" + @printf "$(PROGRESS) Step 6/8: Unit tests...\n" @cargo test --lib --quiet && printf "$(GREEN)$(CHECK) Unit tests passed$(RESET)\n" - @printf "$(PROGRESS) Step 7/7: Frontend tests...\n" + @printf "$(PROGRESS) Step 7/8: Frontend tests...\n" @node --test tests/*.test.js && printf "$(GREEN)$(CHECK) Frontend tests passed$(RESET)\n" + @printf "$(PROGRESS) Step 8/8: Browser smoke tests...\n" + @node tests/demo-browser-check.js && printf "$(GREEN)$(CHECK) Browser smoke tests passed$(RESET)\n" @printf "\n$(GREEN)$(BOLD)╔══════════════════════════════════════════════════════════╗$(RESET)\n" @printf "$(GREEN)$(BOLD)║ $(CHECK) CI SIMULATION PASSED ║$(RESET)\n" @printf "$(GREEN)$(BOLD)╚══════════════════════════════════════════════════════════╝$(RESET)\n\n" @@ -209,7 +228,7 @@ pre-release: banner @$(MAKE) fmt-check --no-print-directory @$(MAKE) clippy --no-print-directory @printf "$(PROGRESS) Running full test suite...\n" - @cargo test --quiet && node --test tests/*.test.js && printf "$(GREEN)$(CHECK) All tests passed$(RESET)\n" + @cargo test --quiet && node --test tests/*.test.js && node tests/demo-browser-check.js && printf "$(GREEN)$(CHECK) All tests passed$(RESET)\n" @printf "$(PROGRESS) Dry-run publish...\n" @cargo publish --dry-run 2>&1 | tail -1 @printf "$(PROGRESS) Verifying packaged contents...\n" @@ -266,7 +285,7 @@ watch: demo-serve: assets @printf "$(ARROW) Serving demos at http://localhost:8000/demos/\n" - @python3 -m http.server 8000 + @python3 scripts/demo_server.py # ============== Help ============== diff --git a/README.md b/README.md index dc40a98..a89120a 100644 --- a/README.md +++ b/README.md @@ -531,6 +531,8 @@ Runnable demo fixtures live in `demos/`. - `demos/full-surface.html` exercises the primary shipped component surface together. - `demos/rail.html` focuses on resource cards, blocks, gauges, and changeovers. - `make demo-serve` serves the repository at `http://localhost:8000/demos/` for local validation. +- `make test-browser` runs browser-level smoke tests against both demo fixtures. +- Run `make browser-setup` once on a machine to install the Playwright test dependency and Chromium. ## Acknowledgments diff --git a/demos/README.md b/demos/README.md index ff11db6..a5ea7fa 100644 --- a/demos/README.md +++ b/demos/README.md @@ -16,6 +16,22 @@ Then open: - `http://localhost:8000/demos/full-surface.html` - `http://localhost:8000/demos/rail.html` +## Automated browser verification + +Install the browser-test dependency and Chromium once: + +```bash +make browser-setup +``` + +Then run the smoke checks: + +```bash +make test-browser +``` + +The automated check serves the repository locally, opens both runnable demo fixtures in Chromium, fails on page or script errors, and verifies that the primary shipped UI surfaces mount successfully. + ## Coverage - `full-surface.html`: header, status bar, tabs, buttons, modal, toast, table, rail, Gantt, API guide, and footer diff --git a/demos/full-surface.html b/demos/full-surface.html index 1961fb3..01bb9c8 100644 --- a/demos/full-surface.html +++ b/demos/full-surface.html @@ -100,35 +100,11 @@ return card.el; } - function buildGantt() { + function createGanttPanel() { var mount = document.createElement('div'); mount.className = 'demo-gantt'; - - var gantt = SF.gantt.create({ - gridTitle: 'Tasks', - chartTitle: 'Schedule', - viewMode: 'Half Day', - splitSizes: [38, 62], - columns: [ - { key: 'name', label: 'Task' }, - { key: 'start', label: 'Start' }, - { key: 'end', label: 'End' }, - { key: 'priority', label: 'P', render: function (task) { - return 'P' + task.priority + ''; - } } - ], - onTaskClick: function (task) { - SF.showToast({ - title: 'Gantt task', - message: task.name, - variant: 'success', - delay: 1800 - }); - } - }); - - gantt.mount(mount); - gantt.setTasks([ + var mounted = false; + var tasks = [ { id: 'task-1', name: 'Design review', @@ -156,9 +132,41 @@ custom_class: 'project-color-2 priority-3', dependencies: 'task-2' } - ]); + ]; - return mount; + var gantt = SF.gantt.create({ + gridTitle: 'Tasks', + chartTitle: 'Schedule', + viewMode: 'Half Day', + splitSizes: [38, 62], + columns: [ + { key: 'name', label: 'Task' }, + { key: 'start', label: 'Start' }, + { key: 'end', label: 'End' }, + { key: 'priority', label: 'P', render: function (task) { + return 'P' + task.priority + ''; + } } + ], + onTaskClick: function (task) { + SF.showToast({ + title: 'Gantt task', + message: task.name, + variant: 'success', + delay: 1800 + }); + } + }); + + return { + mountIfNeeded: function () { + if (mounted) return; + if (!mount.isConnected || mount.offsetWidth === 0 || mount.offsetHeight === 0) return; + gantt.mount(mount); + gantt.setTasks(tasks); + mounted = true; + }, + el: mount + }; } function buildApiGuide() { @@ -186,6 +194,8 @@ } document.addEventListener('DOMContentLoaded', function () { + var ganttPanel = createGanttPanel(); + var header = SF.createHeader({ logo: '../static/sf/img/ouroboros.svg', title: 'Planner123', @@ -195,7 +205,14 @@ { id: 'gantt', label: 'Gantt', icon: 'fa-chart-gantt' }, { id: 'api', label: 'API', icon: 'fa-plug' } ], - onTabChange: function (id) { SF.showTab(id); }, + onTabChange: function (id) { + SF.showTab(id); + if (id === 'gantt') { + requestAnimationFrame(function () { + ganttPanel.mountIfNeeded(); + }); + } + }, actions: { onSolve: function () { statusBar.setSolving(true); @@ -280,7 +297,7 @@ var panel = document.createElement('section'); panel.className = 'demo-card'; panel.innerHTML = '

Gantt

'; - panel.appendChild(buildGantt()); + panel.appendChild(ganttPanel.el); return panel; })() }, @@ -302,6 +319,10 @@ main.appendChild(tabs.el); document.body.appendChild(main); + requestAnimationFrame(function () { + ganttPanel.mountIfNeeded(); + }); + document.body.appendChild(SF.createFooter({ links: [ { label: 'Demo Index', url: './index.html' }, diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bf17921 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,60 @@ +{ + "name": "solverforge-ui", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "solverforge-ui", + "devDependencies": { + "playwright": "^1.58.2" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bb95a5c --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "solverforge-ui", + "private": true, + "scripts": { + "test:browser": "node tests/demo-browser-check.js" + }, + "devDependencies": { + "playwright": "^1.58.2" + } +} diff --git a/scripts/demo_server.py b/scripts/demo_server.py new file mode 100644 index 0000000..7673d9a --- /dev/null +++ b/scripts/demo_server.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 + +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path, PurePosixPath +from urllib.parse import unquote, urlsplit + + +ROOT = Path(__file__).resolve().parent.parent + + +class DemoRequestHandler(SimpleHTTPRequestHandler): + def translate_path(self, path): + path_only = unquote(urlsplit(path).path) + if path_only.startswith("/sf/"): + path_only = "/static" + path_only + + relative_parts = [] + for part in PurePosixPath(path_only).parts: + if part in ("", "/", "."): + continue + if part == "..": + continue + relative_parts.append(part) + + candidate = ROOT.joinpath(*relative_parts).resolve() + try: + candidate.relative_to(ROOT) + except ValueError: + return str(ROOT) + return str(candidate) + + +if __name__ == "__main__": + server = ThreadingHTTPServer(("127.0.0.1", 8000), DemoRequestHandler) + print("Serving demos at http://127.0.0.1:8000/demos/") + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() diff --git a/tests/demo-browser-check.js b/tests/demo-browser-check.js new file mode 100644 index 0000000..2bf7413 --- /dev/null +++ b/tests/demo-browser-check.js @@ -0,0 +1,209 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); + +function contentTypeFor(filePath) { + switch (path.extname(filePath).toLowerCase()) { + case '.css': + return 'text/css; charset=utf-8'; + case '.html': + return 'text/html; charset=utf-8'; + case '.js': + return 'application/javascript; charset=utf-8'; + case '.json': + return 'application/json; charset=utf-8'; + case '.svg': + return 'image/svg+xml'; + case '.woff2': + return 'font/woff2'; + default: + return 'application/octet-stream'; + } +} + +function mapRequestPath(requestPath) { + const decodedPath = decodeURIComponent(requestPath).replace(/^\/+/, ''); + if (decodedPath.startsWith('sf/')) { + return path.join('static', decodedPath); + } + return decodedPath; +} + +function createStaticServer(rootDir) { + const sockets = new Set(); + const server = http.createServer((req, res) => { + const requestPath = new URL(req.url, 'http://127.0.0.1').pathname; + const relativePath = mapRequestPath(requestPath); + const targetPath = path.resolve(rootDir, relativePath || 'index.html'); + + if (!targetPath.startsWith(rootDir)) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + + let filePath = targetPath; + if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) { + filePath = path.join(filePath, 'index.html'); + } + + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + res.writeHead(404); + res.end('Not found'); + return; + } + + res.writeHead(200, { + 'Connection': 'close', + 'Content-Type': contentTypeFor(filePath), + }); + fs.createReadStream(filePath).pipe(res); + }); + + server.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + resolve({ + close: () => new Promise((done) => { + sockets.forEach((socket) => socket.destroy()); + if (typeof server.closeAllConnections === 'function') { + server.closeAllConnections(); + } + server.close(done); + }), + origin: `http://127.0.0.1:${address.port}`, + }); + }); + }); +} + +async function withPage(callback) { + let playwright; + try { + playwright = require('playwright'); + } catch (error) { + throw new Error('Playwright is not installed. Run `make browser-setup` first.'); + } + + const server = await createStaticServer(ROOT); + let browser; + + try { + browser = await playwright.chromium.launch({ headless: true }); + } catch (error) { + await server.close(); + throw new Error('Chromium for Playwright is not installed. Run `make browser-setup` first.'); + } + + const page = await browser.newPage(); + const pageErrors = []; + const requestFailures = []; + const consoleErrors = []; + + page.on('pageerror', (error) => { + pageErrors.push(error.message); + }); + page.on('requestfailed', (request) => { + requestFailures.push(`${request.method()} ${request.url()} :: ${request.failure().errorText}`); + }); + page.on('console', (msg) => { + if (msg.type() === 'error') consoleErrors.push(msg.text()); + }); + + try { + await callback({ + assertNoBrowserErrors() { + assert.deepEqual(pageErrors, [], `Page errors: ${pageErrors.join(' | ')}`); + assert.deepEqual(requestFailures, [], `Request failures: ${requestFailures.join(' | ')}`); + assert.deepEqual(consoleErrors, [], `Console errors: ${consoleErrors.join(' | ')}`); + }, + goto: (relativePath) => page.goto(`${server.origin}${relativePath}`, { waitUntil: 'load' }), + page, + }); + } finally { + await page.close(); + await browser.close(); + await server.close(); + } +} + +async function runCheck(name, fn) { + try { + await fn(); + process.stdout.write(`PASS ${name}\n`); + } catch (error) { + process.stderr.write(`FAIL ${name}\n${error.stack || error.message}\n`); + throw error; + } +} + +async function checkFullSurface() { + await withPage(async ({ goto, page, assertNoBrowserErrors }) => { + const response = await goto('/demos/full-surface.html'); + assert.equal(response.status(), 200); + + await page.waitForSelector('.sf-header', { timeout: 10000 }); + await page.waitForSelector('.sf-statusbar', { timeout: 10000 }); + await page.waitForSelector('.sf-tabs-container', { timeout: 10000 }); + await page.waitForSelector('.sf-table', { timeout: 10000 }); + await page.waitForSelector('.sf-rail', { timeout: 10000 }); + await page.waitForSelector('.sf-footer', { timeout: 10000 }); + + await page.getByRole('tab', { name: /gantt/i }).click({ timeout: 10000 }); + await page.waitForSelector('.sf-gantt-split', { timeout: 10000 }); + + await page.getByRole('tab', { name: /api/i }).click({ timeout: 10000 }); + await page.waitForSelector('.sf-api-guide', { timeout: 10000 }); + + const title = await page.locator('.sf-header-title').textContent(); + assert.equal(title, 'Planner123'); + + const ganttRows = await page.locator('.sf-gantt-row').count(); + assert.equal(ganttRows, 3); + + const apiSections = await page.locator('.sf-api-section').count(); + assert.equal(apiSections, 2); + + assertNoBrowserErrors(); + }); +} + +async function checkRailDemo() { + await withPage(async ({ goto, page, assertNoBrowserErrors }) => { + const response = await goto('/demos/rail.html'); + assert.equal(response.status(), 200); + + await page.waitForSelector('#app', { timeout: 10000 }); + await page.waitForSelector('.sf-rail', { timeout: 10000 }); + + const railCount = await page.locator('.sf-rail').count(); + const blockCount = await page.locator('.sf-block').count(); + const gaugeCount = await page.locator('.sf-gauge-row').count(); + + assert.equal(railCount, 2); + assert.equal(blockCount, 4); + assert.equal(gaugeCount, 4); + + const furnaceLabels = await page.locator('.sf-resource-name').allTextContents(); + assert.deepEqual(furnaceLabels, ['FORNO 1', 'FORNO 2']); + + assertNoBrowserErrors(); + }); +} + +(async function main() { + try { + await runCheck('full-surface demo', checkFullSurface); + await runCheck('rail demo', checkRailDemo); + } catch (error) { + process.exit(1); + } +})();