Milestone 2 Plan
Plan · approved on 2026-10-15

Implementation Plan, Milestone 2

Fractional Factorial Design Translator

Test-first tasks for the R engine in the browser, the workspace shell and the project storage. WebR and native R must give byte-identical reports.

Plan
replication_materials/plans/2026-10-15-m2-browser-engine-plan.md
Specification
replication_materials/plans/2026-10-14-ff-design-translator-design.md
Status
Approved on 2026-10-15. Executed subagent-driven.
Tasks
12, each test-first and committed on its own
Maintained by
Alan R. Vazquez (Tecnológico de Monterrey) and Fadel M. Megahed (Miami University)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

1Context

Milestone 1 (the R core) was merged into main on 2026-10-15. Section 18 of the spec defines Milestone 2:

  • the Next.js shell (landing page, workspace, step rail);
  • runtime setup;
  • the WebR worker and codegen;
  • project storage (IndexedDB and the pointer);
  • differential tests and performance tests.

Milestone 1 left four items for this milestone (spec 17.1 and the execution ledger):

  1. Fix the JavaScript shape of the ffd_report() result and test it in WebR.
  2. A refused catalogue entry keeps its name-based class: 27-18.1 says "MA" while ok is FALSE. Make it agree with data/guarantee-table.json (broken_entry).
  3. Compare repeat_only, randomize and seed in the spec with the design, in the post-checks.
  4. Set the supported limits for runs and factors from the performance tests.

Planning measured WebR 0.6.0 in Node and compared it with native R 4.6.1 (table below). Two of the results change the design:

  • FrF2 orders its alias chains by the collation of the R session. WebR uses C. Native R on Windows uses English_United States.utf8. So 3 of 11 measured designs gave a different report in WebR. The fix is a canonical order inside ffd_report().
  • webR.interrupt() stops R, but WebR 0.6.0 then does not answer. So Cancel terminates the worker and starts a new one.

Goal: A Next.js app that loads WebR and FrF2 in a worker and makes a design from a spec through codegen. Its report must be byte-identical to native R. The app stores projects on the device. Performance tests set its time budgets and supported limits.

Architecture:

  • App. A Next.js 16 app (App Router) sends cross-origin isolation headers on every route.
  • Worker. A static module worker (public/workers/webr-worker.mjs) loads WebR from a same-origin copy. It checks the SHA-256 of each R package file from the same-origin mirror before it unpacks it. The R library is kept in IndexedDB. The worker then runs the codegen output and the report helper.
  • Shared engine core. public/workers/engine-core.mjs holds the steps that the browser worker and the Node tests share. So the differential and performance tests use the production code path.

Tech Stack:

  • Node 22.14.0 and npm 11.5.2.
  • Next.js 16.3.8, React 19.3.0, zod 4.6.5, Tailwind CSS 4.3.3, TypeScript 5.9.3.
  • WebR 0.6.0 (R 4.6.0).
  • vitest 4.1.11, fake-indexeddb 6.2.5, Playwright 1.63.0.
  • Native R 4.6.1 with FrF2 2.3-5, DoE.base 1.2-5 and jsonlite 2.0.0.

Spec: replication_materials/plans/2026-10-14-ff-design-translator-design.md (version 2), Sections 4, 5.2, 6, 7, 8.1, 8.2, 9.1 to 9.3, 9.7 to 9.10, 11, 13 and 18. The Milestone 1 plan (2026-10-15-m1-r-core-plan.md) and its execution ledger give the background of the R core.

2Global Constraints

  • Versions. Pin every npm package exactly, with no ^ or ~. A project .npmrc sets save-exact=true. The versions are the ones in the Tech Stack.
  • Native R. Use R 4.6.1 at C:/Program Files/R/R-4.6.1/bin/Rscript.exe. The variable FFD_RSCRIPT overrides the path on another machine (scripts/lib/rscript.mjs). Never use the Rscript on the PATH (R 4.5.2).
  • Rscript calls. Never pass several statements in one Rscript -e. On this R build only the first statement runs, and the exit status is still 0 (ChatISA lesson). Always run a script file.
  • R style. All R code (app, codegen output, scripts and tests) uses pkg::fun(), with no library() or require(). No function in r/ffd_report.R reads an object from the search path.
  • Codegen. It is the only source of R code (spec 4.2). It never passes nruns and resolution together (spec 6.2). The R code view shows exactly the code that the worker runs (spec 7.1).
  • Fail closed. If a post-check fails, the report does not have the expected form, or the engine fails, there is no design: there is a stated reason.
  • Isolation. COOP same-origin and COEP require-corp go on every route (/:path*): pages, /workers/*, /runtimes/* and /r/*.
  • Self-hosted. The browser loads no third-party script, font or style sheet. next/font downloads Source Serif 4 at build time and serves it from the app.
  • Storage. All access to IndexedDB and localStorage is in try/catch. The app works without storage (spec 8.2).
  • Wording. User-visible text is plain academic wording (spec 9.7): no exclamation marks, no marketing words, no second-person slogans. Messages state facts. Comments and documents are about 80% ASD-STE100.
  • Accessibility (spec 9.10). Skip link, landmarks, a page title on each page, and focus on the step heading after a step change. Status is always text, in a polite live region. Targets are at least 24 × 24 px.
  • Tests. All tests are in replication_materials/tests/ (spec 11). Performance CSVs go to replication_materials/tests/perf/.
  • Generated files. public/runtimes/ and public/r/ are generated and not in git. data/runtime-manifest.json, config/runtime-budgets.json and data/supported-limits.json are committed.
  • Commits. One commit per task. Every commit message ends with these two lines: Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015r24Rsz9sKUkmq6wr9MGso The git commit commands in the tasks show only the subject line. Always add the two lines.
  • JSON in browser tests. A module that a Playwright spec imports, directly or through other modules, imports JSON only with with { type: "json" }. Playwright loads the specs as native ES modules, and Node 22 refuses a JSON import without that attribute (measured in the pre-flight scan).
  • Outside contact. Nothing is pushed or published. The only network access is the npm registry, repo.r-wasm.org (runtime setup) and Google Fonts (at build time).

3Review Focus

These are the five inputs that a person is most likely to meet and that the spec does not test directly. Each one has tests in the task that owns its code.

  1. Factor names that are not valid R names. These include spaces, accents, quotes, R reserved words and non-Latin scripts. The R code uses a fixed sanitized name. The report and the interface show the name as typed. WebR and native R agree byte for byte. Tests: Task 3, names.test.ts. Task 6, the random cases assert r_name and label.
  2. No storage. IndexedDB or localStorage is blocked, for example in private browsing. The workspace still works, a notice says that projects last for this session only, and the pointer is skipped. Tests: Task 8, store and pointer fallbacks. Task 9, end-to-end with IndexedDB blocked and with localStorage blocked.
  3. The engine cannot start. A runtime file is missing, gives HTTP 404, or fails the SHA-256 check. The footer shows "R engine: not available" with the reason and a Retry button. Nothing waits without a message. Tests: Task 5, a tampered file. Task 7, init-error in the client. Task 9, the footer with a 404 and Retry. Task 10, a tampered file in Chromium.
  4. Two tabs, or a restarted worker, share one IndexedDB library. Both reach "ready" and run designs. Tests: Task 10, two workers in one page, and a restart that loads from the cache.
  5. A library from an older package manifest. The library is installed again. It is never used as it is. Test: Task 5, a different manifest id gives a new install.

4Decisions made during planning

The plan makes these decisions. Each one has a reason and a cost if it is wrong. The authors can change any of them before execution.

# Decision Reason Cost if wrong
P1 The worker installs packages by writing each verified .tgz file into WebR and calling utils::untar(tar = "internal"). It does not use webR.installPackages() (the ChatISA method). The spec asks for SHA-256 checks and a progress bar in bytes. installPackages() gives neither. Measured: 0.5 s to write all 29 files and 3.9 s to unpack them in Node. One function in engine-core.mjs.
P2 Cancel and the hard limit terminate the worker and start a new one. webR.interrupt() is not used. Measured: after an interrupt, WebR 0.6.0 does not answer 1 + 1. With the IndexedDB library, a restart downloads nothing. Cancel becomes slower than an interrupt.
P3 ffd_report() sorts alias chains by factor position: inside a chain, shorter terms come first, then factor order; chains follow their first term. The report version becomes 1.1.0. FrF2's order depends on the locale (measured). The new order keeps the two Milestone 1 expectations (checked during planning). The display order of alias chains.
P4 The report JSON comes from ffd_report_json() in R (jsonlite 2.0.0 in both runtimes). Arrays stay arrays, scalars are unboxed one by one, and the run sheet is in rows. versions is the last key. Spec 17.1 asks for a fixed shape. Native R and WebR give the same bytes (measured: identical except for the alias order, P3). The zod schema in lib/report/types.ts.
P5 A catalogue entry whose generators check fails is reported as broken_entry (rule generators_check), whatever its name says. This is Milestone 1 carry-over 2. All 7 refused entries fail this check. One branch in ffd_report().
P6 New post-check run_order: repeat_only, randomize and seed in the spec equal the values that the design stores. Codegen uses repeat_only && replications > 1, because FrF2 accepts repeat.only = TRUE with one replication and stores TRUE without a message (measured). This is Milestone 1 carry-over 3. The check fails closed if codegen and the spec disagree.
P7 The strict core-spec schema (lib/spec/validate.ts) and the factor-name rule (lib/spec/names.ts) are made here, not in Milestone 3. A quantitative factor must have numeric levels. Codegen, the project schema and the differential cases need them. The numeric-level rule follows from "quantitative" and from the center-point rule. Milestone 3 adds the model-facing schema next to this one.
P8 Supported limits: every usable catalogue default size, plus the full factorials of up to 4096 runs. A size is left out if its estimated warm time in Chromium is above a 120-second ceiling. The maximum total rows comes from a ladder of replications. Spec 6.2 and 7.9 ask for measured limits. The 120-second ceiling is a product decision of the authors (2026-10-15); it is not a measurement. Measured during planning: the slowest design is 3.4 s in Node WebR, so the ceiling probably removes nothing. The authors can change CEILING_MS in scripts/derive-budgets.mjs.
P9 The footer has "Home", the version and the R engine status. The Glossary, Architecture and Paper links come when those pages exist (Milestones 3 and 4). The landing page has no video block until Milestone 4. Links to pages that do not exist would give 404 errors. Two lines in SiteFooter.tsx.
P10 If TRANSLATE_MODE is not set, or names an unknown provider, the privacy notice is the ensemble notice. This notice names every provider that can get the request text. The production default comes from the evaluation (spec 12.4). One function in lib/ops/privacy.ts.
P11 The Node tests use a copy of WebR in public/runtimes/webr/ with one patched line in webr-worker.js. WebR 0.6.0 calls import(path.resolve(file)). On Windows, Node refuses the C:\ path. Module hooks do not reach worker threads in Node 22.14 (measured). The patched line runs only in Node. The patch fails loudly if WebR changes.
P12 No ESLint in Milestone 2. The checks are TypeScript (tsc --noEmit) and the tests. YAGNI. Next.js 16 has no built-in next lint. A later task can add ESLint.
P13 The engine starts on every page (landing and workspace). "Start a design" is a client-side link, so the worker continues. Spec 7.9 says "the page starts the worker when it opens". A full page load would start the download a second time. One component.
P14 Steps 1, 2, 3 and 6 show "This step is not available in this version." until Milestone 3. The steps belong to Milestone 3. The shell, the rail and the focus behaviour can be tested now. Placeholder text, replaced in Milestone 3.
P15 While a design runs, the footer shows "R engine: computing the design (m:ss)" and an activity bar (an indeterminate progress bar) from the start of the run. At the soft limit, the text changes to "R engine: still running (m:ss)" and a Cancel button appears (spec 7.9). Milestone 3 shows the same indicator in step 3, and moves the engine announcements into the global announcer (spec 9.10). With reduced motion, the bar is hidden and the text stays. Requested by Fadel M. Megahed on 2026-10-15. FrF2 does not report its progress, so the bar shows activity, not a percent. The elapsed time shows that the run continues. One hook, one footer branch, and a test hook that only the browser-test build has (NEXT_PUBLIC_FFD_TEST_HOOKS=1).

Review of 2026-10-15. Fadel M. Megahed approved P3, P8 and P10, changed the ceiling of P8 from 60 to 120 seconds, and asked for a computing indicator during a design run (P15). The plan runs subagent-driven.

5Facts measured during planning (2026-10-15)

Fact Value
WebR repository https://repo.r-wasm.org/bin/emscripten/contrib/4.6 has FrF2 2.3-5, DoE.base 1.2-5 and jsonlite 2.0.0, the same versions as native R. igraph is 2.3.1 there and 2.3.3 in native R. The report does not use igraph.
Package closure 29 packages for FrF2 and jsonlite (Depends and Imports): DoE.base, FrF2, MASS, Matrix, Rdpack, cli, colorspace, combinat, conf.design, glue, gmp, igraph, jsonlite, lattice, lifecycle, lmtest, magrittr, numbers, partitions, pkgconfig, polynom, rbibutils, rlang, scatterplot3d, sets, sfsmisc, vcd, vctrs, zoo. Total 25.3 MB. The repository has .tgz files only, with no .data filesystem images (HTTP 403). The base library of WebR has no MASS, lattice or Matrix.
WebR in Node on Windows import(path.resolve(...)) in webr-worker.js fails with "Received protocol 'c:'". Fix: wrap the path in url.pathToFileURL(...).href. A --import resolve hook is not inherited by WebR's worker thread in Node 22.14.
Install by untar utils::untar(f, exdir = lib, tar = "internal"). Without tar = "internal", R calls system(), which WebR does not have. In Node: write 29 files 0.47 s; unpack 3.9 s; loadNamespace("FrF2") 0.55 s; cold start to FrF2 loaded 5.4 s. Network install with installPackages(): 11.9 s.
Start-up message Loading the DoE.base namespace prints "Registered S3 method overwritten by 'DoE.base'". Load the namespaces with suppressMessages() during start-up, so the message is not in the first design.
Design and report times In seconds, WebR in Node against native R. 16×7: 0.06+0.03 / 0.29+0.11. 128×24: 0.13+0.03 / 0.03+0.03. 128×127: 1.84+0.25 / 0.47+0.06. 512×29: 0.23+0.04 / 0.06+0.02. 512×160: 3.09+0.36 / 0.86+0.09. 1024×28: 0.17+0.04 / 0.06+0.01. 4096×26: 0.31+0.09 / 0.11+0.05. 4096×65: 1.20+0.22 / 0.41+0.12. Report JSON 2 KB (16×7) to 1.9 MB (4096×65).
Byte identity The report JSON without versions is byte-identical in WebR and native R for 8 of 11 designs. The other 3 (128×127, 512×29, 512×160) differ only in the order of the alias chains. With Sys.setlocale("LC_COLLATE", "C") in native R, all 11 are identical. The canonical order of P3 gives identical chains under both collations.
Run order Date seeds and 2147483647 give the same run order in WebR (R 4.6.0) and native R 4.6.1, for the measured designs.
design.info() randomize is TRUE/FALSE. seed is NULL when FrF2 gets no seed (also with randomize = TRUE) and a double otherwise. repeat.only is TRUE/FALSE.
Interrupt webR.interrupt() 1 s into a 4-second loop: the evaluation rejects after 3 ms with "A non-local transfer of control occurred during evaluation". After that, evalRNumber("1 + 1") does not return.
WebR API new shelter.REnvironment() makes an environment whose parent is the global environment. shelter.captureR(code, { env, captureConditions: true }) returns warnings and messages as conditions. An R error rejects: "Error in `eval(expr, env)`: boom". result.toArray() gives ["..."] for a character result. Sys.getenv("WEBR_VERSION") is "0.6.0". FS.mkdir on an existing path rejects (ErrnoError 20). FS.readFile of a missing file rejects (ErrnoError 44).
Next.js 16.3.8 headers() with source /:path* adds COOP and COEP to pages, /workers/*.mjs, public scripts and SVG files. next start prints "does not work with output: standalone" but serves the app. React 19 renders <script src="/pointer-redirect.js" /> in <head> as a synchronous script. next/font/google downloads Source Serif 4 at build time.
npm versions These are the newest versions released before 2026-10-01, so each is at least two weeks old. next 16.3.8 (2026-09-30); react and react-dom 19.3.0; typescript 5.9.3 (TypeScript 7 is the new native compiler, which Next.js does not use yet); vitest 4.1.11; @playwright/test 1.63.0; zod 4.6.5; tailwindcss and @tailwindcss/postcss 4.3.3; @types/node 22.20.4; @types/react and @types/react-dom 19.3.0; fake-indexeddb 6.2.5; webr 0.6.0.
ChatISA lessons Import /runtimes/webr/webr.js (the browser build) in the worker, not webr.mjs. Keep captureConditions: true: with false, stop("boom") resolves as a success. Keep the R library in IDBFS. COEP must also be on the worker and runtime paths.
Logos Tec: replication_materials/mockups/2026-10-14-ui/assets/tec.svg, copied to public/brand/tecnologico-de-monterrey-blue.svg. Miami: the horizontal lockup used in the approved mockups, replication_materials/mockups/2026-10-14-ui/assets/miami.png (1314 × 312).

The probe scripts are in replication_materials/plans/m2-probes/.

6File structure

File Responsibility
r/ffd_report.R Canonical alias order, run_order check, broken_entry class, ffd_report_json(), ffd_read_errata()
package.json, .npmrc, tsconfig.json, next.config.ts, postcss.config.mjs, vitest.config.ts, playwright.config.ts Project configuration
app/layout.tsx, app/globals.css Root layout, fonts, colour tokens, pointer script
app/page.tsx Landing page
app/app/page.tsx Workspace page
components/shell/*.tsx Header, footer, engine status, workspace, sidebar, step rail, step panel, skip link
lib/site.ts Name, version, maintainers, citation
lib/isolation.ts Cross-origin isolation headers
lib/spec/validate.ts, names.ts, seed.ts Strict core spec, R name rule, seeds
lib/codegen/frf2.ts Design code and report call
lib/report/types.ts zod schema of the report JSON
lib/webr/protocol.ts, client.ts, engine.ts, use-engine.ts, status-text.ts Worker messages, engine client, singleton, React hook, status text
lib/config/budgets.ts, config/runtime-budgets.json R time budgets
lib/projects/schema.ts, store.ts, pointer.ts, format.ts Projects in IndexedDB, last-page pointer, display helpers
lib/steps.ts, lib/ops/privacy.ts Steps of the rail; privacy notice
public/workers/engine-core.mjs Engine steps shared by the worker and the Node tests
public/workers/webr-worker.mjs Browser worker
public/pointer-redirect.js Opens the last page before the landing page is drawn
public/brand/ Logos and favicon
scripts/setup-runtimes.mjs, scripts/lib/runtime-manifest.mjs WebR copy, package mirror, R files; manifest helpers
scripts/check.mjs, scripts/run-r-tests.mjs, scripts/lib/rscript.mjs The local check command; native R
scripts/derive-budgets.mjs Budgets and limits from the performance CSVs
data/runtime-manifest.json, data/supported-limits.json Package pins with SHA-256; supported limits
replication_materials/tests/unit/ vitest unit tests (no WebR, no R)
replication_materials/tests/webr/ vitest tests with WebR in Node (and native R for comparisons)
replication_materials/tests/e2e/ Playwright tests in Chromium
replication_materials/tests/fixtures/ Shared specs and report fixtures
replication_materials/tests/r/ Native R tests (Milestone 1, extended in Task 1)
README.md How to set up, run and test

7Task 1: Report JSON shape and the Milestone 1 carry-overs (R)

Files:

  • Modify: r/ffd_report.R, r/README.md, replication_materials/tests/r/helper-ffd.R
  • Create: replication_materials/tests/r/test-report-json.R
  • Rebuild: data/catalogue-summary.json, data/guarantee-table.json (they record report_version)

Interfaces:

  • Produces:
    • FFD_REPORT_VERSION is "1.1.0".
    • ffd_report_json(r) returns a character(1) JSON string.
    • ffd_read_errata(path) returns the names of the entries with a verified stored-WLP error.
    • ffd_run_order_matches(info, spec) returns a logical.
    • spec gains three optional fields: repeat_only (logical), randomize (logical) and seed (whole number from 1 to 2147483647, or NULL).
    • New post-check run_order.
    • catalogue$class is "broken_entry" (rule "generators_check") when the generators check fails.
    • ffd_alias_chains() returns chains in canonical order.
  • JSON shape (Task 7 writes the zod schema from it):
    • report_version string; ok boolean; failed_checks string[]; checks {name: boolean}.
    • design {type, nruns, nrows, replications, repeat_only, center_points, randomized, seed | null}.
    • factors [{r_name, label}]; catalogue {entry, class, rule, statement, stored_wlp}.
    • wlp {values number[], max_length, complete}; resolution {value | null, full_factorial, at_least | null}.
    • generators [{factor, product, coded}]; aliases {main string[], two_factor string[]}; clear_2fis string[].
    • run_sheet {run int[], std int[], levels (string | null)[][] as rows, coded (number | null)[][] as rows}.
    • versions {R, FrF2, DoE.base, report}, always the last key.

In replication_materials/tests/r/helper-ffd.R, replace the ffd_spec function with:

# A spec as the client sends it (spec Section 5.2), with the defaults of the card.
# repeat_only, randomize and seed are optional here; the worker always gives them.
ffd_spec <- function(nfactors, nruns = NULL, replications = 1, center_points = 0,
                     min_resolution = NULL, factor_labels = NULL,
                     repeat_only = NULL, randomize = NULL, seed = NULL) {
  list(nfactors = nfactors, nruns = nruns, replications = replications, center_points = center_points,
       min_resolution = min_resolution, factor_labels = factor_labels,
       repeat_only = repeat_only, randomize = randomize, seed = seed)
}

Create replication_materials/tests/r/test-report-json.R:

report_json <- function(d, spec, errata = NULL) ffd_report_json(ffd_report(d, spec, errata))

test_that("arrays stay arrays when they hold one element or none", {
  d <- FrF2::FrF2(nruns = 8, nfactors = 4, randomize = FALSE)
  js <- report_json(d, ffd_spec(4, nruns = 16))
  expect_true(grepl('"failed_checks":["row_count"]', js, fixed = TRUE))
  expect_true(grepl('"generators":[{"factor":"D","product":"A:B:C","coded":"D=ABC"}]', js, fixed = TRUE))
  expect_true(grepl('"main":[]', js, fixed = TRUE))
  expect_true(grepl('"clear_2fis":[]', js, fixed = TRUE))
  expect_true(grepl('"ok":false', js, fixed = TRUE))
})

test_that("NULL and NA values become null", {
  d <- FrF2::FrF2(nruns = 8, nfactors = 4, randomize = FALSE)
  js <- report_json(d, ffd_spec(4, nruns = 8))
  expect_true(grepl('"seed":null', js, fixed = TRUE))
  expect_true(grepl('"at_least":null', js, fixed = TRUE))
  full <- suppressMessages(FrF2::FrF2(nfactors = 3, resolution = 5, randomize = FALSE))
  js_full <- report_json(full, ffd_spec(3, min_resolution = 5))
  expect_true(grepl('"resolution":{"value":null,"full_factorial":true,"at_least":null}', js_full, fixed = TRUE))
  expect_true(grepl('"generators":[]', js_full, fixed = TRUE))
})

test_that("tables are records, the run sheet is in rows and versions come last", {
  d <- ffd_coating(randomize = TRUE, seed = 48213)
  j <- jsonlite::fromJSON(report_json(d, ffd_spec(7, nruns = 16)), simplifyVector = FALSE)
  expect_identical(j$factors[[1]], list(r_name = "Temp", label = "Temp"))
  expect_length(j$run_sheet$levels, 16)
  expect_identical(unlist(j$run_sheet$levels[[1]]), c("150", "3", "fast", "20", "0.1", "1", "off"))
  expect_length(j$run_sheet$coded[[1]], 7)
  expect_identical(names(j)[length(j)], "versions")
  expect_identical(j$report_version, "1.1.0")
})

test_that("alias chains are in factor order inside and across chains", {
  map <- stats::setNames(c("Temp", "Pressure", "Speed", "Time"), c("A", "B", "C", "D"))
  expect_identical(ffd_alias_chains(c("BC=AD", "CD=AB"), map),
                   c("Temp:Pressure = Speed:Time", "Temp:Time = Pressure:Speed"))
  expect_identical(ffd_alias_chains("BC=A", map), "Temp = Pressure:Speed")
  expect_identical(ffd_alias_chains(NULL, map), character())
})

test_that("the report does not depend on the collation of the R session", {
  dirs <- c(C = tempfile("collate-c-"), default = tempfile("collate-default-"))
  code <- c(
    sprintf('source("%s")', file.path(ffd_root, "r", "ffd_report.R")),
    'args <- commandArgs(trailingOnly = TRUE)',
    'if (args[1] == "C") invisible(Sys.setlocale("LC_COLLATE", "C"))',
    'dir.create(args[2])',
    'for (s in list(c(128, 127), c(512, 29), c(512, 160))) {',
    '  d <- FrF2::FrF2(nruns = s[1], nfactors = s[2], randomize = TRUE, seed = 20261014)',
    '  r <- ffd_report(d, list(nfactors = s[2], nruns = s[1], replications = 1, center_points = 0))',
    '  writeLines(ffd_report_json(r), file.path(args[2], paste0(s[1], "x", s[2], ".json")), sep = "")',
    '}')
  for (k in names(dirs)) expect_identical(ffd_run_r(code, args = c(k, dirs[[k]]))$status, 0L, info = k)
  files <- list.files(dirs[["C"]])
  expect_length(files, 3)
  for (f in files) {
    expect_identical(readLines(file.path(dirs[["C"]], f), warn = FALSE),
                     readLines(file.path(dirs[["default"]], f), warn = FALSE), info = f)
  }
})

test_that("refused catalogue entries are reported as broken entries", {
  table <- jsonlite::read_json(file.path(ffd_root, "data", "guarantee-table.json"), simplifyVector = TRUE)$entries
  for (name in c("28-16", "29-17", "30-18", "31-19", "32-20", "26-17.1", "27-18.1")) {
    e <- FrF2::catlg[[name]]
    r <- ffd_report(FrF2::FrF2(design = name, randomize = FALSE), ffd_spec(e$nfac, nruns = e$nruns))
    expect_false(r$ok, info = name)
    expect_identical(r$catalogue$class, "broken_entry", info = name)
    expect_identical(r$catalogue$rule, "generators_check", info = name)
    expect_identical(r$catalogue$class, table$class[table$name == name], info = name)
  }
})

test_that("the run order settings of the design must match the spec", {
  d <- FrF2::FrF2(nruns = 16, nfactors = 5, randomize = TRUE, seed = 20261014)
  good <- ffd_spec(5, nruns = 16, repeat_only = FALSE, randomize = TRUE, seed = 20261014)
  expect_true(ffd_report(d, good)$ok)
  expect_identical(ffd_report(d, utils::modifyList(good, list(seed = 20261015)))$failed_checks, "run_order")
  expect_identical(ffd_report(d, utils::modifyList(good, list(randomize = FALSE, seed = NULL)))$failed_checks, "run_order")
  expect_identical(ffd_report(d, utils::modifyList(good, list(repeat_only = TRUE)))$failed_checks, "run_order")
  plain <- FrF2::FrF2(nruns = 16, nfactors = 5, randomize = FALSE)
  expect_true(ffd_report(plain, ffd_spec(5, nruns = 16, repeat_only = FALSE, randomize = FALSE))$ok)
  rep2 <- FrF2::FrF2(nruns = 8, nfactors = 4, replications = 2, repeat.only = TRUE, randomize = TRUE, seed = 7)
  expect_true(ffd_report(rep2, ffd_spec(4, nruns = 8, replications = 2, repeat_only = TRUE,
                                        randomize = TRUE, seed = 7))$ok)
})

test_that("the run order fields of the spec are checked", {
  d <- FrF2::FrF2(nruns = 8, nfactors = 4, randomize = FALSE)
  expect_error(ffd_report(d, ffd_spec(4, nruns = 8, randomize = "yes")),
               "spec field randomize must be TRUE or FALSE", fixed = TRUE)
  expect_error(ffd_report(d, ffd_spec(4, nruns = 8, repeat_only = NA)),
               "spec field repeat_only must be TRUE or FALSE", fixed = TRUE)
  for (bad in list(0, 2147483648, 1.5)) {
    expect_error(ffd_report(d, ffd_spec(4, nruns = 8, seed = bad)),
                 "spec field seed must be a single whole number from 1 to 2147483647", fixed = TRUE)
  }
})

test_that("the errata names are read from the errata file", {
  names <- ffd_read_errata(file.path(ffd_root, "data", "catalogue-errata.json"))
  expect_length(names, 89)
  expect_true("21-16.1" %in% names)
})

Run: "/c/Program Files/R/R-4.6.1/bin/Rscript.exe" replication_materials/tests/r/run.R Expected: FAIL in test-report-json.R with could not find function "ffd_report_json".

In r/ffd_report.R, make these six changes.

(a) Change the version line to:

FFD_REPORT_VERSION <- "1.1.0"

(b) Replace the whole ffd_alias_chains function and its two comment lines with:

# Alias chains with display names, for example "Temp:Pressure = Speed:Feed = Time:Depth".
# A label that is not in the map is shown as it is, and the aliases post-check fails.
# FrF2 sorts the chains with the collation of the R session (measured on 2026-10-15: WebR
# uses "C", native R on Windows "English_United States.utf8"). So the helper sorts them
# again, by factor position, with radix sorting, which does not use the locale: inside a
# chain, shorter terms first, then the terms in factor order; the chains in the order of
# their first term.
ffd_alias_chains <- function(chains, to_display) {
  if (length(chains) == 0) return(character())
  multi <- any(nchar(names(to_display)) > 1)
  term_key <- function(tags) {
    pos <- match(tags, names(to_display))
    pos[is.na(pos)] <- length(to_display) + 1L
    paste(sprintf("%04d", c(length(tags), sort(pos))), collapse = "|")
  }
  parsed <- lapply(chains, function(chain) {
    tags <- lapply(strsplit(chain, "=", fixed = TRUE)[[1]], ffd_term_labels, multi = multi)
    keys <- vapply(tags, term_key, character(1))
    o <- order(keys, method = "radix")
    text <- vapply(tags[o], function(tg) {
      shown <- unname(to_display[tg])
      shown[is.na(shown)] <- tg[is.na(shown)]
      paste(shown, collapse = ":")
    }, character(1))
    list(key = keys[o[1]], text = paste(text, collapse = " = "))
  })
  keys <- vapply(parsed, function(p) p$key, character(1))
  texts <- vapply(parsed, function(p) p$text, character(1))
  texts[order(keys, method = "radix")]
}

(c) In ffd_check_spec, replace the last line invisible(TRUE) of the function with:

  for (field in c("repeat_only", "randomize")) {
    value <- spec[[field]]
    if (!is.null(value) && !(is.logical(value) && length(value) == 1 && !is.na(value))) {
      stop("spec field ", field, " must be TRUE or FALSE", call. = FALSE)
    }
  }
  seed <- spec$seed
  if (!is.null(seed) && !(is.numeric(seed) && length(seed) == 1 && is.finite(seed) &&
                          seed == round(seed) && seed >= 1 && seed <= 2147483647)) {
    stop("spec field seed must be a single whole number from 1 to 2147483647", call. = FALSE)
  }
  invisible(TRUE)

(d) Insert this function directly above the comment block of ffd_post_checks:

# TRUE if the run-order settings that the design stores equal the spec (Milestone 1
# carry-over). A field that the spec does not give is not compared (direct calls from R);
# the worker always gives all three. A randomized design must store the seed of the spec;
# a design in standard order must store no seed (codegen gives none).
ffd_run_order_matches <- function(info, spec) {
  if (!is.null(spec$repeat_only) && !identical(isTRUE(info$repeat.only), isTRUE(spec$repeat_only))) return(FALSE)
  if (is.null(spec$randomize)) return(TRUE)
  if (!identical(isTRUE(info$randomize), isTRUE(spec$randomize))) return(FALSE)
  if (isTRUE(spec$randomize)) {
    return(!is.null(spec$seed) && !is.null(info$seed) && isTRUE(as.numeric(info$seed) == as.numeric(spec$seed)))
  }
  is.null(info$seed)
}

(e) In ffd_post_checks, change the end of the checks list from

    aliases = ffd_aliases_known(info$aliased, to_display)
  )

to

    aliases = ffd_aliases_known(info$aliased, to_display),
    run_order = ffd_run_order_matches(info, spec)
  )

(f) In ffd_report, directly after the line checks <- ffd_post_checks(d, spec, cube, wlp, resolution, to_display, errata), insert:

  # An entry whose generators do not fit the design is refused (spec 7.7). Its class is
  # broken_entry, as in data/guarantee-table.json, whatever its name says.
  if (nzchar(entry_name) && !checks[["generators"]]) {
    guarantee <- list(class = "broken_entry", rule = "generators_check")
  }

Append at the end of r/ffd_report.R:

# ---- JSON for the browser (spec 17.1) ----

# The report as JSON text, with a fixed shape that the worker sends to the page:
#   - vectors stay arrays, also with one element or none; known scalars are unboxed;
#   - NULL and NA become null;
#   - data frames become arrays of records; matrices become arrays of rows;
#   - run_sheet$levels becomes rows (one array of strings per run), like run_sheet$coded;
#   - versions is the last key.
# Native R and WebR use jsonlite 2.0.0, so both give the same bytes.
ffd_report_json <- function(r) {
  unbox <- function(x) if (is.null(x)) NULL else jsonlite::unbox(x)
  unbox_all <- function(l) lapply(l, unbox)
  r$report_version <- unbox(r$report_version)
  r$ok <- unbox(r$ok)
  r$checks <- unbox_all(r$checks)
  r$design <- unbox_all(r$design)
  r$catalogue <- unbox_all(r$catalogue)
  r$wlp$max_length <- unbox(r$wlp$max_length)
  r$wlp$complete <- unbox(r$wlp$complete)
  r$resolution <- unbox_all(r$resolution)
  r$versions <- unbox_all(r$versions)
  r$run_sheet$levels <- unname(do.call(cbind, r$run_sheet$levels))
  as.character(jsonlite::toJSON(r, auto_unbox = FALSE, null = "null", na = "null", digits = NA,
                                dataframe = "rows", matrix = "rowmajor"))
}

# Names of the catalogue entries with a verified stored-WLP error (data/catalogue-errata.json).
ffd_read_errata <- function(path) {
  jsonlite::read_json(path, simplifyVector = TRUE)$stored_wlp_error$name
}

Run: "/c/Program Files/R/R-4.6.1/bin/Rscript.exe" scripts/build-catalogue.R Expected: Wrote 3099 entries and 158 factor counts to .../data. git diff --stat data shows changes only in report_version lines.

Run: "/c/Program Files/R/R-4.6.1/bin/Rscript.exe" replication_materials/tests/r/run.R Expected: all files pass. The Milestone 1 alias expectations still hold: Temp:Pressure = Speed:Feed = Time:Depth and F1 = F2:F7 = F3:F8... (checked during planning). If an older test fails, report it; do not change its expected text.

In r/README.md, replace the code block and the paragraph after it (from spec = list(nfactors, replications, center_points, to the end of the paragraph that starts "nfactors, replications and center_points are required.") with:

    spec = list(nfactors, replications, center_points,
                nruns = NULL, min_resolution = NULL, factor_labels = NULL,
                repeat_only = NULL, randomize = NULL, seed = NULL)
    errata = ffd_read_errata("data/catalogue-errata.json")

`nfactors`, `replications` and `center_points` are required. They have no default: without them, `ffd_report()` stops, because the post-checks need them. Each of them, and `nruns` and `min_resolution` when they are given, must be a single whole number. `repeat_only` and `randomize` must be `TRUE` or `FALSE`, and `seed` a whole number from 1 to 2147483647. When they are given, the `run_order` check compares them with the design. The worker always gives them.

Replace the sentence "The result has these parts: ..." with:

The result has these parts: `report_version`, `ok`, `failed_checks`, `checks`, `design`, `factors`, `catalogue`, `wlp`, `resolution`, `generators`, `aliases`, `clear_2fis`, `run_sheet`, `versions`.

`ffd_report_json(r)` writes the result as JSON with a fixed shape for the browser: arrays stay arrays, `NULL` and `NA` become `null`, tables become arrays of records, the run sheet is in rows, and `versions` is the last key. The alias chains are in factor order, so the report does not depend on the locale of the R session.

A catalogue entry whose generators do not fit the design gets the class `broken_entry`, as in `data/guarantee-table.json`. The two files name different rules for these entries on purpose: the report gives the check that refused the design (`generators_check`), and the table gives the source of the list (`catalogue_errata`).
git add r replication_materials/tests/r data/catalogue-summary.json data/guarantee-table.json
git commit -m "feat: give the report a fixed JSON shape, a canonical alias order and a run-order check"

8Task 2: Node project scaffold

Files:

  • Create: package.json, .npmrc, tsconfig.json, next.config.ts, postcss.config.mjs, vitest.config.ts, app/layout.tsx, app/globals.css, app/page.tsx (temporary; Task 9 replaces it), lib/site.ts, lib/isolation.ts, scripts/check.mjs, scripts/run-r-tests.mjs, scripts/lib/rscript.mjs, public/brand/miami-university-horizontal.png (copy), public/brand/tecnologico-de-monterrey-blue.svg (copy), public/brand/favicon.svg
  • Modify: .gitignore
  • Test: replication_materials/tests/unit/isolation.test.ts, replication_materials/tests/unit/site.test.ts

Interfaces:

  • Produces:
    • Constants: APP_NAME, APP_VERSION, MAINTAINERS and CITATION (lib/site.ts); ISOLATION_HEADERS and ISOLATED_SOURCES (lib/isolation.ts); RSCRIPT (scripts/lib/rscript.mjs).
    • The import alias @/ points to the repository root.
    • The vitest project unit.
    • npm run check, which later tasks extend: each adds one step to steps in scripts/check.mjs.
    • Colour tokens (Tailwind): miami-red, accent-red, tan-light, tan-medium, tan-dark, ink, warm-white. Fonts: font-display (Source Serif 4) and font-body (Arial).

package.json:

{
  "name": "fractional-factorial-design-translator",
  "version": "0.2.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22.14.0" },
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "typecheck": "tsc --noEmit",
    "test": "vitest run --project unit",
    "test:r": "node scripts/run-r-tests.mjs",
    "check": "node scripts/check.mjs"
  },
  "dependencies": {
    "next": "16.3.8",
    "react": "19.3.0",
    "react-dom": "19.3.0",
    "zod": "4.6.5"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "4.3.3",
    "@types/node": "22.20.4",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "tailwindcss": "4.3.3",
    "typescript": "5.9.3",
    "vitest": "4.1.11"
  }
}

.npmrc:

save-exact=true

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "incremental": true,
    "module": "esnext",
    "esModuleInterop": true,
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "react-jsx",
    "plugins": [{ "name": "next" }],
    "paths": { "@/*": ["./*"] }
  },
  "include": ["next-env.d.ts", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules", ".next", "public/runtimes"]
}

lib/isolation.ts:

/**
 * Cross-origin isolation (spec Section 7.9). COOP same-origin and COEP require-corp give the
 * page a SharedArrayBuffer, which WebR uses for its fast channel. The headers go on every
 * route, so no page, worker script or runtime file is served without them. ChatISA put them
 * on a list of routes and missed pages (lesson of 2026-07-26). The app is self-hosted, so
 * require-corp blocks nothing that it loads.
 */
