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
110 changes: 110 additions & 0 deletions .github/workflows/lighthouse.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: Lighthouse (warn-only)

on:
pull_request:
paths:
- 'web/**'
- 'app.py'
- 'requirements.txt'
- 'package.json'
- '.github/workflows/lighthouse.yml'
- 'lighthouse-budget.json'
workflow_dispatch:

jobs:
lighthouse:
name: Desktop Lighthouse (pipeline, library, reader)
runs-on: ubuntu-latest
continue-on-error: true

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip

- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm

- name: Install Python deps
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt

- name: Install Node deps
run: npm ci --no-audit --no-fund

- name: Build runtime bundle
run: npm run build

- name: Start backend
env:
OPENAI_API_KEY: dummy-ci-key
STORYFORGE_LOG_LEVEL: WARNING
run: |
nohup python app.py >server.log 2>&1 &
for i in {1..30}; do
curl -fsS http://localhost:7860/api/health && exit 0 || sleep 1
done
echo "::error::Backend never became healthy"
tail -n 80 server.log
exit 1

- name: Run Lighthouse on 3 routes
run: |
mkdir -p lh-reports
for route in pipeline library reader; do
npx --yes lighthouse \
"http://localhost:7860/#${route}" \
--preset=desktop \
--quiet \
--chrome-flags="--headless=new --no-sandbox" \
--budget-path=lighthouse-budget.json \
--output=json \
--output=html \
--output-path="lh-reports/${route}" \
--only-categories=performance,accessibility,best-practices \
|| echo "::warning::Lighthouse run for ${route} returned non-zero (warn-only)"
done

- name: Summarize scores
if: always()
run: |
echo "## Lighthouse summary (desktop, warn-only)" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Route | Perf | A11y | BP | FCP (ms) | LCP (ms) | TBT (ms) | CLS |" >> "$GITHUB_STEP_SUMMARY"
echo "|---|---:|---:|---:|---:|---:|---:|---:|" >> "$GITHUB_STEP_SUMMARY"
for route in pipeline library reader; do
f="lh-reports/${route}.report.json"
if [ -f "$f" ]; then
node -e "
const j=require('./${f}');
const a=j.audits;
const c=j.categories;
const row=['${route}',
Math.round(c.performance.score*100),
Math.round(c.accessibility.score*100),
Math.round(c['best-practices'].score*100),
Math.round(a['first-contentful-paint'].numericValue),
Math.round(a['largest-contentful-paint'].numericValue),
Math.round(a['total-blocking-time'].numericValue),
(a['cumulative-layout-shift'].numericValue).toFixed(3)
];
console.log('| ' + row.join(' | ') + ' |');
" >> "$GITHUB_STEP_SUMMARY"
else
echo "| ${route} | (no report) | | | | | | |" >> "$GITHUB_STEP_SUMMARY"
fi
done

- name: Upload Lighthouse artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: lighthouse-reports
path: lh-reports/
retention-days: 14
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ web/js/**/*.js.map
# Vitest global setup is plain JS with no .ts source — keep it tracked
!web/js/__tests__/setup.js
# Playwright visual test spec output is .ts source — no exceptions needed for .js
web/dist/
web/dist/*
# …but track the runtime bundle (perf/forge-shell P2): production prefers it,
# and rebuilding it on every container boot would add ~3s to startup.
!web/dist/
!web/dist/js/
web/dist/js/*
!web/dist/js/runtime-bundle.min.js

# ── IDE & OS ───────────────────────────────────────────────────────
.DS_Store
Expand Down
24 changes: 24 additions & 0 deletions lighthouse-budget.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[
{
"path": "/*",
"timings": [
{ "metric": "first-contentful-paint", "budget": 2000 },
{ "metric": "largest-contentful-paint", "budget": 2800 },
{ "metric": "total-blocking-time", "budget": 200 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 },
{ "metric": "speed-index", "budget": 2200 },
{ "metric": "interactive", "budget": 3000 }
],
"resourceSizes": [
{ "resourceType": "script", "budget": 200 },
{ "resourceType": "stylesheet", "budget": 80 },
{ "resourceType": "image", "budget": 200 },
{ "resourceType": "font", "budget": 100 },
{ "resourceType": "document", "budget": 80 },
{ "resourceType": "total", "budget": 600 }
],
"resourceCounts": [
{ "resourceType": "third-party", "budget": 10 }
]
}
]
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.build.json",
"build": "tsc -p tsconfig.build.json && node scripts/build-runtime-bundle.mjs",
"build:bundle": "node scripts/build-runtime-bundle.mjs",
"build:css": "tailwindcss -i web/css/main.css -o web/css/main.built.css --minify",
"build:vite": "vite build",
"preview": "vite preview",
Expand Down
108 changes: 108 additions & 0 deletions scripts/build-runtime-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* build-runtime-bundle.mjs — concat + minify the 13 runtime <script src> files
* referenced at the bottom of web/index.html into a single bundle.
*
* Sprint perf/forge-shell P2: collapses 13 HTTP requests + parse round-trips
* into one. The source files use classic-script globals (no ES module syntax),
* so plain concatenation preserves semantics — they share window scope today
* and they share it after bundling.
*
* Output: web/dist/js/runtime-bundle.min.js (committed for prod serving)
* Re-run: npm run build:bundle
*/

