diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 004ff027..021e53a8 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -12,24 +12,23 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install JupyterLite dependencies - run: | - python -m pip install -r jupyterlite/requirements.txt - - name: Build JupyterLite site - run: | - jupyter lite build --contents jupyterlite/content --output-dir public/jupyter - name: Use Node.js 20.x uses: actions/setup-node@v4 with: node-version: "20.x" - - name: Patch JupyterLite service worker (COOP/COEP for the Notebook iframe) - run: node scripts/patch-jupyterlite-sw.mjs + # npm install must come before the JupyterLite build: `make jupyter` + # copies node_modules/lammps.js/dist into public/jupyter/lammps. + # (make jupyter also applies the COOP/COEP service-worker patch via + # scripts/jupyter_coi_patch.py, superseding main's standalone + # patch-jupyterlite-sw.mjs step.) - name: Install Packages run: npm install + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Build JupyterLite site + run: make jupyter - name: Run Typecheck run: npm run typecheck - name: Run Tests diff --git a/.gitignore b/.gitignore index 6084dc11..f3c730cb 100644 --- a/.gitignore +++ b/.gitignore @@ -2,11 +2,15 @@ node_modules cpp/lammps cpp/obj /dist -# JupyterLite build output (built in CI, or locally via `jupyter lite build`) +# JupyterLite build output (built in CI, or locally via `make jupyter`) /public/jupyter .jupyterlite.doit.db +/.venv-jupyterlite .DS_Store cpp/lammps.mjs cpp/lammps.wasm cpp/lammps.worker.js -build/ \ No newline at end of file +build/ +/.cache/ +/playwright-report/ +/test-results/ diff --git a/.prettierignore b/.prettierignore index 9986be0d..fac5e912 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,4 +5,15 @@ cpp jupyterlite public src/wasm -.github/dependabot.yml \ No newline at end of file +.github/dependabot.yml +# Build/tool output (CI runs `make jupyter` before prettier: the venv and +# the built site must never be formatted — formatting the 500 MB venv hung +# the CI prettier step for 35+ minutes) +.venv-jupyterlite +dist +pypi +test-results +playwright-report +.claude +# Separate service with its own tooling (ruff); never formatted from here +backend diff --git a/Makefile b/Makefile index 64734257..90c9cc1a 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,25 @@ frontend-lint: ## Run frontend linter (prettier) frontend-lint-fix: ## Fix frontend linting issues cd . && npx prettier --write . +## JUPYTER + +JUPYTER_VENV := .venv-jupyterlite + +jupyter: ## Build the JupyterLite site into public/jupyter (piplite wheels, lammps.js client, COI patch) + test -d node_modules/lammps.js/dist || { echo "error: node_modules/lammps.js/dist is missing — run 'make frontend-install' (npm install) first" >&2; exit 1; } + test -x $(JUPYTER_VENV)/bin/pip || python3 -m venv $(JUPYTER_VENV) + $(JUPYTER_VENV)/bin/pip install --quiet -r jupyterlite/requirements.txt build + rm -rf public/jupyter .jupyterlite.doit.db + # Wheels in /pypi/ (lammps-js, lammps-logfile) are auto-indexed + # into the piplite index because the lite dir is the repo root. + $(JUPYTER_VENV)/bin/jupyter lite build --contents jupyterlite/content --output-dir public/jupyter + # Notebooks import the engine from {site}/lammps/client.js; ship the whole + # built package so its relative imports (worker, wasm modules) resolve. + mkdir -p public/jupyter/lammps + cp -R node_modules/lammps.js/dist/. public/jupyter/lammps/ + # Cross-origin isolation on static hosting (ADR-002 §5). + $(JUPYTER_VENV)/bin/python scripts/jupyter_coi_patch.py public/jupyter + ## BACKEND backend-install: ## Install backend dependencies diff --git a/docs/adr/001-projects-runs-and-storage.md b/docs/adr/001-projects-runs-and-storage.md new file mode 100644 index 00000000..9fdf2f29 --- /dev/null +++ b/docs/adr/001-projects-runs-and-storage.md @@ -0,0 +1,432 @@ +# ADR-001: Projects, runs, and a single project filesystem + +- **Status**: Accepted (2026-07-11) +- **Date**: 2026-07-11 +- **Deciders**: Anders Hafreager, Claude + +## Context + +Today a "simulation" in Atomify is a single in-memory object +(`Simulation` in `src/store/simulation.ts`): + +```ts +export interface Simulation { + id: string; + files: SimulationFile[]; + inputScript: string; + analysisDescription?: string; + analysisScript?: string; + start: boolean; + vars?: Record; + showSimulationBox?: boolean; + showWalls?: boolean; +} +``` + +Its problems, in order of user pain: + +1. **Nothing persists.** The New Simulation modal literally warns + *"these files are not stored across Atomify sessions yet"* + (`src/containers/NewSimulation.tsx`). Closing the tab loses all edits. +2. **There is no history.** Every run overwrites the previous one's outputs. + Comparing runs — the single most common workflow in MD work — requires + manually copying files out of the browser. +3. **One simulation at a time, keyed by a collision-prone `id`** that + doubles as the WASM FS directory and the JupyterLite folder name. +4. **Edits bypass the store.** Monaco mutates `file.content` in place + (`src/containers/Edit.tsx`), invisible to easy-peasy/immer — no reliable + "content changed" signal exists to hook persistence onto. +5. **The notebook integration is one-way and stale.** `syncFilesJupyterLite` + was written when the WASM FS lived on the main thread. Since the worker + migration, the main thread's `getWasm().FS` is a **write-only bridge** + (a JS `Map` mirroring only files the main thread itself wrote — + `src/wasm/LammpsWorkerProxy.ts:300-362`), and the worker protocol has no + file-read command (`src/wasm/workerMessages.ts`). So today's "sync" only + round-trips input files; **run outputs never reach the notebook at all.** + +The goal is a product-grade story: a **library of projects** that persists +across sessions, where each project accumulates **runs** with full +provenance, and where the Jupyter notebook operates on the *same files* the +app does (ADR-002). The user-facing noun is **project** (per the design, +ADR-003); "simulation" describes what a run executes. + +## Decision + +### 1. Domain model: Project = working tree + runs + +A **project** is a named directory containing the user's *source files* +(input scripts, data files, notebooks) — the working tree — plus a `runs/` +directory that accumulates immutable **run records**. + +A **run** is created every time a simulation is executed — from the app's +Run button, from a parameter sweep (§7), or from Python in the notebook +(ADR-002). It contains a snapshot of the source files as they were at +launch, all outputs the run produced, and a metadata record. Runs are +append-only history; the working tree is the only thing the user edits. + +Each project has **at most one designated input script** +(`project.json.inputScript`). It is set automatically when unambiguous +(project created from an example; or exactly one `in.*`/`*.in` file in the +working tree) and is otherwise unset. **Run behavior when unset**: the Run +button prompts the user to choose from the project's candidate scripts and +persists the choice (ADR-003 §5). Any file can also be run directly +("Run this file") without changing the default. + +### 2. Canonical filesystem layout + +The layout is storage-agnostic — the same tree works in IndexedDB today and +on a server filesystem behind an API later: + +``` +diffusion/ # project directory (dirName = identity) + .atomify/ + project.json # project metadata + in.diffusion # working tree: source files + data.lammps + analysis.ipynb # project notebook (ADR-002) + runs/ + run-001/ # run directory + .atomify/ + run.json # run metadata + frame.png # final viewport capture (ADR-003) + in.diffusion # snapshot of sources at launch + log.lammps # outputs + msd.dat + run-002/ + ... +``` + +`project.json`: + +```jsonc +{ + "schemaVersion": 1, + "displayName": "Diffusion coefficients", // free-form, renameable + "description": "LJ binary diffusion", + "createdAt": "2026-07-11T09:00:00Z", + "inputScript": "in.diffusion", // designated script; may be absent + "color": "#3F6EFF", // library identity dot (ADR-003) + "source": { "type": "example", "exampleId": "diffusion" } + // source.type: "blank" | "upload" | "example" | "shared" +} +``` + +`run.json`: + +```jsonc +{ + "schemaVersion": 1, + "id": "run-002", + "inputScript": "in.diffusion", + "vars": { "T": 3.0 }, // LAMMPS variables injected (§7) + "sweepId": "sweep-yj2k", // present when part of a sweep (§7) + "status": "completed", // running | completed | failed | canceled | interrupted + "startedAt": "2026-07-11T09:05:00Z", + "finishedAt": "2026-07-11T09:06:31Z", + "stats": { "timesteps": 5000, "numAtoms": 6750, "wallSeconds": 91.2 }, + "error": null, // LAMMPS error message when failed + "origin": "app" // app | sweep | notebook (ADR-002) +} +``` + +Naming rules: + +- **`dirName` is the project identity.** Slug of the display name at + creation (`"Diffusion coefficients"` → `diffusion-coefficients`), + alphabet `[a-z0-9-]`, non-empty (fallback `project`), unique-ified with a + numeric suffix. It is what the user sees in the Jupyter file browser, so + it must be human-readable. Renaming a project changes only `displayName`; + `dirName` is immutable so notebook paths never break. The rename dialog + and the workspace header show the dirName (mono font) so the mapping is + never a mystery. `%` and `/` are rejected in all file names (JupyterLite's + drive `decodeURIComponent`s paths; `%` throws in its UI). +- **Run directories are `run-NNN`** (zero-padded sequence, per the design). + Allocation takes the max existing number + 1; if the allocated directory + already exists (concurrent creation from the notebook side), allocation + retries with the next number. The app **claims the directory by writing + its directory record before anything else**, and the notebook template + recommends non-numeric run names (`run-T3.0`), so simultaneous + allocation cannot silently merge two runs. Runs created by external + tools with other names are still listed (§5). + +**Snapshot contract** (explicit allow/deny — review finding): a run +snapshot copies the working tree **excluding** `runs/`, `.atomify/`, and +`*.ipynb`. Notebooks are analysis, not simulation input; `runs/` excluded +prevents recursive snapshot growth. When a file is byte-identical to the +same path in the previous run's snapshot, the copy skips re-reading and +re-encoding the working-tree body — but **the bytes are still duplicated +per run** (the contents schema stores full content per path-keyed record). +True dedup (content-addressed records behind the storage interface) is +future work; the size caps below are what actually bound quota today. +Files larger than a threshold (default 64 MB) are not snapshotted; the run +records their names + hashes in `run.json` so provenance gaps are explicit. + +### 3. The project filesystem IS the JupyterLite contents store + +There is **one** persistent filesystem, not two stores kept in sync. +Projects live in the IndexedDB database JupyterLite's contents manager +already uses (`"JupyterLite Storage"`, per `jupyter-lite.json`), in +JupyterLite's contents schema: one record per file/directory keyed by path, +value `{name, path, content, format, mimetype, type, last_modified, +created, size, writable}`. + +This single decision does the heavy lifting: "sync with the notebook" stops +being a mechanism and becomes a non-event; files created by the pyodide +kernel are project files with zero extra code; and the future API backend +maps 1:1 onto Jupyter Server's contents REST API. + +**Facts we design around** (verified against `@jupyterlite/contents`' +`BrowserStorageDrive`, jupyterlite-core 0.8): + +- The DB has **three object stores**: `files`, `counters`, and + `checkpoints`. JupyterLite saves up to 5 full checkpoint copies of files + edited in its UI. Our `remove`/`rename`/`deleteProject` must clear + matching `checkpoints` entries (mirroring JupyterLite's own + `forgetPath`), or deleted projects leak quota and can be "restored" from + the checkpoint UI. +- Listing a directory in JupyterLite **iterates every record in the store, + materializing values**. Browsing cost therefore scales with total bytes + stored, which is another reason the size caps in §2/§8 exist. +- `get()` on a path with no record **throws**; every ancestor directory + needs an explicit directory record. `ProjectStorage.write()` auto-creates + missing parent directory records. +- Notebooks are stored as **parsed JSON** (`format: "json"`), binary files + as base64 strings, text as strings. The storage implementation owns the + extension→`{type, format, mimetype}` table and it must match JupyterLite's + file registry. +- Dot-directories (`.atomify/`) get records like any other path; JupyterLab's + file browser hides dotfiles by default (`showHiddenFiles` off), which is + exactly the behavior the layout wants. +- The kernel-side DriveFS proxy flushes writes on **file close** — a file + LAMMPS holds open all run (e.g. `log.lammps`) reaches the store at + completion, not incrementally (consequences in ADR-002 §4). + +**App-side listing strategy** (avoids the O(store bytes) trap): the app +never bulk-reads file bodies to render lists. `listProjects()` scans +**keys only** (`localforage.keys()`), finds top-level dirs, and reads just +their `.atomify/project.json` records. The Runs tab likewise scans keys +under `/runs/` and reads only `run.json` records lazily. `stat()` +of a working-tree file does read its record (localforage has no partial +reads), which is acceptable because working trees are small; the app never +`stat`s `runs/` contents wholesale. + +### 4. Swappable storage interface + +All persistence goes through a `ProjectStorage` interface; nothing else +touches localforage/IndexedDB. + +```ts +// src/storage/types.ts +export type FileFormat = "text" | "json" | "base64"; + +export interface FileStat { + path: string; // project-relative + type: "file" | "notebook" | "directory"; + format: FileFormat; + mimetype: string; + lastModified: string; // ISO 8601 + size: number; +} + +export interface ProjectStorage { + // Projects + listProjects(): Promise; + createProject(meta: NewProjectMeta): Promise; // allocates dirName + updateProjectMeta(dirName: string, patch: Partial): Promise; + deleteProject(dirName: string): Promise; // recursive, incl. checkpoints + + // Files (project-relative paths; parents auto-created on write) + list(dirName: string, subdir?: string): Promise; // key-scan, no body reads + read(dirName: string, path: string): Promise; // Uint8Array iff base64 + write(dirName: string, path: string, content: string | Uint8Array): Promise; + rename(dirName: string, from: string, to: string): Promise; // incl. checkpoints + remove(dirName: string, path: string): Promise; // incl. checkpoints + stat(dirName: string, path: string): Promise; +} +``` + +`read` returns `Uint8Array` exactly when the stored format is `base64`; +notebooks come back as their JSON text. Binary uploads are supported by +reading `File.arrayBuffer()` (the current `originFile.text()` upload path +is replaced). + +Implementations: + +- **`JupyterContentsStorage`** (now): the IndexedDB implementation above. +- **`MemoryProjectStorage`** (now): in-memory map for **embedded mode**, + **quick runs** (§6), and unit tests. +- **`ApiProjectStorage`** (later): same interface over HTTP. + +The storage a given project uses is resolved by context (persistent library +vs. quick-run/embed scratch space) — not a single global chosen once. Both +implementations are injected into the easy-peasy store via `createStore` +`injections` so thunks receive them as dependencies. + +Run management (snapshotting, run-dir allocation, `run.json`, sweeps) is a +`src/storage/runs.ts` module implemented **on top of** `ProjectStorage` — +layout convention, not a storage concern, so the swap surface stays minimal. + +### 5. Run outputs data path (new worker protocol) + +The runs mechanism requires reading files back out of the worker, which is +impossible today (Context #5). The worker protocol gains: + +- `snapshotWorkdir` request → worker responds with `{path, bytes}[]` for + files under the run's working directory, **collected inside the + step-callback window** (the WASM module sits suspended in ASYNCIFY sleep + between steps and must only be touched synchronously inside the step + callback — established project constraint). Buffers are transferred, not + copied. +- The collection is **size-aware**: per file, only files ≤ the copy + threshold are included mid-run; everything (subject to §2 caps) is + included in the final end-of-run snapshot. `log.lammps` and small series + files stream live; a growing multi-hundred-MB dump does not stall the + simulation loop. + +Flow per run: + +1. Flush pending editor debounce (§8), snapshot working tree → + `runs/run-NNN/` (from the store's buffers, so what runs is what is + recorded — no debounce race). +2. Write `run.json` (`status: "running"`), sync sources into the worker's + WASM FS under `//runs/run-NNN/`, run there (cwd = run dir, so + LAMMPS relative writes land in the run by construction). +3. Every ~3 s of wall clock, the step callback triggers `snapshotWorkdir` + (thresholded) → `ProjectStorage.write` the changed outputs. +4. On finish: final snapshot (all sizes, subject to caps), update + `run.json` (`completed`/`failed`/`canceled` + stats + error), capture + `frame.png`. + +**Reconciliation** (crash/zombie handling — review finding): on project +open and on library listing, a run with `status: "running"` that this +session does not own is rewritten to `"interrupted"` (partial outputs +kept, badge in UI) — with an **ownership/grace rule** so live notebook +runs are not killed: app/sweep-origin runs reconcile immediately (one app +session owns the engine), while `origin: "notebook"` runs reconcile only +when their `run.json` `last_modified` is older than a grace window +(10 min). Runs in `runs/*` without any `run.json` (external/scripted) +are listed as "external run" rows with mtime-derived timestamps, never +reconciled. A top-level directory without `.atomify/project.json` +is not listed; the library offers "recover as project" for such orphans. +If the project directory disappears mid-run (deleted from the Jupyter +side), the run is canceled and its output copies stop. + +### 6. Product rules: where files come from + +| Entry point | Behavior | +| --- | --- | +| **New project** | Modal: name + start blank / from example / drop files. Creates a persisted project. | +| **Example → "Use as project"** | Instantiates a project from the example's files. | +| **Example → "Quick run"** | Runs immediately in a **scratch project on `MemoryProjectStorage`** — nothing persisted, banner offers **"Save as project"**, which materializes the working tree *and completed runs* into the library. Closing the tab discards it. This keeps tire-kicking from littering the library (review finding) while "everything in the library persists" stays absolutely true. | +| **Share link** | ~~Normal mode: opens as a quick run (same banner/save flow). Embedded mode: `MemoryProjectStorage`, no save affordance, Notebook hidden (ADR-002 §1).~~ Removed — see the 2026-07-11 amendment below. | + +### 7. Parameter sweeps + +A script that uses LAMMPS variables (`variable T equal 3.0` / +`${T}`-style references, already injectable via the existing +`prepareVarsScript` mechanism, `src/store/simulation.ts:37-59`) can be +**parameterized at run time**: the Run flow lets the user define values or +ranges for one or more variables (e.g. `T = 1.0, 1.5, … 3.0`), producing +the cartesian product as a **sweep** — one run per combination: + +- Each combination becomes an ordinary run (`run-014`, `run-015`, …) with + `vars` and a shared `sweepId` in `run.json`. There is no separate sweep + entity in storage — a sweep is a label over runs, so cross-run analysis + in the notebook (`glob("runs/*/log.lammps")` + reading `vars` from + `run.json`) works identically for swept and manual runs. +- The app executes sweep runs **sequentially** (one WASM engine); the Runs + tab shows queued entries (`status: "queued"` in memory only — never + persisted, so no zombie queue states) with progress per run and + cancel-remaining. Consequence of in-memory queuing: a page refresh + mid-sweep drops the not-yet-started combinations; on reload the app + shows "sweep interrupted — N runs not started" with a re-enqueue + affordance (completed runs are untouched). +- Variable values are injected per run via the vars-wrapper mechanism, and + recorded in `run.json.vars` — the Runs list surfaces them (ADR-003), which + is what makes ten near-identical rows distinguishable. +- Sweeps from Python in the notebook remain a plain `for` loop (ADR-002); + the app sweep is the no-code path over the same storage layout. + +### 8. Write-through editing, single source of truth + +Store state and Monaco buffers are **caches** of the project filesystem: + +- Editor changes dispatch a store action (fixing the in-place mutation) and + persist via debounced (~500 ms) `storage.write`. The editor shows + three save states: *saving / saved / couldn't save (retry)* — a saved + indicator that can silently lie is worse than none (review finding). +- Debounce is **flushed** on run start (§5), on `visibilitychange: hidden`, + and on pane switches. +- On focus regain and pane switches, the working tree is re-`stat`ed; + files newer in storage than the cache (edited in the Jupyter UI) reload — + **unless the local buffer is dirty**, in which case the local buffer wins + until saved (per-file last-writer-wins with a dirty-buffer carve-out). +- Editing is **no longer locked during runs** — runs execute from their + snapshot, so the working tree is free (product win the old design left + on the table; the current read-only editor lock is removed). + +### 9. Migration + +Nothing today persists projects, but the legacy one-way sync did write +`analyze.ipynb` and `/…` entries that may hold real user analysis +work. Policy: leave legacy records untouched; they remain reachable from +the Jupyter file browser. The Notebook pane's flat-path logic +(`src/containers/Notebook.tsx`) and `syncFilesJupyterLite` are removed in +the **same change** that introduces project-scoped notebooks (they are +load-bearing for each other — review finding). Examples that ship a curated +`analysisScript` notebook copy it into the project working tree at +instantiation (taking precedence over the generated template, ADR-002 §2). + +## Alternatives considered + +- **Separate Atomify IndexedDB + two-way sync into JupyterLite storage.** + Rejected: perpetual sync code, conflict windows, duplicated large files, + a second source of truth to corrupt. +- **OPFS / File System Access API.** Not readable by JupyterLite's contents + manager → reintroduces two-store sync. FSA additionally has heavy + permission UX and no writable-handle support in Firefox/Safari. Either + can become a `ProjectStorage` implementation later. +- **UUID project identity.** Rejected: the directory name is user-visible + in Jupyter; readable notebook paths beat opaque ones. +- **A custom JupyterLite drive plugin** to avoid the contents-schema + coupling. Deferred: version-pinned against JupyterLite internals and not + needed while the schema-fidelity tests pass. +- **Run snapshots as diffs.** The skip-unchanged rule (§2) captures the + dominant saving (large data files rarely change between runs) without a + diff format. + +## Consequences + +- (+) Projects survive sessions; runs are comparable, reproducible history + with explicit provenance (including what was *not* snapshotted). +- (+) Notebook integration is structural (ADR-002 builds on §3). +- (+) Storage backend swap is one class per backend, as requested. +- (−) IndexedDB quota is finite and JupyterLite browsing cost scales with + stored bytes. Mitigations **in priority order**: (1) size caps on + snapshot/copy with explicit gaps recorded (§2, §5); (2) skip-unchanged + snapshot dedup (§2); (3) per-project size surfaced in the UI + delete-run + affordance (ADR-003); (4) `navigator.storage.persist()` (prevents + eviction; adds no quota). A quota error during output copy marks the run + `failed` with reason `storage full` (partial outputs kept) and raises a + visible warning naming the project — never silent loss. +- (−) Base64 inflates binary outputs by ~33 % and giant single-record + strings are the known fragile spot in browsers; the size caps above are + the guard. Blob-valued records are a possible future optimization if + JupyterLite compatibility allows. +- (−) The app owns contents-schema fidelity (directory records, checkpoint + cleanup, format table), verified by unit tests against fixtures recorded + from a real JupyterLite session. +- (−) A new worker protocol (`snapshotWorkdir`) is required; its + step-callback scheduling is constrained by ASYNCIFY re-entry rules and + must be reviewed against them. + +## Amendment (2026-07-11) + +Embedded mode and URL-encoded share links (`?data=`/`?script=`, the embed +codec, `EmbeddedApp` and the entire legacy shell) were removed. Real +projects blow past GitHub Pages' ~2 KB URL limit, and the iframe/book use +case is moving to a different mechanism. Sharing is now **project zip +export/import**: "Download project" in the workspace ⋯ menu produces a zip +of the tree (optionally with `runs/`), and the New Project modal's Upload +path imports a single .zip as a whole project (its `.atomify/project.json` +supplies defaults). A backend-based sharing story comes later. diff --git a/docs/adr/002-notebook-first-class.md b/docs/adr/002-notebook-first-class.md new file mode 100644 index 00000000..62864b7b --- /dev/null +++ b/docs/adr/002-notebook-first-class.md @@ -0,0 +1,214 @@ +# ADR-002: The notebook as a first-class analysis and scripting surface + +- **Status**: Accepted (2026-07-11) +- **Date**: 2026-07-11 +- **Deciders**: Anders Hafreager, Claude +- **Depends on**: ADR-001 (projects, runs, single project filesystem) + +## Context + +Atomify embeds JupyterLite (pyodide kernel) in an iframe +(`src/containers/Notebook.tsx`). The intended integration — push run +outputs into the notebook — is shallower than it looks: since the engine +moved into a Web Worker, `syncFilesJupyterLite` only round-trips **input** +files (the main-thread FS bridge is write-only; see ADR-001 Context #5). +Run outputs (`log.lammps`, dumps) never reach the notebook today. Beyond +that: the generated `analyze.ipynb` is flat and global, successive runs +overwrite each other, and the notebook can only look at results — it +cannot run LAMMPS. + +The upstream **lammps.js** repo ships a proven deeper integration +(deployed at editor.lammps.org/notebook): + +- A Python package **`lammps-js`** (`python/lammps/__init__.py`) mirroring + the official LAMMPS Python API (`lmp = await lammps()`, `command`, + `get_thermo`, `extract_atom`, …), built as a wheel into the JupyterLite + piplite index. +- Inside the pyodide worker it dynamically imports the lammps.js ES module + from `{site}/lammps/client.js` — engine and kernel share one thread, no + message passing. URL derivation from the kernel URL is verified to work + under Atomify's `/atomify/jupyter/` base. +- A proxy Emscripten FS (`_MOUNT_DRIVEFS_JS`) mounts JupyterLite's DriveFS + onto the engine's workdir, mapped to **the kernel's cwd** (re-evaluated + per operation, so `os.chdir` moves it): files LAMMPS writes land next to + the notebook. +- Cross-origin isolation on static hosting via a build-time service-worker + patch (`examples/notebook/coi_patch.py`) — JupyterLite's own SW must + keep owning its scope, so the generic `coi-serviceworker` shim cannot be + used inside it. + +Atomify pins the same JupyterLite versions (`jupyterlite-core==0.8.0`, +`jupyterlite-pyodide-kernel==0.8.1`), so the pieces port — but review +established the port is **not** turnkey; §3 states the real supply chain. + +With ADR-001, projects and runs live *in* the JupyterLite contents store. +This ADR decides the notebook experience on top of that. + +## Decision + +### 1. The notebook operates on the project directory + +The Notebook tab opens JupyterLite at the current project's notebook: +`lab/index.html?path=/analysis.ipynb`. The kernel cwd is the +project directory; by ADR-001's layout the notebook sees the working tree +and `runs/`. **Cross-run analysis is a glob, not a feature**: + +```python +import glob, json, lammps_logfile +for path in sorted(glob.glob("runs/*/log.lammps")): + meta = json.load(open(path.rsplit("/", 1)[0] + "/.atomify/run.json")) + log = lammps_logfile.File(path) # plot D vs meta["vars"]["T"], etc. +``` + +The Notebook tab is available only for **library projects**. Quick runs +and embeds live on `MemoryProjectStorage` (ADR-001 §6), which JupyterLite +cannot see — the tab is hidden there and appears after "Save as project". +(A scoped in-memory contents drive is possible future work.) + +### 2. A per-project `analysis.ipynb`, generated once + +At project creation Atomify generates `/analysis.ipynb` from a +template (evolving `src/utils/AnalyzeNotebook.ts`): a header, a +latest-run plot cell, the compare-runs glob cell above, and a commented +scripting cell (§4). Examples that ship a curated notebook copy that in +instead. The file is **owned by the user afterwards** — never overwritten +(preserving today's guarantee, `src/store/simulation.ts:443-447`). Because +runs are discovered by glob at execution time, the notebook needs no +regeneration when runs appear — that is what makes generate-once viable. + +`lammps_logfile` moves from a source-directory copy at the contents root +(which becomes unimportable once the notebook cwd is `/`) to the +**`lammps-logfile` wheel from PyPI in the piplite index**; the content-dir +copy is deleted. Templates start with +`%pip install lammps-logfile lammps-js`. + +### 3. Bundle the `lammps-js` Python package — with an explicit supply chain + +Goal: notebooks can run entire simulations in the kernel: + +```python +%pip install lammps-js +from lammps import lammps +import os +home = os.getcwd() +for T in [1.0, 2.0, 3.0]: + rundir = f"runs/run-T{T}" + os.makedirs(rundir, exist_ok=True) # mkdir BEFORE LAMMPS writes there + os.chdir(rundir) # dumps/write_data land in the run, + try: # not the working tree (DriveFS + lmp = await lammps() # follows the kernel cwd) + lmp.command(f"variable T equal {T}") + lmp.file("../../in.diffusion") # script from the working tree + lmp.close() + finally: + os.chdir(home) +``` + +**Supply chain** (review blocker — the wheel exists nowhere consumable +today: PyPI 404s, and the npm tarball excludes `python/`): + +- **Upstream first** (we control lammps.js): PR to publish the `lammps-js` + wheel — either to PyPI in `release.yml`, or by adding `python/` to the + npm `files` so consumers build it from `node_modules`. Same PR bumps + `python/pyproject.toml` to track the npm version (already skewed: + 1.5.0 vs 1.5.1). +- **Interim**: vendor the prebuilt wheel in-repo at + `pypi/lammps_js--py3-none-any.whl`, pinned to the npm lammps.js + version, with a comment pointing at the upstream issue. +- **Build mechanics** (verified specifics): Atomify's lite dir is the repo + root, so wheels go in `/pypi/` for auto-indexing; the built + lammps.js client is copied `node_modules/lammps.js/dist/` → + `public/jupyter/lammps/` **after** `npm install` — `deploy.yaml` + currently builds the Jupyter site before npm install, so the workflow + steps are reordered. A `make jupyter` target gives the same build + locally (none exists today; `public/jupyter` is CI-only). + +**Capability boundary** (stated honestly): the kernel client loads only +the *serial* or *KOKKOS* wasm variants (MOLECULE package set + KOKKOS); +the app's engine is the full-package `lammps-atomify` build. Projects +using packages outside that set (ReaxFF, KSPACE/water, Vashishta/silica…) +run in the app but **error in the kernel** until an upstream +`variant: "atomify"` client/python option lands (small change — the wasm +already ships in the npm dist as `./wasm-atomify`; it also means the dist +copy ships all three variants, ~55 MB, not ~40). The template's scripting +cell names this limitation. + +Notebook-created runs carry `"origin": "notebook"` when the script writes +`run.json`; the app lists any `runs/*` directory regardless, rendering +metadata-less ones as "external run" rows with mtime-derived timestamps +(ADR-001 §5). The app's Run button and sweeps (ADR-001 §7) are one +producer of runs; Python loops are another; both write the same layout. + +### 4. App ⇄ notebook freshness — corrected semantics + +One store (ADR-001 §3) means no sync protocol, only cache refresh. Review +verified `BrowserStorageDrive` holds **no in-memory cache** — but two +timing realities must be designed around, not wished away: + +- **App → notebook**: app-side writes are visible to the kernel and file + browser on their next operation (JupyterLab's browser auto-refreshes + ~10 s while visible). The app copies run outputs into `runs//` + during a run (ADR-001 §5 data path), so a notebook **can** analyze an + app-driven run in flight. +- **Notebook → app**: the DriveFS proxy flushes on **file close**. LAMMPS + holds `log.lammps` open for the whole run, so kernel-driven runs become + visible **at completion**, not in flight. Consequently: the Runs tab + discovers notebook runs on its normal refresh triggers after they + finish; and a file held open by the kernel clobbers concurrent app-side + edits at close (the per-file LWW window is the open duration — + acceptable, documented). +- The app re-stats the working tree on `visibilitychange` and pane + switches (ADR-001 §8) to pick up notebook-side edits. + +No postMessage bridge in v1; a same-origin `BroadcastChannel` is the +upgrade path if push-refresh is ever wanted. + +### 5. Cross-origin isolation strategy + +KOKKOS in the kernel needs `crossOriginIsolated`. Inside the Jupyter +scope, JupyterLite's own service worker must stay in control (DriveFS's +contents API rides on it), so Atomify ports lammps.js's `coi_patch.py`: +patch the built `service-worker.js` to add COOP/COEP/CORP headers, with +the one-shot reload bootstrap injected into the app pages. Two nested +service workers then coexist: the parent app's `coi-serviceworker` +(scope `/atomify/`, COEP `credentialless`) and JupyterLite's patched SW +(scope `/atomify/jupyter/`, `require-corp`). This combination is legal +(same-origin child with its own COEP) but the first-load sequencing — +two independent SW installs, two reload bootstraps — gets an **explicit +manual test matrix** (cold load, hard refresh, SW update) on the deployed +site before release; per project memory, dev/preview cannot reproduce +GitHub Pages SW behavior. Serial fallback keeps notebooks functional if +isolation fails. + +## Alternatives considered + +- **Notebook as viewer only.** Rejected: scripted parameter sweeps are the + core scientific workflow requested, and lammps.js already paid the cost. +- **postMessage/custom JupyterLite extension for sync.** Heavy, + version-pinned, unnecessary with a shared store. +- **One engine (run app simulations in the kernel too).** Couples the 3D + visualization pipeline (worker heap streaming, ASYNCIFY constraints) to + notebook lifecycle for no user benefit. +- **Regenerating `analysis.ipynb` per run.** Clobbers user work; glob + discovery makes it moot. +- **Bundling `lammps_logfile` as content.** That is the status quo; it + breaks under project-scoped cwds. Wheel via piplite is strictly better. + +## Consequences + +- (+) "Always synced with the notebook" holds by construction, with the + honest caveat that kernel-side writes surface at file close. +- (+) Scripted sweeps and app sweeps produce the same on-disk shape; + analysis code never cares who ran the simulation. +- (−) Two upstream lammps.js PRs are on the critical path for the full + vision (wheel publication; `variant: "atomify"`). Both are small and we + control the repo; the vendored wheel and the stated capability boundary + keep Atomify shippable meanwhile. +- (−) The Jupyter site grows by the lammps.js dist (all wasm variants, + ~55 MB). Trim to serial+kokkos if size bites. +- (−) The SW patch pins jupyterlite-core 0.8.0 internals (tracked + upstream, jupyterlite#1409); version bumps require re-validating the + patch asserts. +- (−) JupyterLite directory listings deserialize the entire store — + ADR-001's size caps are what keep the notebook file browser fast; this + coupling is a standing constraint on how much run output we persist. diff --git a/docs/adr/003-ui-shell-and-simulations-experience.md b/docs/adr/003-ui-shell-and-simulations-experience.md new file mode 100644 index 00000000..05cc4db1 --- /dev/null +++ b/docs/adr/003-ui-shell-and-simulations-experience.md @@ -0,0 +1,225 @@ +# ADR-003: UI shell redesign and the projects experience + +- **Status**: Accepted (2026-07-11) +- **Date**: 2026-07-11 +- **Deciders**: Anders Hafreager, Claude +- **Depends on**: ADR-001 (projects & runs), ADR-002 (notebook) +- **Design source**: claude.ai Design project `f50a64cb-1082-404c-b41f-728c9d24e37f`, + file **`Atomify Projects.dc.html`** (supersedes `Atomify.dc.html`). The + design is still being iterated; the latest version is re-fetched + immediately before UI implementation. This ADR fixes structure and + behavior; pixels follow the design file. + +## Context + +The current shell is an antd `Layout` whose `Sider` menu drives an +invisible antd `Tabs` (`src/App.tsx`, `src/containers/Main.tsx`, +`src/hooks/useMenuItems.tsx`). There is no library, no run history, no +file management, and the run-completion moment is a Console *modal* +popping over everything (`handleRunResult` → `setShowConsole(true)`). + +The design establishes a different information architecture than the old +menu — and different from rev 1 of this ADR: there are no global +View/Console panes; those fold into a per-run **Run detail** screen. + +## Decision + +### 1. Vocabulary (glossary — binding for all UI copy) + +| Term | Meaning | UI usage | +| --- | --- | --- | +| **Project** | Named directory: working tree + runs (ADR-001) | "Projects", "New project", breadcrumb `Projects /` | +| **Run** | One execution, immutable record | "Run #005", "Runs" tab | +| **Simulation** | What a run executes | verbs/marketing only: "Run simulation" button | +| **Quick run** | Ephemeral scratch project (ADR-001 §6) | banner: "This is a quick run — files are temporary" | + +"Your simulations" from the original brief is realized as the **Projects** +library. The word "project" is the user-facing noun (per the design); +runs are numbered `#NNN`. The internal `dirName` appears as a mono-font +subtitle in the workspace header and rename dialog ("folder name stays +`diffusion`"), so the Jupyter file browser never shows a mystery name. + +### 2. Information architecture (per `Atomify Projects.dc.html`) + +``` +Sidebar (global): Home · Example library · PROJECTS (list, + New project) + footer: theme toggle · Settings + +Home: greeting · "Continue where you left off" card (live run + status) · Create new project · Quick-run an example · + Recent projects grid · "Start from an example" row + +Example library: card grid; per card: "Use as project" | "Quick run" + +Project workspace: + header: breadcrumb · displayName · Running pill · Share · Run simulation + tabs: Files | Runs | Notebook + Files: table (name/size/modified/actions) · search · New file · + Upload · drop-anywhere · runs/ folder row (tagged) · + input-script chip on the designated script + Editor: sub-screen of Files (back button, filename, type chip, + save state, Save & run) + Runs: run rows: status icon, #NNN, when, script (mono), summary, + vars, live progress bar for running runs + Run detail: sub-screen of Runs: 3D viewport + following console strip + + right panel (status stats, speed slider, output files) · + Stop (while running) · "Analyze in notebook" + Notebook: JupyterLite iframe at /analysis.ipynb + (design shows a project-files rail; v1 relies on + JupyterLite's own file browser) +``` + +Sidebar projects show a **color identity dot** (from `project.json.color`, +assigned from the design palette at creation) and a pulsing dot when +running. **No thumbnails in v1** — the design uses color dots for cards; +the only image is the Home "continue" card, which uses the last run's +`frame.png` (captured from the WebGL canvas at run completion, +ADR-001 §5) when present, else a gradient. + +### 3. Navigation state + +No router (GitHub Pages subpath + embeds); the ad-hoc `selectedMenu` +string is replaced by typed state: + +```ts +type Screen = + | { name: "home" } + | { name: "examples" } + | { name: "project"; dirName: string; tab: "files" | "runs" | "notebook"; + filePath?: string; // files tab: editor sub-screen + runId?: string } // runs tab: run-detail sub-screen +``` + +Deep links (`?project=diffusion&tab=runs&run=run-005`) map 1:1 so refresh +restores position. Legacy params (`script=`, embed codec URLs) resolve +into quick-run state. Embedded mode lands on the run detail of its +auto-started run and hides sidebar/library (as today) and the Notebook tab +(ADR-002 §1). + +### 4. The run lifecycle (the core loop, specified end-to-end) + +1. **Run simulation** (header) or **Save & run** (editor): flush editor + saves, create the run (ADR-001 §5), navigate to **Run detail** of the + new run — live viewport, following console, stats. The old + console-completion modal is **removed**. +2. **While running**: sidebar project row and header show the running + pulse; the Runs tab shows the live row with progress. Speed/UI sliders + live in the run-detail right panel. Editing files stays enabled + (runs execute from snapshots). +3. **Completion**: run-detail status pill flips (completed/failed — + failed shows the LAMMPS error from `run.json.error` prominently); + output files panel fills in; `frame.png` captured. If the user is + elsewhere in the app, a toast: "Run #005 finished — View · Analyze in + notebook". No modal. +4. **Run button with no input script** (user requirement): if + `project.json.inputScript` is unset and more than one candidate + exists, the Run button opens a "Choose input script" dialog listing + the working tree's scripts; the choice persists to `project.json`. + Exactly one candidate → auto-set silently. Zero candidates → dialog + explains and offers New file/Upload. The Files tab shows an + "input script" chip on the designated file, and each script row has + "Set as input script" + one-off "Run this file" actions. +5. **Parameter sweep** (user requirement): a dropdown on the Run button — + "Run simulation" / "Sweep variables…". The sweep dialog detects + `variable X equal …` definitions in the input script, lets the user + enter a list or range per variable (`1.0, 1.5 … 3.0`), previews the + run count, and enqueues the cartesian product as sequential runs + (ADR-001 §7). The Runs tab groups rows sharing a `sweepId` under a + collapsible header with "cancel remaining"; each row shows its `vars`. +6. **Opening a previously-run project**: workspace opens on **Files** + for never-run projects, **Runs** otherwise. Run detail of a finished + run shows `frame.png` in the viewport area (with "Run again" overlay) + plus the console (rendered from the run's persisted `log.lammps`) and + outputs — never a silent black canvas (review blocker). Trajectories + are not replayed in v1. +7. **Interrupted runs** (ADR-001 §5 reconciliation) render with a + distinct badge and keep partial outputs. + +### 5. Files, editing, deletion + +- Files tab lists the working tree plus the `runs/` folder row (tagged + "one directory per run", navigating to the Runs tab). `.atomify/` is + hidden. Run outputs open **read-only** in Monaco with a "snapshot of + run #NNN" banner; immutability of runs is a UI convention (the kernel + is not blocked from writing there — documented, not enforced). +- Editor autosaves (debounced) with visible *saving / saved / couldn't + save (retry)* states (ADR-001 §8); the design's Save button is the + immediate-flush affordance. `Save & run` = flush + run. +- **Delete project**: confirm modal states run count and total size + ("Deletes 14 runs, 320 MB — cannot be undone"); blocked while that + project has a live run. **Delete run**: plain confirm with size. No + undo/trash in v1 (decided, not deferred-by-silence). +- **Duplicate project**: copies working tree + notebook, **not** runs; + fresh dirName. (Explicitly: the copied notebook's glob sees zero runs + until the copy is run.) +- Rename edits `displayName` only (§1). +- **Storage pressure**: Settings shows a storage meter (via + `navigator.storage.estimate()`) with per-project sizes; quota failures + surface as a toast naming the project (ADR-001 consequences). + +### 6. Visual implementation strategy + +- **The shell is bespoke**: design tokens as CSS custom properties + (`--bg/--surface/--text/--accent/…`) with `[data-theme]` dark/light + variants; Manrope UI font, JetBrains Mono for code/paths. The antd + `Menu`/`Tabs` shell is removed. +- **antd remains for commodity inner components** (Upload dragger, + notifications/toasts, tooltips), bridged to the same tokens via + `ConfigProvider`; Monaco, dygraphs, omovi untouched. +- **Theme** is a persisted setting (`data-theme` on root); the design's + light palette becomes Atomify's first light mode. The JupyterLite + iframe keeps its own theme setting in v1. +- The WASM-loading progress must not block Home/library browsing (today a + modal blocks everything until the engine loads) — the engine is needed + only to *run*, not to browse, edit, or read notebooks. + +### 7. v1 vs deferred + +| In v1 | Deferred | +| --- | --- | +| Home, library, create (blank/example/upload), open, rename, duplicate, delete | Project tags/search/sort beyond updated-at | +| Files CRUD + Monaco autosave + input-script selection | Working-tree subfolders UI, drag-reorder | +| Runs list + run detail + interrupted reconciliation + delete run | Run comparison/diff UI, run notes | +| Variable sweeps (list/range, sequential queue) | Parallel sweep execution, sweep charts | +| Quick run + Save as project; share links open as quick runs | Publishing a project as a shareable example | +| Design tokens, dark + light, storage meter | Accent color picker, thumbnails on cards | + +## Alternatives considered + +- **react-router**: small screen graph, trivial deep-link mapping; a + router adds base-path friction on Pages for no structural gain. +- **Restyle antd in place**: the sidebar/cards/run-rows don't map onto + antd idioms; owning ~600 lines of layout primitives is cheaper than + fighting the library. +- **Global View/Console panes (rev 1 of this ADR)**: superseded by the + design — run-scoped detail makes "which run am I looking at?" + unaskable, and kills the completion-modal pattern naturally. +- **Thumbnails on all cards (rev 1)**: dropped in favor of the design's + color-dot identity; `frame.png` capture is kept only where it earns + its cost (continue card, run detail). + +## Consequences + +- (+) The core loop is fully specified: create → edit (autosave) → run + (live detail) → analyze (notebook glob) → iterate (sweeps, history). +- (+) Typed navigation kills the `"file"+fileName` string-key hack; + every screen is deep-linkable and testable. +- (−) `App.tsx`, `Main.tsx`, `useMenuItems.tsx` are substantially + rewritten; embed + AutoStart flows need regression tests (they bypass + Home and land on run detail). +- (−) Two styling systems coexist during transition (tokens + antd + bridge), contained inside panes. +- (−) Run detail for historical runs is a still image + logs in v1; + trajectory replay stays future work. + +## Amendment (2026-07-11) + +Embedded mode is gone: `embed=true` iframes, `AutoStartSimulation`, the +invisible-tabs `Main` shell, the Share modal and the URL-encoding codec +were all removed — the projects shell is the whole app. The §3 note about +legacy params resolving into quick-run state no longer applies (they are +ignored), and the workspace header has no Share button. URL-encoded +sharing died on GitHub Pages' ~2 KB URL limit; project zip export/import +(workspace ⋯ menu / New Project upload) is the sharing story, and the book +embedding use case will use a different mechanism. Sharing via a backend +is future work. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..71c2ea8f --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,10 @@ +# Architecture Decision Records + +ADRs document significant architectural decisions for Atomify. Each record states +context, the decision, alternatives considered, and consequences. + +| ADR | Title | Status | +| --- | --- | --- | +| [ADR-001](001-projects-runs-and-storage.md) | Projects, runs, and a single project filesystem | Accepted | +| [ADR-002](002-notebook-first-class.md) | The notebook as a first-class analysis and scripting surface | Accepted | +| [ADR-003](003-ui-shell-and-simulations-experience.md) | UI shell redesign and the projects experience | Accepted | diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts new file mode 100644 index 00000000..ac0ddbf9 --- /dev/null +++ b/e2e/editor.spec.ts @@ -0,0 +1,100 @@ +/** + * Editor flows: Monaco autosave with the Saved indicator, persistence + * across reload, the set-as-input-script chip, and the input-script chooser + * the Run button opens when two scripts exist and none is designated. + */ + +import { test, expect } from "@playwright/test"; +import { + createBlankProject, + createFile, + FAST_LJ_SCRIPT, + gotoApp, + openInEditor, + typeInEditor, + waitForEngine, +} from "./helpers"; + +test("autosave, persistence across reload, input-script chip", async ({ + page, +}) => { + await test.step("create a project with a new script file", async () => { + await gotoApp(page); + await createBlankProject(page, "Editor study", "editor-study"); + await createFile(page, "in.melt"); + }); + + await test.step("type LAMMPS content in Monaco, autosave says Saved", async () => { + await openInEditor(page, "in.melt"); + await typeInEditor(page, FAST_LJ_SCRIPT); + }); + + await test.step("content persists across a reload", async () => { + // The deep link (?project=…&file=…) restores the editor directly. + await page.reload(); + await expect(page.getByTestId("editor-screen")).toBeVisible(); + await expect(page.getByTestId("editor-filename")).toHaveText("in.melt"); + await expect(page.locator(".view-lines").first()).toContainText( + "lattice fcc 0.8442", + { timeout: 60_000 }, + ); + }); + + await test.step("set as input script from the Files tab", async () => { + await page.getByTestId("editor-back").click(); + await expect(page.getByTestId("files-tab")).toBeVisible(); + const row = page.getByTestId("file-row-in.melt"); + await expect(row).not.toContainText("Input script"); + await page.getByTestId("set-input-in.melt").click(); + await expect(row).toContainText("Input script"); + // The chip replaces the set-as-input action for that row. + await expect(page.getByTestId("set-input-in.melt")).toHaveCount(0); + }); +}); + +test("Run opens the input-script chooser when two scripts exist", async ({ + page, +}) => { + await test.step("create a project with two scripts and no input script", async () => { + await gotoApp(page); + await createBlankProject(page, "Chooser study", "chooser-study"); + await createFile(page, "in.first"); + await createFile(page, "in.second"); + await expect(page.getByTestId("file-row-in.first")).not.toContainText( + "Input script", + ); + await expect(page.getByTestId("file-row-in.second")).not.toContainText( + "Input script", + ); + }); + + await test.step("Run simulation opens the chooser", async () => { + await waitForEngine(page); // the Run button is disabled until then + await page.getByTestId("run-simulation").click(); + await expect(page.getByTestId("new-run-modal")).toBeVisible(); + await expect(page.getByTestId("script-choice-in.first")).toBeVisible(); + await expect(page.getByTestId("script-choice-in.second")).toBeVisible(); + }); + + await test.step("choosing a script designates it as the input script", async () => { + await page.getByTestId("script-choice-in.second").click(); + // The modal moves on to the run-parameters view with the chosen script. + await expect(page.getByTestId("run-input-script")).toHaveText("in.second"); + await page.getByRole("button", { name: "Cancel" }).click(); + await expect(page.getByTestId("new-run-modal")).toHaveCount(0); + await expect(page.getByTestId("file-row-in.second")).toContainText( + "Input script", + ); + }); + + await test.step("the choice persists across a reload", async () => { + await page.reload(); + await expect(page.getByTestId("files-tab")).toBeVisible(); + await expect(page.getByTestId("file-row-in.second")).toContainText( + "Input script", + ); + await expect(page.getByTestId("file-row-in.first")).not.toContainText( + "Input script", + ); + }); +}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 00000000..6dbc9c6f --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,170 @@ +/** + * Shared helpers for the Atomify shell e2e suite. All selectors are the + * data-testids the shell components expose (src/shell/*). + */ + +import { expect, type Page } from "@playwright/test"; + +export const APP_URL = "/atomify/"; + +/** First engine fetch + wasm compile can take a while headless. */ +export const ENGINE_TIMEOUT = 120_000; +/** A small LJ run end-to-end, including snapshot + persistence writes. */ +export const RUN_TIMEOUT = 180_000; + +/** + * Small, fast LJ melt (~1–3 s once the engine is warm). `variable T` is a + * plain-number variable, so it shows up as an overridable parameter in the + * New Run modal (used by the sweep spec). + */ +export const FAST_LJ_SCRIPT = `variable T equal 1.44 +units lj +lattice fcc 0.8442 +region box block 0 3 0 3 0 3 +create_box 1 box +create_atoms 1 box +mass 1 1.0 +velocity all create \${T} 87287 +pair_style lj/cut 2.5 +pair_coeff 1 1 1.0 1.0 2.5 +fix 1 all nve +run 400 +`; + +/** Load the shell and wait for it to mount. */ +export async function gotoApp(page: Page): Promise { + await page.goto(APP_URL); + await expect(page.getByTestId("shell-root")).toBeVisible(); +} + +/** + * Wait until the LAMMPS engine is ready (the sidebar loading chip is only + * rendered while it isn't). Browsing/editing works before this; running + * doesn't. + */ +export async function waitForEngine(page: Page): Promise { + await expect(page.getByTestId("engine-loading-chip")).toHaveCount(0, { + timeout: ENGINE_TIMEOUT, + }); +} + +/** Create a blank project through the New Project modal. */ +export async function createBlankProject( + page: Page, + name: string, + dirName: string, +): Promise { + await page.getByTestId("sidebar-new-project").click(); + await expect(page.getByTestId("new-project-modal")).toBeVisible(); + await page.getByTestId("project-name-input").fill(name); + // Folder preview updates live from the name. + await expect(page.getByTestId("new-project-modal")).toContainText( + `${dirName}/`, + ); + await page.getByTestId("create-project").click(); + await expect(page.getByTestId("project-title")).toHaveText(name); + await expect(page.getByTestId("project-dirname")).toHaveText(`${dirName}/`); +} + +/** Create an empty file from the Files tab (must be on the Files tab). */ +export async function createFile(page: Page, name: string): Promise { + await page.getByTestId("new-file-button").click(); + await page.getByTestId("prompt-input").fill(name); + await page.getByTestId("prompt-confirm").click(); + await expect(page.getByTestId(`file-row-${name}`)).toBeVisible(); +} + +/** Open a file row in the Monaco editor sub-screen and wait for Monaco. */ +export async function openInEditor(page: Page, path: string): Promise { + await page.getByTestId(`file-row-${path}`).click(); + await expect(page.getByTestId("editor-screen")).toBeVisible(); + await expect(page.getByTestId("editor-filename")).toHaveText(path); + await expect(page.locator(".monaco-editor").first()).toBeVisible({ + timeout: 60_000, + }); +} + +/** + * Type into the open Monaco editor and wait for the debounced autosave + * (500 ms) to land, i.e. the indicator settles back on "Saved". + */ +export async function typeInEditor(page: Page, text: string): Promise { + await page.locator(".monaco-editor").first().click(); + await page.keyboard.insertText(text); + // Past the debounce window the indicator is either "Saving…" (write in + // flight) or already "Saved"; waiting closes the race with the default + // "Saved" state shown before any edit. + await page.waitForTimeout(700); + await expect(page.getByTestId("editor-save-state")).toContainText("Saved", { + timeout: 15_000, + }); +} + +/** + * Create a project containing a single fast LJ input script, typed through + * the real editor. Leaves the page on the editor screen. + */ +export async function createFastLjProject( + page: Page, + name: string, + dirName: string, + fileName = "in.melt", + script = FAST_LJ_SCRIPT, +): Promise { + await createBlankProject(page, name, dirName); + await createFile(page, fileName); + await openInEditor(page, fileName); + await typeInEditor(page, script); +} + +/** + * Wait for the live run to finish successfully: the run-detail screen the + * store navigated to shows the Completed pill. + */ +export async function waitRunCompleted(page: Page): Promise { + await expect(page.getByTestId("run-detail")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("status-pill-completed")).toBeVisible({ + timeout: RUN_TIMEOUT, + }); +} + +/** + * Read a raw contents record from the app's IndexedDB ("JupyterLite + * Storage" database, "files" object store — the same store JupyterLite's + * contents manager reads). Keys are `/`. + * For .json files the stored `content` is the parsed object. + */ +export async function readContentsRecord( + page: Page, + key: string, +): Promise<{ content: unknown; format: string | null } | null> { + return page.evaluate(async (recordKey) => { + const db = await new Promise((resolve, reject) => { + const request = indexedDB.open("JupyterLite Storage"); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + try { + const record = await new Promise((resolve, reject) => { + const get = db + .transaction("files", "readonly") + .objectStore("files") + .get(recordKey); + get.onsuccess = () => resolve(get.result); + get.onerror = () => reject(get.error); + }); + if (!record) { + return null; + } + const { content, format } = record as { + content: unknown; + format: string | null; + }; + return { content, format }; + } finally { + db.close(); + } + }, key); +} diff --git a/e2e/library.spec.ts b/e2e/library.spec.ts new file mode 100644 index 00000000..9bef8be8 --- /dev/null +++ b/e2e/library.spec.ts @@ -0,0 +1,129 @@ +/** + * Project library lifecycle: create through the New Project modal, persist + * across reload (IndexedDB), rename (displayName only — dirName immutable), + * duplicate, and delete with the confirm summary. + */ + +import { test, expect } from "@playwright/test"; +import { createBlankProject, createFile, gotoApp } from "./helpers"; + +test("create, persist, rename, duplicate and delete a project", async ({ + page, +}) => { + const sidebarProjects = page.locator('[data-testid^="sidebar-project-"]'); + + await test.step("create a blank project via the modal", async () => { + await gotoApp(page); + await createBlankProject(page, "Melt study", "melt-study"); + }); + + await test.step("project appears in the sidebar and Files tab", async () => { + await expect(page.getByTestId("sidebar-project-melt-study")).toBeVisible(); + await expect(page.getByTestId("sidebar-project-melt-study")).toContainText( + "Melt study", + ); + await expect(page.getByTestId("files-tab")).toBeVisible(); + // Blank projects start with the notebook template and the runs/ folder. + await expect(page.getByTestId("file-row-analysis.ipynb")).toBeVisible(); + await expect(page.getByTestId("file-row-runs")).toBeVisible(); + }); + + await test.step("project survives a reload (IndexedDB persistence)", async () => { + await page.reload(); + await expect(page.getByTestId("shell-root")).toBeVisible(); + await expect(page.getByTestId("sidebar-project-melt-study")).toBeVisible(); + // Deep link restored the project workspace too. + await expect(page.getByTestId("project-title")).toHaveText("Melt study"); + }); + + await test.step("rename changes displayName but not the folder", async () => { + await page.getByTestId("project-menu-toggle").click(); + await page.getByTestId("project-menu-rename").click(); + await page.getByTestId("prompt-input").fill("Melt study v2"); + await page.getByTestId("prompt-confirm").click(); + await expect(page.getByTestId("project-title")).toHaveText("Melt study v2"); + await expect(page.getByTestId("project-dirname")).toHaveText("melt-study/"); + await expect(page.getByTestId("sidebar-project-melt-study")).toContainText( + "Melt study v2", + ); + }); + + await test.step("duplicate copies the project", async () => { + await page.getByTestId("project-menu-toggle").click(); + await page.getByTestId("project-menu-duplicate").click(); + await expect(sidebarProjects).toHaveCount(2); + // The copy becomes the active project. + await expect(page.getByTestId("project-title")).toHaveText( + "Melt study v2 (copy)", + ); + await expect(page.getByTestId("project-dirname")).not.toHaveText( + "melt-study/", + ); + }); + + await test.step("delete via the confirm modal", async () => { + await page.getByTestId("project-menu-toggle").click(); + await page.getByTestId("project-menu-delete").click(); + await expect(page.getByTestId("delete-project-modal")).toBeVisible(); + await expect(page.getByTestId("delete-summary")).toContainText( + "This cannot be undone.", + ); + await page.getByTestId("delete-project-confirm").click(); + await expect(sidebarProjects).toHaveCount(1); + }); + + await test.step("deletion persists across reload", async () => { + await page.goto("/atomify/"); // leave the deleted project's deep link + await expect(page.getByTestId("shell-root")).toBeVisible(); + await expect(page.getByTestId("sidebar-project-melt-study")).toBeVisible(); + await expect(sidebarProjects).toHaveCount(1); + }); +}); + +test("export a project as a zip and import it back", async ({ + page, +}, testInfo) => { + await test.step("create a project with a source file", async () => { + await gotoApp(page); + await createBlankProject(page, "Zip trip", "zip-trip"); + await createFile(page, "in.melt"); + }); + + const zipPath = testInfo.outputPath("zip-trip.zip"); + await test.step("download via the project menu", async () => { + await page.getByTestId("project-menu-toggle").click(); + const downloadPromise = page.waitForEvent("download"); + await page.getByTestId("project-menu-download").click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toBe("zip-trip.zip"); + await download.saveAs(zipPath); + }); + + await test.step("import the zip through the New Project modal", async () => { + await page.getByTestId("sidebar-new-project").click(); + await expect(page.getByTestId("new-project-modal")).toBeVisible(); + await page.getByTestId("project-name-input").fill("Zip trip imported"); + await page.getByTestId("source-upload").click(); + await page + .getByTestId("upload-dropzone") + .locator('input[type="file"]') + .setInputFiles(zipPath); + await expect(page.getByTestId("np-import-zip")).toBeVisible(); + await page.getByTestId("create-project").click(); + }); + + await test.step("the imported project has the exported tree", async () => { + await expect(page.getByTestId("project-title")).toHaveText( + "Zip trip imported", + ); + await expect(page.getByTestId("project-dirname")).not.toHaveText( + "zip-trip/", + ); + await expect(page.getByTestId("file-row-in.melt")).toBeVisible(); + await expect(page.getByTestId("file-row-analysis.ipynb")).toBeVisible(); + // The zip's single script becomes the imported project's input script. + await expect(page.getByTestId("file-row-in.melt")).toContainText( + "Input script", + ); + }); +}); diff --git a/e2e/notebook.spec.ts b/e2e/notebook.spec.ts new file mode 100644 index 00000000..dc16688b --- /dev/null +++ b/e2e/notebook.spec.ts @@ -0,0 +1,40 @@ +/** + * Notebook tab: the JupyterLite iframe targets the project's analysis + * notebook. Only the iframe target is asserted — booting JupyterLite is out + * of scope, and the site only exists in dev when public/jupyter has been + * built (it's gitignored), so the test skips itself when it's absent. + */ + +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { test, expect } from "@playwright/test"; +import { createBlankProject, gotoApp } from "./helpers"; + +const JUPYTER_BUILT = existsSync( + join(__dirname, "..", "public", "jupyter", "lab", "index.html"), +); + +test("notebook iframe targets the project's analysis notebook", async ({ + page, +}) => { + test.skip( + !JUPYTER_BUILT, + "public/jupyter is not built — run `make jupyterlite` first", + ); + + await test.step("create a project and open the Notebook tab", async () => { + await gotoApp(page); + await createBlankProject(page, "Notebook study", "notebook-study"); + await page.getByTestId("tab-notebook").click(); + await expect(page.getByTestId("notebook-tab")).toBeVisible(); + }); + + await test.step("the iframe src points at the project notebook", async () => { + const iframe = page.getByTestId("notebook-iframe"); + await expect(iframe).toBeVisible(); + await expect(iframe).toHaveAttribute( + "src", + /jupyter\/lab\/index\.html\?path=notebook-study%2Fanalysis\.ipynb$/, + ); + }); +}); diff --git a/e2e/quickrun.spec.ts b/e2e/quickrun.spec.ts new file mode 100644 index 00000000..050851d1 --- /dev/null +++ b/e2e/quickrun.spec.ts @@ -0,0 +1,63 @@ +/** + * Quick run from the example library: scratch project with the temporary + * banner and no Notebook tab, then Save as project materializes it into the + * library (banner gone, Notebook tab present, sidebar entry). + */ + +import { test, expect } from "@playwright/test"; +import { gotoApp, RUN_TIMEOUT, waitForEngine } from "./helpers"; + +// Small 2D binary LJ gas — the lightest auto-startable example. +const EXAMPLE_ID = "2D-lj-fluid"; +const EXAMPLE_TITLE = "2D Lennard Jones fluid"; + +test("quick run an example, then save it as a project", async ({ page }) => { + await test.step("quick run from the example library", async () => { + await gotoApp(page); + await page.getByTestId("nav-examples").click(); + await expect(page.getByTestId("examples-screen")).toBeVisible(); + await expect( + page.getByTestId(`library-example-${EXAMPLE_ID}`), + ).toBeVisible({ timeout: 30_000 }); + await waitForEngine(page); // Quick run is disabled until the engine is up + await page.getByTestId(`library-quick-${EXAMPLE_ID}`).click(); + }); + + await test.step("banner visible, Notebook tab hidden", async () => { + await expect(page.getByTestId("quick-run-banner")).toBeVisible({ + timeout: 60_000, + }); + await expect(page.getByTestId("quick-run-banner")).toContainText( + "files are temporary", + ); + await expect(page.getByTestId("tab-files")).toBeVisible(); + await expect(page.getByTestId("tab-runs")).toBeVisible(); + await expect(page.getByTestId("tab-notebook")).toHaveCount(0); + await expect(page.getByTestId("project-title")).toContainText( + `Quick run — ${EXAMPLE_TITLE}`, + ); + }); + + await test.step("wait for the auto-started run to finish", async () => { + // The quick run auto-starts and navigates to its live run detail; wait + // until the run is no longer live so saving is deterministic. + await expect(page.getByTestId("run-detail")).toBeVisible({ + timeout: 60_000, + }); + await expect(page.getByTestId("stop-run")).toHaveCount(0, { + timeout: RUN_TIMEOUT, + }); + }); + + await test.step("save as project materializes it into the library", async () => { + await page.getByTestId("save-as-project").click(); + await expect(page.getByTestId("quick-run-banner")).toHaveCount(0, { + timeout: 60_000, + }); + await expect(page.getByTestId("project-title")).toHaveText(EXAMPLE_TITLE); + await expect(page.getByTestId("tab-notebook")).toBeVisible(); + const sidebarProjects = page.locator('[data-testid^="sidebar-project-"]'); + await expect(sidebarProjects).toHaveCount(1); + await expect(sidebarProjects.first()).toContainText(EXAMPLE_TITLE); + }); +}); diff --git a/e2e/run.spec.ts b/e2e/run.spec.ts new file mode 100644 index 00000000..e9741e3f --- /dev/null +++ b/e2e/run.spec.ts @@ -0,0 +1,151 @@ +/** + * The core run flow with the real wasm engine: run a small LJ melt, watch + * the live run detail, verify the Completed pill, the runs/run-001 history + * row, output files (log.lammps), the read-only snapshot banner, and that + * the run detail is never blank after a reload (frame or placeholder plus + * the persisted console). + */ + +import { test, expect } from "@playwright/test"; +import { + createFastLjProject, + FAST_LJ_SCRIPT, + gotoApp, + RUN_TIMEOUT, + waitForEngine, + waitRunCompleted, +} from "./helpers"; + +// The fast LJ melt plus an msd compute (1D data for the live figure) and a +// longer run: at the default speed the engine syncs every timestep, so 3000 +// steps keeps the run live for several seconds — a reliable window for the +// live-analysis assertions below without slowing the suite meaningfully. +const ANALYSIS_LJ_SCRIPT = FAST_LJ_SCRIPT.replace( + "run 400", + "compute msd all msd\nrun 3000", +); + +test("run a simulation end-to-end and inspect the recorded run", async ({ + page, +}) => { + await test.step("create a project with a fast LJ script", async () => { + await gotoApp(page); + await createFastLjProject( + page, + "LJ melt", + "lj-melt", + "in.melt", + ANALYSIS_LJ_SCRIPT, + ); + }); + + await test.step("run the simulation and watch live progress", async () => { + await waitForEngine(page); + await page.getByTestId("editor-save-and-run").click(); + // The store navigates to the live run detail screen. + await expect(page.getByTestId("run-detail")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("status-pill-running")).toBeVisible({ + timeout: 60_000, + }); + // Live console follows the engine output. + await expect(page.getByTestId("run-console")).toContainText("LAMMPS", { + timeout: 60_000, + }); + }); + + await test.step("live analysis lists computes and opens the figure", async () => { + // The side panel's analysis section is only rendered for the live run. + await expect(page.getByTestId("run-analysis-section")).toBeVisible(); + // The msd compute appears once the engine synchronizes UI state. + await expect(page.getByTestId("analysis-entry-msd")).toBeVisible({ + timeout: 60_000, + }); + // 1D entries open the real-time figure in the shell modal. + await page.getByTestId("analysis-entry-msd").click(); + await expect(page.getByTestId("analysis-figure")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("analysis-figure")).toHaveCount(0); + }); + + await test.step("the console strip collapses and expands", async () => { + // The run's own setup output is in the console whether the run is still + // live or just finished (the persisted log renders in the same strip). + // The engine banner ("LAMMPS (…)") is NOT reliable here: the live output + // buffer is cleared at run start, after the banner was printed. + const setupLine = "Created 108 atoms"; + await expect(page.getByTestId("run-console")).toContainText(setupLine); + await page.getByTestId("run-console-toggle").click(); + // Collapsed: the header bar stays, the log body is gone. + await expect(page.getByTestId("run-console")).not.toContainText(setupLine); + await expect(page.getByTestId("run-console")).toContainText( + "console — log.lammps", + ); + // Clicking the header bar itself toggles too. + await page.getByTestId("run-console").click(); + await expect(page.getByTestId("run-console")).toContainText(setupLine); + }); + + await test.step("the run completes", async () => { + await waitRunCompleted(page); + }); + + await test.step("runs/run-001 is listed in the Runs tab", async () => { + await page.getByTestId("run-detail-back").click(); + await expect(page.getByTestId("runs-tab")).toBeVisible(); + const row = page.getByTestId("run-row-run-001"); + await expect(row).toBeVisible(); + await expect(row).toContainText("in.melt"); + await expect(row).toContainText("runs/run-001"); + }); + + await test.step("output files include log.lammps", async () => { + await page.getByTestId("run-row-run-001").click(); + await expect(page.getByTestId("run-detail")).toBeVisible(); + await expect(page.getByTestId("status-pill-completed")).toBeVisible(); + await expect(page.getByTestId("run-output-log.lammps")).toBeVisible({ + timeout: 30_000, + }); + }); + + await test.step("the log opens as a read-only snapshot", async () => { + await page.getByTestId("run-output-log.lammps").click(); + await expect(page.getByTestId("editor-screen")).toBeVisible(); + await expect(page.getByTestId("editor-filename")).toHaveText( + "runs/run-001/log.lammps", + ); + await expect(page.getByTestId("editor-snapshot-banner")).toContainText( + "Read-only — snapshot of run #001", + ); + // Read-only files have no autosave indicator and no Save & run. + await expect(page.getByTestId("editor-save-state")).toHaveCount(0); + }); + + await test.step("run detail after reload is never blank", async () => { + // Navigate back to the run detail so the ?run= deep link is in the URL. + await page.getByTestId("editor-back").click(); + await page.getByTestId("tab-runs").click(); + await page.getByTestId("run-row-run-001").click(); + await expect(page.getByTestId("run-detail")).toBeVisible(); + + await page.reload(); + await expect(page.getByTestId("run-detail")).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("status-pill-completed")).toBeVisible(); + // Either the captured frame.png or the designed placeholder — never a + // silent black canvas. + await expect( + page.getByTestId("run-frame").or(page.getByTestId("run-placeholder")), + ).toBeVisible({ timeout: 30_000 }); + // The persisted console is shown for finished runs. + await expect(page.getByTestId("run-console")).toContainText( + /Loop time|Total wall time/, + { timeout: RUN_TIMEOUT }, + ); + await expect(page.getByTestId("run-console")).not.toContainText( + "no log recorded", + ); + }); +}); diff --git a/e2e/sweep.spec.ts b/e2e/sweep.spec.ts new file mode 100644 index 00000000..fda7e6bc --- /dev/null +++ b/e2e/sweep.spec.ts @@ -0,0 +1,102 @@ +/** + * Variable sweep: sweep T over two values from the New Run modal, watch + * both runs execute sequentially, verify the runs list shows both rows with + * their vars under a shared sweep header, and verify the persisted + * run.json vars straight from IndexedDB. + */ + +import { test, expect } from "@playwright/test"; +import { + createFastLjProject, + gotoApp, + readContentsRecord, + RUN_TIMEOUT, + waitForEngine, +} from "./helpers"; + +interface RunMetaLike { + status: string; + vars?: Record; + sweepId?: string; +} + +test("sweep T over two values runs sequentially and records vars", async ({ + page, +}) => { + await test.step("create a project with a parameterized script", async () => { + await gotoApp(page); + await createFastLjProject(page, "Sweep study", "sweep-study"); + await page.getByTestId("editor-back").click(); + }); + + await test.step("configure a 2-value sweep of T", async () => { + await waitForEngine(page); // Start runs is disabled until then + await page.getByTestId("run-menu-toggle").click(); + await page.getByTestId("run-menu-sweep").click(); + await expect(page.getByTestId("new-run-modal")).toBeVisible(); + // No input script is designated yet, so the chooser comes first. + await page.getByTestId("script-choice-in.melt").click(); + await expect(page.getByTestId("run-input-script")).toHaveText("in.melt"); + + await expect(page.getByTestId("var-row-T")).toBeVisible(); + await page.getByTestId("sweep-toggle-T").click(); + await page.getByTestId("var-from-T").fill("1"); + await page.getByTestId("var-to-T").fill("2"); + await page.getByTestId("var-steps-T").fill("2"); + await expect(page.getByTestId("run-summary")).toContainText( + "Creates 2 runs", + ); + await expect(page.getByTestId("run-summary")).toContainText("T = 1, 2"); + await expect(page.getByTestId("start-runs")).toHaveText("Start 2 runs"); + }); + + await test.step("both runs execute sequentially to completion", async () => { + await page.getByTestId("start-runs").click(); + // The store navigates to each run's detail screen as it starts; wait + // for the second run's detail, then for its completion. + await expect(page.getByTestId("run-detail")).toBeVisible({ + timeout: 60_000, + }); + await expect( + page.getByTestId("run-detail").getByText("runs/run-002"), + ).toBeVisible({ timeout: RUN_TIMEOUT }); + await expect(page.getByTestId("status-pill-completed")).toBeVisible({ + timeout: RUN_TIMEOUT, + }); + }); + + await test.step("the runs list shows both rows under one sweep header", async () => { + await page.getByTestId("run-detail-back").click(); + await expect(page.getByTestId("runs-tab")).toBeVisible(); + const row1 = page.getByTestId("run-row-run-001"); + const row2 = page.getByTestId("run-row-run-002"); + await expect(row1).toBeVisible(); + await expect(row2).toBeVisible(); + await expect(row1).toContainText("T = 1"); + await expect(row2).toContainText("T = 2"); + const sweepHeaders = page.locator('[data-testid^="sweep-header-"]'); + await expect(sweepHeaders).toHaveCount(1); + await expect(sweepHeaders.first()).toContainText("Sweep · T = 1 → 2 · 2 runs"); + }); + + await test.step("run.json records the vars (read from IndexedDB)", async () => { + const record1 = await readContentsRecord( + page, + "sweep-study/runs/run-001/.atomify/run.json", + ); + const record2 = await readContentsRecord( + page, + "sweep-study/runs/run-002/.atomify/run.json", + ); + expect(record1).not.toBeNull(); + expect(record2).not.toBeNull(); + const meta1 = record1!.content as RunMetaLike; + const meta2 = record2!.content as RunMetaLike; + expect(meta1.status).toBe("completed"); + expect(meta2.status).toBe("completed"); + expect(meta1.vars).toEqual({ T: 1 }); + expect(meta2.vars).toEqual({ T: 2 }); + expect(meta1.sweepId).toBeTruthy(); + expect(meta1.sweepId).toBe(meta2.sweepId); + }); +}); diff --git a/e2e/theme.spec.ts b/e2e/theme.spec.ts new file mode 100644 index 00000000..ea3dafce --- /dev/null +++ b/e2e/theme.spec.ts @@ -0,0 +1,44 @@ +/** + * Theme toggle: switching to light sets [data-theme=light] on the document + * root, the token-driven shell background follows, and the choice persists + * across a reload (localStorage). + */ + +import { test, expect } from "@playwright/test"; +import { gotoApp } from "./helpers"; + +// tokens.css: --bg is #0b0d12 (dark) and #f3f5f9 (light). +const DARK_BG = "rgb(11, 13, 18)"; +const LIGHT_BG = "rgb(243, 245, 249)"; + +test("light theme toggles the tokens and persists across reload", async ({ + page, +}) => { + await test.step("the app starts in the dark theme", async () => { + await gotoApp(page); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await expect(page.getByTestId("shell-root")).toHaveCSS( + "background-color", + DARK_BG, + ); + }); + + await test.step("toggling light updates data-theme and the background", async () => { + await page.getByTestId("theme-light").click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + await expect(page.getByTestId("shell-root")).toHaveCSS( + "background-color", + LIGHT_BG, + ); + }); + + await test.step("the choice persists across a reload", async () => { + await page.reload(); + await expect(page.getByTestId("shell-root")).toBeVisible(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "light"); + await expect(page.getByTestId("shell-root")).toHaveCSS( + "background-color", + LIGHT_BG, + ); + }); +}); diff --git a/index.html b/index.html index 25733c7a..9ebf635b 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,13 @@ Atomify LAMMPS - a real time simulator in the browser + + + + +