export const ISOLATION_HEADERS = [
  { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
  { key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
] as const;

export const ISOLATED_SOURCES = ["/:path*"] as const;

next.config.ts:

import type { NextConfig } from "next";
import { ISOLATED_SOURCES, ISOLATION_HEADERS } from "./lib/isolation";

const nextConfig: NextConfig = {
  // Deployment is a standalone build (spec Section 13.1).
  output: "standalone",
  async headers() {
    return ISOLATED_SOURCES.map((source) => ({ source, headers: [...ISOLATION_HEADERS] }));
  },
};

export default nextConfig;

postcss.config.mjs:

export default { plugins: { "@tailwindcss/postcss": {} } };

vitest.config.ts:

import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";

const root = fileURLToPath(new URL(".", import.meta.url));

export default defineConfig({
  resolve: { alias: { "@": root } },
  test: {
    projects: [
      {
        extends: true,
        test: { name: "unit", environment: "node", include: ["replication_materials/tests/unit/**/*.test.ts"] },
      },
    ],
  },
});

lib/site.ts:

import pkg from "@/package.json";

// Names and texts that the whole app uses (spec Sections 9.2, 9.7 and 9.8).
export const APP_NAME = "Fractional Factorial Design Translator";
export const APP_VERSION: string = pkg.version;

export const MAINTAINERS = [
  { name: "Alan R. Vazquez", affiliation: "Tecnológico de Monterrey", url: "https://research.tec.mx/vivo-tec/display/PID_115457" },
  { name: "Fadel M. Megahed", affiliation: "Miami University", url: "https://miamioh.edu/fsb/directory/?up=/directory/megahefm" },
] as const;

export const CITATION = `Vazquez, A. R., and Megahed, F. M. (2026). ${APP_NAME} (version ${APP_VERSION}) [Computer software].`;

app/globals.css:

@import "tailwindcss";

/* Interface tokens from ChatISA (spec Section 9.8). Light theme only in Phase 1. */
@theme {
  --color-miami-red: #c41230;
  --color-accent-red: #ad102a;
  --color-tan-light: #edece2;
  --color-tan-medium: #ccc9b8;
  --color-tan-dark: #70685c;
  --color-ink: #000000;
  --color-warm-white: #faf9f7;
  --font-display: var(--font-source-serif), Georgia, serif;
  --font-body: Arial, Helvetica, sans-serif;
}

html {
  font-family: var(--font-body);
  color: var(--color-ink);
  background: #ffffff;
}

:focus-visible {
  outline: 2px solid var(--color-miami-red);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation: none !important;
    transition: none !important;
    scroll-behavior: auto !important;
  }
}

app/layout.tsx:

import type { Metadata } from "next";
import { Source_Serif_4 } from "next/font/google";
import { APP_NAME } from "@/lib/site";
import "./globals.css";

// Downloaded at build time and served by the app: the browser loads no font CDN (spec 9.8).
const serif = Source_Serif_4({ subsets: ["latin"], variable: "--font-source-serif", display: "swap" });

export const metadata: Metadata = {
  title: { default: APP_NAME, template: `%s | ${APP_NAME}` },
  description: "Two-level fractional factorial designs from a request in plain language. FrF2 makes the design in the browser.",
  icons: { icon: "/brand/favicon.svg" },
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={serif.variable}>
      <body className="min-h-screen bg-white">{children}</body>
    </html>
  );
}

app/page.tsx (temporary; Task 9 writes the landing page):

import { APP_NAME } from "@/lib/site";

export default function LandingPage() {
  return (
    <main id="main">
      <h1 className="font-display text-4xl">{APP_NAME}</h1>
    </main>
  );
}

scripts/lib/rscript.mjs:

// Native R for the tests (spec Section 11): R 4.6.1, named explicitly, because the Rscript on
// the PATH can be another version. FFD_RSCRIPT overrides the path on another machine.
// Never pass several statements in one "Rscript -e": on this R build only the first one runs,
// with exit status 0 (ChatISA lesson). Run a script file.
export const RSCRIPT = process.env.FFD_RSCRIPT ?? "C:/Program Files/R/R-4.6.1/bin/Rscript.exe";

scripts/run-r-tests.mjs:

// Runs the native R tests (replication_materials/tests/r/run.R) with R 4.6.1.
import { spawnSync } from "node:child_process";
import { RSCRIPT } from "./lib/rscript.mjs";

const result = spawnSync(RSCRIPT, ["replication_materials/tests/r/run.R"], { stdio: "inherit" });
process.exit(result.status ?? 1);

scripts/check.mjs:

// The local check command (spec Section 11: "CI" means `npm run check`).
// It runs each test layer in order and stops at the first failure.
//   npm run check            all layers
//   npm run check -- --fast  without the browser tests
import { spawnSync } from "node:child_process";

const fast = process.argv.includes("--fast");
const steps = [
  { name: "Types", cmd: "npx", args: ["tsc", "--noEmit"] },
  { name: "Unit tests", cmd: "npx", args: ["vitest", "run", "--project", "unit"] },
  { name: "R tests", cmd: "node", args: ["scripts/run-r-tests.mjs"] },
];

for (const step of steps) {
  if (fast && step.browser) continue;
  console.log(`\n== ${step.name}: ${step.cmd} ${step.args.join(" ")}`);
  // shell: true lets Windows find npx.cmd.
  const result = spawnSync(step.cmd, step.args, { stdio: "inherit", shell: process.platform === "win32" });
  if (result.status !== 0) {
    console.error(`\n${step.name} failed (exit status ${result.status}).`);
    process.exit(result.status ?? 1);
  }
}
console.log("\nAll checks passed.");

public/brand/favicon.svg (the 23−12^{3-1} half fraction: the four corners with xyz=+1x y z = +1 are filled):

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
  <title>Half fraction of a 2^3 design</title>
  <g fill="none" stroke-width="1.5">
    <path d="M18 8H40V30H18Z" stroke="#70685c"/>
    <path d="M8 18L18 8M30 18L40 8M30 40L40 30M8 40L18 30" stroke="#70685c"/>
    <path d="M8 18H30V40H8Z" stroke="#000000"/>
  </g>
  <g fill="#c41230">
    <circle cx="8" cy="18" r="4"/><circle cx="30" cy="40" r="4"/>
    <circle cx="40" cy="8" r="4"/><circle cx="18" cy="30" r="4"/>
  </g>
  <g fill="#ffffff" stroke="#000000" stroke-width="1.5">
    <circle cx="30" cy="18" r="3.2"/><circle cx="8" cy="40" r="3.2"/>
    <circle cx="18" cy="8" r="3.2"/><circle cx="40" cy="30" r="3.2"/>
  </g>
</svg>