import { promises as fs } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { build } from 'esbuild';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');

// Order MUST match the 13 <script src> tags at the bottom of web/index.html.
// storage-manager + api-client define globals consumed by pages/*; app.js boots
// Alpine after every page function is registered, so it MUST come last.
const SOURCES = [
'web/js/storage-manager.js',
'web/js/api-client.js',
'web/js/pages/library.js',
'web/js/pages/export.js',
'web/js/pages/analytics.js',
'web/js/pages/branching.js',
'web/js/pages/pipeline.js',
'web/js/pages/providers.js',
'web/js/pages/settings.js',
'web/js/branch-reader.js',
'web/js/tree-visualizer.js',
'web/js/i18n.js',
'web/js/app.js',
];

const OUT_DIR = path.join(ROOT, 'web', 'dist', 'js');
const OUT_FILE = path.join(OUT_DIR, 'runtime-bundle.min.js');

async function main() {
const missing = [];
for (const rel of SOURCES) {
try {
await fs.access(path.join(ROOT, rel));
} catch {
missing.push(rel);
}
}
if (missing.length) {
console.error('[bundle] missing sources (run `npm run build` first?):\n ' + missing.join('\n '));
process.exit(1);
}

const parts = [];
parts.push('/* runtime-bundle.min.js — concat of ' + SOURCES.length + ' source files (perf/forge-shell P2) */');
for (const rel of SOURCES) {
let code = await fs.readFile(path.join(ROOT, rel), 'utf8');
// tsc emits `export function`/`export const` even with isolatedModules=true; the
// pre-bundling world treated these as window globals, so strip the `export ` prefix
// to keep the classic-script semantics under concatenation.
code = code.replace(/^\s*export\s+(function|const|let|var|class|async\s+function)\s+/gm, '$1 ');
// Strip any stray ESM marker that would force module mode at runtime.
code = code.replace(/^Object\.defineProperty\(exports,\s*"__esModule",[^)]*\);?\s*$/gm, '');
// Drop `import { X, Y } from '...';` lines — concatenation already puts every
// symbol in shared global scope, and the import binding collides with the
// `function X` declaration once `export` is stripped from the source module.
code = code.replace(/^\s*import\s+(?:[\s\S]*?)\s+from\s+['"][^'"]+['"];?\s*$/gm, '');
code = code.replace(/^\s*import\s+['"][^'"]+['"];?\s*$/gm, '');
parts.push(`/* === ${rel} === */`);
parts.push(code);
}
const concatenated = parts.join('\n');

await fs.mkdir(OUT_DIR, { recursive: true });

const result = await build({
stdin: { contents: concatenated, loader: 'js', resolveDir: ROOT },
write: false,
bundle: false,
minify: true,
target: 'es2020',
// No format: classic-script semantics — every top-level `function name()` stays
// a window-scoped global so Alpine.data() and inline event handlers can resolve
// them, exactly as they did before bundling.
legalComments: 'none',
logLevel: 'warning',
});

const out = result.outputFiles[0].text;
await fs.writeFile(OUT_FILE, out, 'utf8');

const sizeRaw = Buffer.byteLength(concatenated, 'utf8');
const sizeMin = Buffer.byteLength(out, 'utf8');
console.log(`[bundle] wrote ${path.relative(ROOT, OUT_FILE)}`);
console.log(`[bundle] sources: ${SOURCES.length} files`);
console.log(`[bundle] raw: ${sizeRaw.toLocaleString()} bytes`);
console.log(`[bundle] minified: ${sizeMin.toLocaleString()} bytes (${((sizeMin / sizeRaw) * 100).toFixed(1)}% of raw)`);
}

main().catch((err) => {
console.error('[bundle] failed:', err);
process.exit(1);
});
6 changes: 6 additions & 0 deletions web/dist/js/runtime-bundle.min.js

Large diffs are not rendered by default.

Loading
Loading