From 1f35f2cbf5f0ce6e1f6f9892cb2877471bc113c4 Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Tue, 21 Apr 2026 16:33:24 +0200 Subject: [PATCH 1/4] ci: add PR vs main benchmark workflow with report comment --- .github/workflows/benchmark-pr-vs-main.yml | 111 +++++++++++ scripts/ci/benchmark-pr-vs-main.mjs | 216 +++++++++++++++++++++ 2 files changed, 327 insertions(+) create mode 100644 .github/workflows/benchmark-pr-vs-main.yml create mode 100644 scripts/ci/benchmark-pr-vs-main.mjs diff --git a/.github/workflows/benchmark-pr-vs-main.yml b/.github/workflows/benchmark-pr-vs-main.yml new file mode 100644 index 0000000..10b0036 --- /dev/null +++ b/.github/workflows/benchmark-pr-vs-main.yml @@ -0,0 +1,111 @@ +name: Benchmark PR vs main + +on: + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + benchmark: + name: Benchmark + runs-on: ubuntu-latest + env: + npm_config_legacy_peer_deps: "true" + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + + - name: Checkout main baseline + uses: actions/checkout@v4 + with: + ref: main + path: main-baseline + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "lts/*" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install repository dependencies + run: npm ci + + - name: Build repository + run: npm run build + + - name: Clone benchmark suite + run: git clone --depth=1 https://github.com/milomg/js-reactivity-benchmark.git .tmp/js-reactivity-benchmark + + - name: Install benchmark dependencies + working-directory: .tmp/js-reactivity-benchmark + run: pnpm install --frozen-lockfile + + - name: Build benchmark core package + working-directory: .tmp/js-reactivity-benchmark + run: pnpm --filter js-reactivity-benchmark build + + - name: Run PR vs main benchmark + run: node scripts/ci/benchmark-pr-vs-main.mjs + env: + BENCH_CORE_DIST_DIR: .tmp/js-reactivity-benchmark/packages/core/dist + CURRENT_SIGNALS_DIR: packages/rescript-signals/src/signals + MAIN_SIGNALS_DIR: main-baseline/packages/rescript-signals/src/signals + BENCH_OUT_DIR: benchmark-results/ci/pr-vs-main + + - name: Upload benchmark artifacts + uses: actions/upload-artifact@v4 + with: + name: benchmark-pr-vs-main + path: benchmark-results/ci/pr-vs-main + if-no-files-found: error + + - name: Add workflow summary + run: | + echo "## ReScript Signals benchmark: PR vs main" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + cat benchmark-results/ci/pr-vs-main/pr-comment-latest.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upsert PR benchmark comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + const marker = ""; + const body = fs.readFileSync("benchmark-results/ci/pr-vs-main/pr-comment-latest.md", "utf8"); + const commentBody = `${marker}\n${body}`; + + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: commentBody, + }); + } diff --git a/scripts/ci/benchmark-pr-vs-main.mjs b/scripts/ci/benchmark-pr-vs-main.mjs new file mode 100644 index 0000000..0379443 --- /dev/null +++ b/scripts/ci/benchmark-pr-vs-main.mjs @@ -0,0 +1,216 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +function envOrDefault(name, fallback) { + const value = process.env[name]; + return value && value.length > 0 ? value : fallback; +} + +function toCsvValue(value) { + if (typeof value === "number") return value.toString(); + if (value.includes(",") || value.includes("\"") || value.includes("\n")) { + return `"${value.replaceAll("\"", "\"\"")}"`; + } + return value; +} + +function percentDelta(current, baseline) { + if (baseline === 0) return Number.NaN; + return ((current - baseline) / baseline) * 100; +} + +function toMarkdownComment(mainLabel, currentLabel, results) { + const byFramework = new Map(); + for (const row of results) { + const list = byFramework.get(row.framework) ?? []; + list.push(row); + byFramework.set(row.framework, list); + } + + const mainRows = byFramework.get(mainLabel) ?? []; + const currentRows = byFramework.get(currentLabel) ?? []; + const testNames = Array.from( + new Set(mainRows.map((r) => r.test).concat(currentRows.map((r) => r.test))), + ).sort(); + + const mainByTest = new Map(mainRows.map((r) => [r.test, r.time])); + const currentByTest = new Map(currentRows.map((r) => [r.test, r.time])); + + const totalMain = mainRows.reduce((sum, row) => sum + row.time, 0); + const totalCurrent = currentRows.reduce((sum, row) => sum + row.time, 0); + const totalDiff = totalCurrent - totalMain; + const totalDiffPct = percentDelta(totalCurrent, totalMain); + + const lines = []; + lines.push("### ReScript Signals benchmark: PR vs main"); + lines.push(""); + lines.push("Compared implementations:"); + lines.push(`- ${mainLabel}`); + lines.push(`- ${currentLabel}`); + lines.push(""); + lines.push("Overall:"); + lines.push(""); + lines.push("| Version | Total ms | Avg ms/test |"); + lines.push("| --- | ---: | ---: |"); + lines.push( + `| ${mainLabel} | ${totalMain.toFixed(2)} | ${(totalMain / testNames.length).toFixed(2)} |`, + ); + lines.push( + `| ${currentLabel} | ${totalCurrent.toFixed(2)} | ${(totalCurrent / testNames.length).toFixed(2)} |`, + ); + lines.push( + `| Delta (${currentLabel} - ${mainLabel}) | ${totalDiff.toFixed(2)} | ${totalDiffPct.toFixed(2)}% |`, + ); + lines.push(""); + lines.push("Per-test delta (lower is better):"); + lines.push(""); + lines.push("| Test | Main ms | PR ms | Diff ms | Diff % |"); + lines.push("| --- | ---: | ---: | ---: | ---: |"); + for (const test of testNames) { + const mainTime = mainByTest.get(test) ?? Number.NaN; + const currentTime = currentByTest.get(test) ?? Number.NaN; + const diffMs = currentTime - mainTime; + const diffPct = percentDelta(currentTime, mainTime); + lines.push( + `| ${test} | ${mainTime.toFixed(2)} | ${currentTime.toFixed(2)} | ${diffMs.toFixed(2)} | ${diffPct.toFixed(2)}% |`, + ); + } + lines.push(""); + lines.push( + "_Note: single-machine run in CI. Numbers can vary with runner load and Node/V8 version._", + ); + lines.push(""); + + return lines.join("\n"); +} + +function createReScriptFramework(name, modules) { + let disposers = []; + + return { + name, + signal: (initialValue) => { + const s = modules.Signal.make(initialValue); + return { + read: () => modules.Signal.get(s), + write: (v) => modules.Signal.set(s, v), + }; + }, + computed: (fn) => { + const c = modules.Computed.make(fn); + return { + read: () => modules.Signal.get(c), + }; + }, + effect: (fn) => { + const disposer = modules.Effect.runWithDisposer(() => { + fn(); + return undefined; + }); + disposers.push(disposer); + }, + withBatch: (fn) => { + modules.Signal.batch(fn); + }, + withBuild: (fn) => fn(), + cleanup: () => { + for (const disposer of disposers) { + disposer.dispose(); + } + disposers = []; + }, + }; +} + +async function importSignalModules(signalsDir) { + return { + Signal: await import(pathToFileURL(resolve(signalsDir, "Signal.res.mjs")).href), + Computed: await import(pathToFileURL(resolve(signalsDir, "Computed.res.mjs")).href), + Effect: await import(pathToFileURL(resolve(signalsDir, "Effect.res.mjs")).href), + }; +} + +async function main() { + const repoRoot = process.cwd(); + const benchCoreDistDir = envOrDefault( + "BENCH_CORE_DIST_DIR", + resolve(repoRoot, ".tmp/js-reactivity-benchmark/packages/core/dist"), + ); + const currentSignalsDir = envOrDefault( + "CURRENT_SIGNALS_DIR", + resolve(repoRoot, "packages/rescript-signals/src/signals"), + ); + const mainSignalsDir = envOrDefault( + "MAIN_SIGNALS_DIR", + resolve(repoRoot, "main-baseline/packages/rescript-signals/src/signals"), + ); + const outDir = envOrDefault( + "BENCH_OUT_DIR", + resolve(repoRoot, "benchmark-results/ci/pr-vs-main"), + ); + + const benchApi = await import(pathToFileURL(resolve(benchCoreDistDir, "index.js")).href); + const { runTests } = benchApi; + + const mainModules = await importSignalModules(mainSignalsDir); + const currentModules = await importSignalModules(currentSignalsDir); + + const mainLabel = "ReScript Signals (main)"; + const currentLabel = "ReScript Signals (PR)"; + + const frameworks = [ + { framework: createReScriptFramework(mainLabel, mainModules), testPullCounts: false }, + { framework: createReScriptFramework(currentLabel, currentModules), testPullCounts: false }, + ]; + + const results = []; + console.assert = () => {}; + console.log(`Running benchmark: ${mainLabel} vs ${currentLabel}`); + await runTests(frameworks, (result) => { + results.push(result); + }); + + mkdirSync(outDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const csvPath = resolve(outDir, `results-${timestamp}.csv`); + const jsonPath = resolve(outDir, `results-${timestamp}.json`); + const commentPath = resolve(outDir, `pr-comment-${timestamp}.md`); + const latestCsvPath = resolve(outDir, "results-latest.csv"); + const latestJsonPath = resolve(outDir, "results-latest.json"); + const latestCommentPath = resolve(outDir, "pr-comment-latest.md"); + + const csvLines = [ + "framework,test,time_ms", + ...results.map((r) => + [r.framework, r.test, r.time.toFixed(4)].map(toCsvValue).join(","), + ), + ]; + + const payload = { + timestamp: new Date().toISOString(), + benchmark: "milomg/js-reactivity-benchmark", + compared: [mainLabel, currentLabel], + results, + }; + + const comment = toMarkdownComment(mainLabel, currentLabel, results); + + writeFileSync(csvPath, csvLines.join("\n")); + writeFileSync(jsonPath, JSON.stringify(payload, null, 2)); + writeFileSync(commentPath, comment); + + writeFileSync(latestCsvPath, csvLines.join("\n")); + writeFileSync(latestJsonPath, JSON.stringify(payload, null, 2)); + writeFileSync(latestCommentPath, comment); + + console.log(`Saved CSV: ${csvPath}`); + console.log(`Saved JSON: ${jsonPath}`); + console.log(`Saved PR comment: ${commentPath}`); + console.log(`Saved latest PR comment: ${latestCommentPath}`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); From 389fcd30ff2124cd357718121329cf68e653b21a Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Tue, 21 Apr 2026 16:42:12 +0200 Subject: [PATCH 2/4] ci: avoid benchmark core tsc errors in PR benchmark workflow --- .github/workflows/benchmark-pr-vs-main.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/benchmark-pr-vs-main.yml b/.github/workflows/benchmark-pr-vs-main.yml index 10b0036..e9db6cb 100644 --- a/.github/workflows/benchmark-pr-vs-main.yml +++ b/.github/workflows/benchmark-pr-vs-main.yml @@ -49,9 +49,9 @@ jobs: working-directory: .tmp/js-reactivity-benchmark run: pnpm install --frozen-lockfile - - name: Build benchmark core package - working-directory: .tmp/js-reactivity-benchmark - run: pnpm --filter js-reactivity-benchmark build + - name: Build benchmark core runtime bundle + working-directory: .tmp/js-reactivity-benchmark/packages/core + run: pnpm exec esbuild src/index.ts --bundle --format=esm --target=esnext --outdir=dist --sourcemap=external - name: Run PR vs main benchmark run: node scripts/ci/benchmark-pr-vs-main.mjs From d3c6059a51cf4b1d55de49b05d72ea4a8386e2c8 Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Tue, 21 Apr 2026 16:46:09 +0200 Subject: [PATCH 3/4] ci: fix PR benchmark module resolution for main baseline --- .github/workflows/benchmark-pr-vs-main.yml | 12 +++++-- scripts/ci/benchmark-pr-vs-main.mjs | 40 +++++++++++++++++----- 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.github/workflows/benchmark-pr-vs-main.yml b/.github/workflows/benchmark-pr-vs-main.yml index e9db6cb..d57a3fd 100644 --- a/.github/workflows/benchmark-pr-vs-main.yml +++ b/.github/workflows/benchmark-pr-vs-main.yml @@ -42,6 +42,14 @@ jobs: - name: Build repository run: npm run build + - name: Install baseline dependencies + working-directory: main-baseline + run: npm ci + + - name: Build baseline + working-directory: main-baseline + run: npm run build + - name: Clone benchmark suite run: git clone --depth=1 https://github.com/milomg/js-reactivity-benchmark.git .tmp/js-reactivity-benchmark @@ -57,8 +65,8 @@ jobs: run: node scripts/ci/benchmark-pr-vs-main.mjs env: BENCH_CORE_DIST_DIR: .tmp/js-reactivity-benchmark/packages/core/dist - CURRENT_SIGNALS_DIR: packages/rescript-signals/src/signals - MAIN_SIGNALS_DIR: main-baseline/packages/rescript-signals/src/signals + CURRENT_SIGNALS_DIR: packages/rescript-signals + MAIN_SIGNALS_DIR: main-baseline/packages/rescript-signals BENCH_OUT_DIR: benchmark-results/ci/pr-vs-main - name: Upload benchmark artifacts diff --git a/scripts/ci/benchmark-pr-vs-main.mjs b/scripts/ci/benchmark-pr-vs-main.mjs index 0379443..2d2899f 100644 --- a/scripts/ci/benchmark-pr-vs-main.mjs +++ b/scripts/ci/benchmark-pr-vs-main.mjs @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -123,11 +123,33 @@ function createReScriptFramework(name, modules) { }; } -async function importSignalModules(signalsDir) { +function resolveSignalsDir(baseDir, label) { + const candidates = [ + baseDir, + resolve(baseDir, "src/signals"), + resolve(baseDir, "lib/bs/src/signals"), + ]; + + for (const candidate of candidates) { + const signalFile = resolve(candidate, "Signal.res.mjs"); + const computedFile = resolve(candidate, "Computed.res.mjs"); + const effectFile = resolve(candidate, "Effect.res.mjs"); + if (existsSync(signalFile) && existsSync(computedFile) && existsSync(effectFile)) { + return candidate; + } + } + + throw new Error( + `Could not resolve ReScript module directory for ${label}. Tried: ${candidates.join(", ")}`, + ); +} + +async function importSignalModules(signalsDir, label) { + const resolvedSignalsDir = resolveSignalsDir(signalsDir, label); return { - Signal: await import(pathToFileURL(resolve(signalsDir, "Signal.res.mjs")).href), - Computed: await import(pathToFileURL(resolve(signalsDir, "Computed.res.mjs")).href), - Effect: await import(pathToFileURL(resolve(signalsDir, "Effect.res.mjs")).href), + Signal: await import(pathToFileURL(resolve(resolvedSignalsDir, "Signal.res.mjs")).href), + Computed: await import(pathToFileURL(resolve(resolvedSignalsDir, "Computed.res.mjs")).href), + Effect: await import(pathToFileURL(resolve(resolvedSignalsDir, "Effect.res.mjs")).href), }; } @@ -139,11 +161,11 @@ async function main() { ); const currentSignalsDir = envOrDefault( "CURRENT_SIGNALS_DIR", - resolve(repoRoot, "packages/rescript-signals/src/signals"), + resolve(repoRoot, "packages/rescript-signals"), ); const mainSignalsDir = envOrDefault( "MAIN_SIGNALS_DIR", - resolve(repoRoot, "main-baseline/packages/rescript-signals/src/signals"), + resolve(repoRoot, "main-baseline/packages/rescript-signals"), ); const outDir = envOrDefault( "BENCH_OUT_DIR", @@ -153,8 +175,8 @@ async function main() { const benchApi = await import(pathToFileURL(resolve(benchCoreDistDir, "index.js")).href); const { runTests } = benchApi; - const mainModules = await importSignalModules(mainSignalsDir); - const currentModules = await importSignalModules(currentSignalsDir); + const mainModules = await importSignalModules(mainSignalsDir, "main"); + const currentModules = await importSignalModules(currentSignalsDir, "PR"); const mainLabel = "ReScript Signals (main)"; const currentLabel = "ReScript Signals (PR)"; From 4886daa71c967ea3d9df4b7ef5b679c53430e33b Mon Sep 17 00:00:00 2001 From: Bernardo Gurgel Date: Tue, 21 Apr 2026 17:07:39 +0200 Subject: [PATCH 4/4] ci: add PR benchmark workflow against top frameworks --- .../workflows/benchmark-pr-vs-frameworks.yml | 104 ++++++++ scripts/ci/benchmark-pr-vs-frameworks.mjs | 245 ++++++++++++++++++ 2 files changed, 349 insertions(+) create mode 100644 .github/workflows/benchmark-pr-vs-frameworks.yml create mode 100644 scripts/ci/benchmark-pr-vs-frameworks.mjs diff --git a/.github/workflows/benchmark-pr-vs-frameworks.yml b/.github/workflows/benchmark-pr-vs-frameworks.yml new file mode 100644 index 0000000..4ba8981 --- /dev/null +++ b/.github/workflows/benchmark-pr-vs-frameworks.yml @@ -0,0 +1,104 @@ +name: Benchmark PR vs frameworks + +on: + pull_request: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + benchmark: + name: Benchmark + runs-on: ubuntu-latest + env: + npm_config_legacy_peer_deps: "true" + steps: + - name: Checkout PR branch + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "lts/*" + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install repository dependencies + run: npm ci + + - name: Build repository + run: npm run build + + - name: Clone benchmark suite + run: git clone --depth=1 https://github.com/milomg/js-reactivity-benchmark.git .tmp/js-reactivity-benchmark + + - name: Install benchmark dependencies + working-directory: .tmp/js-reactivity-benchmark + run: pnpm install --frozen-lockfile + + - name: Build benchmark core runtime bundle + working-directory: .tmp/js-reactivity-benchmark/packages/core + run: pnpm exec esbuild src/index.ts --bundle --format=esm --target=esnext --outdir=dist --sourcemap=external + + - name: Run PR vs frameworks benchmark + run: node scripts/ci/benchmark-pr-vs-frameworks.mjs + env: + BENCH_CORE_DIST_DIR: .tmp/js-reactivity-benchmark/packages/core/dist + CURRENT_SIGNALS_DIR: packages/rescript-signals + BENCH_OUT_DIR: benchmark-results/ci/pr-vs-frameworks + + - name: Upload benchmark artifacts + uses: actions/upload-artifact@v4 + with: + name: benchmark-pr-vs-frameworks + path: benchmark-results/ci/pr-vs-frameworks + if-no-files-found: error + + - name: Add workflow summary + run: | + echo "## ReScript Signals benchmark: PR vs frameworks" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + cat benchmark-results/ci/pr-vs-frameworks/pr-comment-latest.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upsert PR benchmark comment + if: github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const fs = require("fs"); + const marker = ""; + const body = fs.readFileSync("benchmark-results/ci/pr-vs-frameworks/pr-comment-latest.md", "utf8"); + const commentBody = `${marker}\n${body}`; + + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + + const existing = comments.find((c) => c.body && c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body: commentBody, + }); + } else { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: commentBody, + }); + } diff --git a/scripts/ci/benchmark-pr-vs-frameworks.mjs b/scripts/ci/benchmark-pr-vs-frameworks.mjs new file mode 100644 index 0000000..0fa14d3 --- /dev/null +++ b/scripts/ci/benchmark-pr-vs-frameworks.mjs @@ -0,0 +1,245 @@ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const SELECTED_FRAMEWORKS = [ + "Alien Signals", + "Preact Signals", + "SolidJS", + "Svelte v5", + "Vue", +]; + +function envOrDefault(name, fallback) { + const value = process.env[name]; + return value && value.length > 0 ? value : fallback; +} + +function toCsvValue(value) { + if (typeof value === "number") return value.toString(); + if (value.includes(",") || value.includes("\"") || value.includes("\n")) { + return `"${value.replaceAll("\"", "\"\"")}"`; + } + return value; +} + +function summarize(results) { + const byFramework = new Map(); + const tests = new Set(); + + for (const row of results) { + tests.add(row.test); + const list = byFramework.get(row.framework) ?? []; + list.push(row); + byFramework.set(row.framework, list); + } + + const ranking = [...byFramework.entries()] + .map(([framework, rows]) => { + const total = rows.reduce((sum, r) => sum + r.time, 0); + const avg = total / rows.length; + return { framework, rows, total, avg }; + }) + .sort((a, b) => a.total - b.total); + + return { ranking, tests: [...tests].sort() }; +} + +function toMarkdownComment(frameworkNames, results) { + const { ranking, tests } = summarize(results); + + const lines = []; + lines.push("### Reactivity benchmark: PR vs top frameworks"); + lines.push(""); + lines.push("Compared implementations:"); + for (const name of frameworkNames) { + lines.push(`- ${name}`); + } + lines.push(""); + lines.push("Overall ranking (lower total ms is better):"); + lines.push(""); + lines.push("| Rank | Framework | Total ms | Avg ms/test |"); + lines.push("| --- | --- | ---: | ---: |"); + ranking.forEach((entry, i) => { + lines.push( + `| ${i + 1} | ${entry.framework} | ${entry.total.toFixed(2)} | ${entry.avg.toFixed(2)} |`, + ); + }); + lines.push(""); + lines.push("Per-test runtime (ms):"); + lines.push(""); + lines.push(`| Framework | ${tests.join(" | ")} |`); + lines.push(`| --- | ${tests.map(() => "---:").join(" | ")} |`); + for (const entry of ranking) { + const byTest = new Map(entry.rows.map((r) => [r.test, r.time])); + const values = tests.map((test) => (byTest.get(test) ?? Number.NaN).toFixed(2)); + lines.push(`| ${entry.framework} | ${values.join(" | ")} |`); + } + lines.push(""); + lines.push( + "_Note: single-machine run in CI. Numbers can vary with runner load and Node/V8 version._", + ); + lines.push(""); + + return lines.join("\n"); +} + +function createReScriptFramework(name, modules) { + let disposers = []; + + return { + name, + signal: (initialValue) => { + const s = modules.Signal.make(initialValue); + return { + read: () => modules.Signal.get(s), + write: (v) => modules.Signal.set(s, v), + }; + }, + computed: (fn) => { + const c = modules.Computed.make(fn); + return { + read: () => modules.Signal.get(c), + }; + }, + effect: (fn) => { + const disposer = modules.Effect.runWithDisposer(() => { + fn(); + return undefined; + }); + disposers.push(disposer); + }, + withBatch: (fn) => { + modules.Signal.batch(fn); + }, + withBuild: (fn) => fn(), + cleanup: () => { + for (const disposer of disposers) { + disposer.dispose(); + } + disposers = []; + }, + }; +} + +function resolveSignalsDir(baseDir) { + const candidates = [ + baseDir, + resolve(baseDir, "src/signals"), + resolve(baseDir, "lib/bs/src/signals"), + ]; + + for (const candidate of candidates) { + const signalFile = resolve(candidate, "Signal.res.mjs"); + const computedFile = resolve(candidate, "Computed.res.mjs"); + const effectFile = resolve(candidate, "Effect.res.mjs"); + if (existsSync(signalFile) && existsSync(computedFile) && existsSync(effectFile)) { + return candidate; + } + } + + throw new Error(`Could not resolve ReScript modules. Tried: ${candidates.join(", ")}`); +} + +async function importSignalModules(baseDir) { + const signalsDir = resolveSignalsDir(baseDir); + return { + Signal: await import(pathToFileURL(resolve(signalsDir, "Signal.res.mjs")).href), + Computed: await import(pathToFileURL(resolve(signalsDir, "Computed.res.mjs")).href), + Effect: await import(pathToFileURL(resolve(signalsDir, "Effect.res.mjs")).href), + }; +} + +function pickFrameworks(allFrameworks, rescriptFramework) { + const map = new Map(allFrameworks.map((entry) => [entry.framework.name, entry])); + const chosen = []; + + for (const name of SELECTED_FRAMEWORKS) { + const found = map.get(name); + if (!found) { + throw new Error(`Framework not found in benchmark suite: ${name}`); + } + chosen.push(found); + } + + chosen.push({ + framework: rescriptFramework, + testPullCounts: false, + }); + + return chosen; +} + +async function main() { + const repoRoot = process.cwd(); + const benchCoreDistDir = envOrDefault( + "BENCH_CORE_DIST_DIR", + resolve(repoRoot, ".tmp/js-reactivity-benchmark/packages/core/dist"), + ); + const currentSignalsDir = envOrDefault( + "CURRENT_SIGNALS_DIR", + resolve(repoRoot, "packages/rescript-signals"), + ); + const outDir = envOrDefault( + "BENCH_OUT_DIR", + resolve(repoRoot, "benchmark-results/ci/pr-vs-frameworks"), + ); + + const benchApi = await import(pathToFileURL(resolve(benchCoreDistDir, "index.js")).href); + const { runTests, allFrameworks } = benchApi; + + const currentModules = await importSignalModules(currentSignalsDir); + const rescriptFramework = createReScriptFramework("ReScript Signals (PR)", currentModules); + const frameworks = pickFrameworks(allFrameworks, rescriptFramework); + const frameworkNames = frameworks.map((entry) => entry.framework.name); + + const results = []; + console.assert = () => {}; + console.log(`Running selected frameworks: ${frameworkNames.join(", ")}`); + await runTests(frameworks, (result) => { + results.push(result); + }); + + mkdirSync(outDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const csvPath = resolve(outDir, `results-${timestamp}.csv`); + const jsonPath = resolve(outDir, `results-${timestamp}.json`); + const commentPath = resolve(outDir, `pr-comment-${timestamp}.md`); + const latestCsvPath = resolve(outDir, "results-latest.csv"); + const latestJsonPath = resolve(outDir, "results-latest.json"); + const latestCommentPath = resolve(outDir, "pr-comment-latest.md"); + + const csvLines = [ + "framework,test,time_ms", + ...results.map((r) => + [r.framework, r.test, r.time.toFixed(4)].map(toCsvValue).join(","), + ), + ]; + + const payload = { + timestamp: new Date().toISOString(), + benchmark: "milomg/js-reactivity-benchmark", + frameworks: frameworkNames, + results, + }; + + const comment = toMarkdownComment(frameworkNames, results); + + writeFileSync(csvPath, csvLines.join("\n")); + writeFileSync(jsonPath, JSON.stringify(payload, null, 2)); + writeFileSync(commentPath, comment); + + writeFileSync(latestCsvPath, csvLines.join("\n")); + writeFileSync(latestJsonPath, JSON.stringify(payload, null, 2)); + writeFileSync(latestCommentPath, comment); + + console.log(`Saved CSV: ${csvPath}`); + console.log(`Saved JSON: ${jsonPath}`); + console.log(`Saved PR comment: ${commentPath}`); + console.log(`Saved latest PR comment: ${latestCommentPath}`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});