Copy the two logos from the approved mockups (the Tec file is byte-identical to the authors' copy):

cp replication_materials/mockups/2026-10-14-ui/assets/miami.png public/brand/miami-university-horizontal.png
cp replication_materials/mockups/2026-10-14-ui/assets/tec.svg public/brand/tecnologico-de-monterrey-blue.svg

Append to .gitignore:

node_modules/
.next/
next-env.d.ts
*.tsbuildinfo
public/runtimes/
public/r/
test-results/
playwright-report/
.env*.local

replication_materials/tests/unit/isolation.test.ts:

import { describe, expect, it } from "vitest";
import nextConfig from "@/next.config";

describe("cross-origin isolation (spec 7.9)", () => {
  it("sends COOP and COEP on every route", async () => {
    const rules = await nextConfig.headers!();
    expect(rules).toEqual([
      {
        source: "/:path*",
        headers: [
          { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
          { key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
        ],
      },
    ]);
  });
});

replication_materials/tests/unit/site.test.ts:

import { existsSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { APP_NAME, APP_VERSION, CITATION, MAINTAINERS } from "@/lib/site";

describe("site constants", () => {
  it("spells out the working title (spec 9.8)", () => {
    expect(APP_NAME).toBe("Fractional Factorial Design Translator");
  });
  it("takes the version from package.json", () => {
    expect(APP_VERSION).toBe(JSON.parse(readFileSync("package.json", "utf8")).version);
  });
  it("names both maintainers with their middle initials", () => {
    expect(MAINTAINERS.map((m) => m.name)).toEqual(["Alan R. Vazquez", "Fadel M. Megahed"]);
    expect(CITATION).toContain("Vazquez, A. R., and Megahed, F. M. (2026).");
  });
  it("has the logos and the favicon", () => {
    for (const file of ["tecnologico-de-monterrey-blue.svg", "miami-university-horizontal.png", "favicon.svg"]) {
      expect(existsSync(`public/brand/${file}`), file).toBe(true);
    }
  });
});

Run: npm install Expected: package-lock.json is created, with no error.

Run: npx vitest run --project unit Expected: 5 tests pass. (Both files are new, and the configuration they test exists. If a test fails, fix the configuration, not the test.)

Run: npx next build Expected: "Compiled successfully", with routes / and /_not-found. Next.js can add fields to tsconfig.json; keep them.

Run: npm run check Expected: Types, Unit tests and R tests pass, then "All checks passed."

git add package.json package-lock.json .npmrc tsconfig.json next.config.ts postcss.config.mjs vitest.config.ts app lib scripts/check.mjs scripts/run-r-tests.mjs scripts/lib public/brand .gitignore replication_materials/tests/unit
git commit -m "feat: add the Next.js scaffold with isolation headers and the check command"

9Task 3: Core spec, seeds, R names and codegen

Files:

  • Create: lib/spec/validate.ts, lib/spec/names.ts, lib/spec/seed.ts, lib/codegen/frf2.ts, replication_materials/tests/fixtures/specs.ts
  • Test: replication_materials/tests/unit/validate.test.ts, replication_materials/tests/unit/names.test.ts, replication_materials/tests/unit/seed.test.ts, replication_materials/tests/unit/codegen.test.ts

Interfaces:

  • Produces:
    • CoreSpecSchema, FactorSchema, RUN_SIZES, and the types CoreSpec, Factor, Level (lib/spec/validate.ts).
    • sanitizeFactorName(name: string): string (lib/spec/names.ts).
    • MAX_SEED, isValidSeed(n), defaultSeed(now?: Date): number and withDefaultSeed(spec, now?) (lib/spec/seed.ts).
    • generateDesignCode(spec), generateReportCall(spec), generateCode(spec): { designCode, reportCall }, rString, rNumber, rLevel and effectiveRepeatOnly(spec) (lib/codegen/frf2.ts).
    • coatingSpec (replication_materials/tests/fixtures/specs.ts).
  • The report call refers to ffd_errata, an R object that the engine defines at start-up (Task 5).

replication_materials/tests/fixtures/specs.ts:

import { CoreSpecSchema, type CoreSpec } from "@/lib/spec/validate";

// The coating study of the mockups (spec Section 7.1), with the date seed of 2026-10-14.
export const coatingSpec: CoreSpec = CoreSpecSchema.parse({
  design_family: "regular",
  factors: [
    { name: "Temp", low: 150, high: 200, unit: "°C", quantitative: true },
    { name: "Pressure", low: 1, high: 3, unit: "bar", quantitative: true },
    { name: "Speed", low: "slow", high: "fast", unit: null, quantitative: false },
    { name: "Time", low: 10, high: 20, unit: "min", quantitative: true },
    { name: "Feed", low: 0.1, high: 0.2, unit: null, quantitative: true },
    { name: "Depth", low: 1, high: 2, unit: "mm", quantitative: true },
    { name: "Coolant", low: "off", high: "on", unit: null, quantitative: false },
  ],
  nruns: 16,
  min_resolution: null,
  replications: 1,
  repeat_only: false,
  center_points: 0,
  randomize: true,
  seed: 20261014,
});

/** The coating spec with some fields changed, validated again. */
export function coatingWith(fields: Partial<CoreSpec>): CoreSpec {
  return CoreSpecSchema.parse({ ...coatingSpec, ...fields });
}

/** m quantitative factors F1, F2, ... with levels -1 and +1. */
export function plainFactors(m: number): CoreSpec["factors"] {
  return Array.from({ length: m }, (_, i) => ({ name: `F${i + 1}`, low: -1, high: 1, unit: null, quantitative: true }));
}

replication_materials/tests/unit/names.test.ts:

import { describe, expect, it } from "vitest";
import { sanitizeFactorName } from "@/lib/spec/names";

describe("R names of factors (spec 5.2)", () => {
  it.each([
    ["Temp", "Temp"],
    ["Feed rate", "Feed_rate"],
    ["Temp (°C)", "Temp_C"],
    ["Presión", "Presion"],
    ["Say \"hi\"", "Say_hi"],
    ["2nd stage", "X2nd_stage"],
    [".5x", "X.5x"],
    ["a.b", "a.b"],
    ["if", "if_"],
    ["TRUE", "TRUE_"],
    ["Blocks", "Blocks_"],
    ["...", "X..."],
    ["°", "X"],
    ["温度", "X"],
  ])("%s -> %s", (name, expected) => {
    expect(sanitizeFactorName(name)).toBe(expected);
  });

  it("gives valid R names: letters, digits, dots and underscores, not starting with a digit or underscore", () => {
    for (const name of ["Temp (°C)", "  spaced  out  ", "x--y", "_lead", "ß-Wert"]) {
      expect(sanitizeFactorName(name)).toMatch(/^([A-Za-z]|\.(?![0-9]))[A-Za-z0-9._]*$/);
    }
  });
});

replication_materials/tests/unit/seed.test.ts:

import { describe, expect, it } from "vitest";
import { MAX_SEED, defaultSeed, isValidSeed, withDefaultSeed } from "@/lib/spec/seed";
import { coatingWith } from "../fixtures/specs";

describe("seeds (spec 7.3)", () => {
  it("uses the local date as yyyymmdd, also just before midnight", () => {
    expect(defaultSeed(new Date(2026, 9, 14, 23, 59, 59))).toBe(20261014);
    expect(defaultSeed(new Date(2026, 0, 1, 0, 0, 0))).toBe(20260101);
  });
  it("accepts integers from 1 to 2147483647 only", () => {
    expect(MAX_SEED).toBe(2147483647);
    for (const ok of [1, 20261014, 2147483647]) expect(isValidSeed(ok)).toBe(true);
    for (const bad of [0, -1, 2147483648, 1.5, Number.NaN, "5", null]) expect(isValidSeed(bad)).toBe(false);
  });
  it("fills a missing seed only when the design is randomized", () => {
    const now = new Date(2026, 9, 15, 9, 0, 0);
    expect(withDefaultSeed(coatingWith({ seed: null }), now).seed).toBe(20261015);
    expect(withDefaultSeed(coatingWith({ seed: 42 }), now).seed).toBe(42);
    expect(withDefaultSeed(coatingWith({ randomize: false, seed: null }), now).seed).toBeNull();
  });
});

replication_materials/tests/unit/validate.test.ts:

import { describe, expect, it } from "vitest";
import { CoreSpecSchema } from "@/lib/spec/validate";
import { coatingSpec } from "../fixtures/specs";

const messages = (input: unknown): string[] => {
  const result = CoreSpecSchema.safeParse(input);
  return result.success ? [] : result.error.issues.map((i) => i.message);
};

describe("strict core spec (spec 5.2)", () => {
  it("accepts the coating study", () => {
    expect(messages(coatingSpec)).toEqual([]);
  });
  it("needs the number of runs or the minimum resolution", () => {
    expect(messages({ ...coatingSpec, nruns: null, min_resolution: null })).toContain("Give the number of runs or the minimum resolution.");
  });
  it("needs at least 2 factors", () => {
    expect(messages({ ...coatingSpec, factors: coatingSpec.factors.slice(0, 1) })).toContain("A design needs at least 2 factors.");
  });
  it("needs a power of 2 from 4 to 4096 runs", () => {
    for (const nruns of [2, 12, 8192]) expect(messages({ ...coatingSpec, nruns })).toContain("The number of runs must be a power of 2 from 4 to 4096.");
  });
  it("refuses two names with the same R name", () => {
    const factors = [...coatingSpec.factors];
    factors[0] = { ...factors[0], name: "Feed rate" };
    factors[1] = { ...factors[1], name: "Feed_rate" };
    expect(messages({ ...coatingSpec, factors })).toContain('"Feed_rate" and "Feed rate" have the same R name (Feed_rate).');
  });
  it("refuses equal levels, also as text and number", () => {
    const factors = [...coatingSpec.factors];
    factors[1] = { ...factors[1], low: 1, high: "1", quantitative: false };
    expect(messages({ ...coatingSpec, factors })).toContain('The two levels of "Pressure" are the same.');
  });
  it("needs numeric levels for a quantitative factor", () => {
    const factors = [...coatingSpec.factors];
    factors[2] = { ...factors[2], quantitative: true };
    expect(messages({ ...coatingSpec, factors })).toContain('"Speed" is quantitative, so its levels must be numbers.');
  });
  it("needs all factors quantitative for center points", () => {
    expect(messages({ ...coatingSpec, center_points: 3 })).toContain("Center points need all factors to be quantitative.");
  });
  it("checks the ranges of the numbers", () => {
    for (const fields of [{ seed: 0 }, { seed: 2147483648 }, { seed: 1.5 }, { replications: 0 }, { center_points: -1 }, { min_resolution: 2 }]) {
      expect(messages({ ...coatingSpec, ...fields }).length, JSON.stringify(fields)).toBeGreaterThan(0);
    }
  });
});

replication_materials/tests/unit/codegen.test.ts:

import { describe, expect, it } from "vitest";
import { generateCode, generateDesignCode, generateReportCall, rNumber, rString } from "@/lib/codegen/frf2";
import { coatingSpec, coatingWith, plainFactors } from "../fixtures/specs";

describe("design code (spec 7.1)", () => {
  it("writes the coating example", () => {
    expect(generateDesignCode(coatingSpec)).toBe([
      "d <- FrF2::FrF2(nruns = 16, nfactors = 7,",
      "                factor.names = list(Temp = c(150, 200), Pressure = c(1, 3),",
      '                                    Speed = c("slow", "fast"), Time = c(10, 20),',
      "                                    Feed = c(0.1, 0.2), Depth = c(1, 2),",
      '                                    Coolant = c("off", "on")),',
      "                randomize = TRUE, seed = 20261014)",
    ].join("\n"));
  });

  it("uses the resolution, not nruns, when only the minimum resolution is given", () => {
    const code = generateDesignCode(coatingWith({ nruns: null, min_resolution: 4 }));
    expect(code.startsWith("d <- FrF2::FrF2(nfactors = 7, resolution = 4,\n")).toBe(true);
    expect(code).not.toContain("nruns");
  });

  it("never passes nruns and resolution together (spec 6.2)", () => {
    for (const spec of [coatingSpec, coatingWith({ min_resolution: 4 }), coatingWith({ nruns: null, min_resolution: 5 })]) {
      const code = generateDesignCode(spec);
      expect(code.includes("nruns =") && code.includes("resolution =")).toBe(false);
    }
  });

  it("writes replications, repeated runs and center points", () => {
    const spec = coatingWith({ factors: plainFactors(7), replications: 2, repeat_only: true, center_points: 3 });
    expect(generateDesignCode(spec).endsWith([
      "                replications = 2, repeat.only = TRUE, ncenter = 3,",
      "                randomize = TRUE, seed = 20261014)",
    ].join("\n"))).toBe(true);
  });

  it("leaves out repeat.only with one replication, and the seed without randomization", () => {
    const code = generateDesignCode(coatingWith({ repeat_only: true, randomize: false, seed: null }));
    expect(code).not.toContain("repeat.only");
    expect(code).not.toContain("seed =");
    expect(code.endsWith("\n                randomize = FALSE)")).toBe(true);
  });

  it("uses R names and quotes text levels", () => {
    const factors = [...coatingSpec.factors];
    factors[0] = { name: "Feed rate", low: 1, high: 2, unit: null, quantitative: true };
    factors[1] = { name: 'Say "hi"', low: "a\\b", high: "c", unit: null, quantitative: false };
    const code = generateDesignCode(coatingWith({ factors }));
    expect(code).toContain("Feed_rate = c(1, 2)");
    expect(code).toContain('Say_hi = c("a\\\\b", "c")');
  });

  it("keeps lines within 80 characters, also with 127 factors", () => {
    for (const spec of [coatingSpec, coatingWith({ nruns: 128, factors: plainFactors(127) })]) {
      for (const line of generateDesignCode(spec).split("\n")) expect(line.length, line).toBeLessThanOrEqual(80);
    }
  });

  it("refuses a randomized spec without a seed", () => {
    expect(() => generateCode(coatingWith({ seed: null }))).toThrow("needs a seed");
  });
});

describe("report call", () => {
  it("passes the spec, the names as typed and the run-order settings", () => {
    expect(generateReportCall(coatingSpec)).toBe(
      "ffd_report_json(ffd_report(d, list(nfactors = 7, nruns = 16, replications = 1, center_points = 0, " +
        'min_resolution = NULL, factor_labels = c("Temp", "Pressure", "Speed", "Time", "Feed", "Depth", "Coolant"), ' +
        "repeat_only = FALSE, randomize = TRUE, seed = 20261014), errata = ffd_errata))",
    );
  });
  it("gives repeat_only = FALSE with one replication, and seed = NULL without randomization", () => {
    const call = generateReportCall(coatingWith({ repeat_only: true, randomize: false, seed: null }));
    expect(call).toContain("repeat_only = FALSE, randomize = FALSE, seed = NULL");
  });
});

describe("R literals", () => {
  it("writes numbers as R reads them", () => {
    expect([-1, 0.001, 1e-7, 1e21, 150].map(rNumber)).toEqual(["-1", "0.001", "1e-7", "1e+21", "150"]);
    expect(() => rNumber(Number.NaN)).toThrow();
    expect(() => rNumber(Number.POSITIVE_INFINITY)).toThrow();
  });
  it("escapes backslashes, quotes and control characters", () => {
    expect(rString('a"b\\c\nd\te')).toBe('"a\\"b\\\\c\\nd\\te"');
  });
});

Run: npx vitest run --project unit Expected: FAIL with Cannot find module '@/lib/spec/validate' (and the same for the other new modules).

lib/spec/names.ts:

// Fixed rule that turns a factor name into a valid R name (spec Section 5.2). The R code uses
// the R name; the report and the interface show the name as the user typed it.
//   1. Remove accents ("Presión" -> "Presion").
//   2. Replace each run of characters other than A-Z, a-z, 0-9, "." and "_" with "_".
//   3. Remove "_" at the start and at the end. An empty result becomes "X".
//   4. Add "X" in front of a name that starts with a digit, with "_", or with "." and a digit,
//      and in front of "..." and "..1", "..2", ...
//   5. Add "_" after an R reserved word and after a column name that FrF2 or the report adds.
// Names that give the same R name are refused by CoreSpecSchema.

const RESERVED = new Set([
  "if", "else", "repeat", "while", "function", "for", "next", "break", "in",
  "TRUE", "FALSE", "NULL", "Inf", "NaN", "NA", "NA_integer_", "NA_real_", "NA_character_", "NA_complex_",
]);
const DESIGN_COLUMNS = new Set(["Blocks", "run.no", "run.no.in.std.order"]);

export function sanitizeFactorName(name: string): string {
  let s = name.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
  s = s.replace(/[^A-Za-z0-9._]+/g, "_").replace(/^_+|_+$/g, "");
  if (s === "") s = "X";
  if (/^[0-9_]/.test(s) || /^\.[0-9]/.test(s) || /^\.\.(\.|[0-9]+)$/.test(s)) s = `X${s}`;
  if (RESERVED.has(s) || DESIGN_COLUMNS.has(s)) s = `${s}_`;
  return s;
}

lib/spec/seed.ts:

import type { CoreSpec } from "./validate";

// Seeds (spec Section 7.3): an integer from 1 to 2,147,483,647 (.Machine$integer.max in R).
// FrF2 refuses a larger seed ("supplied seed is not a valid integer").
export const MAX_SEED = 2_147_483_647;

export function isValidSeed(value: unknown): value is number {
  return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= MAX_SEED;
}

/** The default seed: the local date of the device as yyyymmdd, for example 20261014. */
export function defaultSeed(now: Date = new Date()): number {
  return now.getFullYear() * 10_000 + (now.getMonth() + 1) * 100 + now.getDate();
}

/** The spec with the default seed when the design is randomized and has no seed. The LLM never sets a seed. */
export function withDefaultSeed(spec: CoreSpec, now: Date = new Date()): CoreSpec {
  return spec.randomize && spec.seed === null ? { ...spec, seed: defaultSeed(now) } : spec;
}

lib/spec/validate.ts:

import { z } from "zod";
import { sanitizeFactorName } from "./names";
import { MAX_SEED } from "./seed";

// The strict core spec (spec Section 5.2). The client validates every spec with it before
// codegen, whether the LLM or the user filled it in. Messages state facts (spec 9.7).

export const RUN_SIZES = [4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096] as const;

const LevelSchema = z.union([z.string().trim().min(1), z.number()]);

export const FactorSchema = z.object({
  name: z.string().trim().min(1, "A factor needs a name."),
  low: LevelSchema,
  high: LevelSchema,
  unit: z.string().trim().min(1).nullable(),
  quantitative: z.boolean(),
});

export const CoreSpecSchema = z
  .object({
    design_family: z.literal("regular"),
    factors: z.array(FactorSchema).min(2, "A design needs at least 2 factors."),
    nruns: z
      .number()
      .refine((n) => (RUN_SIZES as readonly number[]).includes(n), "The number of runs must be a power of 2 from 4 to 4096.")
      .nullable(),
    min_resolution: z.number().int().min(3).nullable(),
    replications: z.number().int().min(1),
    repeat_only: z.boolean(),
    center_points: z.number().int().min(0),
    randomize: z.boolean(),
    seed: z.number().int().min(1).max(MAX_SEED).nullable(),
  })
  .superRefine((spec, ctx) => {
    if (spec.nruns === null && spec.min_resolution === null) {
      ctx.addIssue({ code: "custom", path: ["nruns"], message: "Give the number of runs or the minimum resolution." });
    }
    const firstWithRName = new Map<string, number>();
    spec.factors.forEach((factor, i) => {
      const rName = sanitizeFactorName(factor.name);
      const earlier = firstWithRName.get(rName);
      if (earlier === undefined) firstWithRName.set(rName, i);
      else {
        ctx.addIssue({
          code: "custom",
          path: ["factors", i, "name"],
          message: `"${factor.name}" and "${spec.factors[earlier].name}" have the same R name (${rName}).`,
        });
      }
      if (String(factor.low) === String(factor.high)) {
        ctx.addIssue({ code: "custom", path: ["factors", i, "high"], message: `The two levels of "${factor.name}" are the same.` });
      }
      if (factor.quantitative && (typeof factor.low !== "number" || typeof factor.high !== "number")) {
        ctx.addIssue({ code: "custom", path: ["factors", i, "quantitative"], message: `"${factor.name}" is quantitative, so its levels must be numbers.` });
      }
    });
    if (spec.center_points > 0 && spec.factors.some((f) => !f.quantitative)) {
      ctx.addIssue({ code: "custom", path: ["center_points"], message: "Center points need all factors to be quantitative." });
    }
  });

export type CoreSpec = z.infer<typeof CoreSpecSchema>;
export type Factor = z.infer<typeof FactorSchema>;
export type Level = Factor["low"];

lib/codegen/frf2.ts:

import { sanitizeFactorName } from "@/lib/spec/names";
import { isValidSeed } from "@/lib/spec/seed";
import type { CoreSpec, Level } from "@/lib/spec/validate";

// Codegen (spec Sections 4.2 and 7.1): the only source of R code. From a valid spec it makes
//   - the design code, which the "R code" view shows and the worker runs exactly as shown;
//   - the report call, which the worker runs after the design code, in the same environment.
// Rules: pkg::fun() only; nruns and resolution are never given together (spec 6.2); the
// factor order of the spec sets the column order. Lines wrap at 80 characters.

const DESIGN_START = "d <- FrF2::FrF2(";
const INDENT = " ".repeat(DESIGN_START.length);
const FACTORS_START = `${INDENT}factor.names = list(`;
const WIDTH = 80;

/** An R string literal in double quotes. */
export function rString(value: string): string {
  const escaped = value
    .replace(/\\/g, "\\\\")
    .replace(/"/g, '\\"')
    .replace(/\n/g, "\\n")
    .replace(/\r/g, "\\r")
    .replace(/\t/g, "\\t");
  return `"${escaped}"`;
}

/** An R numeric literal. JavaScript writes numbers in a form that R reads as the same double. */
export function rNumber(value: number): string {
  if (!Number.isFinite(value)) throw new Error(`An R number must be finite, not ${value}.`);
  return String(value);
}

export function rLevel(level: Level): string {
  return typeof level === "number" ? rNumber(level) : rString(level);
}

/** FrF2 stores repeat.only = TRUE also with one replication, so the code gives it only with replications. */
export function effectiveRepeatOnly(spec: CoreSpec): boolean {
  return spec.repeat_only && spec.replications > 1;
}

// Writes start + items joined by ", ". A new line, indented by `indent`, starts when the next
// item would pass column WIDTH. Items end with "," and the last item ends with `end`.
function wrapList(start: string, items: string[], indent: string, end: string): string[] {
  const lines: string[] = [];
  let line = start;
  let empty = true;
  items.forEach((item, i) => {
    const piece = item + (i === items.length - 1 ? end : ",");
    if (empty) {
      line += piece;
      empty = false;
    } else if (line.length + 1 + piece.length > WIDTH) {
      lines.push(line);
      line = indent + piece;
    } else {
      line += ` ${piece}`;
    }
  });
  lines.push(line);
  return lines;
}

function assertReady(spec: CoreSpec): void {
  if (spec.randomize && !isValidSeed(spec.seed)) {
    throw new Error("A randomized design needs a seed from 1 to 2147483647 before codegen.");
  }
  if (spec.nruns === null && spec.min_resolution === null) {
    throw new Error("A spec needs the number of runs or the minimum resolution.");
  }
}

export function generateDesignCode(spec: CoreSpec): string {
  assertReady(spec);
  const m = spec.factors.length;
  const size = spec.nruns !== null ? [`nruns = ${spec.nruns}`, `nfactors = ${m}`] : [`nfactors = ${m}`, `resolution = ${spec.min_resolution}`];
  const factorItems = spec.factors.map((f) => `${sanitizeFactorName(f.name)} = c(${rLevel(f.low)}, ${rLevel(f.high)})`);
  const options: string[] = [];
  if (spec.replications > 1) options.push(`replications = ${spec.replications}`);
  if (effectiveRepeatOnly(spec)) options.push("repeat.only = TRUE");
  if (spec.center_points > 0) options.push(`ncenter = ${spec.center_points}`);
  options.push(`randomize = ${spec.randomize ? "TRUE" : "FALSE"}`);
  if (spec.randomize) options.push(`seed = ${spec.seed}`);
  return [
    ...wrapList(DESIGN_START, size, INDENT, ","),
    ...wrapList(FACTORS_START, factorItems, " ".repeat(FACTORS_START.length), "),"),
    ...wrapList(INDENT, options, INDENT, ")"),
  ].join("\n");
}

export function generateReportCall(spec: CoreSpec): string {
  assertReady(spec);
  const fields = [
    `nfactors = ${spec.factors.length}`,
    `nruns = ${spec.nruns ?? "NULL"}`,
    `replications = ${spec.replications}`,
    `center_points = ${spec.center_points}`,
    `min_resolution = ${spec.min_resolution ?? "NULL"}`,
    `factor_labels = c(${spec.factors.map((f) => rString(f.name)).join(", ")})`,
    `repeat_only = ${effectiveRepeatOnly(spec) ? "TRUE" : "FALSE"}`,
    `randomize = ${spec.randomize ? "TRUE" : "FALSE"}`,
    `seed = ${spec.randomize ? spec.seed : "NULL"}`,
  ];
  return `ffd_report_json(ffd_report(d, list(${fields.join(", ")}), errata = ffd_errata))`;
}

export interface GeneratedCode {
  designCode: string;
  reportCall: string;
}

export function generateCode(spec: CoreSpec): GeneratedCode {
  return { designCode: generateDesignCode(spec), reportCall: generateReportCall(spec) };
}

Run: npx vitest run --project unit Expected: all unit tests pass.

Run: npx tsc --noEmit Expected: no error.

git add lib/spec lib/codegen replication_materials/tests/unit replication_materials/tests/fixtures/specs.ts
git commit -m "feat: add the strict core spec, the R name rule, seeds and codegen"

10Task 4: Runtime setup (WebR copy, package mirror with SHA-256, R files)

Files:

  • Create: scripts/lib/runtime-manifest.mjs, scripts/setup-runtimes.mjs, data/runtime-manifest.json (generated by --update, committed)
  • Modify: package.json (dependency webr, scripts), scripts/check.mjs
  • Test: replication_materials/tests/unit/runtime-manifest.test.ts

Interfaces:

  • Produces:
    • public/runtimes/webr/ holds all of node_modules/webr/dist, with the Node path patch (P11).
    • public/runtimes/repo/<name>_<version>.tgz holds the 29 package files.
    • public/runtimes/repo/manifest.json is { id, packages: [{ name, version, file, size, sha256 }] }, in install order. id is the SHA-256 of the file list.
    • public/r/ffd_report.R and public/r/catalogue-errata.json are byte copies of r/ffd_report.R and data/catalogue-errata.json.
    • data/runtime-manifest.json is { webr, r, repository, roots, packages: [{ name, version, file, size, sha256, needs }] }.
    • From scripts/lib/runtime-manifest.mjs: REPOSITORY, ROOT_PACKAGES, PINNED, BASE_PACKAGES, WEBR_PATCH, parsePackages(text), runtimeDeps(record), installOrder(records, roots), sha256(bytes) and manifestId(packages).
  • Commands: npm run setup:runtimes (runs again before dev and build); node scripts/setup-runtimes.mjs --verify; node scripts/setup-runtimes.mjs --update.

replication_materials/tests/unit/runtime-manifest.test.ts:

import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import manifest from "@/data/runtime-manifest.json";
import { PINNED, WEBR_PATCH, installOrder, manifestId, parsePackages, runtimeDeps } from "@/scripts/lib/runtime-manifest.mjs";

const sample = [
  "Package: B", "Version: 1.0", "Depends: R (>= 3.5.0), A", "Imports: C (>= 2.0),", "  D", "LinkingTo: cpp11", "",
  "Package: A", "Version: 2.0", "Imports: stats", "",
  "Package: C", "Version: 0.1", "",
  "Package: D", "Version: 3", "Imports: A", "",
].join("\n");

describe("repository index", () => {
  it("reads continuation lines", () => {
    expect(parsePackages(sample).get("B")?.Imports).toBe("C (>= 2.0), D");
  });
  it("keeps Depends and Imports, and leaves out R, base packages and LinkingTo", () => {
    const records = parsePackages(sample);
    expect(runtimeDeps(records.get("B"))).toEqual(["A", "C", "D"]);
    expect(runtimeDeps(records.get("A"))).toEqual([]);
  });
  it("puts each package after the packages it needs", () => {
    expect(installOrder(parsePackages(sample), ["B"])).toEqual(["A", "C", "D", "B"]);
  });
  it("stops when a package is missing", () => {
    expect(() => installOrder(parsePackages(sample), ["E"])).toThrow("Package E is not in the repository");
  });
});

describe("committed manifest (data/runtime-manifest.json)", () => {
  it("pins WebR, FrF2, DoE.base and jsonlite", () => {
    expect(manifest.webr).toBe("0.6.0");
    for (const [name, version] of Object.entries(PINNED)) {
      expect(manifest.packages.find((p) => p.name === name)?.version, name).toBe(version);
    }
  });
  it("lists 29 packages, each after the packages it needs", () => {
    expect(manifest.packages).toHaveLength(29);
    const installed = new Set<string>();
    for (const p of manifest.packages) {
      for (const need of p.needs) expect(installed.has(need), `${p.name} needs ${need}`).toBe(true);
      installed.add(p.name);
    }
  });
  it("records a file name, a size and a SHA-256 for each package", () => {
    for (const p of manifest.packages) {
      expect(p.file).toBe(`${p.name}_${p.version}.tgz`);
      expect(p.size).toBeGreaterThan(0);
      expect(p.sha256).toMatch(/^[0-9a-f]{64}$/);
    }
  });
  it("makes an id that changes when a file changes", () => {
    const changed = manifest.packages.map((p, i) => (i === 0 ? { ...p, sha256: "0".repeat(64) } : p));
    expect(manifestId(changed)).not.toBe(manifestId(manifest.packages));
  });
  it("finds exactly one copy of the line that the Node path patch changes", () => {
    const text = readFileSync("node_modules/webr/dist/webr-worker.js", "utf8");
    expect(text.split(WEBR_PATCH.from)).toHaveLength(2);
  });
});

Run: npx vitest run --project unit runtime-manifest Expected: FAIL with Cannot find module '@/data/runtime-manifest.json' or '@/scripts/lib/runtime-manifest.mjs'.

scripts/lib/runtime-manifest.mjs:

// Helpers for the runtime mirror (spec Section 7.9). scripts/setup-runtimes.mjs uses them, and
// replication_materials/tests/unit/runtime-manifest.test.ts checks them.
import { createHash } from "node:crypto";

export const REPOSITORY = "https://repo.r-wasm.org/bin/emscripten/contrib/4.6";
export const ROOT_PACKAGES = ["FrF2", "jsonlite"];
// Pinned versions (spec Section 7.9). --update stops if the repository has other versions.
export const PINNED = { FrF2: "2.3-5", "DoE.base": "1.2-5", jsonlite: "2.0.0" };
// Packages inside R or WebR. The mirror does not hold them.
export const BASE_PACKAGES = new Set([
  "R", "base", "compiler", "datasets", "grDevices", "graphics", "grid", "methods", "parallel",
  "splines", "stats", "stats4", "tcltk", "tools", "utils", "webr",
]);

// WebR 0.6.0 loads R.js in Node with import(path.resolve(file)). On Windows that is "C:\...",
// which the ESM loader of Node refuses ("Received protocol 'c:'"), and a module hook does not
// reach the worker thread of WebR (measured with Node 22.14 on 2026-10-15). The copy in
// public/runtimes/webr gets a file URL instead. This line runs only in Node; browsers use
// importScripts() or import() of a URL.
export const WEBR_PATCH = {
  file: "webr-worker.js",
  from: 'await import((await import("path")).default.resolve(e))',
  to: 'await import((await import("url")).pathToFileURL((await import("path")).default.resolve(e)).href)',
};

/** Reads an R repository PACKAGES file into a Map: package name -> fields. */
export function parsePackages(text) {
  const records = new Map();
  for (const block of text.replace(/\r\n/g, "\n").split(/\n\s*\n/)) {
    const fields = {};
    let key = null;
    for (const line of block.split("\n")) {
      if (/^\s/.test(line) && key) {
        fields[key] += ` ${line.trim()}`;
        continue;
      }
      const match = line.match(/^([^:\s]+):\s*(.*)$/);
      if (match) {
        key = match[1];
        fields[key] = match[2];
      }
    }
    if (fields.Package) records.set(fields.Package, fields);
  }
  return records;
}

/**
 * The packages that a binary package needs when it runs: Depends and Imports, without version
 * limits and without base packages. LinkingTo is left out, because the mirror holds built
 * binaries (ChatISA lesson).
 */
export function runtimeDeps(record) {
  return ["Depends", "Imports"]
    .flatMap((field) => (record[field] ?? "").split(","))
    .map((entry) => entry.trim().split(/[\s(]/)[0])
    .filter((name) => name && !BASE_PACKAGES.has(name));
}

/** The dependency closure of the roots, with each package after the packages it needs. Alphabetical where the order is free. */
export function installOrder(records, roots) {
  const order = [];
  const state = new Map();
  const visit = (name, path) => {
    if (BASE_PACKAGES.has(name) || state.get(name) === "done") return;
    if (state.get(name) === "visiting") throw new Error(`Dependency cycle: ${[...path, name].join(" -> ")}`);
    const record = records.get(name);
    if (!record) throw new Error(`Package ${name} is not in the repository (needed by ${path.at(-1) ?? "the roots"}).`);
    state.set(name, "visiting");
    for (const dep of [...runtimeDeps(record)].sort()) visit(dep, [...path, name]);
    state.set(name, "done");
    order.push(name);
  };
  for (const root of [...roots].sort()) visit(root, []);
  return order;
}

export function sha256(bytes) {
  return createHash("sha256").update(bytes).digest("hex");
}

/** The id of a package list. The worker stores it with the R library and installs again when it changes. */
export function manifestId(packages) {
  return sha256(JSON.stringify(packages.map((p) => [p.file, p.sha256])));
}

scripts/setup-runtimes.mjs:

// Runtime setup (spec Section 7.9). Output, not in git: public/runtimes/webr/, public/runtimes/repo/, public/r/.
//   node scripts/setup-runtimes.mjs           copy WebR, get missing package files, copy the R files
//   node scripts/setup-runtimes.mjs --verify  check only: exit status 1 if a file is missing or differs
//   node scripts/setup-runtimes.mjs --update  write data/runtime-manifest.json again from the repository, then set up
// The manifest pins each package file by size and SHA-256. A download that differs stops the setup.
import { existsSync } from "node:fs";
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
  PINNED, REPOSITORY, ROOT_PACKAGES, WEBR_PATCH,
  installOrder, manifestId, parsePackages, runtimeDeps, sha256,
} from "./lib/runtime-manifest.mjs";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const manifestPath = path.join(root, "data", "runtime-manifest.json");
const webrSource = path.join(root, "node_modules", "webr", "dist");
const webrTarget = path.join(root, "public", "runtimes", "webr");
const repoTarget = path.join(root, "public", "runtimes", "repo");
const rTarget = path.join(root, "public", "r");
const R_FILES = [
  ["r/ffd_report.R", "ffd_report.R"],
  ["data/catalogue-errata.json", "catalogue-errata.json"],
];
const verifyOnly = process.argv.includes("--verify");
const problems = [];

async function download(url) {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`Could not download ${url} (HTTP ${response.status}).`);
  return Buffer.from(await response.arrayBuffer());
}

async function installedWebRVersion() {
  return JSON.parse(await readFile(path.join(root, "node_modules", "webr", "package.json"), "utf8")).version;
}

async function updateManifest() {
  const records = parsePackages((await download(`${REPOSITORY}/PACKAGES`)).toString("utf8"));
  for (const [name, version] of Object.entries(PINNED)) {
    const found = records.get(name)?.Version;
    if (found !== version) throw new Error(`The repository has ${name} ${found}; the pin is ${version}. Change a pin only after a review.`);
  }
  await mkdir(repoTarget, { recursive: true });
  const packages = [];
  for (const name of installOrder(records, ROOT_PACKAGES)) {
    const record = records.get(name);
    const file = `${name}_${record.Version}.tgz`;
    const bytes = await download(`${REPOSITORY}/${file}`);
    await writeFile(path.join(repoTarget, file), bytes);
    packages.push({ name, version: record.Version, file, size: bytes.length, sha256: sha256(bytes), needs: runtimeDeps(record).sort() });
  }
  // WebR 0.6.0 contains R 4.6.0 (measured).
  const manifest = { webr: await installedWebRVersion(), r: "4.6.0", repository: REPOSITORY, roots: ROOT_PACKAGES, packages };
  await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
  const megabytes = packages.reduce((sum, p) => sum + p.size, 0) / 1e6;
  console.log(`Wrote data/runtime-manifest.json: ${packages.length} packages, ${megabytes.toFixed(1)} MB.`);
}

async function setupWebR(manifest) {
  const installed = await installedWebRVersion();
  if (installed !== manifest.webr) {
    problems.push(`node_modules holds WebR ${installed}; the manifest pins ${manifest.webr}. Run npm ci.`);
    return;
  }
  const marker = path.join(webrTarget, ".ffd-webr.json");
  const workerFile = path.join(webrTarget, WEBR_PATCH.file);
  const current = existsSync(marker) ? JSON.parse(await readFile(marker, "utf8")) : null;
  const patched = existsSync(workerFile) && (await readFile(workerFile, "utf8")).includes(WEBR_PATCH.to);
  if (current?.version === manifest.webr && patched) return;
  if (verifyOnly) {
    problems.push("public/runtimes/webr is missing or out of date. Run npm run setup:runtimes.");
    return;
  }
  await rm(webrTarget, { recursive: true, force: true });
  // The files of the distribution refer to each other by relative path, so the whole folder is copied (ChatISA lesson).
  await cp(webrSource, webrTarget, { recursive: true });
  const text = await readFile(workerFile, "utf8");
  if (text.split(WEBR_PATCH.from).length !== 2) {
    throw new Error(`Expected one copy of the import line in ${WEBR_PATCH.file}. WebR changed; check WEBR_PATCH.`);
  }
  await writeFile(workerFile, text.replace(WEBR_PATCH.from, WEBR_PATCH.to));
  await writeFile(marker, `${JSON.stringify({ version: manifest.webr, patched: true })}\n`);
  console.log(`Copied WebR ${manifest.webr} to public/runtimes/webr/ and applied the Node path patch.`);
}

async function setupPackages(manifest) {
  await mkdir(repoTarget, { recursive: true });
  for (const p of manifest.packages) {
    const file = path.join(repoTarget, p.file);
    if (existsSync(file) && sha256(await readFile(file)) === p.sha256) continue;
    if (verifyOnly) {
      problems.push(`${p.file} is missing or differs from the manifest. Run npm run setup:runtimes.`);
      continue;
    }
    const bytes = await download(`${manifest.repository}/${p.file}`);
    if (bytes.length !== p.size || sha256(bytes) !== p.sha256) {
      throw new Error(`${p.file} from the repository does not match the manifest (size or SHA-256). The repository changed.`);
    }
    await writeFile(file, bytes);
    console.log(`Downloaded ${p.file} (${(p.size / 1e6).toFixed(2)} MB).`);
  }
  // The copy that the worker reads (same origin): the files in install order and the id.
  const packages = manifest.packages.map(({ name, version, file, size, sha256: hash }) => ({ name, version, file, size, sha256: hash }));
  const text = `${JSON.stringify({ id: manifestId(packages), packages }, null, 2)}\n`;
  const servedPath = path.join(repoTarget, "manifest.json");
  if (existsSync(servedPath) && (await readFile(servedPath, "utf8")) === text) return;
  if (verifyOnly) problems.push("public/runtimes/repo/manifest.json is missing or out of date. Run npm run setup:runtimes.");
  else await writeFile(servedPath, text);
}

async function setupRFiles() {
  await mkdir(rTarget, { recursive: true });
  for (const [from, to] of R_FILES) {
    const source = await readFile(path.join(root, from));
    const target = path.join(rTarget, to);
    if (existsSync(target) && Buffer.compare(source, await readFile(target)) === 0) continue;
    if (verifyOnly) problems.push(`public/r/${to} differs from ${from}. Run npm run setup:runtimes.`);
    else await writeFile(target, source);
  }
}

if (process.argv.includes("--update")) await updateManifest();
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
await setupWebR(manifest);
await setupPackages(manifest);
await setupRFiles();
if (problems.length) {
  for (const problem of problems) console.error(problem);
  process.exit(1);
}
console.log(verifyOnly ? "Runtimes verified." : "Runtimes ready.");

In package.json, add to dependencies: "webr": "0.6.0". Add to scripts:

    "setup:runtimes": "node scripts/setup-runtimes.mjs",
    "predev": "node scripts/setup-runtimes.mjs",
    "prebuild": "node scripts/setup-runtimes.mjs",

In scripts/check.mjs, add this step first in steps:

  { name: "Runtimes", cmd: "node", args: ["scripts/setup-runtimes.mjs", "--verify"] },

Run: npm install --save-exact webr@0.6.0 Expected: package.json has "webr": "0.6.0".

Run: node scripts/setup-runtimes.mjs --update Expected, after about a minute: Wrote data/runtime-manifest.json: 29 packages, 25.3 MB., Copied WebR 0.6.0 to public/runtimes/webr/ and applied the Node path patch., Runtimes ready.

Run: node scripts/setup-runtimes.mjs --verify Expected: Runtimes verified.

Check that a changed file is found: Run: node -e "require('fs').appendFileSync('public/runtimes/repo/zoo_1.9-1.tgz', 'x')", then node scripts/setup-runtimes.mjs --verify Expected: exit status 1 and zoo_1.9-1.tgz is missing or differs from the manifest. Run npm run setup:runtimes. Run: npm run setup:runtimes, then node scripts/setup-runtimes.mjs --verify Expected: Downloaded zoo_1.9-1.tgz (0.81 MB)., then Runtimes verified.

Run: npx vitest run --project unit Expected: all unit tests pass.

git add scripts/lib/runtime-manifest.mjs scripts/setup-runtimes.mjs scripts/check.mjs data/runtime-manifest.json package.json package-lock.json replication_materials/tests/unit/runtime-manifest.test.ts
git commit -m "feat: set up WebR and a SHA-256-pinned mirror of the R packages"

11Task 5: Engine core and WebR in Node

Files:

  • Create: public/workers/engine-core.mjs, replication_materials/tests/webr/helpers/node-engine.ts
  • Modify: vitest.config.ts (project webr), package.json (script test:webr), scripts/check.mjs
  • Test: replication_materials/tests/webr/engine.test.ts, replication_materials/tests/webr/engine-install.test.ts

Interfaces:

  • Consumes: public/runtimes/ and public/r/ (Task 4); ffd_report_json() and ffd_read_errata() (Task 1); generateCode() and coatingSpec (Task 3).
  • Produces, from public/workers/engine-core.mjs:
    • LIB_DIR (/home/web_user/ffd-library) and FFD_DIR (/home/web_user/ffd).
    • sha256Hex(bytes): Promise<string>.
    • installPackages(webR, manifest, readFile, onProgress?): Promise<{ installed: boolean }>. readFile(file, onBytes) returns a Uint8Array, and onProgress({ loaded, total }) reports bytes.
    • prepareSession(webR, { reportSource, errataJson }): Promise<Versions>, where Versions is { R, webR, FrF2, "DoE.base", jsonlite, report }.
    • runDesign(webR, { designCode, reportCall }): Promise<{ code, reportJson, conditions, reportConditions, timings: { designMs, reportMs } }>, where conditions is { type: "warning" | "message", text }[].
    • cleanRError(message): string.
  • Produces, from the Node helper: ROOT, startWebR(), readManifest(), readPackage(file) and startNodeEngine(): Promise<NodeEngine>, where NodeEngine is { webR, versions, coldMs, run(code), close() }.
  • The R session defines ffd_errata (the errata names) and the functions of ffd_report.R in the global environment. Each design runs in a new environment whose parent is the global environment.

Add the webr project to vitest.config.ts, after the unit project in projects:

      {
        extends: true,
        test: {
          name: "webr",
          environment: "node",
          include: ["replication_materials/tests/webr/**/*.test.ts"],
          testTimeout: 600_000,
          hookTimeout: 3_600_000,
          fileParallelism: false,
        },
      },

In package.json, add the script "test:webr": "vitest run --project webr". In scripts/check.mjs, add this step after "Unit tests":

  { name: "WebR tests", cmd: "npx", args: ["vitest", "run", "--project", "webr"] },

replication_materials/tests/webr/helpers/node-engine.ts:

import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { WebR } from "webr";
import { installPackages, prepareSession, runDesign } from "@/public/workers/engine-core.mjs";

// WebR in Node, with the same engine core as the browser worker (spec Section 11: the
// differential and performance tests use the production code path).

export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
const RUNTIMES = path.join(ROOT, "public", "runtimes");

export type DesignCode = { designCode: string; reportCall: string };
export type RunResult = Awaited<ReturnType<typeof runDesign>>;

export interface NodeEngine {
  webR: WebR;
  versions: Record<string, string>;
  coldMs: number;
  run(code: DesignCode): Promise<RunResult>;
  close(): void;
}

export async function readManifest() {
  return JSON.parse(await readFile(path.join(RUNTIMES, "repo", "manifest.json"), "utf8"));
}

export async function readPackage(file: string): Promise<Uint8Array> {
  return new Uint8Array(await readFile(path.join(RUNTIMES, "repo", file)));
}

/** Starts WebR from the copy in public/runtimes/webr, which has the Node path patch (Task 4). */
export async function startWebR(): Promise<WebR> {
  const webR = new WebR({ interactive: false, baseUrl: path.join(RUNTIMES, "webr") + path.sep });
  await webR.init();
  return webR;
}

export async function startNodeEngine(): Promise<NodeEngine> {
  const t0 = performance.now();
  const webR = await startWebR();
  await installPackages(webR, await readManifest(), readPackage);
  const versions = await prepareSession(webR, {
    reportSource: new Uint8Array(await readFile(path.join(ROOT, "public", "r", "ffd_report.R"))),
    errataJson: new Uint8Array(await readFile(path.join(ROOT, "public", "r", "catalogue-errata.json"))),
  });
  return {
    webR,
    versions,
    coldMs: performance.now() - t0,
    run: (code) => runDesign(webR, code),
    close: () => webR.close(),
  };
}

replication_materials/tests/webr/engine.test.ts:

import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { generateCode } from "@/lib/codegen/frf2";
import { cleanRError } from "@/public/workers/engine-core.mjs";
import { coatingSpec } from "../fixtures/specs";
import { startNodeEngine, type NodeEngine } from "./helpers/node-engine";

let engine: NodeEngine;
beforeAll(async () => {
  engine = await startNodeEngine();
});
afterAll(() => engine?.close());

describe("the engine in Node WebR", () => {
  it("runs the pinned versions", () => {
    expect(engine.versions).toEqual({ R: "4.6.0", webR: "0.6.0", FrF2: "2.3.5", "DoE.base": "1.2.5", jsonlite: "2.0.0", report: "1.1.0" });
  });

  it("runs the design code exactly as given and returns a passing report", async () => {
    const code = generateCode(coatingSpec);
    const result = await engine.run(code);
    expect(result.code).toBe(code.designCode);
    const report = JSON.parse(result.reportJson);
    expect(report.ok).toBe(true);
    expect(report.catalogue.entry).toBe("7-3.1");
    expect(report.design.seed).toBe(20261014);
  });

  it("attaches no package and gives no warning or message (spec 7.2)", async () => {
    const result = await engine.run(generateCode(coatingSpec));
    expect(result.conditions).toEqual([]);
    expect(result.reportConditions).toEqual([]);
    const search: string[] = await engine.webR.evalRRaw("search()", "string[]");
    expect(search.filter((s) => /FrF2|DoE\.base|jsonlite/.test(s))).toEqual([]);
  });

  it("keeps the design out of the global environment", async () => {
    await engine.run(generateCode(coatingSpec));
    expect(await engine.webR.evalRBoolean('exists("d", envir = globalenv(), inherits = FALSE)')).toBe(false);
  });

  it("captures FrF2 warnings as text", async () => {
    const result = await engine.run({
      designCode: "d <- FrF2::FrF2(nruns = 8, nfactors = 7, resolution = 4, randomize = FALSE)",
      reportCall: "ffd_report_json(ffd_report(d, list(nfactors = 7, nruns = 8, replications = 1, center_points = 0)))",
    });
    expect(result.conditions).toEqual([{ type: "warning", text: "resolution is ignored, if nruns is given." }]);
  });

  it("rejects with the R error when the design code fails", async () => {
    await expect(engine.run({ designCode: "d <- FrF2::FrF2(nruns = 12, nfactors = 4)", reportCall: "1" })).rejects.toThrow(
      "nruns must be a power of 2.",
    );
  });

  it("removes the WebR prefix from R errors", () => {
    expect(cleanRError("T: Error in `eval(expr, env)`: boom")).toBe("Error: boom");
    expect(cleanRError("Error in `eval(ei, envir)`: boom")).toBe("Error: boom");
    expect(cleanRError("Error: plain")).toBe("Error: plain");
  });
});

replication_materials/tests/webr/engine-install.test.ts:

import { describe, expect, it } from "vitest";
import { installPackages } from "@/public/workers/engine-core.mjs";
import { readManifest, readPackage, startWebR } from "./helpers/node-engine";

describe("package installation", () => {
  it("installs once, reports progress up to the total, and installs again for another manifest", async () => {
    const webR = await startWebR();
    try {
      const manifest = await readManifest();
      const total = manifest.packages.reduce((sum: number, p: { size: number }) => sum + p.size, 0);
      const progress: number[] = [];
      expect((await installPackages(webR, manifest, readPackage, ({ loaded }: { loaded: number }) => progress.push(loaded))).installed).toBe(true);
      expect(progress.at(-1)).toBe(total);
      expect(progress.every((v, i) => i === 0 || v >= progress[i - 1])).toBe(true);
      expect((await installPackages(webR, manifest, readPackage)).installed).toBe(false);
      // A library that holds another manifest (Review Focus 5) is not used as it is.
      expect((await installPackages(webR, { ...manifest, id: "another-manifest" }, readPackage)).installed).toBe(true);
    } finally {
      webR.close();
    }
  });

  it("refuses a package file whose SHA-256 differs from the manifest", async () => {
    const webR = await startWebR();
    try {
      const manifest = await readManifest();
      const first = manifest.packages[0];
      const tampered = { ...manifest, packages: [{ ...first, sha256: "0".repeat(64) }, ...manifest.packages.slice(1)] };
      await expect(installPackages(webR, tampered, readPackage)).rejects.toThrow(`The package file ${first.file} does not match the manifest.`);
    } finally {
      webR.close();
    }
  });
});

Run: npx vitest run --project webr Expected: FAIL with Cannot find module '@/public/workers/engine-core.mjs'.

public/workers/engine-core.mjs:

/**
 * Engine core (spec Section 7.9): the R steps that the browser worker and the Node tests share,
 * so that the tests use the production code path. It gets a started WebR object and a function
 * that reads a package file, because the browser reads from the network and Node from disk.
 * Rules:
 *   - The design code runs exactly as given (spec 7.1), in a new R environment.
 *   - captureConditions is TRUE. With FALSE, an R error looks like a success (ChatISA lesson,
 *     measured with WebR 0.6.0).
 *   - Nothing is attached: search() keeps only the base packages (spec 7.2).
 */

/** The R library of the app. The browser worker keeps it in IndexedDB (IDBFS). */
export const LIB_DIR = "/home/web_user/ffd-library";
/** The folder for the report helper and the errata list. */
export const FFD_DIR = "/home/web_user/ffd";
const MARKER = `${LIB_DIR}/.ffd-manifest`;

/** An R string literal (JSON string syntax is valid R for these paths). */
function rq(value) {
  return JSON.stringify(String(value));
}

/** Hex SHA-256 of bytes, with Web Crypto (browser workers and Node 22). */
export async function sha256Hex(bytes) {
  const digest = await crypto.subtle.digest("SHA-256", bytes);
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
}

async function libraryHolds(webR, id) {
  try {
    return new TextDecoder().decode(await webR.FS.readFile(MARKER)) === id;
  } catch {
    return false;
  }
}

/**
 * Installs the packages of the manifest into LIB_DIR, unless LIB_DIR already holds this
 * manifest. Checks the size and the SHA-256 of each file before it unpacks it.
 * utils::untar() needs tar = "internal": the default calls system(), which WebR does not have
 * (measured). readFile(file, onBytes) returns the bytes of a package file and can report bytes
 * while they arrive. onProgress({ loaded, total }) reports the bytes done.
 */
export async function installPackages(webR, manifest, readFile, onProgress = (_progress) => {}) {
  const total = manifest.packages.reduce((sum, p) => sum + p.size, 0);
  if (await libraryHolds(webR, manifest.id)) {
    onProgress({ loaded: total, total });
    return { installed: false };
  }
  // A library of another manifest, or a part of one, is removed first.
  await webR.evalRVoid(`dir.create(${rq(LIB_DIR)}, showWarnings = FALSE, recursive = TRUE)
unlink(list.files(${rq(LIB_DIR)}, full.names = TRUE, all.files = TRUE, no.. = TRUE), recursive = TRUE)`);
  let done = 0;
  for (const pkg of manifest.packages) {
    let partial = 0;
    const bytes = await readFile(pkg.file, (n) => {
      partial += n;
      onProgress({ loaded: done + Math.min(partial, pkg.size), total });
    });
    if (bytes.byteLength !== pkg.size || (await sha256Hex(bytes)) !== pkg.sha256) {
      throw new Error(`The package file ${pkg.file} does not match the manifest.`);
    }
    const tmp = `/tmp/${pkg.file}`;
    await webR.FS.writeFile(tmp, bytes);
    await webR.evalRVoid(`utils::untar(${rq(tmp)}, exdir = ${rq(LIB_DIR)}, tar = "internal")
invisible(file.remove(${rq(tmp)}))`);
    done += pkg.size;
    onProgress({ loaded: done, total });
  }
  await webR.FS.writeFile(MARKER, new TextEncoder().encode(manifest.id));
  return { installed: true };
}

/**
 * Loads the namespaces, sources the report helper and reads the errata names into ffd_errata.
 * Loading the namespaces here keeps their start-up message ("Registered S3 method overwritten
 * by 'DoE.base'") out of the first design. Returns the versions.
 */
export async function prepareSession(webR, { reportSource, errataJson }) {
  // mkdir rejects if the folder exists (ErrnoError 20). Any other failure makes the
  // writeFile below reject, so the session still fails with a reason.
  await webR.FS.mkdir(FFD_DIR).catch(() => {});
  await webR.FS.writeFile(`${FFD_DIR}/ffd_report.R`, reportSource);
  await webR.FS.writeFile(`${FFD_DIR}/catalogue-errata.json`, errataJson);
  await webR.evalRVoid(`.libPaths(unique(c(${rq(LIB_DIR)}, .libPaths())))
invisible(suppressMessages(loadNamespace("FrF2")))
invisible(suppressMessages(loadNamespace("jsonlite")))
source(${rq(`${FFD_DIR}/ffd_report.R`)})
ffd_errata <- ffd_read_errata(${rq(`${FFD_DIR}/catalogue-errata.json`)})`);
  const json = await webR.evalRString(`as.character(jsonlite::toJSON(list(
  R = paste(R.version$major, R.version$minor, sep = "."),
  webR = Sys.getenv("WEBR_VERSION"),
  FrF2 = as.character(utils::packageVersion("FrF2")),
  DoE.base = as.character(utils::packageVersion("DoE.base")),
  jsonlite = as.character(utils::packageVersion("jsonlite")),
  report = FFD_REPORT_VERSION), auto_unbox = TRUE))`);
  return JSON.parse(json);
}

// Only the message is read: the call in a condition cannot be converted to JavaScript.
// A warning whose message cannot be read is still reported (spec 7.5: warnings are shown).
async function conditionText(condition) {
  try {
    const message = await (await condition.get("message")).toJs();
    return (message.values ?? []).join("").replace(/\n+$/, "") || null;
  } catch {
    return "The text of this R condition could not be read.";
  }
}

async function readConditions(output) {
  const conditions = [];
  for (const entry of output) {
    if (entry.type !== "warning" && entry.type !== "message") continue;
    const text = await conditionText(entry.data);
    if (text) conditions.push({ type: entry.type, text });
  }
  return conditions;
}

const CAPTURE = { withAutoprint: false, captureStreams: true, captureConditions: true, captureGraphics: false };

/**
 * Runs the design code, then the report call, in one new environment whose parent is the
 * global environment. Returns the report JSON text, the FrF2 warnings and messages, and the
 * times. An R error rejects with the R message.
 */
export async function runDesign(webR, { designCode, reportCall }) {
  const shelter = await new webR.Shelter();
  try {
    const env = await new shelter.REnvironment();
    const t0 = performance.now();
    const design = await shelter.captureR(designCode, { ...CAPTURE, env });
    const t1 = performance.now();
    const report = await shelter.captureR(reportCall, { ...CAPTURE, env });
    const t2 = performance.now();
    const reportJson = (await report.result.toArray())[0];
    return {
      code: designCode,
      reportJson,
      conditions: await readConditions(design.output),
      reportConditions: await readConditions(report.output),
      timings: { designMs: t1 - t0, reportMs: t2 - t1 },
    };
  } finally {
    await shelter.purge();
  }
}

/** WebR adds a type letter and its own call to an R error: "T: Error in `eval(expr, env)`: boom" (ChatISA lesson). */
export function cleanRError(message) {
  return String(message).replace(/^[A-Z]:\s+/, "").replace(/^Error in `eval\([^`]*\)`:\s*/, "Error: ");
}

Run: npx vitest run --project webr Expected: all tests pass in about a minute.

Run: npx tsc --noEmit Expected: no error.

git add public/workers/engine-core.mjs replication_materials/tests/webr vitest.config.ts package.json scripts/check.mjs
git commit -m "feat: add the engine core and run it in Node WebR"

12Task 6: Differential tests (WebR against native R)

Files:

  • Create: replication_materials/tests/webr/helpers/native-r.ts, replication_materials/tests/webr/helpers/cases.ts, replication_materials/tests/webr/helpers/compare.ts, replication_materials/tests/webr/helpers/csv.ts
  • Test: replication_materials/tests/webr/differential.test.ts
  • Create (full run only): replication_materials/tests/perf/m2-differential.csv

Interfaces:

  • Consumes: startNodeEngine(), ROOT and DesignCode (Task 5); generateCode(), CoreSpecSchema, sanitizeFactorName() and MAX_SEED (Task 3); plainFactors() (Task 3 fixture); data/catalogue-summary.json (Milestone 1).
  • Produces:
    • runNative(codes: DesignCode[]): string[] runs each case in a new environment in one native R 4.6.1 session.
    • DiffCase = { id, spec } and the case builders catalogueCases(), optionCases(), resolutionCases(), seedCases(), randomCases(count, prngSeed?), fastCases() and fullCases().
    • splitVersions(json) returns { body, versions }.
    • firstDifference(a, b): string | null.
    • writeCsv(name, rows): string.
  • Environment: FFD_FULL_DIFFERENTIAL=1 selects the full set (spec 11: "the full Phase 1 grid plus random specs and seeds").

replication_materials/tests/webr/helpers/native-r.ts:

import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { RSCRIPT } from "@/scripts/lib/rscript.mjs";
import { ROOT, type DesignCode } from "./node-engine";

// The native side of the differential tests. It does in native R 4.6.1 what the engine core
// does in WebR: source the report helper, read the errata names, and run each design code and
// its report call in a new environment.
const RUNNER = `args <- commandArgs(trailingOnly = TRUE)
dir <- args[1]
root <- args[2]
source(file.path(root, "r", "ffd_report.R"))
ffd_errata <- ffd_read_errata(file.path(root, "data", "catalogue-errata.json"))
invisible(suppressMessages(loadNamespace("FrF2")))
n <- length(list.files(dir, pattern = "[.]design[.]R$"))
for (i in seq_len(n) - 1) {
  env <- new.env()
  eval(parse(file.path(dir, sprintf("case-%d.design.R", i)), encoding = "UTF-8"), envir = env)
  json <- eval(parse(file.path(dir, sprintf("case-%d.report.R", i)), encoding = "UTF-8"), envir = env)
  writeLines(json, file.path(dir, sprintf("case-%d.json", i)), sep = "", useBytes = TRUE)
}
`;

/** Runs the cases in one native R session and returns the report JSON text of each, in order. */
export function runNative(codes: DesignCode[]): string[] {
  const dir = mkdtempSync(path.join(os.tmpdir(), "ffd-native-"));
  codes.forEach((c, i) => {
    writeFileSync(path.join(dir, `case-${i}.design.R`), c.designCode);
    writeFileSync(path.join(dir, `case-${i}.report.R`), c.reportCall);
  });
  const script = path.join(dir, "run.R");
  writeFileSync(script, RUNNER);
  const result = spawnSync(RSCRIPT, ["--vanilla", script, dir, ROOT], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
  if (result.status !== 0) throw new Error(`Native R failed (status ${result.status}):\n${result.stderr}`);
  return codes.map((_, i) => readFileSync(path.join(dir, `case-${i}.json`), "utf8"));
}

replication_materials/tests/webr/helpers/compare.ts:

/**
 * Splits a report JSON text into the part before "versions" and the versions. WebR has R 4.6.0
 * and the native tests R 4.6.1, so only the versions may differ (spec Section 11).
 */
export function splitVersions(json: string): { body: string; versions: Record<string, string> } {
  const key = ',"versions":';
  const at = json.lastIndexOf(key);
  if (at < 0 || !json.endsWith("}}")) throw new Error("The report JSON does not end with its versions.");
  return { body: json.slice(0, at), versions: JSON.parse(json.slice(at + key.length, -1)) };
}

/** null if the texts are equal; else the position and the text around the first difference. */
export function firstDifference(a: string, b: string): string | null {
  if (a === b) return null;
  let i = 0;
  while (i < a.length && i < b.length && a[i] === b[i]) i++;
  const around = (s: string) => JSON.stringify(s.slice(Math.max(0, i - 40), i + 40));
  return `first difference at character ${i}: WebR ${around(a)}; native ${around(b)}`;
}

replication_materials/tests/webr/helpers/csv.ts:

import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { ROOT } from "./node-engine";

/** Writes rows to replication_materials/tests/perf/<name> (spec Section 11) and returns the path. */
export function writeCsv(name: string, rows: Record<string, string | number | boolean>[]): string {
  const dir = path.join(ROOT, "replication_materials", "tests", "perf");
  mkdirSync(dir, { recursive: true });
  const columns = Object.keys(rows[0]);
  const lines = [columns.join(","), ...rows.map((row) => columns.map((c) => String(row[c])).join(","))];
  const file = path.join(dir, name);
  writeFileSync(file, `${lines.join("\n")}\n`);
  return file;
}

replication_materials/tests/webr/helpers/cases.ts:

import summary from "@/data/catalogue-summary.json" with { type: "json" };
import { MAX_SEED } from "@/lib/spec/seed";
import { CoreSpecSchema, type CoreSpec } from "@/lib/spec/validate";
import { plainFactors } from "../../fixtures/specs";

// Cases of the differential tests (spec Section 11). Every spec is valid (CoreSpecSchema), so
// a case that the schema refuses fails at once.

export interface DiffCase {
  id: string;
  spec: CoreSpec;
}

const DATE_SEED = 20261014;

/** A valid case: the defaults of the spec card, the date seed, and the given fields. */
export function make(id: string, fields: Partial<CoreSpec> & Pick<CoreSpec, "factors">): DiffCase {
  const spec = CoreSpecSchema.parse({
    design_family: "regular", nruns: null, min_resolution: null, replications: 1, repeat_only: false,
    center_points: 0, randomize: true, seed: DATE_SEED, ...fields,
  });
  return { id, spec };
}

/** Every catalogue size that FrF2 can build (status ok or stored_wlp_error), with the date seed. */
export function catalogueCases(): DiffCase[] {
  return summary.factors.flatMap((f) =>
    f.run_sizes
      .filter((s) => s.status !== "broken")
      .map((s) => make(`catalogue-${s.nruns}x${f.nfactors}`, { nruns: s.nruns, factors: plainFactors(f.nfactors) })),
  );
}

/** Replications, repeated runs, center points and randomization on three sizes (spec 7.8, check 1). */
export function optionCases(): DiffCase[] {
  const out: DiffCase[] = [];
  for (const [nruns, m] of [[8, 4], [16, 7], [32, 9]] as const) {
    for (const replications of [1, 2]) {
      for (const repeat_only of [false, true]) {
        for (const center_points of [0, 3]) {
          for (const randomize of [true, false]) {
            const id = `options-${nruns}x${m}-r${replications}${repeat_only ? "o" : ""}-c${center_points}-${randomize ? "rand" : "std"}`;
            out.push(make(id, { nruns, factors: plainFactors(m), replications, repeat_only, center_points, randomize, seed: randomize ? DATE_SEED : null }));
          }
        }
      }
    }
  }
  return out;
}

/** Requests that give only a resolution: a catalogue fraction, or else a full factorial of at most 4096 runs. */
export function resolutionCases(): DiffCase[] {
  const out: DiffCase[] = [];
  for (let m = 3; m <= 20; m++) {
    const f = summary.factors.find((x) => x.nfactors === m);
    for (let r = 3; r <= 6; r++) {
      const fraction = f?.smallest_runs_by_resolution.some((x) => x.resolution === r);
      if (!fraction && 2 ** m > 4096) continue;
      out.push(make(`resolution-${m}-r${r}`, { min_resolution: r, factors: plainFactors(m) }));
    }
  }
  return out;
}

/** The ends of the seed range and two date seeds on three designs (spec 7.3). */
export function seedCases(): DiffCase[] {
  const out: DiffCase[] = [];
  for (const seed of [1, DATE_SEED, 20261231, MAX_SEED]) {
    for (const [nruns, m] of [[16, 7], [32, 9], [64, 12]] as const) {
      out.push(make(`seed-${seed}-${nruns}x${m}`, { nruns, factors: plainFactors(m), seed }));
    }
  }
  return out;
}

// Names that are not valid R names (Review Focus 1). The position number keeps them unique.
const NAME_POOL = ["Temp (°C)", "Presión", "Feed rate", "pH", 'Say "hi"', "Line\\speed", "温度", "if", "2nd stage", "Coolant"] as const;
const TEXT_LEVELS = ["low", "off", "slow", 'A "x"', "a\\b", "ñandú"] as const;

function mulberry32(seed: number): () => number {
  let a = seed;
  return () => {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Random specs from a fixed generator: names, text levels, decimals, options and seeds. */
export function randomCases(count: number, prngSeed = 20261015): DiffCase[] {
  const rand = mulberry32(prngSeed);
  const pick = <T,>(xs: readonly T[]): T => xs[Math.floor(rand() * xs.length)];
  const round2 = (x: number) => Math.round(x * 100) / 100;
  const sizes = summary.factors.flatMap((f) =>
    f.run_sizes.filter((s) => s.status !== "broken" && s.nruns <= 256).map((s) => [s.nruns, f.nfactors] as const),
  );
  const out: DiffCase[] = [];
  for (let i = 0; i < count; i++) {
    const [nruns, m] = pick(sizes);
    const withCenter = rand() < 0.25;
    const factors = Array.from({ length: m }, (_, j) => {
      const name = `${pick(NAME_POOL)} ${j + 1}`;
      if (!withCenter && rand() < 0.3) {
        const low = pick(TEXT_LEVELS);
        return { name, low, high: `${low} 2`, unit: null, quantitative: false };
      }
      return { name, low: round2(rand() * 100), high: round2(100.5 + rand() * 100), unit: pick(["°C", "bar", null] as const), quantitative: true };
    });
    const randomize = rand() < 0.85;
    out.push(make(`random-${i + 1}`, {
      nruns,
      factors,
      replications: pick([1, 1, 1, 2, 3]),
      repeat_only: rand() < 0.3,
      center_points: withCenter ? pick([1, 2, 4]) : 0,
      randomize,
      seed: randomize ? 1 + Math.floor(rand() * MAX_SEED) : null,
    }));
  }
  return out;
}

function unique(cases: DiffCase[]): DiffCase[] {
  const seen = new Set<string>();
  return cases.filter((c) => !seen.has(c.id) && seen.add(c.id));
}

/** The default set (about 90 cases, a few minutes): seeds, options, 10 random specs, 20 catalogue sizes and 512 x 160. */
export function fastCases(): DiffCase[] {
  const catalogue = catalogueCases();
  const step = Math.ceil(catalogue.length / 20);
  return unique([
    ...seedCases(), ...optionCases(), ...randomCases(10),
    ...catalogue.filter((_, i) => i % step === 0),
    ...catalogue.filter((c) => c.id === "catalogue-512x160"),
  ]);
}

/** The full set (FFD_FULL_DIFFERENTIAL=1): every usable catalogue size, the resolution grid and 200 random specs. */
export function fullCases(): DiffCase[] {
  return unique([...seedCases(), ...optionCases(), ...resolutionCases(), ...randomCases(200), ...catalogueCases()]);
}

replication_materials/tests/webr/differential.test.ts:

import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { generateCode } from "@/lib/codegen/frf2";
import { sanitizeFactorName } from "@/lib/spec/names";
import { fastCases, fullCases, type DiffCase } from "./helpers/cases";
import { firstDifference, splitVersions } from "./helpers/compare";
import { writeCsv } from "./helpers/csv";
import { runNative } from "./helpers/native-r";
import { startNodeEngine, type NodeEngine } from "./helpers/node-engine";

// Spec Section 11: the WebR output (design matrix and report JSON) is byte-identical to the
// native R output. The report JSON holds the run sheet, so it covers the design matrix.
const full = process.env.FFD_FULL_DIFFERENTIAL === "1";
const cases: DiffCase[] = full ? fullCases() : fastCases();
const webr = new Map<string, { json: string; ms: number }>();
const native = new Map<string, string>();
let engine: NodeEngine;

beforeAll(async () => {
  engine = await startNodeEngine();
  for (const c of cases) {
    const t0 = performance.now();
    const result = await engine.run(generateCode(c.spec));
    webr.set(c.id, { json: result.reportJson, ms: performance.now() - t0 });
  }
  const out = runNative(cases.map((c) => generateCode(c.spec)));
  cases.forEach((c, i) => native.set(c.id, out[i]));
  if (full) {
    writeCsv("m2-differential.csv", cases.map((c) => {
      const w = webr.get(c.id)!;
      const report = JSON.parse(w.json);
      return {
        id: c.id, nruns: report.design.nruns, nfactors: c.spec.factors.length, rows: report.design.nrows,
        webr_ms: Math.round(w.ms), json_bytes: w.json.length,
        identical: splitVersions(w.json).body === splitVersions(native.get(c.id)!).body,
      };
    }));
  }
}, 3_600_000);

afterAll(() => engine?.close());

describe(`WebR and native R give the same report (${full ? "full" : "fast"} set, ${cases.length} cases)`, () => {
  it.each(cases.map((c) => [c.id, c] as const))("%s", (_id, c) => {
    const w = splitVersions(webr.get(c.id)!.json);
    const n = splitVersions(native.get(c.id)!);
    expect(firstDifference(w.body, n.body)).toBeNull();
    expect(w.versions).toEqual({ ...n.versions, R: "4.6.0" });
    expect(n.versions.R).toBe("4.6.1");
    const report = JSON.parse(webr.get(c.id)!.json);
    expect(report.ok, report.failed_checks.join(", ")).toBe(true);
    expect(report.factors.map((f: { r_name: string }) => f.r_name)).toEqual(c.spec.factors.map((f) => sanitizeFactorName(f.name)));
    expect(report.factors.map((f: { label: string }) => f.label)).toEqual(c.spec.factors.map((f) => f.name));
  });
});

Run: npx vitest run --project webr differential Expected: all cases pass (about 90). This test checks code that already exists, so it can pass at once. If a case fails, the firstDifference message shows where. Report the failure; do not change an expectation. A failure here means WebR and native R disagree, which spec Section 11 does not allow.

Run: FFD_FULL_DIFFERENTIAL=1 npx vitest run --project webr differential Expected: all cases pass. replication_materials/tests/perf/m2-differential.csv has one row for each case, and every identical value is true. Report the number of cases, the total time, and the five largest webr_ms values.

git add replication_materials/tests/webr replication_materials/tests/perf/m2-differential.csv
git commit -m "test: WebR and native R give byte-identical reports"

13Task 7: Browser worker, engine client and report schema

Files:

  • Create: public/workers/webr-worker.mjs, lib/webr/protocol.ts, lib/webr/client.ts, lib/webr/engine.ts, lib/webr/use-engine.ts, lib/webr/status-text.ts, lib/report/types.ts, lib/config/budgets.ts, config/runtime-budgets.json, replication_materials/tests/fixtures/make-report-fixtures.R, replication_materials/tests/fixtures/reports/coating.json and failed-8x4.json (generated)
  • Modify: replication_materials/tests/webr/differential.test.ts
  • Test: replication_materials/tests/unit/engine-client.test.ts, replication_materials/tests/unit/report-types.test.ts, replication_materials/tests/unit/status-text.test.ts

Interfaces:

  • Consumes: installPackages, prepareSession, runDesign, cleanRError and LIB_DIR (Task 5).
  • Produces:
    • Worker messages (lib/webr/protocol.ts). In: { type: "init" } and { type: "run", id, designCode, reportCall }. Out: progress {loaded, total}, ready {versions, cache: "hit" | "stored" | "memory", timings}, init-error {message} and result {id, ok, ...}.
    • EngineClient (lib/webr/client.ts), built from EngineOptions { createWorker, softTimeoutMs, hardTimeoutMs, stallMs }. Methods: start(), retry(), run(code): Promise<RunOutcome>, cancel(), subscribe(listener) and getStatus().
    • EngineStatus is one of idle, loading {loaded, total, stalled}, ready {versions, cache}, running {versions, startedAt, pastSoftLimit} or failed {message}.
    • RunOutcome is { ok: true, code, report, reportJson, conditions, timings } or { ok: false, reason: "r_error" | "cancelled" | "timeout" | "report_shape" | "engine_failed", message }.
    • getEngine() (lib/webr/engine.ts) is the singleton of the page. In a build with NEXT_PUBLIC_FFD_TEST_HOOKS=1 (the browser tests only), it is also globalThis.__ffdEngine. useEngineStatus() (lib/webr/use-engine.ts) starts the engine and returns its status. useNow(active) (same file) returns Date.now() and renders again each second while active is true.
    • engineStatusText(status, now = Date.now()) (lib/webr/status-text.ts) returns { visible, announced }. formatElapsed(ms) (same file) returns m:ss (decision P15).
    • ReportSchema and the type Report (lib/report/types.ts).
    • R_SOFT_TIMEOUT_MS, R_HARD_TIMEOUT_MS and R_STALL_MS (lib/config/budgets.ts).

replication_materials/tests/fixtures/make-report-fixtures.R:

# Writes the report fixtures of the TypeScript unit tests. From the repository root, with R 4.6.1:
#   "C:/Program Files/R/R-4.6.1/bin/Rscript.exe" replication_materials/tests/fixtures/make-report-fixtures.R
source("r/ffd_report.R")
out <- "replication_materials/tests/fixtures/reports"
dir.create(out, showWarnings = FALSE, recursive = TRUE)

coating <- FrF2::FrF2(nruns = 16, nfactors = 7,
                      factor.names = list(Temp = c(150, 200), Pressure = c(1, 3), Speed = c("slow", "fast"),
                                          Time = c(10, 20), Feed = c(0.1, 0.2), Depth = c(1, 2),
                                          Coolant = c("off", "on")),
                      randomize = TRUE, seed = 20261014)
spec <- list(nfactors = 7, nruns = 16, replications = 1, center_points = 0, min_resolution = NULL,
             factor_labels = c("Temp", "Pressure", "Speed", "Time", "Feed", "Depth", "Coolant"),
             repeat_only = FALSE, randomize = TRUE, seed = 20261014)
writeLines(ffd_report_json(ffd_report(coating, spec)), file.path(out, "coating.json"), sep = "")

small <- FrF2::FrF2(nruns = 8, nfactors = 4, randomize = FALSE)
writeLines(ffd_report_json(ffd_report(small, list(nfactors = 4, nruns = 16, replications = 1, center_points = 0))),
           file.path(out, "failed-8x4.json"), sep = "")

Run: "/c/Program Files/R/R-4.6.1/bin/Rscript.exe" replication_materials/tests/fixtures/make-report-fixtures.R Expected: the two JSON files exist. coating.json contains "ok":true and failed-8x4.json contains "failed_checks":["row_count"].

replication_materials/tests/unit/report-types.test.ts:

import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { ReportSchema } from "@/lib/report/types";

const read = (name: string) => JSON.parse(readFileSync(`replication_materials/tests/fixtures/reports/${name}.json`, "utf8"));

describe("report schema (spec 17.1)", () => {
  it("accepts a passing report", () => {
    const report = ReportSchema.parse(read("coating"));
    expect(report.ok).toBe(true);
    expect(report.catalogue.entry).toBe("7-3.1");
    expect(report.run_sheet.levels).toHaveLength(16);
    expect(report.versions.report).toBe("1.1.0");
  });
  it("accepts a report with one failed check", () => {
    expect(ReportSchema.parse(read("failed-8x4")).failed_checks).toEqual(["row_count"]);
  });
  it("accepts null cells, which a broken catalogue entry gives", () => {
    const report = read("coating");
    report.run_sheet.coded[0][0] = null;
    report.run_sheet.levels[0][0] = null;
    expect(ReportSchema.safeParse(report).success).toBe(true);
  });
  it("refuses a report whose arrays were unboxed", () => {
    const report = read("failed-8x4");
    report.failed_checks = "row_count";
    expect(ReportSchema.safeParse(report).success).toBe(false);
  });
});

replication_materials/tests/unit/status-text.test.ts:

import { describe, expect, it } from "vitest";
import { engineStatusText, formatElapsed } from "@/lib/webr/status-text";

const versions = { R: "4.6.0", webR: "0.6.0", FrF2: "2.3.5", "DoE.base": "1.2.5", jsonlite: "2.0.0", report: "1.1.0" };

describe("engine status text (spec 7.9)", () => {
  it("writes the elapsed time as m:ss", () => {
    expect([-5, 0, 999, 59_999, 60_000, 754_000].map(formatElapsed)).toEqual(["0:00", "0:00", "0:00", "0:59", "1:00", "12:34"]);
  });
  it("shows the elapsed time while a design runs, and Still running past the soft limit (P15)", () => {
    const running = { state: "running", versions, startedAt: 1_000, pastSoftLimit: false } as const;
    expect(engineStatusText(running, 4_400)).toEqual({
      visible: "R engine: computing the design (0:03)",
      announced: "R engine: computing the design",
    });
    // The clock of the component can be up to one tick behind the start of the run.
    expect(engineStatusText(running, 500).visible).toBe("R engine: computing the design (0:00)");
    expect(engineStatusText({ ...running, pastSoftLimit: true }, 755_000)).toEqual({
      visible: "R engine: still running (12:34)",
      announced: "R engine: still running. Cancel is available.",
    });
  });
  it("shows the percent and the bytes while loading", () => {
    expect(engineStatusText({ state: "loading", loaded: 12_700_000, total: 25_300_000, stalled: false })).toEqual({
      visible: "R engine: loading (50%), 12.7 of 25.3 MB",
      announced: "R engine: loading",
    });
    expect(engineStatusText({ state: "idle" }).visible).toBe("R engine: loading (0%)");
  });
  it("announces a stall", () => {
    expect(engineStatusText({ state: "loading", loaded: 1, total: 10, stalled: true }).announced).toBe("R engine: loading has stopped");
  });
  it("says ready and not available", () => {
    expect(engineStatusText({ state: "ready", versions, cache: "hit" }).visible).toBe("R engine: ready");
    expect(engineStatusText({ state: "failed", message: "Could not load /runtimes/repo/manifest.json (HTTP 404)." })).toEqual({
      visible: "R engine: not available. Could not load /runtimes/repo/manifest.json (HTTP 404).",
      announced: "R engine: not available",
    });
  });
});

replication_materials/tests/unit/engine-client.test.ts:

import { readFileSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EngineClient, type WorkerLike } from "@/lib/webr/client";
import type { FromWorker, ToWorker } from "@/lib/webr/protocol";

const reportJson = readFileSync("replication_materials/tests/fixtures/reports/coating.json", "utf8");
const versions = { R: "4.6.0", webR: "0.6.0", FrF2: "2.3.5", "DoE.base": "1.2.5", jsonlite: "2.0.0", report: "1.1.0" };
const code = { designCode: "d <- FrF2::FrF2(nruns = 8, nfactors = 4)", reportCall: "ffd_report_json(ffd_report(d, list()))" };

class FakeWorker implements WorkerLike {
  static all: FakeWorker[] = [];
  posted: ToWorker[] = [];
  terminated = false;
  onmessage: ((event: { data: FromWorker }) => void) | null = null;
  onerror: ((event: unknown) => void) | null = null;
  constructor() {
    FakeWorker.all.push(this);
  }
  postMessage(message: ToWorker) {
    this.posted.push(message);
  }
  terminate() {
    this.terminated = true;
  }
  reply(data: FromWorker) {
    this.onmessage?.({ data });
  }
  runs() {
    return this.posted.filter((m): m is Extract<ToWorker, { type: "run" }> => m.type === "run");
  }
}

const make = () => new EngineClient({ createWorker: () => new FakeWorker(), softTimeoutMs: 1_000, hardTimeoutMs: 5_000, stallMs: 2_000 });
const ready = (w: FakeWorker) => w.reply({ type: "ready", versions, cache: "hit", timings: { webrMs: 1, totalMs: 2 } });
const okResult = (id: number, json = reportJson): FromWorker => ({
  type: "result", id, ok: true, code: code.designCode, reportJson: json, conditions: [], reportConditions: [], timings: { designMs: 1, reportMs: 1 },
});

beforeEach(() => {
  FakeWorker.all = [];
  vi.useFakeTimers();
});
afterEach(() => vi.useRealTimers());

describe("engine client", () => {
  it("starts one worker and sends init once", () => {
    const engine = make();
    engine.start();
    engine.start();
    expect(FakeWorker.all).toHaveLength(1);
    expect(FakeWorker.all[0].posted).toEqual([{ type: "init" }]);
  });

  it("reports loading progress and then ready", () => {
    const engine = make();
    engine.start();
    const w = FakeWorker.all[0];
    w.reply({ type: "progress", loaded: 5, total: 10 });
    expect(engine.getStatus()).toEqual({ state: "loading", loaded: 5, total: 10, stalled: false });
    ready(w);
    expect(engine.getStatus()).toEqual({ state: "ready", versions, cache: "hit" });
  });

  it("marks a stall when progress stops, and clears it on progress", () => {
    const engine = make();
    engine.start();
    vi.advanceTimersByTime(2_000);
    expect(engine.getStatus()).toMatchObject({ state: "loading", stalled: true });
    FakeWorker.all[0].reply({ type: "progress", loaded: 1, total: 10 });
    expect(engine.getStatus()).toMatchObject({ state: "loading", stalled: false });
  });

  it("runs a design and returns the parsed report", async () => {
    const engine = make();
    engine.start();
    const w = FakeWorker.all[0];
    ready(w);
    const outcome = engine.run(code);
    await vi.waitFor(() => expect(w.runs()).toHaveLength(1));
    expect(w.runs()[0]).toMatchObject(code);
    expect(engine.getStatus()).toMatchObject({ state: "running", pastSoftLimit: false });
    w.reply(okResult(w.runs()[0].id));
    const result = await outcome;
    expect(result.ok && result.report.catalogue.entry).toBe("7-3.1");
    expect(engine.getStatus().state).toBe("ready");
  });

  it("marks a run past the soft limit and stops it at the hard limit", async () => {
    const engine = make();
    engine.start();
    const w = FakeWorker.all[0];
    ready(w);
    const outcome = engine.run(code);
    await vi.waitFor(() => expect(w.runs()).toHaveLength(1));
    vi.advanceTimersByTime(1_000);
    expect(engine.getStatus()).toMatchObject({ state: "running", pastSoftLimit: true });
    vi.advanceTimersByTime(4_000);
    await expect(outcome).resolves.toMatchObject({ ok: false, reason: "timeout" });
    expect(w.terminated).toBe(true);
    expect(FakeWorker.all).toHaveLength(2);
    expect(FakeWorker.all[1].posted).toEqual([{ type: "init" }]);
  });

  it("cancels a run by replacing the worker, and ignores the old worker", async () => {
    const engine = make();
    engine.start();
    const w = FakeWorker.all[0];
    ready(w);
    const outcome = engine.run(code);
    await vi.waitFor(() => expect(w.runs()).toHaveLength(1));
    engine.cancel();
    await expect(outcome).resolves.toMatchObject({ ok: false, reason: "cancelled" });
    expect(w.terminated).toBe(true);
    expect(FakeWorker.all).toHaveLength(2);
    w.reply(okResult(w.runs()[0].id));
    ready(w);
    expect(engine.getStatus().state).toBe("loading");
  });

  it("fails closed on a report of the wrong form", async () => {
    const engine = make();
    engine.start();
    const w = FakeWorker.all[0];
    ready(w);
    const outcome = engine.run(code);
    await vi.waitFor(() => expect(w.runs()).toHaveLength(1));
    w.reply(okResult(w.runs()[0].id, '{"ok":"yes"}'));
    await expect(outcome).resolves.toMatchObject({ ok: false, reason: "report_shape" });
  });

  it("returns the R error", async () => {
    const engine = make();
    engine.start();
    const w = FakeWorker.all[0];
    ready(w);
    const outcome = engine.run(code);
    await vi.waitFor(() => expect(w.runs()).toHaveLength(1));
    w.reply({ type: "result", id: w.runs()[0].id, ok: false, error: "Error: nruns must be a power of 2." });
    await expect(outcome).resolves.toEqual({ ok: false, reason: "r_error", message: "Error: nruns must be a power of 2." });
  });

  it("reports an engine that cannot start, and runs fail at once (Review Focus 3)", async () => {
    const engine = make();
    engine.start();
    FakeWorker.all[0].reply({ type: "init-error", message: "Could not load /runtimes/repo/manifest.json (HTTP 404)." });
    expect(engine.getStatus()).toEqual({ state: "failed", message: "Could not load /runtimes/repo/manifest.json (HTTP 404)." });
    await expect(engine.run(code)).resolves.toMatchObject({ ok: false, reason: "engine_failed" });
  });

  it("treats a worker error event as a failed engine", () => {
    const engine = make();
    engine.start();
    FakeWorker.all[0].onerror?.(new Event("error"));
    expect(engine.getStatus()).toEqual({ state: "failed", message: "The R engine could not be loaded." });
  });

  it("runs designs one at a time", async () => {
    const engine = make();
    engine.start();
    const w = FakeWorker.all[0];
    ready(w);
    const first = engine.run(code);
    const second = engine.run(code);
    await vi.waitFor(() => expect(w.runs()).toHaveLength(1));
    w.reply(okResult(w.runs()[0].id));
    await first;
    await vi.waitFor(() => expect(w.runs()).toHaveLength(2));
    w.reply(okResult(w.runs()[1].id));
    await expect(second).resolves.toMatchObject({ ok: true });
  });
});

In replication_materials/tests/webr/differential.test.ts, add import { ReportSchema } from "@/lib/report/types"; and, inside the it.each body after const report = ..., add:

    expect(ReportSchema.safeParse(report).success).toBe(true);

Run: npx vitest run --project unit Expected: FAIL with Cannot find module '@/lib/report/types', '@/lib/webr/client' and '@/lib/webr/status-text'.

lib/report/types.ts:

import { z } from "zod";

// The report JSON of ffd_report_json() (r/ffd_report.R, spec 17.1). The engine client parses
// every report with this schema. A report of another form fails closed ("report_shape").
// Cells can be null: a broken catalogue entry gives NA columns, and its report has ok = false.

const nullableNumber = z.number().nullable();

export const ReportSchema = z.object({
  report_version: z.string(),
  ok: z.boolean(),
  failed_checks: z.array(z.string()),
  checks: z.record(z.string(), z.boolean()),
  design: z.object({
    type: z.string(),
    nruns: z.number(),
    nrows: z.number(),
    replications: z.number(),
    repeat_only: z.boolean(),
    center_points: z.number(),
    randomized: z.boolean(),
    seed: z.number().nullable(),
  }),
  factors: z.array(z.object({ r_name: z.string(), label: z.string() })),
  catalogue: z.object({
    entry: z.string(),
    class: z.string(),
    rule: z.string(),
    statement: z.string(),
    stored_wlp: z.enum(["agrees", "verified_error", "disagrees", "none", "not_computed"]),
  }),
  wlp: z.object({ values: z.array(z.number()), max_length: z.number(), complete: z.boolean() }),
  resolution: z.object({ value: nullableNumber, full_factorial: z.boolean(), at_least: nullableNumber }),
  generators: z.array(z.object({ factor: z.string(), product: z.string(), coded: z.string() })),
  aliases: z.object({ main: z.array(z.string()), two_factor: z.array(z.string()) }),
  clear_2fis: z.array(z.string()),
  run_sheet: z.object({
    run: z.array(z.number()),
    std: z.array(z.number()),
    levels: z.array(z.array(z.string().nullable())),
    coded: z.array(z.array(nullableNumber)),
  }),
  versions: z.object({ R: z.string(), FrF2: z.string(), "DoE.base": z.string(), report: z.string() }),
});

export type Report = z.infer<typeof ReportSchema>;

lib/webr/protocol.ts:

// Messages between the page and the R engine worker (public/workers/webr-worker.mjs).

export type Versions = { R: string; webR: string; FrF2: string; "DoE.base": string; jsonlite: string; report: string };
/** hit: the library came from IndexedDB; stored: installed now and kept in IndexedDB; memory: no IndexedDB. */
export type CacheKind = "hit" | "stored" | "memory";
export type Condition = { type: "warning" | "message"; text: string };

export type ToWorker = { type: "init" } | { type: "run"; id: number; designCode: string; reportCall: string };

export type FromWorker =
  | { type: "progress"; loaded: number; total: number }
  | { type: "ready"; versions: Versions; cache: CacheKind; timings: { webrMs: number; totalMs: number } }
  | { type: "init-error"; message: string }
  | {
      type: "result";
      id: number;
      ok: true;
      code: string;
      reportJson: string;
      conditions: Condition[];
      reportConditions: Condition[];
      timings: { designMs: number; reportMs: number };
    }
  | { type: "result"; id: number; ok: false; error: string };

lib/webr/client.ts:

import { ReportSchema, type Report } from "@/lib/report/types";
import type { CacheKind, Condition, FromWorker, ToWorker, Versions } from "./protocol";

// The engine client of the page (spec Section 7.9). It owns the worker, its status, the time
// limits and Cancel. Cancel and the hard limit terminate the worker and start a new one,
// because webR.interrupt() leaves WebR 0.6.0 unable to answer (measured on 2026-10-15). The
// R library is in IndexedDB, so the new worker downloads nothing.

export type EngineStatus =
  | { state: "idle" }
  | { state: "loading"; loaded: number; total: number; stalled: boolean }
  | { state: "ready"; versions: Versions; cache: CacheKind }
  | { state: "running"; versions: Versions; startedAt: number; pastSoftLimit: boolean }
  | { state: "failed"; message: string };

export type RunOutcome =
  | { ok: true; code: string; report: Report; reportJson: string; conditions: Condition[]; timings: { designMs: number; reportMs: number } }
  | { ok: false; reason: "r_error" | "cancelled" | "timeout" | "report_shape" | "engine_failed"; message: string };

export interface WorkerLike {
  postMessage(message: ToWorker): void;
  terminate(): void;
  onmessage: ((event: { data: FromWorker }) => void) | null;
  onerror: ((event: unknown) => void) | null;
}

export interface EngineOptions {
  createWorker: () => WorkerLike;
  softTimeoutMs: number;
  hardTimeoutMs: number;
  stallMs: number;
}

type DesignCode = { designCode: string; reportCall: string };
type Timer = ReturnType<typeof setTimeout>;
type Pending = { id: number; resolve: (outcome: RunOutcome) => void; soft: Timer; hard: Timer };
type ReadyStatus = Extract<EngineStatus, { state: "ready" }>;

function parseJson(text: string): unknown {
  try {
    return JSON.parse(text);
  } catch {
    return undefined;
  }
}

export class EngineClient {
  private worker: WorkerLike | null = null;
  private status: EngineStatus = { state: "idle" };
  private lastReady: ReadyStatus | null = null;
  private readonly listeners = new Set<() => void>();
  private nextId = 1;
  private pending: Pending | null = null;
  private stallTimer: Timer | null = null;
  private readyWaiters: Array<(ready: boolean) => void> = [];
  private chain: Promise<unknown> = Promise.resolve();

  constructor(private readonly options: EngineOptions) {}

  getStatus = (): EngineStatus => this.status;

  subscribe = (listener: () => void): (() => void) => {
    this.listeners.add(listener);
    return () => {
      this.listeners.delete(listener);
    };
  };

  /** Starts the worker ("prewarm"). A second call does nothing. */
  start(): void {
    if (!this.worker) this.spawn();
  }

  /** Stops the worker and starts a new one, for example after a failure or a stall. */
  retry(): void {
    this.stopWorker();
    this.spawn();
  }

  /** Runs one design. A run waits for the engine and for the run before it. */
  run(code: DesignCode): Promise<RunOutcome> {
    const next = this.chain.then(() => this.runNow(code));
    this.chain = next.catch(() => undefined);
    return next;
  }

  /** Stops the current run: the worker is terminated and a new one starts. */
  cancel(): void {
    if (!this.pending) return;
    this.finish({ ok: false, reason: "cancelled", message: "The run was cancelled." });
    this.retry();
  }

  private spawn(): void {
    const worker = this.options.createWorker();
    this.worker = worker;
    worker.onmessage = (event) => this.onMessage(worker, event.data);
    worker.onerror = () => {
      if (this.worker === worker) this.fail("The R engine could not be loaded.");
    };
    this.setStatus({ state: "loading", loaded: 0, total: 0, stalled: false });
    this.armStall();
    worker.postMessage({ type: "init" });
  }

  private stopWorker(): void {
    this.clearStall();
    if (this.pending) this.finish({ ok: false, reason: "engine_failed", message: "The R engine stopped." });
    this.worker?.terminate();
    this.worker = null;
  }

  private onMessage(worker: WorkerLike, message: FromWorker): void {
    // A message from a terminated worker is ignored.
    if (worker !== this.worker) return;
    switch (message.type) {
      case "progress": {
        this.setStatus({ state: "loading", loaded: message.loaded, total: message.total, stalled: false });
        this.armStall();
        break;
      }
      case "ready": {
        this.clearStall();
        this.setStatus({ state: "ready", versions: message.versions, cache: message.cache });
        this.flushReadyWaiters(true);
        break;
      }
      case "init-error": {
        this.fail(message.message);
        break;
      }
      case "result": {
        if (!this.pending || message.id !== this.pending.id) return;
        if (!message.ok) {
          this.finish({ ok: false, reason: "r_error", message: message.error });
          return;
        }
        const parsed = ReportSchema.safeParse(parseJson(message.reportJson));
        if (!parsed.success) {
          this.finish({ ok: false, reason: "report_shape", message: "The report from R does not have the expected form." });
          return;
        }
        this.finish({ ok: true, code: message.code, report: parsed.data, reportJson: message.reportJson, conditions: message.conditions, timings: message.timings });
        break;
      }
    }
  }

  private async runNow(code: DesignCode): Promise<RunOutcome> {
    this.start();
    const isReady = await this.whenReady();
    if (!isReady || this.status.state !== "ready") {
      const message = this.status.state === "failed" ? this.status.message : "The R engine is not ready.";
      return { ok: false, reason: "engine_failed", message };
    }
    const versions = this.status.versions;
    const id = this.nextId++;
    const startedAt = Date.now();
    return new Promise<RunOutcome>((resolve) => {
      const soft = setTimeout(() => this.setStatus({ state: "running", versions, startedAt, pastSoftLimit: true }), this.options.softTimeoutMs);
      const hard = setTimeout(() => {
        this.finish({ ok: false, reason: "timeout", message: "The run took longer than the time limit and was stopped." });
        this.retry();
      }, this.options.hardTimeoutMs);
      this.pending = { id, resolve, soft, hard };
      this.setStatus({ state: "running", versions, startedAt, pastSoftLimit: false });
      this.worker?.postMessage({ type: "run", id, ...code });
    });
  }

  private finish(outcome: RunOutcome): void {
    const pending = this.pending;
    if (!pending) return;
    this.pending = null;
    clearTimeout(pending.soft);
    clearTimeout(pending.hard);
    if (this.status.state === "running" && this.lastReady) this.setStatus(this.lastReady);
    pending.resolve(outcome);
  }

  private fail(message: string): void {
    this.clearStall();
    this.setStatus({ state: "failed", message });
    if (this.pending) this.finish({ ok: false, reason: "engine_failed", message });
    this.flushReadyWaiters(false);
  }

  private whenReady(): Promise<boolean> {
    if (this.status.state === "ready") return Promise.resolve(true);
    if (this.status.state === "failed") return Promise.resolve(false);
    return new Promise((resolve) => this.readyWaiters.push(resolve));
  }

  private flushReadyWaiters(isReady: boolean): void {
    const waiters = this.readyWaiters;
    this.readyWaiters = [];
    for (const resolve of waiters) resolve(isReady);
  }

  private armStall(): void {
    this.clearStall();
    this.stallTimer = setTimeout(() => {
      if (this.status.state === "loading") this.setStatus({ ...this.status, stalled: true });
    }, this.options.stallMs);
  }

  private clearStall(): void {
    if (this.stallTimer) clearTimeout(this.stallTimer);
    this.stallTimer = null;
  }

  private setStatus(status: EngineStatus): void {
    this.status = status;
    if (status.state === "ready") this.lastReady = status;
    for (const listener of this.listeners) listener();
  }
}

config/runtime-budgets.json:

{
  "status": "provisional",
  "note": "Placeholder values. The performance tests of Milestone 2 (Task 11) measure them and replace this file.",
  "soft_timeout_ms": 30000,
  "hard_timeout_ms": 600000,
  "stall_ms": 30000
}

lib/config/budgets.ts:

import budgets from "@/config/runtime-budgets.json";

// R time budgets (spec Section 7.9). The performance tests write config/runtime-budgets.json;
// NEXT_PUBLIC_ variables in .env override the values (spec Section 13.2).
function fromEnv(value: string | undefined, fallback: number): number {
  const n = Number(value);
  return value !== undefined && Number.isFinite(n) && n > 0 ? n : fallback;
}

export const R_SOFT_TIMEOUT_MS = fromEnv(process.env.NEXT_PUBLIC_R_SOFT_TIMEOUT_MS, budgets.soft_timeout_ms);
export const R_HARD_TIMEOUT_MS = fromEnv(process.env.NEXT_PUBLIC_R_HARD_TIMEOUT_MS, budgets.hard_timeout_ms);
export const R_STALL_MS = fromEnv(process.env.NEXT_PUBLIC_R_STALL_MS, budgets.stall_ms);

lib/webr/engine.ts:

import { R_HARD_TIMEOUT_MS, R_SOFT_TIMEOUT_MS, R_STALL_MS } from "@/lib/config/budgets";
import { EngineClient, type WorkerLike } from "./client";

// One engine for the page. It lives in the module, so a client-side navigation keeps the worker.
let engine: EngineClient | null = null;

export function getEngine(): EngineClient {
  engine ??= new EngineClient({
    createWorker: () => new Worker("/workers/webr-worker.mjs", { type: "module" }) as unknown as WorkerLike,
    softTimeoutMs: R_SOFT_TIMEOUT_MS,
    hardTimeoutMs: R_HARD_TIMEOUT_MS,
    stallMs: R_STALL_MS,
  });
  // The browser tests run designs through this engine. Only their build sets the variable
  // (playwright.config.ts); Next.js replaces it at build time, so other builds have no hook.
  if (process.env.NEXT_PUBLIC_FFD_TEST_HOOKS === "1") (globalThis as unknown as Record<string, unknown>).__ffdEngine = engine;
  return engine;
}

lib/webr/use-engine.ts:

"use client";

import { useEffect, useState, useSyncExternalStore } from "react";
import type { EngineStatus } from "./client";
import { getEngine } from "./engine";

const IDLE: EngineStatus = { state: "idle" };

/** Starts the engine when the component appears (spec 7.9: "prewarm") and returns its status. */
export function useEngineStatus(): EngineStatus {
  const engine = getEngine();
  useEffect(() => {
    engine.start();
  }, [engine]);
  return useSyncExternalStore(engine.subscribe, engine.getStatus, () => IDLE);
}

/** The current time, updated each second while active is true (the elapsed time of a run, P15). */
export function useNow(active: boolean): number {
  const [now, setNow] = useState(() => Date.now());
  useEffect(() => {
    if (!active) return;
    setNow(Date.now());
    const timer = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(timer);
  }, [active]);
  return now;
}

lib/webr/status-text.ts:

import type { EngineStatus } from "./client";

const mb = (bytes: number) => (bytes / 1e6).toFixed(1);

/** Elapsed time as m:ss. A negative time (a clock one tick behind the run) is 0:00. */
export function formatElapsed(ms: number): string {
  const seconds = Math.floor(Math.max(0, ms) / 1000);
  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}

/**
 * The footer text of the engine status (spec 7.9). `visible` is on the screen; `announced` goes
 * to the polite live region. It changes only with the state, so a screen reader does not read
 * every percent or every second. `now` gives the elapsed time of a run (decision P15).
 */
export function engineStatusText(status: EngineStatus, now: number = Date.now()): { visible: string; announced: string } {
  switch (status.state) {
    case "idle":
      return { visible: "R engine: loading (0%)", announced: "R engine: loading" };
    case "loading": {
      const percent = status.total > 0 ? Math.floor((100 * status.loaded) / status.total) : 0;
      const bytes = status.total > 0 ? `, ${mb(status.loaded)} of ${mb(status.total)} MB` : "";
      return {
        visible: `R engine: loading (${percent}%)${bytes}`,
        announced: status.stalled ? "R engine: loading has stopped" : "R engine: loading",
      };
    }
    case "ready":
      return { visible: "R engine: ready", announced: "R engine: ready" };
    case "running": {
      const elapsed = formatElapsed(now - status.startedAt);
      return status.pastSoftLimit
        ? { visible: `R engine: still running (${elapsed})`, announced: "R engine: still running. Cancel is available." }
        : { visible: `R engine: computing the design (${elapsed})`, announced: "R engine: computing the design" };
    }
    case "failed":
      return { visible: `R engine: not available. ${status.message}`, announced: "R engine: not available" };
  }
}

public/workers/webr-worker.mjs:

/**
 * R engine worker (spec Section 7.9), loaded as a module worker from /workers/webr-worker.mjs.
 * It starts WebR from /runtimes/webr/, installs the R packages from the same-origin mirror into
 * a library kept in IndexedDB (IDBFS), sources the report helper, and then runs one design at
 * a time. Messages: lib/webr/protocol.ts. Cancel: the page terminates this worker and starts a
 * new one, because webR.interrupt() stops R but leaves WebR 0.6.0 unable to answer (measured
 * on 2026-10-15).
 */
import { LIB_DIR, cleanRError, installPackages, prepareSession, runDesign } from "./engine-core.mjs";

let webR = null;
let queue = Promise.resolve();

self.onmessage = (event) => {
  const message = event.data ?? {};
  // One message at a time, in order: a run waits for init and for the run before it.
  queue = queue.then(() => handle(message)).catch((error) => console.error(error));
};

async function handle(message) {
  if (message.type === "init") await init();
  else if (message.type === "run") await run(message);
}

async function fetchOk(url) {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`Could not load ${url} (HTTP ${response.status}).`);
  return response;
}

/** The bytes of a same-origin file. onBytes(n) reports bytes while they arrive. */
async function fetchBytes(url, onBytes = () => {}) {
  const response = await fetchOk(url);
  if (!response.body) return new Uint8Array(await response.arrayBuffer());
  const reader = response.body.getReader();
  const chunks = [];
  let size = 0;
  for (;;) {
    const { done, value } = await reader.read();
    if (done) break;
    chunks.push(value);
    size += value.byteLength;
    onBytes(value.byteLength);
  }
  const bytes = new Uint8Array(size);
  let offset = 0;
  for (const chunk of chunks) {
    bytes.set(chunk, offset);
    offset += chunk.byteLength;
  }
  return bytes;
}

// The library is kept in IndexedDB, so a later visit or a restarted worker downloads nothing.
// Without IndexedDB (for example in private browsing) it lives in memory for this session.
async function mountLibrary() {
  try {
    await webR.FS.mkdir(LIB_DIR);
    await webR.FS.mount("IDBFS", {}, LIB_DIR);
    await webR.FS.syncfs(true);
    return true;
  } catch {
    return false;
  }
}

async function init() {
  const t0 = performance.now();
  try {
    // The browser build: webr.mjs imports Node built-ins that a browser cannot load (ChatISA lesson).
    const { WebR } = await import("/runtimes/webr/webr.js");
    webR = new WebR({ baseUrl: "/runtimes/webr/", interactive: false });
    await webR.init();
    const webrMs = performance.now() - t0;
    const persistent = await mountLibrary();
    const manifest = await (await fetchOk("/runtimes/repo/manifest.json")).json();
    const { installed } = await installPackages(
      webR,
      manifest,
      (file, onBytes) => fetchBytes(`/runtimes/repo/${file}`, onBytes),
      ({ loaded, total }) => self.postMessage({ type: "progress", loaded, total }),
    );
    if (persistent && installed) await webR.FS.syncfs(false);
    const versions = await prepareSession(webR, {
      reportSource: await fetchBytes("/r/ffd_report.R"),
      errataJson: await fetchBytes("/r/catalogue-errata.json"),
    });
    const cache = !persistent ? "memory" : installed ? "stored" : "hit";
    self.postMessage({ type: "ready", versions, cache, timings: { webrMs, totalMs: performance.now() - t0 } });
  } catch (error) {
    webR = null;
    self.postMessage({ type: "init-error", message: error instanceof Error ? error.message : String(error) });
  }
}

async function run({ id, designCode, reportCall }) {
  if (!webR) {
    self.postMessage({ type: "result", id, ok: false, error: "The R engine is not ready." });
    return;
  }
  try {
    const result = await runDesign(webR, { designCode, reportCall });
    self.postMessage({ type: "result", id, ok: true, ...result });
  } catch (error) {
    self.postMessage({ type: "result", id, ok: false, error: cleanRError(error instanceof Error ? error.message : String(error)) });
  }
}

Run: npx vitest run --project unit Expected: all unit tests pass.

Run: npx vitest run --project webr differential Expected: all cases pass; each report also passes ReportSchema.

Run: npx tsc --noEmit Expected: no error. (The worker file runs in the browser; Task 10 tests it in Chromium.)

git add public/workers/webr-worker.mjs lib/webr lib/report lib/config config replication_materials/tests/unit replication_materials/tests/fixtures replication_materials/tests/webr/differential.test.ts
git commit -m "feat: add the browser worker, the engine client and the report schema"

14Task 8: Project storage and the last-page pointer

Files:

  • Create: lib/projects/schema.ts, lib/projects/store.ts, lib/projects/pointer.ts, lib/projects/format.ts, public/pointer-redirect.js
  • Modify: app/layout.tsx (pointer script), package.json (dev dependency fake-indexeddb)
  • Test: replication_materials/tests/unit/projects.test.ts, replication_materials/tests/unit/pointer.test.ts

Interfaces:

  • Consumes: CoreSpecSchema (Task 3); ReportSchema (Task 7).
  • Produces:
    • PROJECT_SCHEMA_VERSION, ProjectSchema, the type Project, newProject(now, id, timeZone) and isoWithOffset(date) (lib/projects/schema.ts).
    • openProjectStore(factory?) returns a ProjectStore: { persistent, list, get, put, remove, clear } (lib/projects/store.ts).
    • From lib/projects/pointer.ts: LAST_PAGE_KEY, EXPLANATIONS_KEY, LAST_PAGE_PATTERN, safeLocalStorage(), readLastPage(storage?), writeLastPage(path, storage?), readShowExplanations(storage?) and writeShowExplanations(value, storage?).
    • projectsSize(projects), formatBytes(n), formatDate(iso) and designSummary(project) (lib/projects/format.ts).
  • Storage keys: IndexedDB database ffd-projects, store projects, key id. localStorage ffd:last-page and ffd:show-explanations.

Run: npm install --save-exact --save-dev fake-indexeddb@6.2.5

replication_materials/tests/unit/projects.test.ts:

import { IDBFactory } from "fake-indexeddb";
import { describe, expect, it } from "vitest";
import { designSummary, formatBytes, formatDate, projectsSize } from "@/lib/projects/format";
import { ProjectSchema, isoWithOffset, newProject } from "@/lib/projects/schema";
import { openProjectStore } from "@/lib/projects/store";

const at = (minutes: number) => new Date(2026, 9, 15, 12, minutes, 0);
const project = (id: string, minutes: number) => newProject(at(minutes), id, "America/New_York");
const ID1 = "00000000-0000-4000-8000-000000000001";
const ID2 = "00000000-0000-4000-8000-000000000002";

describe("project schema (spec 8.1)", () => {
  it("makes a valid new project with the creation time and the time zone", () => {
    const p = project(ID1, 5);
    expect(ProjectSchema.parse(p)).toEqual(p);
    expect(p.name).toBe("Untitled design");
    expect(p.created_at).toMatch(/^2026-10-15T12:05:00[+-]\d{2}:\d{2}$/);
    expect(p.time_zone).toBe("America/New_York");
    expect(p.spec).toBeNull();
    expect(p.last_step).toBe(1);
  });
  it("writes the local time with its offset", () => {
    expect(isoWithOffset(new Date(2026, 9, 15, 9, 5, 7))).toMatch(/^2026-10-15T09:05:07[+-]\d{2}:\d{2}$/);
  });
});

describe("project store (spec 8.2)", () => {
  it("stores, lists newest first, reads, removes and clears projects in IndexedDB", async () => {
    const store = await openProjectStore(new IDBFactory());
    expect(store.persistent).toBe(true);
    await store.put(project(ID1, 1));
    await store.put(project(ID2, 2));
    expect((await store.list()).map((p) => p.id)).toEqual([ID2, ID1]);
    expect((await store.get(ID1))?.id).toBe(ID1);
    await store.remove(ID1);
    expect((await store.list()).map((p) => p.id)).toEqual([ID2]);
    await store.clear();
    expect(await store.list()).toEqual([]);
  });
  it("refuses a project that does not match the schema", async () => {
    const store = await openProjectStore(new IDBFactory());
    await expect(store.put({ ...project(ID1, 1), id: "not-a-uuid" })).rejects.toThrow();
  });
  it("keeps projects in memory when IndexedDB is missing or blocked (Review Focus 2)", async () => {
    const blocked = { open() { throw new Error("blocked"); } } as unknown as IDBFactory;
    for (const factory of [undefined, blocked]) {
      const store = await openProjectStore(factory);
      expect(store.persistent).toBe(false);
      await store.put(project(ID1, 1));
      expect((await store.list()).map((p) => p.id)).toEqual([ID1]);
    }
  });
  it("keeps projects in memory when the database opens but cannot be read", async () => {
    const brokenDb = { transaction() { throw new Error("The connection is closed."); } };
    const opensBroken = {
      open() {
        const req: Record<string, unknown> = { result: brokenDb };
        setTimeout(() => (req.onsuccess as () => void)());
        return req;
      },
    } as unknown as IDBFactory;
    expect((await openProjectStore(opensBroken)).persistent).toBe(false);
  });
});

describe("display helpers", () => {
  it("formats sizes, dates and the design summary", () => {
    expect([512, 41_000, 2_500_000].map(formatBytes)).toEqual(["512 bytes", "41 KB", "2.5 MB"]);
    expect(formatDate("2026-10-15T12:00:00-04:00")).toBe("Oct 15, 2026");
    expect(designSummary(project(ID1, 1))).toBe("No design yet");
    expect(projectsSize([project(ID1, 1)])).toBeGreaterThan(100);
  });
});

replication_materials/tests/unit/pointer.test.ts:

import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
  LAST_PAGE_KEY, LAST_PAGE_PATTERN, readLastPage, readShowExplanations, writeLastPage, writeShowExplanations,
} from "@/lib/projects/pointer";

function memoryStorage(): Storage {
  const data = new Map<string, string>();
  return {
    get length() { return data.size; },
    clear: () => data.clear(),
    getItem: (key) => data.get(key) ?? null,
    key: (i) => [...data.keys()][i] ?? null,
    removeItem: (key) => { data.delete(key); },
    setItem: (key, value) => { data.set(key, value); },
  };
}

const throwing = new Proxy({}, { get() { throw new Error("blocked"); } }) as Storage;

describe("last-page pointer (spec 9.2)", () => {
  it("stores and reads a workspace page", () => {
    const storage = memoryStorage();
    writeLastPage("/app?project=00000000-0000-4000-8000-000000000001&step=2", storage);
    expect(readLastPage(storage)).toBe("/app?project=00000000-0000-4000-8000-000000000001&step=2");
  });
  it("accepts only workspace pages, so the pointer cannot open another site", () => {
    const storage = memoryStorage();
    for (const bad of ["https://example.org", "//example.org", "/app/../x", "/app?x=<script>", "/"]) {
      storage.setItem(LAST_PAGE_KEY, bad);
      expect(readLastPage(storage), bad).toBeNull();
      storage.setItem(LAST_PAGE_KEY, "/app");
      writeLastPage(bad, storage);
      expect(storage.getItem(LAST_PAGE_KEY), bad).toBe("/app");
    }
  });
  it("works without storage and with storage that throws (Review Focus 2)", () => {
    expect(readLastPage(null)).toBeNull();
    expect(readLastPage(throwing)).toBeNull();
    expect(() => writeLastPage("/app", throwing)).not.toThrow();
    expect(readShowExplanations(throwing)).toBe(false);
    expect(() => writeShowExplanations(true, throwing)).not.toThrow();
  });
  it("remembers the Show explanations switch", () => {
    const storage = memoryStorage();
    expect(readShowExplanations(storage)).toBe(false);
    writeShowExplanations(true, storage);
    expect(readShowExplanations(storage)).toBe(true);
  });
  it("uses the same rule in the head script as in the app", () => {
    expect(readFileSync("public/pointer-redirect.js", "utf8")).toContain(LAST_PAGE_PATTERN.source);
  });
});

Run: npx vitest run --project unit projects pointer Expected: FAIL with Cannot find module '@/lib/projects/schema' and '@/lib/projects/pointer'.

lib/projects/schema.ts:

import { z } from "zod";
import { ReportSchema } from "@/lib/report/types";
import { CoreSpecSchema } from "@/lib/spec/validate";

// A project (spec Section 8.1): the unit of work, stored on the device. Version 1. Milestone 3
// fills the request, the spec, the R code, the versions, the SHA-256 and the report.

export const PROJECT_SCHEMA_VERSION = 1;

export const ProjectSchema = z.object({
  schema_version: z.literal(PROJECT_SCHEMA_VERSION),
  id: z.uuid(),
  name: z.string().min(1),
  /** Local creation time with its offset, for example 2026-10-15T09:05:07-04:00 (spec 7.3). */
  created_at: z.string(),
  time_zone: z.string(),
  /** UTC time of the last change. */
  updated_at: z.string(),
  request: z.object({
    text: z.string(),
    turns: z.array(z.object({ role: z.enum(["user", "tool"]), text: z.string() })),
  }).nullable(),
  spec: CoreSpecSchema.nullable(),
  r_code: z.string().nullable(),
  versions: z.record(z.string(), z.string()).nullable(),
  run_sheet_sha256: z.string().regex(/^[0-9a-f]{64}$/).nullable(),
  report: ReportSchema.nullable(),
  last_step: z.number().int().min(1).max(6),
});

export type Project = z.infer<typeof ProjectSchema>;

/** Local date and time with the offset of the device, to the second. */
export function isoWithOffset(date: Date): string {
  const pad = (n: number) => String(Math.abs(n)).padStart(2, "0");
  const offset = -date.getTimezoneOffset();
  const sign = offset >= 0 ? "+" : "-";
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${pad(Math.trunc(offset / 60))}:${pad(offset % 60)}`;
}

export function newProject(now: Date, id: string, timeZone: string): Project {
  return {
    schema_version: PROJECT_SCHEMA_VERSION,
    id,
    name: "Untitled design",
    created_at: isoWithOffset(now),
    time_zone: timeZone,
    updated_at: now.toISOString(),
    request: null,
    spec: null,
    r_code: null,
    versions: null,
    run_sheet_sha256: null,
    report: null,
    last_step: 1,
  };
}

lib/projects/store.ts:

import { ProjectSchema, type Project } from "./schema";

// Projects on this device (spec Section 8.2). IndexedDB holds them. Without IndexedDB (for
// example in private browsing) the store keeps them in memory for this session, and
// persistent is false, so that the sidebar can say so. openProjectStore reads the database
// once before it uses it. A later failure rejects, and the workspace shows a message.

const DB_NAME = "ffd-projects";
const STORE = "projects";

export interface ProjectStore {
  readonly persistent: boolean;
  list(): Promise<Project[]>;
  get(id: string): Promise<Project | undefined>;
  put(project: Project): Promise<void>;
  remove(id: string): Promise<void>;
  clear(): Promise<void>;
}

const newestFirst = (a: Project, b: Project) => Date.parse(b.updated_at) - Date.parse(a.updated_at);

function validOnly(values: unknown[]): Project[] {
  // A stored record that does not match the schema is not shown.
  return values.flatMap((value) => {
    const parsed = ProjectSchema.safeParse(value);
    return parsed.success ? [parsed.data] : [];
  });
}

function request<T>(req: IDBRequest<T>): Promise<T> {
  return new Promise((resolve, reject) => {
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

function openDb(factory: IDBFactory): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = factory.open(DB_NAME, 1);
    req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: "id" });
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
    req.onblocked = () => reject(new Error("The project database is blocked."));
  });
}

function indexedDbStore(db: IDBDatabase): ProjectStore {
  const store = (mode: IDBTransactionMode) => db.transaction(STORE, mode).objectStore(STORE);
  return {
    persistent: true,
    async list() {
      return validOnly(await request(store("readonly").getAll())).sort(newestFirst);
    },
    async get(id) {
      return validOnly([await request(store("readonly").get(id))])[0];
    },
    async put(project) {
      await request(store("readwrite").put(ProjectSchema.parse(project)));
    },
    async remove(id) {
      await request(store("readwrite").delete(id));
    },
    async clear() {
      await request(store("readwrite").clear());
    },
  };
}

function memoryStore(): ProjectStore {
  const projects = new Map<string, Project>();
  return {
    persistent: false,
    async list() {
      return [...projects.values()].sort(newestFirst);
    },
    async get(id) {
      return projects.get(id);
    },
    async put(project) {
      const valid = ProjectSchema.parse(project);
      projects.set(valid.id, valid);
    },
    async remove(id) {
      projects.delete(id);
    },
    async clear() {
      projects.clear();
    },
  };
}

function safeIndexedDb(): IDBFactory | undefined {
  try {
    return globalThis.indexedDB ?? undefined;
  } catch {
    return undefined;
  }
}

export async function openProjectStore(factory: IDBFactory | undefined = safeIndexedDb()): Promise<ProjectStore> {
  if (!factory) return memoryStore();
  try {
    const store = indexedDbStore(await openDb(factory));
    await store.list();
    return store;
  } catch {
    return memoryStore();
  }
}

lib/projects/pointer.ts:

// Two small values in localStorage (spec Section 8.2): the last page of the workspace, which
// public/pointer-redirect.js opens on a later visit to / (spec 9.2), and the "Show
// explanations" switch (spec 9.5). Every access is in try/catch; without storage nothing is kept.

export const LAST_PAGE_KEY = "ffd:last-page";
export const EXPLANATIONS_KEY = "ffd:show-explanations";
// Only workspace pages, so a stored value cannot open another site. public/pointer-redirect.js
// holds the same pattern (a unit test checks this).
export const LAST_PAGE_PATTERN = /^\/app(\?[A-Za-z0-9=&_-]*)?$/;

export function safeLocalStorage(): Storage | null {
  try {
    return globalThis.localStorage ?? null;
  } catch {
    return null;
  }
}

export function readLastPage(storage: Storage | null = safeLocalStorage()): string | null {
  try {
    const value = storage?.getItem(LAST_PAGE_KEY) ?? null;
    return value !== null && LAST_PAGE_PATTERN.test(value) ? value : null;
  } catch {
    return null;
  }
}

export function writeLastPage(path: string, storage: Storage | null = safeLocalStorage()): void {
  if (!LAST_PAGE_PATTERN.test(path)) return;
  try {
    storage?.setItem(LAST_PAGE_KEY, path);
  } catch {
    // Storage is full or blocked: the next visit shows the landing page.
  }
}

export function readShowExplanations(storage: Storage | null = safeLocalStorage()): boolean {
  try {
    return storage?.getItem(EXPLANATIONS_KEY) === "1";
  } catch {
    return false;
  }
}

export function writeShowExplanations(value: boolean, storage: Storage | null = safeLocalStorage()): void {
  try {
    storage?.setItem(EXPLANATIONS_KEY, value ? "1" : "0");
  } catch {
    // The switch is not remembered.
  }
}

lib/projects/format.ts:

import type { Project } from "./schema";

/** Bytes of the projects as JSON (UTF-8): the storage use in the sidebar. */
export function projectsSize(projects: Project[]): number {
  const encoder = new TextEncoder();
  return projects.reduce((sum, p) => sum + encoder.encode(JSON.stringify(p)).length, 0);
}

export function formatBytes(bytes: number): string {
  if (bytes < 1_000) return `${bytes} bytes`;
  if (bytes < 1_000_000) return `${Math.round(bytes / 1_000)} KB`;
  return `${(bytes / 1_000_000).toFixed(1)} MB`;
}

export function formatDate(iso: string): string {
  return new Intl.DateTimeFormat("en", { dateStyle: "medium" }).format(new Date(iso));
}

export function designSummary(project: Project): string {
  return project.report ? `${project.report.design.nruns} runs` : "No design yet";
}

public/pointer-redirect.js:

// Opens the last page of the workspace before the landing page is drawn (spec 9.2).
// /?home always shows the landing page. Without storage, every visit shows the landing page.
// The pattern is LAST_PAGE_PATTERN of lib/projects/pointer.ts (a unit test checks this).
(function () {
  try {
    if (location.pathname !== "/" || /(^|&)home(=|&|$)/.test(location.search.slice(1))) return;
    var last = localStorage.getItem("ffd:last-page");
    if (last && /^\/app(\?[A-Za-z0-9=&_-]*)?$/.test(last)) location.replace(last);
  } catch (e) {}
})();

In app/layout.tsx, change <html lang="en" className={serif.variable}> and the line after it to:

    <html lang="en" className={serif.variable}>
      <head>
        {/* Synchronous same-origin script: it runs before the landing page is drawn (spec 9.2). */}
        <script src="/pointer-redirect.js" />
      </head>

Run: npx vitest run --project unit Expected: all unit tests pass.

Run: npx tsc --noEmit Expected: no error.

git add lib/projects public/pointer-redirect.js app/layout.tsx package.json package-lock.json replication_materials/tests/unit
git commit -m "feat: store projects in IndexedDB and remember the last page"

15Task 9: Landing page, workspace shell and browser tests

Files:

  • Create: components/shell/SkipLink.tsx, SiteHeader.tsx, SiteFooter.tsx, EngineStatus.tsx, StepRail.tsx, StepPanel.tsx, ProjectSidebar.tsx, Workspace.tsx; app/app/page.tsx; lib/steps.ts; lib/ops/privacy.ts; playwright.config.ts
  • Modify: app/page.tsx (the landing page), package.json (dev dependency @playwright/test, script test:e2e), scripts/check.mjs
  • Test: replication_materials/tests/unit/privacy.test.ts, replication_materials/tests/unit/steps.test.ts, replication_materials/tests/e2e/shell.spec.ts, replication_materials/tests/e2e/engine.spec.ts

Interfaces:

  • Consumes: everything in lib/projects (Task 8); useEngineStatus, getEngine and engineStatusText (Task 7); APP_NAME, APP_VERSION, MAINTAINERS and CITATION (Task 2).
  • Produces:
    • STEPS and stepFromQuery(value) (lib/steps.ts).
    • privacyNotice(mode, provider), currentPrivacyNotice(env?) and ENSEMBLE_NOTICE (lib/ops/privacy.ts).
    • Routes: / (landing), /?home (always the landing page) and /app?project=<uuid>&step=<n> (workspace).
    • The footer status has data-testid="engine-status".
  • Selectors that later tasks use:
    • Footer: getByTestId("engine-status"). During a run: getByRole("progressbar", { name: "Computing the design" }), and the button "Cancel" from the soft limit (P15).
    • Sidebar: the buttons "New design", "Clear", "Delete all" and "Keep projects".
    • Rail: the buttons "1 Describe" to "6 Report".
    • The heading of the current step: level 2, for example "3 Design".

replication_materials/tests/unit/privacy.test.ts:

import { describe, expect, it } from "vitest";
import { ENSEMBLE_NOTICE, currentPrivacyNotice, privacyNotice } from "@/lib/ops/privacy";

describe("privacy notice (spec 13.3)", () => {
  it("names one provider in single mode", () => {
    expect(privacyNotice("single", "anthropic")).toBe(
      "Your request text is sent to Anthropic to interpret it. Do not include confidential data. The design is made in your browser.",
    );
  });
  it("names all four providers in ensemble mode", () => {
    expect(privacyNotice("ensemble", "openai")).toBe(
      "Your request text is sent to OpenAI, Anthropic, Google and TypeSafe to interpret it and to check the result. Do not include confidential data. The design is made in your browser.",
    );
  });
  it("gives the ensemble notice when the mode or the provider is missing or unknown (P10)", () => {
    expect(currentPrivacyNotice({})).toBe(ENSEMBLE_NOTICE);
    expect(currentPrivacyNotice({ TRANSLATE_MODE: "single" })).toBe(ENSEMBLE_NOTICE);
    expect(currentPrivacyNotice({ TRANSLATE_MODE: "single", TRANSLATE_SINGLE_PROVIDER: "other" })).toBe(ENSEMBLE_NOTICE);
    expect(currentPrivacyNotice({ TRANSLATE_MODE: "single", TRANSLATE_SINGLE_PROVIDER: "google" })).toContain("sent to Google");
  });
});

replication_materials/tests/unit/steps.test.ts:

import { describe, expect, it } from "vitest";
import { STEPS, stepFromQuery } from "@/lib/steps";

describe("step rail (spec 9.1)", () => {
  it("has the six steps, with steps 4 and 5 marked Phase 2", () => {
    expect(STEPS.map((s) => `${s.n} ${s.title}`)).toEqual(["1 Describe", "2 Confirm", "3 Design", "4 Responses", "5 Analyze", "6 Report"]);
    expect(STEPS.filter((s) => s.phase2).map((s) => s.n)).toEqual([4, 5]);
  });
  it("opens step 1 for a missing, unknown or Phase 2 step", () => {
    expect([null, "x", "0", "4", "5", "7"].map(stepFromQuery)).toEqual([1, 1, 1, 1, 1, 1]);
    expect(["1", "2", "3", "6"].map(stepFromQuery)).toEqual([1, 2, 3, 6]);
  });
});

Run: npx vitest run --project unit privacy steps Expected: FAIL with Cannot find module '@/lib/ops/privacy' and '@/lib/steps'.

lib/steps.ts:

// The step rail (spec Section 9.1). Steps 4 and 5 are visible but not available until Phase 2.
export interface Step {
  n: number;
  title: string;
  phase2: boolean;
}

export const STEPS: readonly Step[] = [
  { n: 1, title: "Describe", phase2: false },
  { n: 2, title: "Confirm", phase2: false },
  { n: 3, title: "Design", phase2: false },
  { n: 4, title: "Responses", phase2: true },
  { n: 5, title: "Analyze", phase2: true },
  { n: 6, title: "Report", phase2: false },
];

/** The step of the address bar; step 1 if the value is missing, unknown or a Phase 2 step. */
export function stepFromQuery(value: string | null): number {
  const step = STEPS.find((s) => String(s.n) === value);
  return step && !step.phase2 ? step.n : 1;
}

lib/ops/privacy.ts:

// The privacy notice (spec Section 13.3). The mode comes from .env (spec 13.2). Until the
// evaluation selects the production default (spec 12.4), a missing or unknown mode or provider
// gives the ensemble notice, which names every provider that can get the request text (P10).

export type TranslateMode = "single" | "ensemble";
export type SingleProvider = "openai" | "anthropic" | "google";

const PROVIDER_NAMES: Record<SingleProvider, string> = { openai: "OpenAI", anthropic: "Anthropic", google: "Google" };

export const ENSEMBLE_NOTICE =
  "Your request text is sent to OpenAI, Anthropic, Google and TypeSafe to interpret it and to check the result. Do not include confidential data. The design is made in your browser.";

export function privacyNotice(mode: TranslateMode, provider: SingleProvider): string {
  if (mode === "ensemble") return ENSEMBLE_NOTICE;
  return `Your request text is sent to ${PROVIDER_NAMES[provider]} to interpret it. Do not include confidential data. The design is made in your browser.`;
}

export function currentPrivacyNotice(env: Record<string, string | undefined> = process.env): string {
  const provider = env.TRANSLATE_SINGLE_PROVIDER;
  if (env.TRANSLATE_MODE === "single" && (provider === "openai" || provider === "anthropic" || provider === "google")) {
    return privacyNotice("single", provider);
  }
  return ENSEMBLE_NOTICE;
}

components/shell/SkipLink.tsx:

export function SkipLink() {
  return (
    <a
      href="#main"
      className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:bg-white focus:px-3 focus:py-2"
    >
      Skip to main content
    </a>
  );
}

components/shell/SiteHeader.tsx:

import { APP_NAME } from "@/lib/site";

// Header (spec 9.1 and 9.8): both logos with equal weight, the name and the current project.
// The logos go to /?home, which always shows the landing page (spec 9.2).
export function SiteHeader({ project }: { project: string | null }) {
  return (
    <header className="border-b border-tan-medium bg-white">
      <div className="mx-auto flex max-w-7xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3">
        <a href="/?home" className="flex items-center gap-4" aria-label={`${APP_NAME}: home`}>
          <img src="/brand/tecnologico-de-monterrey-blue.svg" alt="Tecnológico de Monterrey" className="h-10 w-auto" />
          <img src="/brand/miami-university-horizontal.png" alt="Miami University" className="h-10 w-auto" />
        </a>
        <p className="font-display text-xl font-semibold">{APP_NAME}</p>
        {project ? (
          <p className="text-sm text-tan-dark md:ml-auto">
            Project: <span className="text-ink">{project}</span>
          </p>
        ) : null}
      </div>
    </header>
  );
}

components/shell/EngineStatus.tsx:

"use client";

import { getEngine } from "@/lib/webr/engine";
import { engineStatusText } from "@/lib/webr/status-text";
import { useEngineStatus, useNow } from "@/lib/webr/use-engine";

// The R engine status in the footer (spec 7.9). The visible text has the percent and the bytes,
// or the elapsed time of a run; the live region announces only changes of state. During a run,
// a progress bar with no value shows activity, because FrF2 does not report its progress (P15).
// Cancel appears at the soft limit. Retry appears after a failure or a stall.
export function EngineStatus() {
  const status = useEngineStatus();
  const now = useNow(status.state === "running");
  const text = engineStatusText(status, now);
  const showRetry = status.state === "failed" || (status.state === "loading" && status.stalled);
  return (
    <div className="flex flex-wrap items-center gap-3">
      <p aria-hidden="true" data-testid="engine-status">{text.visible}</p>
      {status.state === "loading" && status.total > 0 ? (
        <progress className="h-2 w-32" value={status.loaded} max={status.total} aria-label="R engine download" />
      ) : null}
      {/* With reduced motion the animated bar is hidden; the text still shows the elapsed time. */}
      {status.state === "running" ? <progress className="h-2 w-32 motion-reduce:hidden" aria-label="Computing the design" /> : null}
      {status.state === "running" && status.pastSoftLimit ? (
        <button type="button" className="min-h-6 underline" onClick={() => getEngine().cancel()}>
          Cancel
        </button>
      ) : null}
      {showRetry ? (
        <button type="button" className="min-h-6 underline" onClick={() => getEngine().retry()}>
          Retry
        </button>
      ) : null}
      <p role="status" className="sr-only">{text.announced}</p>
    </div>
  );
}

components/shell/SiteFooter.tsx:

import { APP_VERSION } from "@/lib/site";
import { EngineStatus } from "./EngineStatus";

// Footer (spec 9.1). Glossary, Architecture and Paper come when those pages exist (P9).
export function SiteFooter() {
  return (
    <footer className="mt-auto border-t border-tan-medium bg-warm-white">
      <div className="mx-auto flex max-w-7xl flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3 text-sm">
        <nav aria-label="Footer">
          <a href="/?home" className="underline">Home</a>
        </nav>
        <p>Version {APP_VERSION}</p>
        <EngineStatus />
      </div>
    </footer>
  );
}

components/shell/StepRail.tsx:

"use client";

import { STEPS } from "@/lib/steps";

export function StepRail({ current, onSelect }: { current: number; onSelect: (n: number) => void }) {
  return (
    <nav aria-label="Steps" className="mb-6">
      <ol className="flex flex-wrap gap-2">
        {STEPS.map((s) => (
          <li key={s.n}>
            {s.phase2 ? (
              <span aria-disabled="true" className="inline-block rounded border border-tan-medium px-3 py-2 text-tan-dark">
                {s.n} {s.title} <span className="text-xs">(Phase 2)</span>
              </span>
            ) : (
              <button
                type="button"
                onClick={() => onSelect(s.n)}
                aria-current={s.n === current ? "step" : undefined}
                className={`rounded border px-3 py-2 ${s.n === current ? "border-miami-red bg-miami-red text-white" : "border-tan-medium bg-white hover:bg-tan-light"}`}
              >
                {s.n} {s.title}
              </button>
            )}
          </li>
        ))}
      </ol>
    </nav>
  );
}

components/shell/StepPanel.tsx:

"use client";

import { useEffect, useRef } from "react";
import { STEPS } from "@/lib/steps";

// The content of a step. Steps 1, 2, 3 and 6 come in Milestone 3 (P14). Focus moves to the step
// heading after each step change (spec 9.10), not on the first load.
export function StepPanel({ step, hasProject }: { step: number; hasProject: boolean }) {
  const heading = useRef<HTMLHeadingElement>(null);
  const shown = useRef(step);
  useEffect(() => {
    if (shown.current === step) return;
    shown.current = step;
    heading.current?.focus();
  }, [step]);
  const current = STEPS.find((s) => s.n === step) ?? STEPS[0];
  return (
    <section aria-labelledby="step-heading">
      <h2 id="step-heading" ref={heading} tabIndex={-1} className="font-display text-2xl">
        {current.n} {current.title}
      </h2>
      {hasProject ? null : <p className="mt-3">Select a project, or select "New design".</p>}
      <p className="mt-3 text-tan-dark">This step is not available in this version.</p>
    </section>
  );
}

components/shell/ProjectSidebar.tsx:

"use client";

import { useState } from "react";
import { designSummary, formatBytes, formatDate } from "@/lib/projects/format";
import type { Project } from "@/lib/projects/schema";

interface Props {
  projects: Project[];
  currentId: string | null;
  persistent: boolean | null;
  error: string | null;
  sizeBytes: number;
  onNew: () => void;
  onOpen: (id: string) => void;
  onClear: () => void;
}

// The sidebar (spec 8.2 and 9.1): "New design", the projects on this device and the storage use.
// "New design" is disabled until the store is open (persistent is null before). "Clear" asks
// for confirmation in the page. At narrow widths the sidebar is a menu.
// "Export all" and "Import" come with the exports in Milestone 3.
export function ProjectSidebar({ projects, currentId, persistent, error, sizeBytes, onNew, onOpen, onClear }: Props) {
  const [open, setOpen] = useState(false);
  const [confirming, setConfirming] = useState(false);
  const n = projects.length;
  return (
    <aside aria-label="Projects" className="md:w-72 md:shrink-0">
      <button
        type="button"
        className="mb-3 rounded border border-tan-medium px-3 py-2 md:hidden"
        aria-expanded={open}
        aria-controls="project-panel"
        onClick={() => setOpen(!open)}
      >
        Projects
      </button>
      <div id="project-panel" className={`${open ? "block" : "hidden"} md:block`}>
        <button
          type="button"
          onClick={onNew}
          disabled={persistent === null}
          className="w-full rounded bg-miami-red px-3 py-2 font-semibold text-white hover:bg-accent-red disabled:opacity-60"
        >
          New design
        </button>
        <h2 className="mt-6 font-display text-lg">Your projects</h2>
        <p className="text-sm text-tan-dark">On this device only.</p>
        {persistent === false ? (
          <p className="mt-2 rounded border border-tan-medium bg-tan-light p-2 text-sm">
            This browser does not allow storage. Projects last only for this session.
          </p>
        ) : null}
        {error ? (
          <p role="status" className="mt-2 rounded border border-miami-red p-2 text-sm">
            {error}
          </p>
        ) : null}
        {n === 0 ? (
          <p className="mt-3 text-sm">No projects yet.</p>
        ) : (
          <ul className="mt-3 space-y-1">
            {projects.map((p) => (
              <li key={p.id}>
                <button
                  type="button"
                  onClick={() => onOpen(p.id)}
                  aria-current={p.id === currentId ? "page" : undefined}
                  className={`w-full rounded px-2 py-1 text-left ${p.id === currentId ? "bg-tan-light font-semibold" : "hover:bg-tan-light"}`}
                >
                  <span className="block">{p.name}</span>
                  <span className="block text-xs text-tan-dark">{designSummary(p)} · {formatDate(p.created_at)}</span>
                </button>
              </li>
            ))}
          </ul>
        )}
        <h2 className="mt-6 font-display text-lg">Storage</h2>
        <p className="text-sm">{n} {n === 1 ? "project" : "projects"} · {formatBytes(sizeBytes)}</p>
        {confirming ? (
          <div role="group" aria-labelledby="clear-question" className="mt-2 rounded border border-miami-red p-2">
            <p id="clear-question" className="text-sm">
              {n === 1 ? "Delete the project on this device?" : `Delete the ${n} projects on this device?`} This cannot be undone.
            </p>
            <div className="mt-2 flex gap-2">
              <button type="button" className="rounded bg-miami-red px-3 py-1 text-white" onClick={() => { setConfirming(false); onClear(); }}>
                Delete all
              </button>
              <button type="button" autoFocus className="rounded border border-tan-medium px-3 py-1" onClick={() => setConfirming(false)}>
                Keep projects
              </button>
            </div>
          </div>
        ) : (
          <button type="button" className="mt-2 min-h-6 text-sm underline disabled:text-tan-dark" onClick={() => setConfirming(true)} disabled={n === 0}>
            Clear
          </button>
        )}
      </div>
    </aside>
  );
}

components/shell/Workspace.tsx:

"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { projectsSize } from "@/lib/projects/format";
import { writeLastPage } from "@/lib/projects/pointer";
import { newProject, type Project } from "@/lib/projects/schema";
import { openProjectStore, type ProjectStore } from "@/lib/projects/store";
import { stepFromQuery } from "@/lib/steps";
import { ProjectSidebar } from "./ProjectSidebar";
import { SiteFooter } from "./SiteFooter";
import { SiteHeader } from "./SiteHeader";
import { SkipLink } from "./SkipLink";
import { StepPanel } from "./StepPanel";
import { StepRail } from "./StepRail";

// A store call that fails after the store opened (for example a full disk) shows this text
// in the sidebar (spec 8.2: no silent failure).
const STORE_ERROR = "The project could not be stored on this device.";

// The workspace (spec 9.1): header, sidebar, step rail, the current step and the footer.
// The address holds the state: /app?project=<id>&step=<n>.
export function Workspace() {
  const params = useSearchParams();
  const router = useRouter();
  const step = stepFromQuery(params.get("step"));
  const projectId = params.get("project");
  const [store, setStore] = useState<ProjectStore | null>(null);
  const [projects, setProjects] = useState<Project[]>([]);
  const [storeError, setStoreError] = useState<string | null>(null);

  useEffect(() => {
    let live = true;
    openProjectStore()
      .then(async (opened) => {
        const list = await opened.list();
        if (live) {
          setStore(opened);
          setProjects(list);
        }
      })
      .catch(() => {
        if (live) setStoreError(STORE_ERROR);
      });
    return () => {
      live = false;
    };
  }, []);

  // The pointer for the next visit to / (spec 9.2).
  useEffect(() => {
    writeLastPage(`/app${window.location.search}`);
  }, [params]);

  const go = useCallback(
    (project: string | null, nextStep: number) => {
      const query = new URLSearchParams();
      if (project) query.set("project", project);
      query.set("step", String(nextStep));
      router.push(`/app?${query.toString()}`);
    },
    [router],
  );

  async function newDesign() {
    if (!store) return;
    const project = newProject(new Date(), crypto.randomUUID(), Intl.DateTimeFormat().resolvedOptions().timeZone);
    try {
      await store.put(project);
      setProjects(await store.list());
      setStoreError(null);
    } catch {
      setStoreError(STORE_ERROR);
      return;
    }
    go(project.id, 1);
  }

  async function clearAll() {
    if (!store) return;
    try {
      await store.clear();
      setProjects([]);
      setStoreError(null);
    } catch {
      setStoreError(STORE_ERROR);
      return;
    }
    go(null, 1);
  }

  const current = projects.find((p) => p.id === projectId) ?? null;
  return (
    <div className="flex min-h-screen flex-col">
      <SkipLink />
      <SiteHeader project={current?.name ?? null} />
      <div className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-8 px-4 py-6 md:flex-row">
        <ProjectSidebar
          projects={projects}
          currentId={current?.id ?? null}
          persistent={store ? store.persistent : null}
          error={storeError}
          sizeBytes={projectsSize(projects)}
          onNew={newDesign}
          onOpen={(id) => go(id, 1)}
          onClear={clearAll}
        />
        <main id="main" tabIndex={-1} className="min-w-0 flex-1">
          <h1 className="sr-only">Workspace</h1>
          <StepRail current={step} onSelect={(n) => go(current?.id ?? null, n)} />
          <StepPanel step={step} hasProject={current !== null} />
        </main>
      </div>
      <SiteFooter />
    </div>
  );
}

app/app/page.tsx:

import type { Metadata } from "next";
import { Suspense } from "react";
import { Workspace } from "@/components/shell/Workspace";

export const metadata: Metadata = { title: "Workspace" };

export default function WorkspacePage() {
  // useSearchParams() needs a Suspense boundary in a static page.
  return (
    <Suspense fallback={null}>
      <Workspace />
    </Suspense>
  );
}

app/page.tsx (replace the temporary page):

import Link from "next/link";
import { SiteFooter } from "@/components/shell/SiteFooter";
import { SiteHeader } from "@/components/shell/SiteHeader";
import { SkipLink } from "@/components/shell/SkipLink";
import { currentPrivacyNotice } from "@/lib/ops/privacy";
import { APP_NAME, CITATION, MAINTAINERS } from "@/lib/site";

// The landing page (spec 9.2). The introduction video comes in Milestone 4 (P9). "Start a
// design" is a client-side link, so the R engine that the footer started keeps loading (P13).
export default function LandingPage() {
  return (
    <div className="flex min-h-screen flex-col">
      <SkipLink />
      <SiteHeader project={null} />
      <main id="main" tabIndex={-1} className="mx-auto w-full max-w-3xl flex-1 px-4 py-10">
        <h1 className="font-display text-4xl">{APP_NAME}</h1>
        <p className="mt-4 text-lg">
          This tool makes two-level fractional factorial designs from a request in plain language. A language model
          translates the request into a specification, and the user confirms the specification. The R package FrF2 then
          makes the design in the browser, and the tool reports the resolution and the word length pattern.
        </p>
        <p className="mt-6">
          <Link href="/app" className="inline-block rounded bg-miami-red px-5 py-3 font-semibold text-white hover:bg-accent-red">
            Start a design
          </Link>
        </p>
        <section aria-labelledby="maintained-by" className="mt-12">
          <h2 id="maintained-by" className="font-display text-2xl">Maintained by</h2>
          <ul className="mt-3 space-y-1">
            {MAINTAINERS.map((m) => (
              <li key={m.name}>
                <a href={m.url} className="underline">{m.name}</a>, {m.affiliation}
              </li>
            ))}
          </ul>
        </section>
        <section aria-labelledby="privacy" className="mt-10">
          <h2 id="privacy" className="font-display text-2xl">Privacy</h2>
          <p className="mt-3">{currentPrivacyNotice()}</p>
          <p className="mt-2">Projects are stored in this browser only. The tool has no accounts and no server storage.</p>
        </section>
        <section aria-labelledby="citation" className="mt-10">
          <h2 id="citation" className="font-display text-2xl">Citation</h2>
          <p className="mt-3">{CITATION}</p>
        </section>
      </main>
      <SiteFooter />
    </div>
  );
}

Run: npx vitest run --project unit Expected: all unit tests pass.

Run: npm run build Expected: the prebuild setup prints "Runtimes ready.", then the build compiles routes /, /_not-found and /app.

Run: npm install --save-exact --save-dev @playwright/test@1.63.0, then npx playwright install chromium.

In package.json, add the script "test:e2e": "playwright test". In scripts/check.mjs, add this step last:

  { name: "Browser tests", cmd: "npx", args: ["playwright", "test"], browser: true },

playwright.config.ts:

import { defineConfig, devices } from "@playwright/test";

// Browser tests in Chromium (spec Sections 1.7 and 11) against the production build.
// "next start" warns about output: standalone but serves the app (measured).
// The test build sets two variables: the engine hook of lib/webr/engine.ts, and a 3-second
// soft limit, so the Cancel test does not wait for the measured limit. Run "npm run build"
// again before a deployment.
export default defineConfig({
  testDir: "replication_materials/tests/e2e",
  timeout: 300_000,
  expect: { timeout: 15_000 },
  fullyParallel: false,
  workers: 1,
  reporter: [["list"]],
  use: { baseURL: "http://localhost:3100", trace: "retain-on-failure" },
  webServer: {
    command: "npm run build && npm run start -- -p 3100",
    url: "http://localhost:3100/",
    reuseExistingServer: false,
    timeout: 600_000,
    env: { NEXT_PUBLIC_FFD_TEST_HOOKS: "1", NEXT_PUBLIC_R_SOFT_TIMEOUT_MS: "3000" },
  },
  projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});

replication_materials/tests/e2e/shell.spec.ts:

import { expect, test } from "@playwright/test";
import { ENSEMBLE_NOTICE } from "@/lib/ops/privacy";

// Not imported from lib/site: it imports package.json, which Playwright cannot load without
// an import attribute (Global Constraints). site.test.ts checks the same value.
const APP_NAME = "Fractional Factorial Design Translator";

test("the first visit shows the landing page", async ({ page }) => {
  await page.goto("/");
  await expect(page).toHaveTitle(APP_NAME);
  await expect(page.getByRole("heading", { level: 1, name: APP_NAME })).toBeVisible();
  for (const name of ["Maintained by", "Privacy", "Citation"]) {
    await expect(page.getByRole("heading", { level: 2, name })).toBeVisible();
  }
  await expect(page.getByText(ENSEMBLE_NOTICE)).toBeVisible();
  await expect(page.getByRole("link", { name: "Fadel M. Megahed" })).toBeVisible();
  await expect(page.getByRole("link", { name: "Alan R. Vazquez" })).toBeVisible();
});

test("Start a design opens the workspace", async ({ page }) => {
  await page.goto("/");
  await page.getByRole("link", { name: "Start a design" }).click();
  await expect(page).toHaveURL(/\/app$/);
  await expect(page).toHaveTitle(`Workspace | ${APP_NAME}`);
  await expect(page.getByRole("navigation", { name: "Steps" })).toBeVisible();
});

test("a later visit to / opens the last page; /?home shows the landing page", async ({ page }) => {
  await page.goto("/app?step=2");
  await expect(page.getByRole("heading", { level: 2, name: "2 Confirm" })).toBeVisible();
  await page.goto("/");
  await expect(page).toHaveURL(/\/app\?step=2$/);
  await page.goto("/?home");
  await expect(page.getByRole("heading", { level: 1, name: APP_NAME })).toBeVisible();
});

test("a new design is stored on the device", async ({ page }) => {
  await page.goto("/app");
  await page.getByRole("button", { name: "New design" }).click();
  await expect(page).toHaveURL(/\/app\?project=[0-9a-f-]{36}&step=1$/);
  await expect(page.getByRole("button", { name: /Untitled design/ })).toBeVisible();
  await page.reload();
  await expect(page.getByRole("button", { name: /Untitled design/ })).toBeVisible();
  await expect(page.getByText(/^1 project · /)).toBeVisible();
});

test("Clear asks in the page before it deletes the projects", async ({ page }) => {
  await page.goto("/app");
  await page.getByRole("button", { name: "New design" }).click();
  await page.getByRole("button", { name: "Clear" }).click();
  await expect(page.getByText("Delete the project on this device? This cannot be undone.")).toBeVisible();
  await expect(page.getByRole("button", { name: "Keep projects" })).toBeFocused();
  await page.getByRole("button", { name: "Keep projects" }).click();
  await expect(page.getByRole("button", { name: /Untitled design/ })).toBeVisible();
  await page.getByRole("button", { name: "Clear" }).click();
  await page.getByRole("button", { name: "Delete all" }).click();
  await expect(page.getByText("No projects yet.")).toBeVisible();
});

test("the step rail moves focus to the step heading; Phase 2 steps are not available", async ({ page }) => {
  await page.goto("/app");
  await page.getByRole("button", { name: "3 Design" }).click();
  await expect(page.getByRole("heading", { level: 2, name: "3 Design" })).toBeFocused();
  await expect(page.getByRole("button", { name: "3 Design" })).toHaveAttribute("aria-current", "step");
  for (const text of ["4 Responses (Phase 2)", "5 Analyze (Phase 2)"]) {
    await expect(page.getByText(text)).toHaveAttribute("aria-disabled", "true");
  }
});

test("the skip link moves to the main content", async ({ page }) => {
  await page.goto("/app");
  await page.keyboard.press("Tab");
  await expect(page.getByRole("link", { name: "Skip to main content" })).toBeFocused();
  await page.keyboard.press("Enter");
  await expect(page.locator("#main")).toBeFocused();
});

test("projects work without IndexedDB, with a notice (Review Focus 2)", async ({ page }) => {
  await page.addInitScript(() => {
    Object.defineProperty(window, "indexedDB", { get() { throw new Error("blocked"); } });
  });
  await page.goto("/app");
  await expect(page.getByText("This browser does not allow storage. Projects last only for this session.")).toBeVisible();
  await page.getByRole("button", { name: "New design" }).click();
  await expect(page.getByRole("button", { name: /Untitled design/ })).toBeVisible();
});

test("the app works without localStorage, and / shows the landing page (Review Focus 2)", async ({ page }) => {
  await page.addInitScript(() => {
    Object.defineProperty(window, "localStorage", { get() { throw new Error("blocked"); } });
  });
  await page.goto("/app?step=3");
  await expect(page.getByRole("heading", { level: 2, name: "3 Design" })).toBeVisible();
  await page.goto("/");
  await expect(page.getByRole("heading", { level: 1, name: APP_NAME })).toBeVisible();
});

replication_materials/tests/e2e/engine.spec.ts:

import { expect, test } from "@playwright/test";

test("the pages and the worker are cross-origin isolated (spec 7.9)", async ({ page }) => {
  for (const url of ["/", "/app"]) {
    await page.goto(url);
    expect(await page.evaluate(() => globalThis.crossOriginIsolated), url).toBe(true);
  }
  const workerCoep = await page.evaluate(async () => (await fetch("/workers/webr-worker.mjs")).headers.get("cross-origin-embedder-policy"));
  expect(workerCoep).toBe("require-corp");
});

test("the R engine loads and the footer says so", async ({ page }) => {
  await page.goto("/app");
  await expect(page.getByTestId("engine-status")).toHaveText("R engine: ready", { timeout: 240_000 });
  await expect(page.getByRole("status").filter({ hasText: "R engine" })).toHaveText("R engine: ready");
});

type TestEngine = { run(code: { designCode: string; reportCall: string }): Promise<{ ok: boolean; reason?: string }> };
type TestGlobals = { __ffdEngine: TestEngine; __ffdOutcome: Promise<{ ok: boolean; reason?: string }> };

test("a run shows the computing indicator, then Still running and Cancel at the soft limit (spec 7.9, P15)", async ({ page }) => {
  test.setTimeout(600_000);
  await page.goto("/app");
  const status = page.getByTestId("engine-status");
  await expect(status).toHaveText("R engine: ready", { timeout: 240_000 });
  // An R loop that does not end stands for a slow design. The test build has a 3-second soft limit.
  await page.evaluate(() => {
    const g = globalThis as unknown as TestGlobals;
    g.__ffdOutcome = g.__ffdEngine.run({ designCode: "repeat {}", reportCall: "NULL" });
  });
  await expect(status).toHaveText(/^R engine: computing the design \(0:0[0-2]\)$/);
  await expect(page.getByRole("progressbar", { name: "Computing the design" })).toBeVisible();
  await expect(page.getByRole("status").filter({ hasText: "R engine" })).toHaveText("R engine: computing the design");
  await expect(page.getByRole("button", { name: "Cancel" })).toHaveCount(0);
  await expect(status).toHaveText(/^R engine: still running \(\d+:\d\d\)$/, { timeout: 15_000 });
  await expect(page.getByRole("status").filter({ hasText: "R engine" })).toHaveText("R engine: still running. Cancel is available.");
  await page.getByRole("button", { name: "Cancel" }).click();
  expect(await page.evaluate(() => (globalThis as unknown as TestGlobals).__ffdOutcome)).toEqual({
    ok: false,
    reason: "cancelled",
    message: "The run was cancelled.",
  });
  // Cancel starts a new worker, which loads the R library from IndexedDB (P2).
  await expect(status).toHaveText("R engine: ready", { timeout: 120_000 });
  await expect(page.getByRole("progressbar")).toHaveCount(0);
});

test("an engine that cannot load says why and offers Retry (Review Focus 3)", async ({ page }) => {
  test.setTimeout(600_000);
  // The worker makes this request, so the route is on the browser context.
  await page.context().route("**/runtimes/repo/manifest.json", (route) => route.fulfill({ status: 404, body: "" }));
  await page.goto("/app");
  await expect(page.getByTestId("engine-status")).toHaveText(
    "R engine: not available. Could not load /runtimes/repo/manifest.json (HTTP 404).",
    { timeout: 120_000 },
  );
  await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
  await page.context().unroute("**/runtimes/repo/manifest.json");
  await page.getByRole("button", { name: "Retry" }).click();
  await expect(page.getByTestId("engine-status")).toHaveText("R engine: ready", { timeout: 240_000 });
});

Run: npx playwright test Expected: all tests pass. The first run builds the app (about 1 minute), and each engine test loads WebR in a fresh browser profile.

Run: npm run check Expected: every step passes, then "All checks passed."

git add app components lib/steps.ts lib/ops playwright.config.ts package.json package-lock.json scripts/check.mjs replication_materials/tests/unit replication_materials/tests/e2e
git commit -m "feat: add the landing page, the workspace shell and the browser tests"

16Task 10: The worker in Chromium (differential subset, cache, Cancel, failures)

Files:

  • Create: replication_materials/tests/e2e/helpers/worker-page.ts
  • Test: replication_materials/tests/e2e/worker.spec.ts

Interfaces:

  • Consumes: the worker (Task 7); generateCode() (Task 3); coatingSpec, coatingWith() and plainFactors() (fixtures); seedCases(), optionCases(), randomCases(), splitVersions(), firstDifference() and runNative() (Task 6).
  • Produces, from worker-page.ts (Task 11 uses them):
    • BASE_URL.
    • openBlank(page) opens an empty isolated page with no app engine.
    • startWorker(page, name): Promise<WorkerReady>, where WorkerReady is { type, cache?, versions?, message?, elapsedMs, progressTimes }.
    • runInWorker(page, name, code): Promise<WorkerResult>, where WorkerResult is { ok, code?, reportJson?, error?, elapsedMs }.
    • terminateWorker(page, name).

The tests drive the worker directly in a page, in the same way as the ChatISA live tests. The empty page has the isolation headers, so its worker behaves like the app's. It has no app engine of its own, so the measurements are clean.

replication_materials/tests/e2e/helpers/worker-page.ts:

import { expect, type Page } from "@playwright/test";

export const BASE_URL = "http://localhost:3100";
const BLANK = "/__worker-test";

export type WorkerReady = {
  type: "ready" | "init-error";
  cache?: "hit" | "stored" | "memory";
  versions?: Record<string, string>;
  message?: string;
  elapsedMs: number;
  progressTimes: number[];
};
export type WorkerResult = { ok: boolean; code?: string; reportJson?: string; error?: string; elapsedMs: number };
type DesignCode = { designCode: string; reportCall: string };

/** An empty page with the isolation headers of the app, so that its worker behaves as in the app. */
export async function openBlank(page: Page): Promise<void> {
  await page.route(`**${BLANK}`, (route) =>
    route.fulfill({
      status: 200,
      contentType: "text/html",
      headers: { "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp" },
      body: "<!doctype html><html lang=\"en\"><title>Worker test</title></html>",
    }),
  );
  await page.goto(`${BASE_URL}${BLANK}`);
  expect(await page.evaluate(() => globalThis.crossOriginIsolated)).toBe(true);
}

/** Starts the app's worker under a name and waits for "ready" or "init-error". */
export function startWorker(page: Page, name: string): Promise<WorkerReady> {
  return page.evaluate((workerName) => {
    const worker = new Worker("/workers/webr-worker.mjs", { type: "module" });
    (globalThis as unknown as Record<string, Worker>)[workerName] = worker;
    const t0 = performance.now();
    const progressTimes: number[] = [];
    return new Promise<WorkerReady>((resolve) => {
      worker.onmessage = (event) => {
        const data = event.data;
        if (data.type === "progress") progressTimes.push(performance.now() - t0);
        if (data.type === "ready" || data.type === "init-error") resolve({ ...data, elapsedMs: performance.now() - t0, progressTimes });
      };
      worker.postMessage({ type: "init" });
    });
  }, name);
}

/** Runs one design in the named worker. elapsedMs includes the message passing, as a user sees it. */
export function runInWorker(page: Page, name: string, code: DesignCode): Promise<WorkerResult> {
  return page.evaluate(
    ([workerName, designCode]) => {
      const worker = (globalThis as unknown as Record<string, Worker>)[workerName];
      const id = Math.floor(Math.random() * 1e9);
      const t0 = performance.now();
      return new Promise<WorkerResult>((resolve) => {
        worker.onmessage = (event) => {
          if (event.data.type === "result" && event.data.id === id) resolve({ ...event.data, elapsedMs: performance.now() - t0 });
        };
        worker.postMessage({ type: "run", id, ...designCode });
      });
    },
    [name, code] as const,
  );
}

export async function terminateWorker(page: Page, name: string): Promise<void> {
  await page.evaluate((workerName) => (globalThis as unknown as Record<string, Worker>)[workerName].terminate(), name);
}

replication_materials/tests/e2e/worker.spec.ts:

import { expect, test } from "@playwright/test";
import { generateCode } from "@/lib/codegen/frf2";
import { coatingSpec, coatingWith, plainFactors } from "../fixtures/specs";
import { optionCases, randomCases, seedCases } from "../webr/helpers/cases";
import { firstDifference, splitVersions } from "../webr/helpers/compare";
import { runNative } from "../webr/helpers/native-r";
import { openBlank, runInWorker, startWorker, terminateWorker } from "./helpers/worker-page";

test("the worker in Chromium gives the same reports as native R (spec 11)", async ({ page }) => {
  test.setTimeout(900_000);
  await openBlank(page);
  const ready = await startWorker(page, "w");
  expect(ready.type, ready.message).toBe("ready");
  expect(ready.versions).toMatchObject({ R: "4.6.0", webR: "0.6.0", FrF2: "2.3.5", "DoE.base": "1.2.5", report: "1.1.0" });
  const cases = [...seedCases(), ...optionCases().filter((_, i) => i % 4 === 0), ...randomCases(5)];
  const codes = cases.map((c) => generateCode(c.spec));
  const native = runNative(codes);
  for (const [i, c] of cases.entries()) {
    const result = await runInWorker(page, "w", codes[i]);
    expect(result.ok, `${c.id}: ${result.error}`).toBe(true);
    // The R code view shows exactly the code that ran (spec 7.1).
    expect(result.code, c.id).toBe(codes[i].designCode);
    expect(firstDifference(splitVersions(result.reportJson!).body, splitVersions(native[i]).body), c.id).toBeNull();
  }
});

test("a restarted worker loads the R library from IndexedDB without a download (Review Focus 4)", async ({ page }) => {
  await openBlank(page);
  const packageRequests: string[] = [];
  page.context().on("request", (request) => {
    if (request.url().endsWith(".tgz")) packageRequests.push(request.url());
  });
  const first = await startWorker(page, "w1");
  expect(first).toMatchObject({ type: "ready", cache: "stored" });
  expect(packageRequests).toHaveLength(29);
  await terminateWorker(page, "w1");
  const second = await startWorker(page, "w2");
  expect(second).toMatchObject({ type: "ready", cache: "hit" });
  expect(packageRequests).toHaveLength(29);
  expect(second.elapsedMs).toBeLessThan(first.elapsedMs);
  expect((await runInWorker(page, "w2", generateCode(coatingSpec))).ok).toBe(true);
});

test("Cancel: a worker stopped during a long design is replaced, and the new one runs designs (spec 7.9)", async ({ page }) => {
  await openBlank(page);
  expect((await startWorker(page, "w1")).type).toBe("ready");
  const long = generateCode(coatingWith({ nruns: 512, factors: plainFactors(160) }));
  await page.evaluate((code) => {
    (globalThis as unknown as Record<string, Worker>).w1.postMessage({ type: "run", id: 1, ...code });
  }, long);
  await page.waitForTimeout(500);
  await terminateWorker(page, "w1");
  const second = await startWorker(page, "w2");
  expect(second).toMatchObject({ type: "ready", cache: "hit" });
  expect((await runInWorker(page, "w2", generateCode(coatingSpec))).ok).toBe(true);
});

test("a package file that fails the SHA-256 check stops the engine with the reason (Review Focus 3)", async ({ page }) => {
  await openBlank(page);
  await page.context().route("**/runtimes/repo/*.tgz", (route) =>
    route.fulfill({ status: 200, contentType: "application/x-tar", body: Buffer.from("not an R package") }),
  );
  const ready = await startWorker(page, "w");
  expect(ready.type).toBe("init-error");
  expect(ready.message).toMatch(/^The package file .+\.tgz does not match the manifest\.$/);
});

test("two workers in one page share the library and both run designs (Review Focus 4)", async ({ page }) => {
  await openBlank(page);
  const [a, b] = await Promise.all([startWorker(page, "wa"), startWorker(page, "wb")]);
  expect(a.type, a.message).toBe("ready");
  expect(b.type, b.message).toBe("ready");
  for (const name of ["wa", "wb"]) {
    expect((await runInWorker(page, name, generateCode(coatingSpec))).ok, name).toBe(true);
  }
});

Run: npx playwright test worker Expected: all five tests pass. This test checks code from Task 7, so it can pass at once. A failure here is a defect of the worker: fix the worker or the engine core, and report the cause. The request listener depends on Playwright reporting worker requests on the context; if it does not, report it and use page.on("request") instead.

Run: npm run check Expected: "All checks passed."

git add replication_materials/tests/e2e
git commit -m "test: run the worker in Chromium against native R, with cache, Cancel and failure cases"

17Task 11: Performance tests, time budgets and supported limits

Files:

  • Create: replication_materials/tests/webr/helpers/perf-cases.ts, replication_materials/tests/webr/perf-node.test.ts, replication_materials/tests/webr/budget-smoke.test.ts, replication_materials/tests/e2e/perf.spec.ts, scripts/derive-budgets.mjs, replication_materials/tests/unit/budgets.test.ts
  • Modify: replication_materials/tests/webr/helpers/csv.ts (add readCsv)
  • Generated and committed: replication_materials/tests/perf/m2-perf-node.csv, m2-perf-node-cold.csv, m2-perf-chromium-cold.csv, m2-perf-chromium-warm.csv; config/runtime-budgets.json (now measured); data/supported-limits.json

Interfaces:

  • Consumes: startNodeEngine() (Task 5); catalogueCases(), writeCsv() and the DiffCase type (Task 6); openBlank(), startWorker(), runInWorker() and terminateWorker() (Task 10).
  • Produces:
    • perfCandidates(): DiffCase[] (replication_materials/tests/webr/helpers/perf-cases.ts).
    • readCsv(name) returns the rows of a CSV in replication_materials/tests/perf/ as records of strings.
    • From scripts/derive-budgets.mjs: CEILING_MS, MIN_NODE_MS_FOR_RATIO and deriveBudgets({ node, cold, warm, today }): { budgets, limits }.
    • config/runtime-budgets.json is { status: "measured", measured_on, rule, sources, soft_timeout_ms, hard_timeout_ms, stall_ms, chromium_p99_warm_ms, max_progress_gap_ms, chromium_node_ratio }.
    • data/supported-limits.json is { status: "measured", measured_on, ceiling_ms, max_runs, max_total_rows, max_full_factorial_factors, unsupported_sizes: [nruns, nfactors][] }. Milestone 3's pre-checks read it.
  • Rules (spec 7.9 and decision P8):
    • soft limit = 3 × the p99 warm time in Chromium, rounded up to whole seconds;
    • stall limit = 3 × the longest gap between progress messages in a Chromium start (cold, or from the cache), rounded up to whole seconds;
    • hard limit = 600,000 ms;
    • ratio = the largest Chromium/Node median ratio among the measured designs whose Node median is at least 500 ms (where message passing is small), or among all of them if none is that slow;
    • a candidate is supported if ratio × its Node maximum ≤ 120,000 ms.

Append to replication_materials/tests/webr/helpers/csv.ts:

import { readCsvFile } from "@/scripts/derive-budgets.mjs";

/** Reads replication_materials/tests/perf/<name> into records of strings (no quoted commas in these files). */
export function readCsv(name: string): Record<string, string>[] {
  return readCsvFile(path.join(ROOT, "replication_materials", "tests", "perf", name));
}

(Move the new import line to the top of the file, next to the other imports.)

replication_materials/tests/webr/helpers/perf-cases.ts:

import { plainFactors } from "../../fixtures/specs";
import { catalogueCases, make, type DiffCase } from "./cases";

/**
 * The candidates for the supported limits (P8): every usable catalogue size, the full
 * factorials of 2 to 12 factors (up to 4096 runs), and a ladder of total rows on 4096 x 26
 * (8192, 16384 and 32768 rows; 4096 rows is the catalogue case).
 */
export function perfCandidates(): DiffCase[] {
  const full = Array.from({ length: 11 }, (_, i) => i + 2).map((m) =>
    make(`full-${2 ** m}x${m}`, { nruns: 2 ** m, factors: plainFactors(m) }),
  );
  const ladder = [2, 4, 8].map((replications) =>
    make(`rows-4096x26-r${replications}`, { nruns: 4096, factors: plainFactors(26), replications }),
  );
  return [...catalogueCases(), ...full, ...ladder];
}

replication_materials/tests/webr/perf-node.test.ts:

import { describe, expect, it } from "vitest";
import { generateCode } from "@/lib/codegen/frf2";
import { writeCsv } from "./helpers/csv";
import { startNodeEngine } from "./helpers/node-engine";
import { perfCandidates } from "./helpers/perf-cases";

const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor((xs.length - 1) / 2)];

// Spec Section 11: cold and warm times for each task, in Node + WebR. Opt-in, because it runs
// every candidate three times (about 20 minutes).
describe.skipIf(process.env.FFD_PERF !== "1")("performance in Node WebR (FFD_PERF=1)", () => {
  it("measures the cold start and each candidate three times", async () => {
    const engine = await startNodeEngine();
    try {
      writeCsv("m2-perf-node-cold.csv", [{ cold_ms: Math.round(engine.coldMs) }]);
      const rows: Record<string, string | number | boolean>[] = [];
      for (const c of perfCandidates()) {
        const code = generateCode(c.spec);
        const times: number[] = [];
        let reportJson = "";
        for (let rep = 0; rep < 3; rep++) {
          const result = await engine.run(code);
          times.push(result.timings.designMs + result.timings.reportMs);
          reportJson = result.reportJson;
        }
        const report = JSON.parse(reportJson);
        expect(report.ok, `${c.id}: ${report.failed_checks.join(", ")}`).toBe(true);
        rows.push({
          id: c.id, nruns: report.design.nruns, nfactors: c.spec.factors.length, rows: report.design.nrows, ok: report.ok,
          warm_ms_median: Math.round(median(times)), warm_ms_max: Math.round(Math.max(...times)), json_bytes: reportJson.length,
        });
      }
      writeCsv("m2-perf-node.csv", rows);
    } finally {
      engine.close();
    }
  }, 7_200_000);
});

replication_materials/tests/e2e/perf.spec.ts:

import { expect, test } from "@playwright/test";
import { generateCode } from "@/lib/codegen/frf2";
import { readCsv, writeCsv } from "../webr/helpers/csv";
import { perfCandidates } from "../webr/helpers/perf-cases";
import { openBlank, runInWorker, startWorker, terminateWorker, type WorkerReady } from "./helpers/worker-page";

// Spec Section 11: cold and warm times in Chromium. Opt-in, and run after perf-node.test.ts,
// whose CSV selects the designs. Each cold start uses a fresh browser profile.

/** The longest time without a message during an engine start: start, progress..., ready. */
function maxProgressGap(start: WorkerReady): number {
  const marks = [0, ...start.progressTimes, start.elapsedMs];
  return Math.max(...marks.slice(1).map((t, i) => t - marks[i]));
}

test.describe("performance in Chromium (FFD_PERF=1)", () => {
  test.skip(process.env.FFD_PERF !== "1", "Set FFD_PERF=1 to run the performance tests.");

  test("cold start, start from the cache and warm runs", async ({ browser }) => {
    test.setTimeout(7_200_000);
    const cold: Record<string, string | number>[] = [];
    for (let trial = 1; trial <= 3; trial++) {
      const context = await browser.newContext();
      const page = await context.newPage();
      await openBlank(page);
      const first = await startWorker(page, "w");
      expect(first.type, first.message).toBe("ready");
      await terminateWorker(page, "w");
      // A start from the cache reads about 25 MB from IndexedDB before its first progress
      // message, so its gaps count for the stall limit too.
      const cached = await startWorker(page, "w2");
      cold.push({
        trial,
        cold_ms: Math.round(first.elapsedMs),
        max_progress_gap_ms: Math.round(maxProgressGap(first)),
        cache_start_ms: Math.round(cached.elapsedMs),
        cache_max_progress_gap_ms: Math.round(maxProgressGap(cached)),
        cache: cached.cache ?? "",
      });
      await context.close();
    }
    writeCsv("m2-perf-chromium-cold.csv", cold);

    // The designs: the 20 slowest in Node, 20 spread over the list, the row ladder and the coating size.
    const node = readCsv("m2-perf-node.csv");
    const slowest = [...node].sort((a, b) => Number(b.warm_ms_median) - Number(a.warm_ms_median)).slice(0, 20).map((r) => r.id);
    const step = Math.ceil(node.length / 20);
    const spread = node.filter((_, i) => i % step === 0).map((r) => r.id);
    const ids = [...new Set([...slowest, ...spread, "rows-4096x26-r2", "rows-4096x26-r4", "rows-4096x26-r8", "catalogue-16x7"])];
    const byId = new Map(perfCandidates().map((c) => [c.id, c]));
    const nodeMedian = new Map(node.map((r) => [r.id, r.warm_ms_median]));

    const context = await browser.newContext();
    const page = await context.newPage();
    await openBlank(page);
    expect((await startWorker(page, "w")).type).toBe("ready");
    const warm: Record<string, string | number>[] = [];
    for (const id of ids) {
      const code = generateCode(byId.get(id)!.spec);
      for (let rep = 1; rep <= 5; rep++) {
        const result = await runInWorker(page, "w", code);
        expect(result.ok, `${id}: ${result.error}`).toBe(true);
        warm.push({ id, rep, chromium_ms: Math.round(result.elapsedMs), node_median_ms: nodeMedian.get(id) ?? "" });
      }
    }
    writeCsv("m2-perf-chromium-warm.csv", warm);
    await context.close();
  });
});

scripts/derive-budgets.mjs:

// Derives the R time budgets and the supported limits from the performance CSVs (spec Sections
// 6.2 and 7.9; plan decision P8). Run after the performance tests: node scripts/derive-budgets.mjs
//   soft limit  = 3 x the p99 warm time in Chromium, rounded up to whole seconds;
//   stall limit = 3 x the longest gap between progress messages in a Chromium start (cold, or
//                 from the cache), rounded up;
//   hard limit  = 600000 ms (spec 7.9 default, 10 minutes);
//   ratio       = the largest Chromium/Node median ratio among the measured designs whose Node
//                 median is at least MIN_NODE_MS_FOR_RATIO (where message passing is small);
//   supported   = each candidate whose estimated Chromium time (ratio x Node maximum) is at most CEILING_MS.
import { readFileSync, writeFileSync } from "node:fs";
import { pathToFileURL } from "node:url";

/** Product decision P8: the longest warm run that a supported size may take. */
export const CEILING_MS = 120_000;
export const MIN_NODE_MS_FOR_RATIO = 500;
const HARD_TIMEOUT_MS = 600_000;
const PERF = "replication_materials/tests/perf";
const SOURCES = ["m2-perf-node.csv", "m2-perf-chromium-cold.csv", "m2-perf-chromium-warm.csv"].map((f) => `${PERF}/${f}`);

export function readCsvFile(file) {
  const [head, ...lines] = readFileSync(file, "utf8").trim().split("\n");
  const columns = head.split(",");
  return lines.map((line) => Object.fromEntries(line.split(",").map((value, i) => [columns[i], value])));
}

const sorted = (xs) => [...xs].sort((a, b) => a - b);
const p99 = (xs) => sorted(xs)[Math.ceil(0.99 * xs.length) - 1];
const median = (xs) => sorted(xs)[Math.floor((xs.length - 1) / 2)];
const upToSecond = (ms) => Math.ceil(ms / 1000) * 1000;

export function deriveBudgets({ node, cold, warm, today }) {
  const p99Warm = p99(warm.map((r) => Number(r.chromium_ms)));
  const maxGap = Math.max(...cold.flatMap((r) => [Number(r.max_progress_gap_ms), Number(r.cache_max_progress_gap_ms)]));
  const nodeMedian = new Map(node.map((r) => [r.id, Number(r.warm_ms_median)]));
  const ids = [...new Set(warm.map((r) => r.id))];
  const ratios = (list) => list.map((id) => median(warm.filter((r) => r.id === id).map((r) => Number(r.chromium_ms))) / nodeMedian.get(id));
  const slow = ids.filter((id) => nodeMedian.get(id) >= MIN_NODE_MS_FOR_RATIO);
  const ratio = Math.max(...ratios(slow.length > 0 ? slow : ids));
  const estimate = (r) => ratio * Number(r.warm_ms_max);
  const supported = (r) => r.ok === "true" && estimate(r) <= CEILING_MS;

  const budgets = {
    status: "measured",
    measured_on: today,
    rule: "soft = 3 x p99 Chromium warm time; stall = 3 x longest progress gap in a Chromium start, cold or from the cache; both rounded up to whole seconds; hard = spec default (10 minutes).",
    sources: SOURCES,
    soft_timeout_ms: upToSecond(3 * p99Warm),
    hard_timeout_ms: HARD_TIMEOUT_MS,
    stall_ms: upToSecond(3 * maxGap),
    chromium_p99_warm_ms: p99Warm,
    max_progress_gap_ms: maxGap,
    chromium_node_ratio: Number(ratio.toFixed(3)),
  };
  const ladder = node.filter((r) => r.id.startsWith("rows-") || r.id === "catalogue-4096x26");
  const limits = {
    status: "measured",
    measured_on: today,
    ceiling_ms: CEILING_MS,
    max_runs: 4096,
    max_total_rows: Math.max(4096, ...ladder.filter(supported).map((r) => Number(r.rows))),
    max_full_factorial_factors: Math.max(...node.filter((r) => r.id.startsWith("full-") && supported(r)).map((r) => Number(r.nfactors))),
    unsupported_sizes: node
      .filter((r) => (r.id.startsWith("catalogue-") || r.id.startsWith("full-")) && !supported(r))
      .map((r) => [Number(r.nruns), Number(r.nfactors)]),
  };
  return { budgets, limits };
}

export function readInputs() {
  return {
    node: readCsvFile(`${PERF}/m2-perf-node.csv`),
    cold: readCsvFile(`${PERF}/m2-perf-chromium-cold.csv`),
    warm: readCsvFile(`${PERF}/m2-perf-chromium-warm.csv`),
  };
}

if (import.meta.url === pathToFileURL(process.argv[1]).href) {
  const { budgets, limits } = deriveBudgets({ ...readInputs(), today: new Date().toISOString().slice(0, 10) });
  writeFileSync("config/runtime-budgets.json", `${JSON.stringify(budgets, null, 2)}\n`);
  writeFileSync("data/supported-limits.json", `${JSON.stringify(limits, null, 2)}\n`);
  console.log(`Soft limit ${budgets.soft_timeout_ms} ms, stall limit ${budgets.stall_ms} ms, Chromium/Node ratio ${budgets.chromium_node_ratio}.`);
  console.log(`Supported: at most ${limits.max_total_rows} rows; full factorials up to ${limits.max_full_factorial_factors} factors; ${limits.unsupported_sizes.length} catalogue sizes left out.`);
}

replication_materials/tests/unit/budgets.test.ts:

import { describe, expect, it } from "vitest";
import budgets from "@/config/runtime-budgets.json";
import limits from "@/data/supported-limits.json";
import { deriveBudgets, readInputs } from "@/scripts/derive-budgets.mjs";

describe("R time budgets and supported limits (spec 7.9, P8)", () => {
  it("are measured, not placeholders", () => {
    expect(budgets.status).toBe("measured");
    expect(limits.status).toBe("measured");
  });
  it("follow the rules of the spec", () => {
    expect(budgets.soft_timeout_ms).toBeGreaterThanOrEqual(3 * budgets.chromium_p99_warm_ms);
    expect(budgets.stall_ms).toBeGreaterThanOrEqual(3 * budgets.max_progress_gap_ms);
    expect(budgets.hard_timeout_ms).toBe(600_000);
    expect(budgets.soft_timeout_ms).toBeLessThan(budgets.hard_timeout_ms);
  });
  it("equal what the committed CSVs give", () => {
    const derived = deriveBudgets({ ...readInputs(), today: budgets.measured_on });
    expect(derived.budgets).toEqual(budgets);
    expect(derived.limits).toEqual(limits);
  });
});

replication_materials/tests/webr/budget-smoke.test.ts:

import { describe, expect, it } from "vitest";
import budgets from "@/config/runtime-budgets.json";
import limits from "@/data/supported-limits.json";
import { generateCode } from "@/lib/codegen/frf2";
import { readCsv } from "./helpers/csv";
import { startNodeEngine } from "./helpers/node-engine";
import { perfCandidates } from "./helpers/perf-cases";

// Spec Section 11: "CI fails if a supported task is over its budget". The three slowest
// supported designs of the last Node sweep run again; their estimated Chromium time must stay
// under the soft limit.
describe("time budget", () => {
  it("the slowest supported designs stay under the soft limit", async () => {
    const unsupported = new Set((limits.unsupported_sizes as number[][]).map(([r, f]) => `${r}x${f}`));
    const slowest = readCsv("m2-perf-node.csv")
      .filter((r) => !r.id.startsWith("rows-") && !unsupported.has(`${r.nruns}x${r.nfactors}`))
      .sort((a, b) => Number(b.warm_ms_median) - Number(a.warm_ms_median))
      .slice(0, 3);
    const candidates = new Map(perfCandidates().map((c) => [c.id, c]));
    const engine = await startNodeEngine();
    try {
      for (const row of slowest) {
        const result = await engine.run(generateCode(candidates.get(row.id)!.spec));
        const ms = result.timings.designMs + result.timings.reportMs;
        expect(ms * budgets.chromium_node_ratio, row.id).toBeLessThanOrEqual(budgets.soft_timeout_ms);
      }
    } finally {
      engine.close();
    }
  });
});

Run: FFD_PERF=1 npx vitest run --project webr perf-node Expected: it passes, in about 20 to 40 minutes. m2-perf-node.csv has one row per candidate, and every ok is true. Report the cold time and the five slowest candidates.

Run: FFD_PERF=1 npx playwright test perf Expected: it passes. m2-perf-chromium-cold.csv has 3 rows, each with cache = hit. m2-perf-chromium-warm.csv has 5 rows per design. Report the cold times, the cache start times and the five slowest designs.

Run: node scripts/derive-budgets.mjs Expected: two summary lines. config/runtime-budgets.json and data/supported-limits.json have "status": "measured". Report both files in full in the task report: the authors review these values (P8).

Run: npx vitest run --project unit budgets, then npx vitest run --project webr budget-smoke Expected: both pass.

Run: npm run check Expected: "All checks passed." The browser tests now use the measured stall and hard limits. Their soft limit stays 3 seconds in the test build (playwright.config.ts).

git add replication_materials/tests scripts/derive-budgets.mjs config/runtime-budgets.json data/supported-limits.json
git commit -m "test: measure performance and set the R time budgets and supported limits"

18Task 12: Documentation, spec facts and the process record

Files:

  • Create: README.md
  • Modify: replication_materials/plans/2026-10-14-ff-design-translator-design.md, then rebuild replication_materials/plans/spec-page/ with node scripts/build-spec-page.mjs
  • Modify: replication_materials/process/process.json
# Fractional Factorial Design Translator

This web app makes two-level fractional factorial designs from a request in plain language. A language model translates the request into a typed specification, and the user confirms it (Milestone 3). The R package FrF2 then makes the design in the browser through WebR. The app computes the resolution and the word length pattern from the design, and it does not show a design that fails a check.

Maintained by Alan R. Vazquez (Tecnológico de Monterrey) and Fadel M. Megahed (Miami University).

The design specification is `replication_materials/plans/2026-10-14-ff-design-translator-design.md`. The implementation plans are in the same folder.

## Status

Milestone 2 of Phase 1 is complete: the R engine in the browser, the workspace shell and the project storage. The translation step and the design view come in Milestone 3.

## Requirements

- Node 22.14 or later, with npm 11.
- For the tests: R 4.6.1 with FrF2 2.3-5, DoE.base 1.2-5, jsonlite 2.0.0 and testthat 3.3.2. If R 4.6.1 is not at `C:/Program Files/R/R-4.6.1/bin/Rscript.exe`, set `FFD_RSCRIPT` to its Rscript.

## Set up

1. Install the npm packages: `npm ci`
2. Get the R runtime (WebR 0.6.0 and 29 R packages): `npm run setup:runtimes`
3. Install the test browser: `npx playwright install chromium`

Step 2 checks the size and the SHA-256 of each R package file against `data/runtime-manifest.json`. The files go to `public/runtimes/`, which is not in git.

## Run

- Development: `npm run dev`, then open http://localhost:3000.
- Production: `npm run build`. The build is a Next.js standalone build (spec Section 13.1).

## Test

- All layers: `npm run check`. Without the browser tests: `npm run check -- --fast`.
- One layer: `npm test` (unit), `npm run test:webr` (WebR in Node), `npm run test:r` (native R), `npm run test:e2e` (Chromium).
- The full differential set: `FFD_FULL_DIFFERENTIAL=1 npm run test:webr`.
- The full catalogue sweep in native R: `FFD_FULL_CATALOGUE=1 npm run test:r`.
- The performance tests: `FFD_PERF=1 npx vitest run --project webr perf-node`, then `FFD_PERF=1 npx playwright test perf`, then `node scripts/derive-budgets.mjs`.

All tests are in `replication_materials/tests/`. Performance results are in `replication_materials/tests/perf/`.

The browser tests build the app with a test hook and a 3-second soft limit (`playwright.config.ts`). Run `npm run build` again before a deployment.

## Layout

| Folder | Content |
|---|---|
| `app/`, `components/` | Pages and interface components |
| `lib/` | Spec validation, codegen, the engine client, projects |
| `public/workers/` | The R engine worker and the engine core |
| `r/` | The report helper `ffd_report.R` |
| `scripts/` | Runtime setup, catalogue builds, the check command |
| `data/`, `config/` | Generated data and measured budgets |
| `replication_materials/` | Plans, tests, findings, mockups and the process log |

In replication_materials/plans/2026-10-14-ff-design-translator-design.md:

  1. After the last bullet of Section 3.4 (the line that starts "- The current version on GitHub (main branch, 6.10.0)"), add a new subsection:

### 3.5 Facts about WebR 0.6.0 (measured on 2026-10-15)

- FrF2 and jsonlite need 29 packages from `repo.r-wasm.org` (25.3 MB). The repository has `.tgz` files only.
- The worker installs each package with `utils::untar(..., tar = "internal")`. The default method calls `system()`, which WebR does not have.
- FrF2 orders its alias chains with the collation of the R session. WebR uses `C`; native R on Windows uses `English_United States.utf8`. So the report helper sorts the chains by factor position (Section 7.5).
- `webR.interrupt()` stops R, but WebR 0.6.0 then does not answer. So Cancel stops the worker and starts a new one (Section 7.9).
- In Node on Windows, WebR 0.6.0 loads `R.js` with a `C:\` path, which Node refuses. The test copy of WebR has a one-line patch (`scripts/lib/runtime-manifest.mjs`). Browsers do not use that line.
- The report JSON of WebR and of native R 4.6.1 is byte-identical, except for the R version (`replication_materials/tests/perf/m2-differential.csv`).
  1. Section 7.5, the alias row: replace shown with the real factor names | with shown with the real factor names, in factor order (Section 3.5) |.

  2. Section 7.8: after item 9, add:

10. If the spec gives them, `repeat_only`, `randomize` and `seed` equal the settings that the design stores. The worker always gives them.
  1. Section 7.9:

    • Replace the bullet that starts "- One module worker." with: - **One module worker.** The page starts the worker when it opens ("prewarm"). The worker loads WebR, checks the size and the SHA-256 of each package file, installs the packages, loads the FrF2 namespace, and sources the report helper.
    • Replace the bullet "- IndexedDB cache. After the first visit, the packages load from the browser cache." with: - **IndexedDB cache.** The R library is kept in IndexedDB (IDBFS). After the first visit, and after a restart of the worker, it loads from there with no download.
    • In the "Headers." bullet, replace the text from "These headers let WebR use SharedArrayBuffer" to the end of the bullet with: These headers give WebR a SharedArrayBuffer. Cancel stops the worker and starts a new one, because WebR 0.6.0 does not answer after an interrupt (Section 3.5).
    • Replace the sub-bullet that starts "Design run: a soft limit shows" with: Design run: from the start of the run, the footer shows "R engine: computing the design (m:ss)" and an activity bar. At the soft limit, it shows "R engine: still running (m:ss)" and a Cancel button. A hard limit (default 10 minutes, set in config) stops a worker that does not respond. Milestone 3 shows the same indicator in step 3.
    • Replace "The values live in config." with: "The measured values are in config/runtime-budgets.json, and the supported limits in data/supported-limits.json."
  2. Section 13.2: after the row of NEXT_PUBLIC_R_SOFT_TIMEOUT_MS, add:

| `NEXT_PUBLIC_R_STALL_MS` | Time without progress before "Retry" appears during the engine start, from the performance tests. |
| `NEXT_PUBLIC_FFD_TEST_HOOKS` | Set to `1` only by the browser-test build. It gives the tests access to the R engine. |
  1. Section 15, "Known values": after the row | WebR | 0.6.0 (R 4.6.0) |, add:
| npm packages | next 16.3.8, react 19.3.0, zod 4.6.5, tailwindcss 4.3.3, typescript 5.9.3, vitest 4.1.11, @playwright/test 1.63.0, fake-indexeddb 6.2.5, webr 0.6.0 (exact pins in `package.json`) |
| WebR packages | 29 packages, pinned by SHA-256 in `data/runtime-manifest.json` |
  1. Section 17.1:
    • Replace the row | Supported limits for runs and factors. | Measured | Milestone 2 (performance tests) | with | Supported limits for runs and factors. | Measured | Done in Milestone 2: \data/supported-limits.json`. The ceiling is 120 seconds (plan decision P8, 2026-10-15). |`
    • Remove the row that starts | Fix the JavaScript shape of the report. Milestone 2 did it (ffd_report_json()).
    • Add three rows at the end of the table:
| The Content Security Policy header (Section 13.1). WebR needs `wasm-unsafe-eval` and worker sources, so the policy is set and tested with the deployment. | Fadel M. Megahed | Deployment (Milestone 4) |
| The link from the landing-page Privacy block to the privacy details (Section 13.3). The details page does not exist yet. | Fadel M. Megahed | Milestone 3 |
| One global announcer (Section 9.10). In Milestone 2 the engine status has its own polite live region; Milestone 3 moves it into the global announcer, and shows the computing indicator in step 3 too. | Fadel M. Megahed | Milestone 3 |

Run: node scripts/build-spec-page.mjs Expected: Built 19 sections, figures: process, architecture. pandoc 3.12.

Add two events to the events array of replication_materials/process/process.json, in the form of the existing events (id, time, stage, actors, title, detail, records, source). Get each time in UTC with TZ=UTC git log -1 --date=iso-strict-local --format=%cd -- <path> and write it as YYYY-MM-DDTHH:MM:SSZ. Each event has "source": { "transcript": "27ea0116", "time": <the same time> }.

id path for the time stage actors title detail records
e17 replication_materials/plans/2026-10-15-m2-browser-engine-plan.md plan people, claude, agents Implementation plan, Milestone 2 The writing-plans skill turns Section 18 into test-first tasks for the browser engine and the projects. Probes measure WebR 0.6.0 in Node and native R; they find the collation dependence of FrF2's alias order and an interrupt that leaves WebR unable to answer. F. M. Megahed approves the plan. plans/2026-10-15-m2-browser-engine-plan.md
e18 data/supported-limits.json build claude, agents, records Milestone 2: browser engine and projects Next.js shell, WebR worker with a SHA-256-pinned package mirror, codegen, project storage, and differential and performance tests. WebR and native R give byte-identical reports. tests/

The stage statuses stay as they are (build is now).

Run: node scripts/build-spec-page.mjs Expected: it builds without error.

Run: npm run check Expected: "All checks passed."

Run: FFD_FULL_DIFFERENTIAL=1 npx vitest run --project webr differential Expected: all cases pass.

git add README.md replication_materials/plans replication_materials/process
git commit -m "docs: record Milestone 2 facts, setup and process events"

The main session then republishes the spec page to the same private link.


19Verification (end to end)

  1. npm run check: Runtimes, Types, Unit tests, WebR tests, R tests and Browser tests all pass.
  2. FFD_FULL_DIFFERENTIAL=1 npm run test:webr: every case of the full set passes. m2-differential.csv has identical = true in every row.
  3. FFD_FULL_CATALOGUE=1 npm run test:r: the Milestone 1 sweep still passes with report version 1.1.0.
  4. node scripts/setup-runtimes.mjs --verify: "Runtimes verified."
  5. A manual look:
    • Run npm run dev and open http://localhost:3000.
    • The landing page shows the name, "Start a design", "Maintained by", "Privacy" and "Citation".
    • "Start a design" opens the workspace. The footer goes from "R engine: loading (n%)" to "R engine: ready".
    • "New design" adds a project, which is still there after a reload. A new visit to / opens the last page.

20After approval (main session)

  1. Copy the planning probes from the scratch folder to replication_materials/plans/m2-probes/ (scripts, the Next.js probe and the two timing CSVs).
  2. Build the plan page with scripts/build-plan-page.mjs (it writes a copy with numbered sections and calls scripts/build-spec-page.mjs), and publish artifact.html as a private page. Fadel M. Megahed asked for the page on 2026-10-15, to help replication.
  3. Commit the plan, the probes, the page and the page script: git add replication_materials/plans/2026-10-15-m2-browser-engine-plan.md replication_materials/plans/m2-probes replication_materials/plans/m2-plan-page scripts/build-plan-page.mjs scripts/build-spec-page.mjs git commit -m "docs: add the Milestone 2 plan, its planning probes and its page"
  4. Execute Tasks 1 to 12 subagent-driven (chosen on 2026-10-15).

21Execution recommendation

Subagent-driven, as in Milestone 1.

  • The tasks pass names across three languages. R (ffd_report_json, ffd_read_errata), the worker (engine-core.mjs) and TypeScript (EngineClient, ReportSchema, generateCode) all depend on each other's names.
  • Several tasks run code that cannot be checked from the plan alone: WebR in Node, IDBFS in Chromium, and Playwright routing for workers. A fresh reviewer after each task catches drift early.
  • Milestone 1 found plan defects during execution. The task loop handled them with rulings.