blendtutor_core/site/mod.rs
1//! Static-site assembly: turn a course into a browser-deployable lesson site.
2//!
3//! `core::site` owns assembly *only* (ADR-0008, §4.1): it decides which files a
4//! site contains and their contents, and commits them to disk. It does **not**
5//! call LLMs or execute learner code — that happens in the browser at learner
6//! time, via the runtime the emitted assets boot (webR / Pyodide).
7//!
8//! The split (§2.1, §2.3): [`plan_site`] is pure — given the loaded lessons and a
9//! [`BuildTarget`] it returns the [`SiteFiles`] (relative paths + contents), with
10//! no filesystem touch, so a whole site is snapshot-testable. [`write_site`] is
11//! the single effectful step. The [`SiteLesson`] JSON is the contract between Rust
12//! (author side) and the JS runtime (learner side): a Rust-side change that keeps
13//! the JSON shape leaves the runtime untouched (§3.2).
14
15mod pyodide;
16mod webr;
17
18use std::error::Error;
19use std::fmt;
20use std::path::{Path, PathBuf};
21
22use base64::Engine;
23use rand_core::{CryptoRng, RngCore};
24use serde::{Deserialize, Serialize};
25
26use crate::course::{LessonSlug, SiteConfig};
27use crate::crypto;
28use crate::lesson::{Language, Lesson};
29
30/// Which browser runtime a built site targets, and so which language it serves.
31///
32/// An enum, not a string (§1.2): an unknown target is rejected at the CLI parse
33/// boundary rather than carried as data. Each target serves exactly one language
34/// (§3.4); the build refuses a course whose lessons do not match it (§1.3.1).
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum BuildTarget {
37 /// webR — runs R in the browser, for R lessons.
38 Webr,
39 /// Pyodide — runs Python in the browser, for Python lessons.
40 Pyodide,
41}
42
43impl BuildTarget {
44 /// The language this target serves: webR runs R, Pyodide runs Python.
45 pub fn language(self) -> Language {
46 match self {
47 BuildTarget::Webr => Language::R,
48 BuildTarget::Pyodide => Language::Python,
49 }
50 }
51}
52
53impl fmt::Display for BuildTarget {
54 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55 f.write_str(match self {
56 BuildTarget::Webr => "webr",
57 BuildTarget::Pyodide => "pyodide",
58 })
59 }
60}
61
62/// Whether a built site's lessons carry eval validation, and at what accuracy.
63///
64/// A *represented* state (§1.2), never a missing-file inference: the effectful
65/// shell decides whether a report is present and hands the pure assembly an
66/// explicit value, so the eval-results page can always distinguish "validated at
67/// N%" from "not validated". A missing report can never masquerade as a real 0%.
68#[derive(Debug, Clone, PartialEq)]
69pub enum EvalSummary {
70 /// Evals were run; `accuracy` is the figure the Slice-13 report recorded,
71 /// carried through as-is (§3.2) and only formatted — never recomputed here.
72 Validated {
73 /// The suite's accuracy as recorded, a fraction in `[0.0, 1.0]`.
74 accuracy: f64,
75 },
76 /// No eval report accompanied the course — the page says so explicitly.
77 NotValidated,
78}
79
80impl EvalSummary {
81 /// The `data-eval-status` body attribute the *validated* page carries. The
82 /// single source for the marker, so the page and any probe asserting it cannot
83 /// drift; mutually exclusive — as a substring — with
84 /// [`Self::NOT_VALIDATED_MARKER`], so asserting one present and the other
85 /// absent pins the state unambiguously.
86 pub const VALIDATED_MARKER: &'static str = r#"data-eval-status="validated""#;
87 /// The `data-eval-status` body attribute the *not-validated* page carries — the
88 /// observable form of the [`EvalSummary::NotValidated`] state (§1.2).
89 pub const NOT_VALIDATED_MARKER: &'static str = r#"data-eval-status="not-validated""#;
90}
91
92/// One lesson as the in-browser runner consumes it: the Rust↔JS JSON contract
93/// (ADR-0008, §3.2).
94///
95/// Deliberately *not* the internal [`Lesson`]: it carries only what the runtime
96/// needs and drops `llm_evaluation_prompt` (a server/CLI concern never shipped to
97/// the client). A Rust-side change invisible to this shape is invisible to JS.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
99pub struct SiteLesson {
100 /// The lesson's course-scoped slug (its manifest id).
101 pub id: String,
102 /// The lesson's human-readable title (its `lesson_name`).
103 pub title: String,
104 /// What the learner is asked to do.
105 pub prompt: String,
106 /// Optional starter code for the editor.
107 pub code_template: Option<String>,
108 /// The check code-strings, run against a submission in the browser to grade it.
109 pub checks: Vec<String>,
110 /// Third-party packages the lesson's code depends on. Always serialized as
111 /// an array — empty when the lesson declares none, never `null` or omitted
112 /// — so the JS runtime contract shape stays stable (ADR-0011, mirroring the
113 /// `solution: null` precedent from ADR-0008).
114 pub packages: Vec<String>,
115 /// The author's known-correct answer, for the runner to self-verify.
116 pub solution: Option<String>,
117 /// Optional learner-facing hints, rendered as an expandable `<details>` panel
118 /// in the browser. Always serialized (null when absent, never dropped via
119 /// `skip_serializing_if`) so the contract shape stays stable — mirroring the
120 /// `solution: null` precedent from ADR-0008.
121 pub hints: Option<String>,
122 /// Optional learner-facing gotchas (common pitfalls), rendered as an
123 /// expandable `<details>` panel in the browser. Always serialized (null
124 /// when absent, never dropped via `skip_serializing_if`) so the contract
125 /// shape stays stable — mirroring the `solution: null` and `hints: null`
126 /// precedents from ADR-0008.
127 pub gotchas: Option<String>,
128 /// Optional author rubric, added to the in-browser LLM feedback prompt
129 /// between the task and the submission (ADR-0020). Always serialized (null
130 /// when absent) like `solution`, `hints`, and `gotchas`.
131 pub success_criteria: Option<String>,
132}
133
134impl SiteLesson {
135 /// Derive the contract row from a discovered lesson and its course slug.
136 ///
137 /// Pure (§2.2): a projection of the already-validated [`Lesson`] that keeps
138 /// only the fields the browser needs. Dropping `llm_evaluation_prompt` here is
139 /// what decouples the JS runtime from the internal schema (§3.2).
140 fn from_lesson(slug: &LessonSlug, lesson: &Lesson) -> SiteLesson {
141 SiteLesson {
142 id: slug.to_string(),
143 title: lesson.lesson_name.to_string(),
144 prompt: lesson.exercise.prompt.clone(),
145 code_template: lesson.exercise.code_template.clone(),
146 checks: lesson.checks.clone(),
147 packages: lesson.packages.clone(),
148 solution: lesson.exercise.solution.clone(),
149 hints: lesson.exercise.hints.clone(),
150 gotchas: lesson.exercise.gotchas.clone(),
151 success_criteria: lesson.exercise.success_criteria.clone(),
152 }
153 }
154}
155
156/// One file in a planned site: a path relative to the output root and its
157/// contents — the unit [`write_site`] commits to disk.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct SiteFile {
160 /// Path relative to the output directory (e.g. `index.html`,
161 /// `lessons/0.json`).
162 pub path: PathBuf,
163 /// The file's full contents.
164 pub contents: String,
165}
166
167/// A fully planned site: every file it comprises, in a deterministic order.
168///
169/// Produced by the pure [`plan_site`] and consumed by the effectful
170/// [`write_site`], so the whole site can be asserted without touching a
171/// filesystem (§2.3).
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct SiteFiles {
174 files: Vec<SiteFile>,
175}
176
177impl SiteFiles {
178 /// The planned files, in deterministic order.
179 pub fn files(&self) -> &[SiteFile] {
180 &self.files
181 }
182}
183
184/// Why a site could not be planned.
185#[derive(Debug)]
186pub enum PlanError {
187 /// A lesson's language does not match the target's (e.g. an R lesson built for
188 /// the Pyodide/Python target). Refused before any file is produced (§1.3.1) so
189 /// a broken, mixed-language site never ships.
190 LanguageMismatch {
191 /// The offending lesson's course slug.
192 lesson: LessonSlug,
193 /// The language that lesson is authored in.
194 lesson_language: Language,
195 /// The target the build was asked for.
196 target: BuildTarget,
197 },
198}
199
200impl fmt::Display for PlanError {
201 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202 match self {
203 PlanError::LanguageMismatch {
204 lesson,
205 lesson_language,
206 target,
207 } => write!(
208 f,
209 "lesson {lesson} is written in {lesson_language:?}, which does not match \
210 the {target} target's language ({:?})",
211 target.language()
212 ),
213 }
214 }
215}
216
217impl Error for PlanError {}
218
219/// A course's bundled eval report could not be read as the Slice-13 JSON artifact.
220///
221/// Distinct from an *absent* report (which the CLI shell maps to
222/// [`EvalSummary::NotValidated`] before parsing): a report that is present but
223/// unreadable fails the build loudly rather than silently dropping to
224/// not-validated, so a corrupt artifact can never quietly unvalidate a course.
225#[derive(Debug)]
226pub enum EvalReportError {
227 /// The report is not valid JSON, or lacks the expected `accuracy` shape.
228 Malformed(serde_json::Error),
229 /// The report parsed but its accuracy lies outside the representable
230 /// `[0.0, 1.0]` (§1.3.1): a hand-edited or corrupt figure that would otherwise
231 /// render nonsense like `200%`. Rejected at the read boundary, not carried.
232 AccuracyOutOfRange {
233 /// The out-of-range figure the report recorded.
234 accuracy: f64,
235 },
236}
237
238impl fmt::Display for EvalReportError {
239 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240 match self {
241 EvalReportError::Malformed(err) => {
242 write!(f, "eval report is not valid Slice-13 JSON: {err}")
243 }
244 EvalReportError::AccuracyOutOfRange { accuracy } => write!(
245 f,
246 "eval report accuracy {accuracy} is outside the expected range [0.0, 1.0]"
247 ),
248 }
249 }
250}
251
252impl Error for EvalReportError {
253 fn source(&self) -> Option<&(dyn Error + 'static)> {
254 match self {
255 EvalReportError::Malformed(err) => Some(err),
256 EvalReportError::AccuracyOutOfRange { .. } => None,
257 }
258 }
259}
260
261/// The in-browser runner core every target's `lesson-runner.js` imports: the
262/// cross-target scaffolding (lesson loading, rendering, pass/fail reporting) that
263/// a target's thin adapter sits atop. Shared, not duplicated per target (§4.2).
264const LESSON_RUNNER_CORE_JS: &str = include_str!(concat!(
265 env!("CARGO_MANIFEST_DIR"),
266 "/assets/shared/lesson-runner-core.js"
267));
268/// The COOP/COEP service-worker shim, so cross-origin isolation (and so
269/// SharedArrayBuffer) works on GitHub Pages. Vendored once and shared by every
270/// target — the isolation it provides is runtime-agnostic.
271const COI_SERVICEWORKER_JS: &str = include_str!(concat!(
272 env!("CARGO_MANIFEST_DIR"),
273 "/assets/shared/coi-serviceworker.js"
274));
275/// The BYOK feedback backend: the `FeedbackBackend` JS contract + its
276/// `byok-anthropic` impl (Slice 18). Shared by every target — the Anthropic
277/// browser call is identical whether the lesson runs in webR or Pyodide, so it is
278/// assembled once here rather than forked per target (§4.2). The seam a future
279/// backend (WebLLM) plugs into with no change to lesson rendering or execution.
280const FEEDBACK_JS: &str = include_str!(concat!(
281 env!("CARGO_MANIFEST_DIR"),
282 "/assets/shared/feedback.js"
283));
284/// The shared design-token stylesheet: the `--bt-` custom-property system and
285/// semantic region rules embedded at compile time and loaded by both targets'
286/// page shells via `<link rel="stylesheet" href="styles.css">`. Shared, not
287/// duplicated per target (§4.2): a single source of truth for the cross-target
288/// visual vocabulary, so a token-value change touches one `:root` declaration
289/// and both targets pick it up without touching Rust or JS.
290const STYLES_CSS: &str = include_str!(concat!(
291 env!("CARGO_MANIFEST_DIR"),
292 "/assets/shared/styles.css"
293));
294/// The vendored CodeMirror 6 ESM bundle: a pre-built (one-time esbuild authoring,
295/// NOT build-time codegen — ADR-0008) bundle of the CM6 core (`EditorView`) plus
296/// the R and Python language packs and the UX-polish extensions (`lineNumbers`,
297/// `highlightActiveLine`, `bracketMatching`, `indentWithTab`). Shared by every
298/// target — the editor is runtime-agnostic, so the bundle is assembled once here
299/// rather than forked per target (§4.2). Main-thread-only (no web workers) to
300/// avoid COOP/COEP conflicts; the seam AC-2/AC-3 consume read-only.
301const CODEMIRROR_JS: &str = include_str!(concat!(
302 env!("CARGO_MANIFEST_DIR"),
303 "/assets/shared/codemirror.js"
304));
305
306/// A build target's own client assets: its page shell and its runner adapter.
307///
308/// A named-field struct, not two positional `&str`s (§1.4): the shell and the
309/// runner are both HTML/JS strings of the same type, so a caller could silently
310/// transpose them — injecting runner JS where the page shell belongs — with no
311/// compile error. Naming the fields makes that illegal state unrepresentable at
312/// the one seam every target extends through.
313pub(super) struct TargetAssets<'a> {
314 /// The target's `index.html` page shell.
315 pub index_html: &'a str,
316 /// The target's `lesson-runner.js` runtime adapter.
317 pub lesson_runner_js: &'a str,
318}
319
320/// Render the `config.js` contract file: the `window.__btConfig` global the
321/// feedback.js rate limiter reads. Pure (§2.2): a direct projection of the
322/// [`SiteConfig`] into the JS contract shape — the Rust→JS boundary for
323/// site-level configuration (§3.2), alongside the per-lesson JSON contract.
324fn config_js(site_config: &SiteConfig) -> String {
325 format!(
326 "window.__btConfig = {{ maxFeedbackPerSession: {} }};",
327 site_config.max_feedback_per_session
328 )
329}
330
331/// Assemble a site from one target's [`TargetAssets`] and the loaded lessons.
332///
333/// The shared scaffolding both targets reuse (§4.2): a target supplies only its
334/// own shell and runner; the runner core, the COOP/COEP shim, the `config.js`
335/// contract, and the per-lesson JSON contract — keyed by position, never by
336/// slug, so an author's slug can never reach the filesystem as a path — are
337/// identical across targets and laid out here in deterministic order. The slug
338/// rides inside the JSON as `id`; `lessons.json` is the ordered slug index the
339/// runner enumerates. `config.js` sits immediately before `feedback.js` so the
340/// `window.__btConfig` global is available when the rate limiter reads it.
341fn assemble(
342 assets: TargetAssets<'_>,
343 lessons: &[(LessonSlug, Lesson)],
344 site_config: &SiteConfig,
345) -> SiteFiles {
346 let config_contents = config_js(site_config);
347 let mut files = vec![
348 asset("index.html", assets.index_html),
349 asset("lesson-runner.js", assets.lesson_runner_js),
350 asset("lesson-runner-core.js", LESSON_RUNNER_CORE_JS),
351 asset("coi-serviceworker.js", COI_SERVICEWORKER_JS),
352 asset("config.js", &config_contents),
353 asset("feedback.js", FEEDBACK_JS),
354 asset("styles.css", STYLES_CSS),
355 asset("codemirror.js", CODEMIRROR_JS),
356 ];
357
358 let mut slugs = Vec::new();
359 for (index, (slug, lesson)) in lessons.iter().enumerate() {
360 let site_lesson = SiteLesson::from_lesson(slug, lesson);
361 files.push(SiteFile {
362 path: format!("lessons/{index}.json").into(),
363 contents: to_json(&site_lesson),
364 });
365 slugs.push(site_lesson.id);
366 }
367 files.push(SiteFile {
368 path: "lessons.json".into(),
369 contents: to_json(&slugs),
370 });
371
372 SiteFiles { files }
373}
374
375/// A static asset file copied verbatim into the site.
376fn asset(path: &str, contents: &str) -> SiteFile {
377 SiteFile {
378 path: path.into(),
379 contents: contents.to_string(),
380 }
381}
382
383/// Serialize a contract value to pretty JSON. Infallible for the plain-data
384/// contract types, so a failure is a programmer error, not a runtime condition.
385fn to_json<T: Serialize>(value: &T) -> String {
386 serde_json::to_string_pretty(value).expect("the site contract serializes infallibly")
387}
388
389/// Fold a Slice-13 eval report's JSON into the page model, taking its accuracy
390/// as-is (§3.2) — the figure the `eval` command recorded, never recomputed here.
391///
392/// Pure (§2.3): the effectful read of the artifact file happens at the CLI edge,
393/// which hands this the bytes. An *absent* report is the shell's concern (it maps
394/// to [`EvalSummary::NotValidated`] without calling this); a *present* report that
395/// does not parse is an [`EvalReportError`], so the build fails loudly rather than
396/// silently treating a corrupt report as unvalidated.
397pub fn eval_summary_from_report_json(json: &str) -> Result<EvalSummary, EvalReportError> {
398 /// The slice of the report contract the page consumes: its aggregate accuracy.
399 /// Per-case results are intentionally ignored — the page shows the headline
400 /// figure, and reading only `accuracy` keeps the build from depending on the
401 /// full case shape.
402 #[derive(Deserialize)]
403 struct ReportArtifact {
404 accuracy: f64,
405 }
406
407 let artifact: ReportArtifact =
408 serde_json::from_str(json).map_err(EvalReportError::Malformed)?;
409 // A genuine Slice-13 report's accuracy is matched/total, always in [0.0, 1.0].
410 // Anything else is a corrupt or hand-edited artifact — reject it at the read
411 // boundary (§1.3.1) so a nonsense figure (`200%`, `-50%`, NaN) never renders.
412 if !(0.0..=1.0).contains(&artifact.accuracy) {
413 return Err(EvalReportError::AccuracyOutOfRange {
414 accuracy: artifact.accuracy,
415 });
416 }
417 Ok(EvalSummary::Validated {
418 accuracy: artifact.accuracy,
419 })
420}
421
422/// The Content-Security-Policy for every built site — the single source of
423/// truth (§1.3.1, §3.2). Both target shells hardcode this same string in their
424/// `<meta http-equiv="Content-Security-Policy">` tag, and [`eval_results_html`]
425/// references this const directly. The test
426/// `plan_site_emits_csp_sri_and_referrer_policy` is the boundary guard that
427/// pins the shells to the const — a drift is a test failure, not a runtime
428/// error.
429///
430/// - `script-src` allows `'unsafe-eval'` + `'wasm-unsafe-eval'` (webR/Pyodide
431/// need `eval` and WASM), both CDN origins, but NO wildcards (`https:`, `*`,
432/// `'unsafe-inline'`).
433/// - `style-src` allows `'unsafe-inline'` because CM6's `StyleModule` injects
434/// `<style>` tags at runtime.
435/// - `connect-src` includes both LLM providers (Fireworks + Anthropic) for
436/// BYOK feedback, and `repo.r-wasm.org` for webR package installs.
437/// - `worker-src` allows `blob:` for webR's channel workers.
438const CSP_POLICY: &str = "\
439default-src 'self'; \
440script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval' \
441https://cdn.jsdelivr.net https://webr.r-wasm.org; \
442style-src 'self' 'unsafe-inline'; \
443connect-src 'self' https://cdn.jsdelivr.net https://webr.r-wasm.org \
444https://repo.r-wasm.org https://api.fireworks.ai https://api.anthropic.com; \
445img-src 'self' data: blob:; \
446worker-src 'self' blob:; \
447frame-src 'none'; \
448object-src 'none'; \
449base-uri 'self';";
450
451/// Render the standalone eval-results page for a site's [`EvalSummary`].
452///
453/// §5.1: the single function mapping the validation state to its page. Each state
454/// renders its own `data-eval-status` marker and human copy, so a learner — and
455/// the build's probe — can always tell a validated course (with its accuracy)
456/// from an unvalidated one (§1.2); a missing report never renders as a real 0%.
457fn eval_results_html(summary: &EvalSummary) -> String {
458 let (status, body) = match summary {
459 EvalSummary::Validated { accuracy } => (
460 EvalSummary::VALIDATED_MARKER,
461 format!(
462 "These lessons were validated against an eval suite at \
463 <strong>{}% accuracy</strong>.",
464 accuracy_percent(*accuracy)
465 ),
466 ),
467 EvalSummary::NotValidated => (
468 EvalSummary::NOT_VALIDATED_MARKER,
469 "These lessons have <strong>not been validated</strong> — no eval \
470 report accompanied this course."
471 .to_string(),
472 ),
473 };
474 format!(
475 "<!doctype html>\n\
476 <html lang=\"en\">\n\
477 <head>\n\
478 <meta charset=\"utf-8\">\n\
479 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
480 <meta http-equiv=\"Content-Security-Policy\" content=\"{csp_policy}\">\n\
481 <meta name=\"referrer\" content=\"no-referrer\">\n\
482 <title>Eval results</title>\n\
483 </head>\n\
484 <body {status}>\n\
485 <h1>Eval results</h1>\n\
486 <p>{body}</p>\n\
487 </body>\n\
488 </html>\n",
489 csp_policy = CSP_POLICY,
490 )
491}
492
493/// The accuracy as a whole-number percentage for display. Reads the report's
494/// figure as-is (§3.2) and only formats it — no re-scoring.
495fn accuracy_percent(accuracy: f64) -> i64 {
496 (accuracy * 100.0).round() as i64
497}
498
499/// Plan the static site for `lessons`, targeting `target`, folding in the eval
500/// validation `eval` carries.
501///
502/// Pure (§2.1): it decides which files exist and their contents — embedded assets
503/// plus the per-lesson JSON contract — with no filesystem access, so the result is
504/// snapshot-testable. Refuses a language/target mismatch before producing anything
505/// (§1.3.1): every lesson must be in the target's language, or the whole build
506/// fails with [`PlanError::LanguageMismatch`] and no [`SiteFiles`] are returned.
507/// Past that guard the build dispatches on the [`BuildTarget`] seam (§3.4) — each
508/// target contributing only its own shell + runner atop the shared `assemble`.
509///
510/// The eval-results page is target-independent, so it is folded in here once
511/// (§4.1) rather than per target: whichever runtime a site serves, the same
512/// [`EvalSummary`] renders the same page — its accuracy, or an explicit
513/// not-validated state — so "validation travels with the lessons".
514pub fn plan_site(
515 lessons: &[(LessonSlug, Lesson)],
516 target: BuildTarget,
517 eval: &EvalSummary,
518 site_config: &SiteConfig,
519) -> Result<SiteFiles, PlanError> {
520 for (slug, lesson) in lessons {
521 if lesson.language != target.language() {
522 return Err(PlanError::LanguageMismatch {
523 lesson: slug.clone(),
524 lesson_language: lesson.language.clone(),
525 target,
526 });
527 }
528 }
529 let mut site = match target {
530 BuildTarget::Webr => webr::plan(lessons, site_config),
531 BuildTarget::Pyodide => pyodide::plan(lessons, site_config),
532 };
533 site.files.push(SiteFile {
534 path: "eval-results.html".into(),
535 contents: eval_results_html(eval),
536 });
537 Ok(site)
538}
539
540/// Write a planned site to `out_dir`, creating parent directories as needed.
541///
542/// The single effectful step (§2.3): everything about *what* the site contains was
543/// already decided by the pure [`plan_site`]; this only commits it to disk. Called
544/// only after `plan_site` succeeds, so a refused build (a language mismatch) never
545/// reaches here and never creates `out_dir`.
546pub fn write_site(out_dir: &Path, site: &SiteFiles) -> std::io::Result<()> {
547 for file in &site.files {
548 let dest = out_dir.join(&file.path);
549 if let Some(parent) = dest.parent() {
550 std::fs::create_dir_all(parent)?;
551 }
552 std::fs::write(&dest, &file.contents)?;
553 }
554 Ok(())
555}
556
557/// The decrypt shell template — a static asset embedded at compile time (§4.1).
558/// Placeholders `{{payload}}`, `{{salt}}`, `{{iv}}`, `{{iterations}}` are filled
559/// in by [`render_decrypt_shell`]. The shell provides a password input, a decrypt
560/// button, an error element, and inline WebCrypto JS that derives a key via
561/// PBKDF2, decrypts the original page via AES-GCM, injects it into the DOM, and
562/// monkeypatches `fetch` for transparent lesson-JSON decryption.
563const DECRYPT_SHELL_HTML: &str = include_str!(concat!(
564 env!("CARGO_MANIFEST_DIR"),
565 "/assets/shared/decrypt-shell.html"
566));
567
568/// Render the decrypt shell by filling in the placeholders with the encrypted
569/// payload's base64-encoded values (§5.1 — one thing: format the shell).
570fn render_decrypt_shell(payload: &crypto::EncryptedPayload) -> String {
571 DECRYPT_SHELL_HTML
572 .replace(
573 "{{payload}}",
574 &base64::engine::general_purpose::STANDARD.encode(&payload.ciphertext),
575 )
576 .replace(
577 "{{salt}}",
578 &base64::engine::general_purpose::STANDARD.encode(payload.salt),
579 )
580 .replace(
581 "{{iv}}",
582 &base64::engine::general_purpose::STANDARD.encode(payload.nonce),
583 )
584 .replace("{{iterations}}", &crypto::PBKDF2_ITERATIONS.to_string())
585}
586
587/// Whether a planned file is a content file that should be encrypted (vs an
588/// infrastructure file passed through unchanged). Content files: `index.html`,
589/// `eval-results.html`, `lessons/*.json`, `lessons.json`. Infrastructure files:
590/// `lesson-runner.js`, `lesson-runner-core.js`, `coi-serviceworker.js`,
591/// `config.js`, `feedback.js`, `styles.css`, `codemirror.js`.
592fn is_content_file(path: &str) -> bool {
593 path == "index.html"
594 || path == "eval-results.html"
595 || path == "lessons.json"
596 || (path.starts_with("lessons/") && path.ends_with(".json"))
597}
598
599/// An API key embedded in an encrypted site payload (§1.2 — a represented
600/// state, not a loose string). When present, the key rides inside the
601/// AES-256-GCM-encrypted `index.html` payload as a JSON object
602/// `{"html":"...","embeddedKey":{"provider":"...","key":"..."}}`. The decrypt
603/// shell extracts it and sets `window.__btEmbeddedKey` before the page
604/// renders, so `feedback.js`'s `applyEmbeddedKey` can pre-load it into
605/// sessionStorage — skipping the key-entry prompt.
606///
607/// The key is never baked into any shipped file as plaintext: it is inside
608/// the ciphertext, and the base64 alphabet (`A-Za-z0-9+/=`) excludes `_` and
609/// `-`, so the `fw_` / `sk-ant-` prefixes can never appear in the encoded
610/// output.
611#[derive(Debug, Clone, Serialize)]
612pub struct EmbeddedKey {
613 /// The provider id: `fireworks` or `anthropic`.
614 pub provider: String,
615 /// The API key (e.g. `fw_...` or `sk-ant-...`).
616 pub key: String,
617}
618
619/// The JSON wire format for an index.html payload that carries an embedded
620/// key (§3.2 — the Rust↔JS contract). Serialized as
621/// `{"html":"<page>","embeddedKey":{"provider":"...","key":"..."}}` so the
622/// decrypt shell can extract the key and the page HTML from a single
623/// decrypted blob. When no key is embedded, the payload is plain HTML (no
624/// JSON wrapping) — the decrypt shell detects this by trying `JSON.parse`
625/// and falling back to treating the plaintext as HTML.
626#[derive(Serialize)]
627struct EmbeddedPayload<'a> {
628 /// The original page HTML (the content that would be encrypted on its own
629 /// when no key is embedded).
630 html: &'a str,
631 /// The embedded API key, carried alongside the HTML so the decrypt shell
632 /// can set `window.__btEmbeddedKey` before rendering.
633 #[serde(rename = "embeddedKey")]
634 embedded_key: &'a EmbeddedKey,
635}
636
637/// Encrypt all content files in a planned site, replacing them with decrypt
638/// shells (for HTML pages) or base64-encoded encrypted payloads (for JSON
639/// files).
640///
641/// Pure (§5.1): transforms a [`SiteFiles`] into an encrypted [`SiteFiles`].
642/// Infrastructure files (lesson-runner.js, feedback.js, styles.css, etc.) are
643/// passed through unchanged. Content files (index.html, lessons/*.json,
644/// lessons.json, eval-results.html) are encrypted with AES-256-GCM + PBKDF2.
645///
646/// When `embed_key` is `Some`, the `index.html` payload is a JSON object
647/// `{"html":"...","embeddedKey":{...}}` (§3.2) so the decrypt shell can
648/// extract the key and pre-load it into the learner's sessionStorage. The key
649/// is inside the ciphertext — never plaintext in any shipped file.
650///
651/// `plan_site` is unchanged — this is a separate post-processing step (§2.1),
652/// so the 55+ existing `plan_site` tests are unaffected.
653pub fn encrypt_site_files(
654 site: &SiteFiles,
655 password: &str,
656 embed_key: Option<&EmbeddedKey>,
657 rng: &mut (impl RngCore + CryptoRng),
658) -> SiteFiles {
659 let mut encrypted_files = Vec::with_capacity(site.files().len());
660 for file in site.files() {
661 let path_str = file.path.to_string_lossy();
662 if path_str == "index.html" || path_str == "eval-results.html" {
663 // When an embed key is present, the index.html payload is a JSON
664 // object {"html":"...","embeddedKey":{...}} so the decrypt shell
665 // can extract the key and set window.__btEmbeddedKey before
666 // rendering. eval-results.html does not load feedback.js, so it
667 // does not need the embedded key — it stays as plain encrypted
668 // HTML (the decrypt shell detects this via JSON.parse fallback).
669 let plaintext = if path_str == "index.html" {
670 if let Some(ek) = embed_key {
671 serde_json::to_string(&EmbeddedPayload {
672 html: &file.contents,
673 embedded_key: ek,
674 })
675 .expect("the embedded payload serializes infallibly")
676 } else {
677 file.contents.clone()
678 }
679 } else {
680 file.contents.clone()
681 };
682 let payload = crypto::encrypt(&plaintext, password, rng);
683 encrypted_files.push(SiteFile {
684 path: file.path.clone(),
685 contents: render_decrypt_shell(&payload),
686 });
687 } else if is_content_file(&path_str) {
688 let payload = crypto::encrypt(&file.contents, password, rng);
689 encrypted_files.push(SiteFile {
690 path: file.path.clone(),
691 contents: payload.to_base64(),
692 });
693 } else {
694 encrypted_files.push(file.clone());
695 }
696 }
697 SiteFiles {
698 files: encrypted_files,
699 }
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705 use crate::course::Course;
706 use serde_json::Value;
707
708 /// Load the all-R fixture course's lessons in full (slug + parsed lesson).
709 fn r_course() -> Vec<(LessonSlug, Lesson)> {
710 Course::open(Path::new(concat!(
711 env!("CARGO_MANIFEST_DIR"),
712 "/tests/fixtures/r-course"
713 )))
714 .expect("r-course opens")
715 .load_lessons()
716 .expect("r-course lessons load")
717 }
718
719 /// The all-Python `python-course` fixture's lessons in full (slug + parsed
720 /// lesson) — a matching course for the Pyodide target, mirroring `r_course`.
721 fn python_course() -> Vec<(LessonSlug, Lesson)> {
722 Course::open(Path::new(concat!(
723 env!("CARGO_MANIFEST_DIR"),
724 "/tests/fixtures/python-course"
725 )))
726 .expect("python-course opens")
727 .load_lessons()
728 .expect("python-course lessons load")
729 }
730
731 /// Find a planned file by its relative path, or panic listing what is present.
732 fn file<'a>(site: &'a SiteFiles, path: &str) -> &'a SiteFile {
733 site.files()
734 .iter()
735 .find(|f| f.path == Path::new(path))
736 .unwrap_or_else(|| {
737 let present: Vec<_> = site.files().iter().map(|f| f.path.clone()).collect();
738 panic!("planned site is missing {path}; present: {present:?}")
739 })
740 }
741
742 /// Plan a site with no eval validation — the default for the tests about
743 /// lesson assembly, not the eval-results page. Eval folding is exercised by the
744 /// dedicated tests below with an explicit [`EvalSummary`]. Uses the default
745 /// [`SiteConfig`] (max_feedback_per_session = 20) — site-config behavior is
746 /// exercised by the dedicated `feedback_rate_limit_*` tests below.
747 fn plan(lessons: &[(LessonSlug, Lesson)], target: BuildTarget) -> Result<SiteFiles, PlanError> {
748 plan_site(
749 lessons,
750 target,
751 &EvalSummary::NotValidated,
752 &SiteConfig::default(),
753 )
754 }
755
756 #[test]
757 fn build_target_language_maps_each_runtime_to_its_language() {
758 assert_eq!(BuildTarget::Webr.language(), Language::R);
759 assert_eq!(BuildTarget::Pyodide.language(), Language::Python);
760 }
761
762 #[test]
763 fn build_target_display_names_each_runtime() {
764 assert_eq!(BuildTarget::Webr.to_string(), "webr");
765 assert_eq!(BuildTarget::Pyodide.to_string(), "pyodide");
766 }
767
768 #[test]
769 fn site_lesson_from_lesson_projects_each_contract_field() {
770 let lessons = r_course();
771 let (slug, lesson) = &lessons[0];
772 let site_lesson = SiteLesson::from_lesson(slug, lesson);
773
774 // Every field is pinned so a producer mutation that swaps two sources is
775 // caught — and so the dropped `llm_evaluation_prompt` stays dropped.
776 assert_eq!(site_lesson.id, "add-two");
777 assert_eq!(site_lesson.title, "Add Two Numbers");
778 assert_eq!(site_lesson.prompt, lesson.exercise.prompt);
779 assert_eq!(site_lesson.code_template, lesson.exercise.code_template);
780 assert_eq!(site_lesson.checks, lesson.checks);
781 assert_eq!(site_lesson.packages, lesson.packages);
782 assert_eq!(site_lesson.solution, lesson.exercise.solution);
783 assert!(
784 site_lesson.solution.as_deref().unwrap().contains("x + y"),
785 "the add-two solution rides the contract verbatim"
786 );
787 assert_eq!(
788 site_lesson.hints, lesson.exercise.hints,
789 "hints ride the contract verbatim from exercise.hints"
790 );
791 assert_eq!(
792 site_lesson.gotchas, lesson.exercise.gotchas,
793 "gotchas ride the contract verbatim from exercise.gotchas"
794 );
795 assert_eq!(
796 site_lesson.success_criteria, lesson.exercise.success_criteria,
797 "success_criteria ride the contract verbatim (ADR-0020)"
798 );
799 }
800
801 #[test]
802 fn plan_site_webr_assembles_the_shell_runner_shim_and_one_json_per_lesson() {
803 let site = plan(&r_course(), BuildTarget::Webr).expect("an all-R course plans for webr");
804
805 // The page shell, the runner, and the COOP/COEP shim all land.
806 assert!(
807 file(&site, "index.html")
808 .contents
809 .contains("coi-serviceworker.js")
810 );
811 assert!(
812 file(&site, "lesson-runner.js")
813 .contents
814 .to_lowercase()
815 .contains("webr"),
816 "the runner boots webR"
817 );
818 // The shared runner core and the shim file both land (the shim's
819 // referencing from index.html is asserted above).
820 let _ = file(&site, "lesson-runner-core.js");
821 let _ = file(&site, "coi-serviceworker.js");
822
823 // One JSON per manifest entry — count matches the course, neither dropped
824 // nor duplicated.
825 let per_lesson = site
826 .files()
827 .iter()
828 .filter(|f| f.path.starts_with("lessons/"))
829 .count();
830 assert_eq!(per_lesson, 2, "two lessons -> two per-lesson JSON files");
831 let _ = file(&site, "lessons/0.json");
832 let _ = file(&site, "lessons/1.json");
833 }
834
835 #[test]
836 fn plan_site_serializes_each_lesson_to_the_browser_contract() {
837 let site = plan(&r_course(), BuildTarget::Webr).expect("plans");
838
839 // The per-lesson JSON carries exactly the contract fields the runner reads.
840 let first: Value = serde_json::from_str(&file(&site, "lessons/0.json").contents)
841 .expect("the per-lesson JSON parses");
842 assert_eq!(first["id"], "add-two");
843 assert_eq!(first["title"], "Add Two Numbers");
844 assert!(first["checks"].as_array().unwrap().len() == 2);
845 assert!(first["solution"].as_str().unwrap().contains("x + y"));
846 assert!(
847 first["hints"].as_str().unwrap().contains("assignment"),
848 "the add-two hints ride the contract JSON verbatim"
849 );
850
851 // The lessons index is the ordered slug list the runner enumerates — by
852 // index, so a lesson slug never becomes a filesystem path.
853 let index: Value =
854 serde_json::from_str(&file(&site, "lessons.json").contents).expect("index parses");
855 assert_eq!(index, serde_json::json!(["add-two", "square"]));
856 }
857
858 #[test]
859 fn plan_site_serializes_a_missing_solution_as_json_null_not_dropped() {
860 // solution is optional (ADR-0008): a lesson without one must still build,
861 // with the field present-but-null so the contract shape stays stable for the
862 // runner — never silently dropped (which a later `skip_serializing_if` would
863 // do, breaking `lessons[i].solution`). course_basic's R lesson carries none.
864 let r_lessons: Vec<(LessonSlug, Lesson)> = Course::open(Path::new(concat!(
865 env!("CARGO_MANIFEST_DIR"),
866 "/tests/fixtures/course_basic"
867 )))
868 .expect("course_basic opens")
869 .load_lessons()
870 .expect("course_basic lessons load")
871 .into_iter()
872 .filter(|(_, lesson)| lesson.language == Language::R)
873 .collect();
874 assert!(!r_lessons.is_empty(), "course_basic has an R lesson");
875
876 let site =
877 plan(&r_lessons, BuildTarget::Webr).expect("a solution-less R course still plans");
878 let lesson: Value =
879 serde_json::from_str(&file(&site, "lessons/0.json").contents).expect("parses");
880 assert!(
881 lesson.get("solution").is_some() && lesson["solution"].is_null(),
882 "a missing solution serializes as null, never dropped: {lesson}"
883 );
884 }
885
886 #[test]
887 fn plan_site_serializes_hints_when_present() {
888 // hints is optional: a lesson that carries one must serialize the text
889 // verbatim into the per-lesson JSON so the browser runner can render it
890 // in an expandable <details>. r-course's add-two lesson carries hints.
891 let site = plan(&r_course(), BuildTarget::Webr).expect("plans");
892 let first: Value = serde_json::from_str(&file(&site, "lessons/0.json").contents)
893 .expect("the per-lesson JSON parses");
894 assert!(
895 first["hints"].is_string(),
896 "a lesson with hints serializes them as a JSON string, got: {first}"
897 );
898 assert!(
899 first["hints"].as_str().unwrap().contains("assignment"),
900 "the hints text rides the contract verbatim: {first}"
901 );
902 }
903
904 #[test]
905 fn plan_site_serializes_a_missing_hints_as_json_null_not_dropped() {
906 // hints is optional: a lesson without one must still build, with the
907 // field present-but-null so the contract shape stays stable for the
908 // runner — never silently dropped (which a `skip_serializing_if` would
909 // do, breaking `lessons[i].hints`). course_basic's R lesson carries none.
910 let r_lessons: Vec<(LessonSlug, Lesson)> = Course::open(Path::new(concat!(
911 env!("CARGO_MANIFEST_DIR"),
912 "/tests/fixtures/course_basic"
913 )))
914 .expect("course_basic opens")
915 .load_lessons()
916 .expect("course_basic lessons load")
917 .into_iter()
918 .filter(|(_, lesson)| lesson.language == Language::R)
919 .collect();
920 assert!(!r_lessons.is_empty(), "course_basic has an R lesson");
921
922 let site = plan(&r_lessons, BuildTarget::Webr).expect("a hints-less R course still plans");
923 let lesson: Value =
924 serde_json::from_str(&file(&site, "lessons/0.json").contents).expect("parses");
925 assert!(
926 lesson.get("hints").is_some() && lesson["hints"].is_null(),
927 "a missing hints serializes as null, never dropped: {lesson}"
928 );
929 }
930
931 #[test]
932 fn plan_site_serializes_gotchas_when_present() {
933 // gotchas is optional: a lesson that carries one must serialize the text
934 // verbatim into the per-lesson JSON so the browser runner can render it.
935 // We parse a lesson with bullet-formatted gotchas and pair it with a slug
936 // from the r-course fixture (the slug is just an identifier).
937 use crate::lesson::Lesson;
938 let yaml = r#"
939lesson_name: "Gotcha Lesson"
940language: R
941exercise:
942 prompt: "Write add_two(x, y)."
943 gotchas: |
944 - R uses '<-' for assignment, not '='.
945 - Functions return their last expression automatically.
946 llm_evaluation_prompt: "Grade this: {student_code}"
947"#;
948 let lesson = Lesson::parse(yaml).expect("a lesson with gotchas should parse");
949 let slug = r_course()[0].0.clone();
950 let lessons = [(slug, lesson)];
951 let site = plan(&lessons, BuildTarget::Webr).expect("plans");
952 let first: Value = serde_json::from_str(&file(&site, "lessons/0.json").contents)
953 .expect("the per-lesson JSON parses");
954 assert!(
955 first["gotchas"].is_string(),
956 "a lesson with gotchas serializes them as a JSON string, got: {first}"
957 );
958 assert!(
959 first["gotchas"].as_str().unwrap().contains("'<-'"),
960 "the gotchas text rides the contract verbatim: {first}"
961 );
962 }
963
964 #[test]
965 fn plan_site_serializes_a_missing_gotchas_as_json_null_not_dropped() {
966 // gotchas is optional: a lesson without one must still build, with the
967 // field present-but-null so the contract shape stays stable for the
968 // runner — never silently dropped (which a `skip_serializing_if` would
969 // do, breaking `lessons[i].gotchas`). course_basic's R lesson carries
970 // none. Mirrors the solution: null and hints: null precedents.
971 let r_lessons: Vec<(LessonSlug, Lesson)> = Course::open(Path::new(concat!(
972 env!("CARGO_MANIFEST_DIR"),
973 "/tests/fixtures/course_basic"
974 )))
975 .expect("course_basic opens")
976 .load_lessons()
977 .expect("course_basic lessons load")
978 .into_iter()
979 .filter(|(_, lesson)| lesson.language == Language::R)
980 .collect();
981 assert!(!r_lessons.is_empty(), "course_basic has an R lesson");
982
983 let site =
984 plan(&r_lessons, BuildTarget::Webr).expect("a gotchas-less R course still plans");
985 let lesson: Value =
986 serde_json::from_str(&file(&site, "lessons/0.json").contents).expect("parses");
987 assert!(
988 lesson.get("gotchas").is_some() && lesson["gotchas"].is_null(),
989 "a missing gotchas serializes as null, never dropped: {lesson}"
990 );
991 assert!(
992 lesson.get("success_criteria").is_some(),
993 "success_criteria is always serialized, never dropped: {lesson}"
994 );
995 }
996
997 #[test]
998 fn plan_site_serializes_packages_as_array_even_when_empty() {
999 // ADR-0011: packages must always serialize as an array — empty when the
1000 // lesson declares none, never null or absent — so the JS runtime
1001 // contract shape stays stable (mirrors the solution: null precedent).
1002 // The r-course fixture lessons have no `packages` key.
1003 let site = plan(&r_course(), BuildTarget::Webr).expect("plans");
1004 let lesson: Value =
1005 serde_json::from_str(&file(&site, "lessons/0.json").contents).expect("parses");
1006 assert!(
1007 lesson["packages"].is_array(),
1008 "packages must be an array, not null/absent: {lesson}"
1009 );
1010 assert!(
1011 lesson["packages"].as_array().unwrap().is_empty(),
1012 "a lesson without packages must emit an empty array: {lesson}"
1013 );
1014 }
1015
1016 #[test]
1017 fn plan_site_refuses_an_r_course_built_for_the_pyodide_target() {
1018 // Symmetric twin of the happy path: a language/target mismatch is refused
1019 // (§1.3.1) and returns no SiteFiles, so write_site is never reached.
1020 let err = plan(&r_course(), BuildTarget::Pyodide)
1021 .expect_err("an R course cannot be built for the Python target");
1022 // PlanError has a single variant today, so a catch-all arm would be
1023 // unreachable; assert the shape with `matches!`. A future variant keeps
1024 // this honest — the R/Pyodide fields are pinned, not wildcarded.
1025 assert!(
1026 matches!(
1027 &err,
1028 PlanError::LanguageMismatch {
1029 lesson_language: Language::R,
1030 target: BuildTarget::Pyodide,
1031 ..
1032 }
1033 ),
1034 "expected an R-vs-Pyodide language mismatch, got {err:?}"
1035 );
1036 }
1037
1038 #[test]
1039 fn plan_site_pyodide_assembles_the_shell_runner_core_shim_and_one_json_per_lesson() {
1040 let site = plan(&python_course(), BuildTarget::Pyodide)
1041 .expect("an all-Python course plans for pyodide");
1042
1043 // The shell boots the Pyodide runtime and references the COOP/COEP shim;
1044 // the runner boots Pyodide — not webR — so this is genuinely the Python
1045 // target and not a verbatim copy of the webR assets.
1046 let index = &file(&site, "index.html").contents;
1047 assert!(index.contains("coi-serviceworker.js"));
1048 assert!(
1049 index.to_lowercase().contains("pyodide"),
1050 "index.html boots the Pyodide runtime"
1051 );
1052 assert!(
1053 file(&site, "lesson-runner.js")
1054 .contents
1055 .to_lowercase()
1056 .contains("pyodide"),
1057 "the runner boots Pyodide"
1058 );
1059 // The shared runner core and the shim both land (referencing asserted above).
1060 let _ = file(&site, "lesson-runner-core.js");
1061 let _ = file(&site, "coi-serviceworker.js");
1062
1063 // One JSON per manifest entry — the same per-lesson contract the webR
1064 // target produces, carried across the seam unchanged (§3.2).
1065 let per_lesson = site
1066 .files()
1067 .iter()
1068 .filter(|f| f.path.starts_with("lessons/"))
1069 .count();
1070 assert_eq!(per_lesson, 1, "one lesson -> one per-lesson JSON file");
1071 let first: Value = serde_json::from_str(&file(&site, "lessons/0.json").contents)
1072 .expect("the per-lesson JSON parses");
1073 assert_eq!(first["id"], "add-two");
1074 assert_eq!(first["title"], "Add Two Numbers");
1075 let index_json: Value =
1076 serde_json::from_str(&file(&site, "lessons.json").contents).expect("index parses");
1077 assert_eq!(index_json, serde_json::json!(["add-two"]));
1078 }
1079
1080 #[test]
1081 fn plan_site_carries_the_same_shared_core_and_shim_across_both_targets() {
1082 // The seam's payoff (§3.2, §4.2): the shared runner core and the COOP/COEP
1083 // shim are byte-identical whichever target produced them — only the shell
1084 // and runner glue differ. A divergence here means a target forked the
1085 // shared scaffolding instead of reusing it.
1086 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1087 let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
1088 for shared in [
1089 "lesson-runner-core.js",
1090 "coi-serviceworker.js",
1091 "styles.css",
1092 "codemirror.js",
1093 ] {
1094 assert_eq!(
1095 file(&webr, shared).contents,
1096 file(&pyodide, shared).contents,
1097 "{shared} must be identical across targets"
1098 );
1099 }
1100 // ...while the per-target shell genuinely differs (R vs Python boot) —
1101 // but only by the 3 known diffs (title, boot text, CDN script).
1102 assert_ne!(
1103 file(&webr, "index.html").contents,
1104 file(&pyodide, "index.html").contents,
1105 "each target carries its own shell"
1106 );
1107 }
1108
1109 #[test]
1110 fn plan_site_styles_css_is_present_for_both_targets() {
1111 // AC-1 predicate 1: styles.css lands as a planned file for both targets,
1112 // not just one — a target that forgot to include it would miss here.
1113 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1114 let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
1115 let _ = file(&webr, "styles.css");
1116 let _ = file(&pyodide, "styles.css");
1117 }
1118
1119 #[test]
1120 fn plan_site_shells_have_link_and_no_inline_style() {
1121 // AC-1 predicate 2: both shells reference the external stylesheet and
1122 // have zero inline <style> blocks. A shell that keeps the old inline
1123 // <style> AND adds the <link> would be caught here.
1124 for (target, course) in [
1125 (BuildTarget::Webr, r_course()),
1126 (BuildTarget::Pyodide, python_course()),
1127 ] {
1128 let site = plan(&course, target).expect("plans");
1129 let html = &file(&site, "index.html").contents;
1130 // Accept both self-closing <link ... /> and HTML5 <link ... >
1131 let link_self_closing = html.contains(r#"<link rel="stylesheet" href="styles.css" />"#);
1132 let link_html5 = html.contains(r#"<link rel="stylesheet" href="styles.css">"#);
1133 assert!(
1134 link_self_closing || link_html5,
1135 "{target} index.html must link styles.css"
1136 );
1137 assert!(
1138 !html.contains("<style>"),
1139 "{target} index.html must not contain inline <style>"
1140 );
1141 }
1142 }
1143
1144 #[test]
1145 fn plan_site_styles_css_declares_tokens_and_uses_them() {
1146 // AC-1 predicate 3: styles.css declares --bt- custom properties in :root
1147 // and has >=4 rules referencing var(--bt-). This proves tokens are used,
1148 // not declared-dead.
1149 let site = plan(&r_course(), BuildTarget::Webr).expect("plans");
1150 let css = &file(&site, "styles.css").contents;
1151 assert!(css.contains(":root"), "styles.css must have a :root block");
1152 assert!(
1153 css.contains("--bt-"),
1154 "styles.css must declare --bt- custom properties"
1155 );
1156 let var_refs: Vec<&str> = css
1157 .lines()
1158 .filter(|line| line.contains("var(--bt-"))
1159 .collect();
1160 assert!(
1161 var_refs.len() >= 4,
1162 "styles.css must have >= 4 rules referencing var(--bt-), got {}: {:?}",
1163 var_refs.len(),
1164 var_refs
1165 );
1166 }
1167
1168 #[test]
1169 fn plan_site_workspace_styles_use_tokens_and_status_renders_as_pill() {
1170 // AC-2: workspace CSS contract — selectors present, token usage floor,
1171 // no hardcoded hex, badge/pill via data-status attribute selectors (not
1172 // class selectors), every AC-2-added token consumed outside :root.
1173 let site = plan(&r_course(), BuildTarget::Webr).expect("plans");
1174 let css = &file(&site, "styles.css").contents;
1175
1176 // 1. Workspace selectors present
1177 for selector in [
1178 ".lesson-picker",
1179 "#lesson-title",
1180 "#lesson-prompt",
1181 "#submission",
1182 ".controls",
1183 "#run",
1184 "#submit",
1185 "#lesson-status",
1186 "#output",
1187 ] {
1188 assert!(
1189 css.contains(selector),
1190 "styles.css must contain selector `{selector}`"
1191 );
1192 }
1193
1194 // 2. ≥6 var(--bt-) references within workspace rules (exclude :root).
1195 // Split on the workspace section marker to isolate workspace rules.
1196 let workspace_marker = "/* === workspace === */";
1197 assert!(
1198 css.contains(workspace_marker),
1199 "styles.css must have a workspace section marker"
1200 );
1201 let after_marker = css
1202 .split(workspace_marker)
1203 .nth(1)
1204 .expect("workspace section exists");
1205 // Only count lines in the workspace section that contain var(--bt-)
1206 let workspace_var_refs: Vec<&str> = after_marker
1207 .lines()
1208 .filter(|line| line.contains("var(--bt-"))
1209 .collect();
1210 assert!(
1211 workspace_var_refs.len() >= 6,
1212 "styles.css workspace section must have >= 6 var(--bt-) references, got {}: {:?}",
1213 workspace_var_refs.len(),
1214 workspace_var_refs
1215 );
1216
1217 // 3. No hardcoded hex in workspace rules — exclude the dark-mode @media block
1218 // (which carries hex token overrides by design).
1219 // Match exactly 3, 6, or 8 hex digits after # (full color or shorthand,
1220 // with optional alpha). Avoids matching non-color hex patterns like
1221 // "#feedback" in comments (7 chars, but {6} and {3} won't match 7).
1222 let before_dark_mode = after_marker
1223 .split("/* ── Dark mode ")
1224 .next()
1225 .unwrap_or(after_marker);
1226 let hex_re =
1227 regex_lite::Regex::new(r"#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?\b|#[0-9a-fA-F]{3}\b")
1228 .unwrap();
1229 if let Some(hit) = hex_re.find(before_dark_mode) {
1230 panic!(
1231 "workspace rules must not contain hardcoded hex literals, found `{}`",
1232 hit.as_str()
1233 );
1234 }
1235
1236 // 4. Exactly 5 #lesson-status[data-status="..."] rules
1237 // (4 status values + 1 dark-mode idle override in @media block)
1238 let status_selector_re =
1239 regex_lite::Regex::new(r#"#lesson-status\[data-status="[^"]+"\]"#).unwrap();
1240 let status_matches: Vec<_> = status_selector_re.find_iter(css).collect();
1241 assert_eq!(
1242 status_matches.len(),
1243 5,
1244 "expected exactly 5 `#lesson-status[data-status=\"...\"]` rules \
1245 (4 status values + 1 dark-mode idle override), got {}: {:?}",
1246 status_matches.len(),
1247 status_matches
1248 );
1249
1250 // 5. No .status-* class selectors
1251 let status_class_re = regex_lite::Regex::new(r"\.status-(idle|running|pass|fail)").unwrap();
1252 assert!(
1253 !status_class_re.is_match(css),
1254 "styles.css must not contain .status-* class selectors"
1255 );
1256
1257 // 6. Every AC-2-added token used >= 1 outside :root.
1258 // The 9 tokens from the AC-2 token table — some may already be declared
1259 // by AC-1 as forward-declarations. We check each declared in :root has
1260 // a var(--bt-...) consumer outside :root.
1261 let ac2_tokens = [
1262 "--bt-color-status-idle",
1263 "--bt-color-border",
1264 "--bt-color-brand-hover",
1265 "--bt-space-xs",
1266 "--bt-space-sm",
1267 "--bt-space-md",
1268 "--bt-space-lg",
1269 "--bt-shadow-sm",
1270 "--bt-radius-pill",
1271 ];
1272 // Parse the :root block to find which tokens AC-2's section adds.
1273 // We use the workspace marker to split: tokens in :root but consumed
1274 // by workspace rules are AC-2's responsibility.
1275 let root_block: &str = &css[..css.find(":root").unwrap_or(0)];
1276 let _root_block = root_block; // suppress unused warning in red phase
1277 let outside_root = &css[css.find("}").map(|i| i + 1).unwrap_or(0)..];
1278
1279 for token in &ac2_tokens {
1280 let token_declared = css.contains(&format!("{token}:"));
1281 let token_consumed = outside_root.contains(&format!("var({token}"));
1282 if token_declared {
1283 assert!(
1284 token_consumed,
1285 "AC-2-added token {token} is declared in :root but never used via var() \
1286 outside :root (dead token)"
1287 );
1288 }
1289 }
1290 }
1291
1292 #[test]
1293 fn plan_site_shells_contain_semantic_regions() {
1294 // AC-1 predicate 4: both shells have the required semantic layout regions
1295 // with the expected structure.
1296 for (target, course) in [
1297 (BuildTarget::Webr, r_course()),
1298 (BuildTarget::Pyodide, python_course()),
1299 ] {
1300 let site = plan(&course, target).expect("plans");
1301 let html = &file(&site, "index.html").contents;
1302
1303 // Exactly one of each semantic region
1304 let header_count = html.matches(r#"<header class="site-header">"#).count();
1305 let main_count = html.matches(r#"<main class="workspace">"#).count();
1306 let footer_count = html.matches(r#"<footer class="site-footer">"#).count();
1307 assert_eq!(
1308 header_count, 1,
1309 "{target}: expected 1 <header class=\"site-header\">"
1310 );
1311 assert_eq!(
1312 main_count, 1,
1313 "{target}: expected 1 <main class=\"workspace\">"
1314 );
1315 assert_eq!(
1316 footer_count, 1,
1317 "{target}: expected 1 <footer class=\"site-footer\">"
1318 );
1319
1320 // Header contains <h1>blendtutor</h1>
1321 assert!(
1322 html.contains("<h1>blendtutor</h1>"),
1323 "{target}: header must contain <h1>blendtutor</h1>"
1324 );
1325
1326 // All 6 data-test hooks present
1327 for hook in [
1328 "lesson-select",
1329 "submission",
1330 "run",
1331 "lesson-status",
1332 "output",
1333 "feedback",
1334 ] {
1335 let attr = format!(r#"data-test="{}""#, hook);
1336 assert!(
1337 html.contains(&attr),
1338 "{target}: missing data-test=\"{hook}\""
1339 );
1340 }
1341
1342 // submit carries data-action="submit"
1343 assert!(
1344 html.contains(r#"data-action="submit""#),
1345 "{target}: submit must carry data-action=\"submit\""
1346 );
1347
1348 // lesson-status has data-status="idle"
1349 assert!(
1350 html.contains(r#"data-status="idle""#),
1351 "{target}: lesson-status must have data-status=\"idle\""
1352 );
1353
1354 // All JS-required IDs present
1355 for id in [
1356 "boot-status",
1357 "lesson-title",
1358 "lesson-prompt",
1359 "submission",
1360 "lesson-select",
1361 "output",
1362 "run",
1363 "feedback",
1364 "submit",
1365 "lesson-status",
1366 ] {
1367 let attr = format!(r#"id="{}""#, id);
1368 assert!(html.contains(&attr), "{target}: missing id=\"{id}\"");
1369 }
1370
1371 // AC-2 clause 2: the submission mount is a <div> (CM6 parent), NOT a
1372 // <textarea>. A shell that kept the old textarea fails here; a shell
1373 // with both fails here too (exactly one div, zero textarea).
1374 assert!(
1375 html.contains(r#"<div id="submission""#),
1376 "{target}: submission mount must be <div id=\"submission\"> (CM6 parent)"
1377 );
1378 assert!(
1379 !html.contains(r#"<textarea id="submission""#),
1380 "{target}: submission mount must NOT be a <textarea> (replaced by CM6 editor)"
1381 );
1382 }
1383 }
1384
1385 #[test]
1386 fn plan_site_shells_have_correct_head_load_order() {
1387 // AC-1 predicate 5: head load order preserved — coi-serviceworker.js
1388 // before styles.css link, and the link is before the body-end module scripts.
1389 for (target, course) in [
1390 (BuildTarget::Webr, r_course()),
1391 (BuildTarget::Pyodide, python_course()),
1392 ] {
1393 let site = plan(&course, target).expect("plans");
1394 let html = &file(&site, "index.html").contents;
1395
1396 let coi_pos = html
1397 .find(r#"src="coi-serviceworker.js""#)
1398 .expect("coi-serviceworker.js must be referenced");
1399 let link_pos = html
1400 .find(r#"href="styles.css""#)
1401 .expect("styles.css link must be present");
1402 assert!(
1403 coi_pos < link_pos,
1404 "{target}: coi-serviceworker.js must appear before styles.css link"
1405 );
1406
1407 // Module scripts come after the stylesheet link (in body-end)
1408 let link_close_pos = html[link_pos..]
1409 .find('>')
1410 .map(|p| link_pos + p)
1411 .expect("link tag closes");
1412 let runner_pos = html
1413 .find(r#"src="lesson-runner.js""#)
1414 .expect("lesson-runner.js must be referenced");
1415 assert!(
1416 link_close_pos < runner_pos,
1417 "{target}: styles.css link must appear before lesson-runner.js script"
1418 );
1419 }
1420 }
1421
1422 #[test]
1423 fn plan_site_shells_differ_only_by_three_known_diffs() {
1424 // AC-1 predicate 6: after normalizing (stripping) the 3 known diffs,
1425 // the two shells are byte-identical. This catches a 4th unintended
1426 // divergence between targets.
1427 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1428 let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
1429
1430 let webr_html = &file(&webr, "index.html").contents;
1431 let pyodide_html = &file(&pyodide, "index.html").contents;
1432
1433 // Normalize: strip <title> contents, #boot-status text, and pyodide CDN
1434 // script block (comment + script tag, only present in pyodide shell).
1435 let normalize = |html: &str| -> String {
1436 let mut s = html.to_string();
1437 // Strip <title> content (keep the tags)
1438 s = s.replace(
1439 "<title>blendtutor — interactive R lessons</title>",
1440 "<title></title>",
1441 );
1442 s = s.replace(
1443 "<title>blendtutor — interactive Python lessons</title>",
1444 "<title></title>",
1445 );
1446 // Strip boot-status text
1447 s = s.replace("Booting webR…", "");
1448 s = s.replace("Booting Pyodide…", "");
1449 // Strip the pyodide CDN comment + script block (only present in pyodide),
1450 // including the indentation whitespace and newline before the comment.
1451 let pyodide_block_start = "<!--\n The Pyodide runtime";
1452 if let Some(start) = s.find(pyodide_block_start) {
1453 // Back up to the preceding newline to drop the indentation too
1454 let preceding = s[..start].rfind('\n').map(|i| i + 1).unwrap_or(start);
1455 // Find the closing </script> after the block start
1456 if let Some(end) = s[start..].find("</script>") {
1457 let block_end = start + end + "</script>".len();
1458 // Strip the trailing newline after the script tag
1459 let after = &s[block_end..];
1460 let strip_newline = after.strip_prefix('\n').unwrap_or(after);
1461 s = format!("{}{}", &s[..preceding], strip_newline);
1462 }
1463 }
1464 s
1465 };
1466
1467 assert_eq!(
1468 normalize(webr_html),
1469 normalize(pyodide_html),
1470 "after normalizing 3 known diffs (title, boot text, CDN script), \
1471 shells must be byte-identical"
1472 );
1473 }
1474
1475 #[test]
1476 fn plan_site_assembles_the_shared_byok_feedback_backend() {
1477 // Slice 18: the FeedbackBackend contract + its byok-anthropic impl ship as a
1478 // single shared feedback.js (§4.2) — the Anthropic BYOK call is identical for
1479 // R and Python lessons — referenced by each target's shell. A per-target fork
1480 // or a shell that never loads it both fail here.
1481 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1482 let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
1483
1484 for site in [&webr, &pyodide] {
1485 let _ = file(site, "feedback.js");
1486 assert!(
1487 file(site, "index.html").contents.contains("feedback.js"),
1488 "each shell must load feedback.js (the seam is dead if unreferenced)"
1489 );
1490 }
1491 assert_eq!(
1492 file(&webr, "feedback.js").contents,
1493 file(&pyodide, "feedback.js").contents,
1494 "feedback.js is shared — byte-identical across targets, not forked"
1495 );
1496 }
1497
1498 #[test]
1499 fn write_site_writes_every_planned_file_to_disk() {
1500 let site = plan(&r_course(), BuildTarget::Webr).expect("plans");
1501 let tmp = tempfile::tempdir().unwrap();
1502 write_site(tmp.path(), &site).expect("the site writes");
1503
1504 // The top-level shell and a nested per-lesson file both land — the latter
1505 // proves the parent directory is created.
1506 assert!(tmp.path().join("index.html").is_file());
1507 let lesson_json = std::fs::read_to_string(tmp.path().join("lessons/0.json"))
1508 .expect("the nested lesson JSON is written");
1509 let parsed: Value = serde_json::from_str(&lesson_json).expect("it parses");
1510 assert_eq!(parsed["id"], "add-two");
1511 }
1512
1513 #[test]
1514 fn plan_error_language_mismatch_displays_the_clash_with_no_nested_source() {
1515 let mismatch = plan(&r_course(), BuildTarget::Pyodide).unwrap_err();
1516 let text = mismatch.to_string().to_lowercase();
1517 assert!(
1518 text.contains("does not match") && text.contains("language"),
1519 "a mismatch names the language/target clash, got: {text}"
1520 );
1521
1522 // A std::error::Error with no nested source.
1523 let as_error: &dyn Error = &mismatch;
1524 assert!(as_error.source().is_none());
1525 }
1526
1527 #[test]
1528 fn eval_results_html_validated_renders_the_accuracy_and_validated_marker() {
1529 // The validated page shows the report's accuracy as a percentage and
1530 // carries the validated marker, never the not-validated one (§1.2). Two
1531 // different accuracies render two different figures, so the percentage is
1532 // derived from the summary, not a constant.
1533 let two_thirds = eval_results_html(&EvalSummary::Validated {
1534 accuracy: 0.6666666666666666,
1535 });
1536 assert!(
1537 two_thirds.contains("67%"),
1538 "2/3 should render 67%: {two_thirds}"
1539 );
1540 assert!(two_thirds.contains(EvalSummary::VALIDATED_MARKER));
1541 assert!(!two_thirds.contains(EvalSummary::NOT_VALIDATED_MARKER));
1542
1543 let half = eval_results_html(&EvalSummary::Validated { accuracy: 0.5 });
1544 assert!(
1545 half.contains("50%"),
1546 "the figure tracks the summary, not a hardcoded constant: {half}"
1547 );
1548
1549 // The inclusive bounds render their whole-number percent — 0% is a real
1550 // (validated, all-wrong) result, distinct from the not-validated state.
1551 assert!(eval_results_html(&EvalSummary::Validated { accuracy: 0.0 }).contains("0%"));
1552 let perfect = eval_results_html(&EvalSummary::Validated { accuracy: 1.0 });
1553 assert!(
1554 perfect.contains("100%"),
1555 "1.0 should render 100%: {perfect}"
1556 );
1557 }
1558
1559 #[test]
1560 fn eval_results_html_not_validated_renders_an_explicit_marker() {
1561 // The not-validated page is explicit (§1.2): it carries the not-validated
1562 // marker and plain human copy, and is *not* the validated state — so a
1563 // missing report is never mistaken for a real result.
1564 let page = eval_results_html(&EvalSummary::NotValidated);
1565 assert!(page.contains(EvalSummary::NOT_VALIDATED_MARKER));
1566 assert!(
1567 page.to_lowercase().contains("not been validated"),
1568 "the page states plainly the lessons were not validated: {page}"
1569 );
1570 assert!(!page.contains(EvalSummary::VALIDATED_MARKER));
1571 }
1572
1573 #[test]
1574 fn eval_summary_from_report_json_takes_the_accuracy_as_is() {
1575 // §3.2: the build consumes the Slice-13 artifact as-is — the accuracy is
1576 // read straight from the JSON, never recomputed from the cases. A report
1577 // whose recorded accuracy (0.5) contradicts its single all-matched case
1578 // (which would re-derive 1.0) proves it: the summary reflects the recorded
1579 // figure, not a re-scored one.
1580 let json = r#"{"cases":[{"expected":"correct","actual":"correct","matched":true}],"accuracy":0.5}"#;
1581 let summary = eval_summary_from_report_json(json).expect("a well-formed report parses");
1582 assert_eq!(summary, EvalSummary::Validated { accuracy: 0.5 });
1583 }
1584
1585 #[test]
1586 fn eval_summary_from_report_json_rejects_a_malformed_report() {
1587 // A present-but-unreadable report is a loud error, not a silent drop to
1588 // not-validated — a corrupt artifact must never quietly unvalidate a course.
1589 assert!(matches!(
1590 eval_summary_from_report_json("{ not json"),
1591 Err(EvalReportError::Malformed(_))
1592 ));
1593 // Missing the accuracy field is equally unreadable for the page's purpose.
1594 assert!(matches!(
1595 eval_summary_from_report_json(r#"{"cases":[]}"#),
1596 Err(EvalReportError::Malformed(_))
1597 ));
1598 }
1599
1600 #[test]
1601 fn eval_summary_from_report_json_rejects_an_out_of_range_accuracy() {
1602 // §1.3.1: a real report's accuracy is matched/total, always in [0.0, 1.0].
1603 // A figure outside that is a corrupt/hand-edited artifact and is rejected at
1604 // the read boundary, so the page never renders nonsense like 200% or -50%.
1605 // (JSON has no NaN/Inf literal, so only finite out-of-range is reachable
1606 // from an artifact; the guard rejects NaN/Inf too, defensively.)
1607 for bogus in ["2.0", "-0.5", "1.0001"] {
1608 let json = format!(r#"{{"cases":[],"accuracy":{bogus}}}"#);
1609 let result = eval_summary_from_report_json(&json);
1610 assert!(
1611 matches!(result, Err(EvalReportError::AccuracyOutOfRange { .. })),
1612 "accuracy {bogus} must be rejected as out of range, got {result:?}"
1613 );
1614 }
1615 // The inclusive bounds 0.0 and 1.0 are valid — a perfect or zero score.
1616 for ok in ["0.0", "1.0"] {
1617 let json = format!(r#"{{"cases":[],"accuracy":{ok}}}"#);
1618 assert!(
1619 eval_summary_from_report_json(&json).is_ok(),
1620 "accuracy {ok} is on the inclusive boundary and must be accepted"
1621 );
1622 }
1623 }
1624
1625 #[test]
1626 fn eval_report_error_displays_each_variant_and_chains_its_source() {
1627 // The malformed variant names the JSON failure and preserves the underlying
1628 // serde error as its source, so a build failure is diagnosable.
1629 let malformed = eval_summary_from_report_json("{ not json").unwrap_err();
1630 assert!(
1631 malformed.to_string().contains("not valid Slice-13 JSON"),
1632 "malformed Display names the JSON failure, got: {malformed}"
1633 );
1634 let as_error: &dyn Error = &malformed;
1635 assert!(
1636 as_error.source().is_some(),
1637 "a malformed report chains its serde source for diagnosis"
1638 );
1639
1640 // The out-of-range variant names the offending figure and the bound, and is
1641 // self-contained — a validation failure, no nested cause.
1642 let out_of_range =
1643 eval_summary_from_report_json(r#"{"cases":[],"accuracy":2.0}"#).unwrap_err();
1644 let text = out_of_range.to_string();
1645 assert!(
1646 text.contains('2') && text.contains("range"),
1647 "out-of-range Display names the bad figure and the range, got: {text}"
1648 );
1649 let as_error: &dyn Error = &out_of_range;
1650 assert!(as_error.source().is_none());
1651 }
1652
1653 #[test]
1654 fn plan_site_folds_the_eval_results_page_for_each_state() {
1655 // The eval-results page rides every built site (§4.1), in whichever state
1656 // the EvalSummary carries — the same plan_site path handles both states.
1657 let validated = plan_site(
1658 &r_course(),
1659 BuildTarget::Webr,
1660 &EvalSummary::Validated {
1661 accuracy: 0.6666666666666666,
1662 },
1663 &SiteConfig::default(),
1664 )
1665 .expect("plans");
1666 let validated_page = file(&validated, "eval-results.html").contents.clone();
1667 assert!(validated_page.contains("67%"));
1668 assert!(validated_page.contains(EvalSummary::VALIDATED_MARKER));
1669 assert!(!validated_page.contains(EvalSummary::NOT_VALIDATED_MARKER));
1670
1671 let unvalidated = plan_site(
1672 &r_course(),
1673 BuildTarget::Webr,
1674 &EvalSummary::NotValidated,
1675 &SiteConfig::default(),
1676 )
1677 .expect("plans");
1678 assert!(
1679 file(&unvalidated, "eval-results.html")
1680 .contents
1681 .contains(EvalSummary::NOT_VALIDATED_MARKER)
1682 );
1683
1684 // Target-independent: the same summary renders the same page whichever
1685 // runtime the site serves (§4.1) — the page is not forked per target.
1686 let pyodide = plan_site(
1687 &python_course(),
1688 BuildTarget::Pyodide,
1689 &EvalSummary::Validated {
1690 accuracy: 0.6666666666666666,
1691 },
1692 &SiteConfig::default(),
1693 )
1694 .expect("plans");
1695 assert_eq!(
1696 file(&pyodide, "eval-results.html").contents,
1697 validated_page,
1698 "the eval-results page is identical across targets for the same summary"
1699 );
1700 }
1701
1702 #[test]
1703 fn plan_site_emits_vendored_codemirror_bundle() {
1704 // AC-1 (code-editor): a pre-built CodeMirror 6 ESM bundle is vendored as a
1705 // committed static asset at assets/shared/codemirror.js, embedded at compile
1706 // time via include_str! (CODEMIRROR_JS const) and emitted to built sites for
1707 // BOTH targets through the shared `assemble` step — not forked per target.
1708 //
1709 // 8 clauses pin the invariant (§1.5 — predicates, not coincident shape):
1710 // 1. codemirror.js present in SiteFiles for both targets
1711 // 2. byte-identical across targets (shared, not forked — §4.2)
1712 // 3. contents.len() > 10_000 (catches empty/comment-only stub)
1713 // 4. contains EditorView (CM6 core export — catches wrong-library/UMD)
1714 // 5. contains evidence of lang-r + lang-python + lineNumbers +
1715 // highlightActiveLine + bracketMatching + indentWithTab
1716 // 6. no `new Worker(` (COOP/COEP isolation — no web workers)
1717 // 7. deterministic order: after styles.css AND before any lessons/ file
1718 // (full positional invariant, not weak "after styles.css" proxy)
1719 // 8. CODEMIRROR_JS const compiles — proved by the test running
1720 // (include_str! refuses a missing asset at compile time — §1.3.1)
1721 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1722 let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
1723
1724 // Clause 1: codemirror.js lands as a planned file for both targets — a
1725 // target that forgot to include it (or embedded it in webr.rs/pyodide.rs
1726 // instead of shared mod.rs) misses here.
1727 let webr_cm = file(&webr, "codemirror.js").contents.clone();
1728 let pyodide_cm = file(&pyodide, "codemirror.js").contents.clone();
1729
1730 // Clause 2: byte-identical across targets — the bundle is shared, not
1731 // forked per target (§4.2). A divergence means a target forked the bundle.
1732 assert_eq!(
1733 webr_cm, pyodide_cm,
1734 "codemirror.js must be byte-identical across targets (shared, not forked)"
1735 );
1736
1737 // Clause 3: the bundle is a real CM6 build, not an empty/comment-only stub.
1738 // CM6 core + lang-r + lang-python is ~150-250KB minified; a 23-byte stub
1739 // fails here.
1740 assert!(
1741 webr_cm.len() > 10_000,
1742 "codemirror.js must be a real bundle (>10_000 bytes), got {} bytes",
1743 webr_cm.len()
1744 );
1745
1746 // Clause 4: EditorView — the CM6 core export. A wrong-library or UMD-global
1747 // bundle lacking the ESM EditorView export fails here.
1748 assert!(
1749 webr_cm.contains("EditorView"),
1750 "codemirror.js must contain EditorView (CM6 core ESM export)"
1751 );
1752
1753 // Clause 5: evidence of both language packs AND the UX-polish exports.
1754 // - `rLanguage` is the R language descriptor name (lang-r evidence —
1755 // distinctive, preserved by esbuild; the bare export name `r` is too
1756 // short to grep reliably).
1757 // - `python` is the lang-python export name (11 occurrences in the bundle).
1758 // - lineNumbers, highlightActiveLine, bracketMatching, indentWithTab are
1759 // the UX-polish exports AC-3 consumes (AC-1 owns the complete export set).
1760 // - keymap is the keymap facet from @codemirror/view — AC-3 composes
1761 // indentWithTab via keymap.of([indentWithTab]) so Tab stays in the
1762 // editor (not browser focus traversal). Re-vendored by AC-3.
1763 // - syntaxHighlighting + defaultHighlightStyle are the token-styling
1764 // exports from @codemirror/language — without these the editor renders
1765 // text with no `.tok-*` classes (the builder-vision-probe regression:
1766 // language support parses, highlight style colors).
1767 // - HighlightStyle + tags are the custom-style exports: HighlightStyle
1768 // (from @codemirror/language) lets the runner define a HighlightStyle
1769 // with deterministic `.tok-*` class names, and tags (from
1770 // @lezer/highlight) supplies the tag constants the style maps. Without
1771 // these the runner cannot override the default's opaque `ͼa` classes.
1772 // A core-only bundle missing the language packs, or a bundle missing the
1773 // UX/highlight exports, fails here.
1774 for needle in [
1775 "rLanguage",
1776 "python",
1777 "lineNumbers",
1778 "highlightActiveLine",
1779 "bracketMatching",
1780 "indentWithTab",
1781 "keymap",
1782 "syntaxHighlighting",
1783 "defaultHighlightStyle",
1784 "HighlightStyle",
1785 "tags",
1786 ] {
1787 assert!(
1788 webr_cm.contains(needle),
1789 "codemirror.js must contain `{needle}` (language pack or UX export evidence)"
1790 );
1791 }
1792
1793 // Clause 6: no web workers — CM6 must be main-thread-only to avoid COOP/COEP
1794 // conflicts. A bundler-emitted `new Worker(` (e.g. a future WASM-parser
1795 // extension) fails here.
1796 assert!(
1797 !webr_cm.contains("new Worker("),
1798 "codemirror.js must not contain `new Worker(` (COOP/COEP isolation)"
1799 );
1800
1801 // Clause 7: full positional invariant — codemirror.js sits after styles.css
1802 // AND before any lessons/ file in the deterministic files vector. This is
1803 // the sufficient condition (not the weak "after styles.css" proxy): a rule
1804 // appended after codemirror.js but before lessons/ would still pass the weak
1805 // proxy but break the intended order. Checking both bounds pins the full
1806 // invariant.
1807 let webr_files = webr.files();
1808 let pos = |name: &str| -> usize {
1809 webr_files
1810 .iter()
1811 .position(|f| f.path == Path::new(name))
1812 .unwrap_or_else(|| panic!("{name} must be in the planned site"))
1813 };
1814 let cm_pos = pos("codemirror.js");
1815 let styles_pos = pos("styles.css");
1816 let first_lesson_pos = webr_files
1817 .iter()
1818 .position(|f| f.path.starts_with("lessons/"))
1819 .expect("at least one lessons/ file exists");
1820 assert!(
1821 cm_pos > styles_pos,
1822 "codemirror.js (index {cm_pos}) must come after styles.css (index {styles_pos})"
1823 );
1824 assert!(
1825 cm_pos < first_lesson_pos,
1826 "codemirror.js (index {cm_pos}) must come before the first lessons/ file \
1827 (index {first_lesson_pos})"
1828 );
1829
1830 // Clause 8: CODEMIRROR_JS const compiles — proved by the test running at
1831 // all. include_str! refuses a missing asset at compile time (§1.3.1), so
1832 // reaching this assertion means the const resolved. No runtime check needed;
1833 // the test's existence is the proof.
1834 }
1835
1836 #[test]
1837 fn plan_site_shells_load_codemirror_as_import() {
1838 // AC-2 (code-editor): the lesson runner core wires a CodeMirror 6 editor
1839 // into the submission mount via a STATIC ESM import from the vendored
1840 // codemirror.js bundle (AC-1), reads the doc back through a
1841 // `getSubmission()` contract (not `.value`), never touches `innerHTML`
1842 // on the editor DOM, and each target adapter declares its `language`
1843 // as a dedicated field (not a string match on `runtime.name`).
1844 //
1845 // 6 clauses pin the build-time invariant (§1.5):
1846 // 3. data-test="submission" + id="submission" preserved on the div;
1847 // the runSubmission contract on window.__bt is unchanged.
1848 // 4. lesson-runner-core.js has a static `import { EditorView ... }`
1849 // from "./codemirror.js" (not a dynamic import, not a global).
1850 // 5. feedback.js reads the submission via `getSubmission()`, never
1851 // `.value` on a submission element (the old textarea proxy).
1852 // 6. lesson-runner-core.js never uses `innerHTML` on the editor DOM
1853 // (untrusted code_template must never be parsed as HTML).
1854 // 7. both lesson-runner.js adapters pass a `language:` field (closed
1855 // set: "r" | "python"), not a runtime.name string match.
1856 // (Clause 1 — codemirror.js in site output — is pinned by
1857 // plan_site_emits_vendored_codemirror_bundle; clause 2 — div not
1858 // textarea — is pinned in plan_site_shells_contain_semantic_regions.)
1859 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1860 let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
1861
1862 // Clause 3: the rodney test contract survives the textarea→div swap. The
1863 // data-test hook and id are still on the submission mount, and the
1864 // window.__bt.runSubmission seam is still exported by the runner core.
1865 for (target, site) in [(BuildTarget::Webr, &webr), (BuildTarget::Pyodide, &pyodide)] {
1866 let html = &file(site, "index.html").contents;
1867 assert!(
1868 html.contains(r#"data-test="submission""#),
1869 "{target}: data-test=\"submission\" hook must be preserved on the div"
1870 );
1871 assert!(
1872 html.contains(r#"id="submission""#),
1873 "{target}: id=\"submission\" must be preserved on the div"
1874 );
1875 }
1876 let core = &file(&webr, "lesson-runner-core.js").contents;
1877 assert!(
1878 core.contains("runSubmission"),
1879 "lesson-runner-core.js must still export the runSubmission contract"
1880 );
1881 assert!(
1882 core.contains("window.__bt"),
1883 "lesson-runner-core.js must still expose window.__bt"
1884 );
1885
1886 // Clause 4: static ESM import of EditorView from the vendored bundle. A
1887 // dynamic import (`import("./codemirror.js")`) or a UMD global fails here
1888 // — the static import is what makes the editor a build-time dependency.
1889 assert!(
1890 core.contains("import { EditorView"),
1891 "lesson-runner-core.js must statically import EditorView from codemirror.js"
1892 );
1893 assert!(
1894 core.contains("\"./codemirror.js\""),
1895 "lesson-runner-core.js must import from \"./codemirror.js\""
1896 );
1897
1898 // Clause 5: feedback.js reads the submission through the getSubmission()
1899 // contract, never the old `.value` textarea proxy. A feedback.js that
1900 // still reads `submissionEl.value` or `getElementById(\"submission\").value`
1901 // fails here — the div has no `.value`, so that path returns undefined.
1902 let feedback = &file(&webr, "feedback.js").contents;
1903 assert!(
1904 feedback.contains("getSubmission"),
1905 "feedback.js must read the submission via getSubmission()"
1906 );
1907 assert!(
1908 !feedback.contains("submissionEl.value"),
1909 "feedback.js must NOT read submissionEl.value (the div has no .value)"
1910 );
1911 assert!(
1912 !feedback.contains("getElementById(\"submission\").value"),
1913 "feedback.js must NOT read .value on the submission element"
1914 );
1915
1916 // Clause 6: no innerHTML on the editor DOM. The code_template is untrusted
1917 // lesson content; parsing it as HTML would be an injection vector (the
1918 // same threat model that keeps lesson titles off innerHTML). The runner
1919 // core must use EditorView.dispatch / textContent, never innerHTML.
1920 assert!(
1921 !core.contains("innerHTML"),
1922 "lesson-runner-core.js must never use innerHTML (untrusted code_template)"
1923 );
1924
1925 // Clause 7: each target adapter declares a `language:` field (closed set),
1926 // not a string match on runtime.name. A webr adapter with `language: \"r\"`
1927 // and a pyodide adapter with `language: \"python\"` pass; an adapter that
1928 // omits the field (relying on runtime.name matching) fails here.
1929 let webr_runner = &file(&webr, "lesson-runner.js").contents;
1930 let pyodide_runner = &file(&pyodide, "lesson-runner.js").contents;
1931 assert!(
1932 webr_runner.contains("language: \"r\""),
1933 "webr/lesson-runner.js must declare language: \"r\""
1934 );
1935 assert!(
1936 pyodide_runner.contains("language: \"python\""),
1937 "pyodide/lesson-runner.js must declare language: \"python\""
1938 );
1939 }
1940
1941 #[test]
1942 fn plan_site_cm6_editor_ux_extensions_configured() {
1943 // AC-3 (code-editor): the CM6 editor is configured with standard
1944 // code-editor UX — line numbers, bracket matching, smart tab handling,
1945 // active line highlighting, spellcheck off. This test pins the 5
1946 // build-time-checkable clauses (1, 5, 8, 10, 11) of the executable spec
1947 // by parsing the assembled SiteFiles content. The rodney browser probes
1948 // (clauses 2/3/4/6/7/9/12) are run by @builder-vision-probe — they need
1949 // a live browser + CM6 boot to check computed styles.
1950 //
1951 // 8 clauses pin the build-time invariant (§1.5 — predicates, not
1952 // coincident shape):
1953 // 1. lineNumbers() wired into the editor extensions
1954 // 2. highlightActiveLine() wired
1955 // 3. bracketMatching() wired
1956 // 4. indentWithTab in a keymap.of([...]) composition (Tab stays in
1957 // editor, not browser focus traversal — sneaky-pass #3)
1958 // 5. spellcheck explicitly set to "false" (not absent — absent
1959 // inherits true on contenteditable, sneaky-pass #5)
1960 // 6. spellcheck never set to "true"
1961 // 7. .cm-matchingBracket CSS rule has a non-transparent visual property
1962 // (background/outline/box-shadow/border-color — sneaky-pass #2:
1963 // bracketMatching() adds the class but no CSS = no visual)
1964 // 8. .cm-gutters not hidden via display:none/visibility:hidden
1965 // (sneaky-pass #1: gutter exists but zero pixels)
1966 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
1967
1968 let core = &file(&webr, "lesson-runner-core.js").contents;
1969 let css = &file(&webr, "styles.css").contents;
1970
1971 // Clause 1: lineNumbers() — the gutter extension is wired. A config
1972 // that imports lineNumbers but forgets to add it to the extensions
1973 // array fails here.
1974 assert!(
1975 core.contains("lineNumbers()"),
1976 "lesson-runner-core.js must wire lineNumbers() into the editor extensions"
1977 );
1978
1979 // Clause 2: highlightActiveLine() — active-line highlighting wired.
1980 assert!(
1981 core.contains("highlightActiveLine()"),
1982 "lesson-runner-core.js must wire highlightActiveLine() into the editor extensions"
1983 );
1984
1985 // Clause 3: bracketMatching() — bracket-match highlighting wired.
1986 assert!(
1987 core.contains("bracketMatching()"),
1988 "lesson-runner-core.js must wire bracketMatching() into the editor extensions"
1989 );
1990
1991 // Clause 4: indentWithTab in a keymap.of([...]) composition. Importing
1992 // indentWithTab alone is insufficient — it must be passed to keymap.of
1993 // so the Tab key is intercepted by the editor (sneaky-pass #3: imported
1994 // but not composed, Tab falls through to browser focus traversal).
1995 assert!(
1996 core.contains("indentWithTab"),
1997 "lesson-runner-core.js must reference indentWithTab"
1998 );
1999 assert!(
2000 core.contains("keymap.of("),
2001 "lesson-runner-core.js must compose indentWithTab via keymap.of([...])"
2002 );
2003
2004 // Clause 5: spellcheck explicitly set to "false". The contenteditable
2005 // .cm-content inherits spellcheck=true by browser default; an absent
2006 // attribute is NOT sufficient (sneaky-pass #5). The explicit "false"
2007 // string must appear alongside spellcheck.
2008 assert!(
2009 core.contains("spellcheck"),
2010 "lesson-runner-core.js must set spellcheck on the editor content"
2011 );
2012 assert!(
2013 core.contains("\"false\""),
2014 "lesson-runner-core.js must set spellcheck to \"false\" (explicit, not absent)"
2015 );
2016
2017 // Clause 6: spellcheck never set to "true". A config that accidentally
2018 // enables spellcheck fails here.
2019 assert!(
2020 !core.contains("spellcheck: \"true\""),
2021 "lesson-runner-core.js must NOT set spellcheck to \"true\""
2022 );
2023 assert!(
2024 !core.contains("spellcheck:'true'"),
2025 "lesson-runner-core.js must NOT set spellcheck to 'true'"
2026 );
2027
2028 // Clause 7: .cm-matchingBracket CSS rule has a non-transparent visual
2029 // property. bracketMatching() adds the cm-matchingBracket class to the
2030 // DOM, but without a CSS rule the class is invisible (sneaky-pass #2).
2031 // The rule must set background/outline/box-shadow/border-color to a
2032 // non-transparent value. We check the rule exists AND contains one of
2033 // the visual properties (a bare `.cm-matchingBracket {}` empty rule fails).
2034 assert!(
2035 css.contains(".cm-matchingBracket"),
2036 "styles.css must contain a .cm-matchingBracket rule"
2037 );
2038 // Extract the .cm-matchingBracket block and verify it has a visual
2039 // property. A transparent-only rule (e.g. background: transparent) is
2040 // insufficient — the bracket match must be VISIBLE.
2041 let bm_pos = css.find(".cm-matchingBracket").expect("bracket-match rule");
2042 let after_bm = &css[bm_pos..];
2043 let brace = after_bm
2044 .find('{')
2045 .expect(".cm-matchingBracket must be followed by a declaration block");
2046 let body_start = bm_pos + brace + 1;
2047 let body_slice = &css[body_start..];
2048 let close = body_slice
2049 .find('}')
2050 .expect(".cm-matchingBracket block must close");
2051 let bm_body = &css[body_start..body_start + close];
2052 assert!(
2053 bm_body.contains("background")
2054 || bm_body.contains("outline")
2055 || bm_body.contains("box-shadow")
2056 || bm_body.contains("border-color"),
2057 ".cm-matchingBracket must have a visual property (background/outline/box-shadow/border-color)"
2058 );
2059 assert!(
2060 !bm_body.contains("transparent"),
2061 ".cm-matchingBracket visual property must NOT be transparent"
2062 );
2063
2064 // Clause 8: .cm-gutters not hidden. A rule that sets display:none or
2065 // visibility:hidden on .cm-gutters makes the gutter zero-pixels
2066 // (sneaky-pass #1: gutter exists in DOM but is invisible).
2067 assert!(
2068 !css.contains(".cm-gutters") || !gutter_hidden(css),
2069 "styles.css must NOT hide .cm-gutters via display:none or visibility:hidden"
2070 );
2071 }
2072
2073 #[test]
2074 fn plan_site_runner_core_sets_cursor_color_via_cm6_theme() {
2075 // v4 cursor fix: CSS-only overrides in styles.css (!important +
2076 // caret-color, PRs #93/#94) did not work in the live browser despite
2077 // correct CSS on the deployed site. CM6's injected base-theme styles
2078 // were winning the cascade. The fix moves cursor styling into a CM6
2079 // EditorView.theme() extension, which injects styles via CM6's own
2080 // style-module system at a higher precedence than the base theme.
2081 //
2082 // 7 clauses pin the fix:
2083 // 1. EditorView.theme() is called to create a theme extension
2084 // 2. The theme targets .cm-cursor with borderLeftColor
2085 // 3. The borderLeftColor uses var(--bt-color-cursor) (adapts to
2086 // light/dark via the CSS variable defined in styles.css)
2087 // 4. The theme sets borderLeftWidth: 2px (wider than CM6 default)
2088 // 5. The theme sets marginLeft: -1px (centers the wider cursor)
2089 // 6. The theme targets .cm-content with caretColor (native caret)
2090 // 7. The theme extension is added to the editorExtensions array
2091 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2092 let core = &file(&webr, "lesson-runner-core.js").contents;
2093
2094 // Clause 1: EditorView.theme() is called — creates a CM6 theme
2095 // extension rather than relying on external CSS.
2096 assert!(
2097 core.contains("EditorView.theme("),
2098 "lesson-runner-core.js must call EditorView.theme() to create a \
2099 cursor theme extension (CSS-only overrides did not work in live \
2100 browser — CM6 base-theme styles won the cascade)"
2101 );
2102
2103 // Clause 2: the theme targets .cm-cursor with borderLeftColor.
2104 // CM6's base theme sets borderLeft: "1.2px solid black" — we must
2105 // override the borderLeftColor to a light value for dark mode.
2106 assert!(
2107 core.contains("borderLeftColor"),
2108 "lesson-runner-core.js cursor theme must set borderLeftColor on \
2109 .cm-cursor (CM6 default is black — invisible on dark background)"
2110 );
2111
2112 // Clause 3: borderLeftColor uses var(--bt-color-cursor) so the cursor
2113 // adapts to light/dark mode via the CSS variable defined in
2114 // styles.css (light: #1a1a1a, dark: #ffffff). The fallback #ffffff
2115 // ensures visibility if the variable is undefined.
2116 assert!(
2117 core.contains("var(--bt-color-cursor, #ffffff)"),
2118 "lesson-runner-core.js cursor theme must use \
2119 var(--bt-color-cursor, #ffffff) for borderLeftColor \
2120 (adapts to light/dark via CSS variable, fallback ensures \
2121 visibility)"
2122 );
2123
2124 // Clause 4: cursor width is 2px (wider than CM6 default 1.2px).
2125 assert!(
2126 core.contains("borderLeftWidth") && core.contains("\"2px\""),
2127 "lesson-runner-core.js cursor theme must set borderLeftWidth: \
2128 \"2px\" (CM6 default 1.2px is too faint — widened for \
2129 accessibility)"
2130 );
2131
2132 // Clause 5: the theme sets marginLeft to center the wider cursor on
2133 // the insertion point. CM6's default cursor is 1.2px with
2134 // marginLeft: -0.6px; at 2px width the cursor must shift to -1px
2135 // to stay centered.
2136 assert!(
2137 core.contains("marginLeft"),
2138 "lesson-runner-core.js cursor theme must set marginLeft for centering"
2139 );
2140
2141 // Clause 6: the theme targets .cm-content with caretColor. The
2142 // native contenteditable caret renders as faint gray in dark mode;
2143 // setting caretColor makes it match the drawn cursor.
2144 assert!(
2145 core.contains("caretColor"),
2146 "lesson-runner-core.js cursor theme must set caretColor on \
2147 .cm-content (native contenteditable caret is faint gray \
2148 without it)"
2149 );
2150
2151 // Clause 7: the theme extension is added to the editorExtensions
2152 // array. A theme defined but not wired into the extensions array
2153 // would have no effect (sneaky-pass: defined but not composed).
2154 assert!(
2155 core.contains("cursorTheme"),
2156 "lesson-runner-core.js must add cursorTheme to the \
2157 editorExtensions array (a theme defined but not wired has no \
2158 effect)"
2159 );
2160 }
2161
2162 #[test]
2163 fn plan_site_runner_core_renders_hints_as_expandable_details() {
2164 // The lesson runner core must render `lesson.hints` as an expandable
2165 // <details> element when the hints field is non-null and non-empty,
2166 // and remove it when switching to a lesson without hints.
2167 //
2168 // Build-time-checkable invariants (the rodney browser probe verifies the
2169 // live DOM behavior):
2170 // 1. References `lesson.hints` — the field is consumed, not ignored.
2171 // 2. Creates a <details> element with id="lesson-hints".
2172 // 3. Creates a <summary> child (the expandable toggle).
2173 // 4. Sets hints text via textContent — NEVER parsed as HTML (the same
2174 // threat model that keeps lesson titles off innerHTML; untrusted
2175 // lesson content must never be parsed as HTML).
2176 // 5. Inserts the details after #lesson-prompt (the prompt element).
2177 // 6. Removes the element when hints is null/empty (lesson switch).
2178 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2179 let core = &file(&webr, "lesson-runner-core.js").contents;
2180
2181 // Clause 1: the hints field is read from the lesson object.
2182 assert!(
2183 core.contains("lesson.hints"),
2184 "lesson-runner-core.js must reference lesson.hints"
2185 );
2186
2187 // Clause 2: a <details> element with id="lesson-hints" is created.
2188 assert!(
2189 core.contains(r#""details""#),
2190 "lesson-runner-core.js must create a <details> element"
2191 );
2192 assert!(
2193 core.contains(r#""lesson-hints""#),
2194 "lesson-runner-core.js must set id=\"lesson-hints\" on the details"
2195 );
2196
2197 // Clause 3: a <summary> child is created.
2198 assert!(
2199 core.contains(r#""summary""#),
2200 "lesson-runner-core.js must create a <summary> element"
2201 );
2202
2203 // Clause 4: hints text is set via textContent. The existing
2204 // `!core.contains("innerHTML")` invariant (plan_site_shells_load_codemirror_as_import
2205 // clause 6) already guarantees no innerHTML is used anywhere — so the
2206 // hints text MUST go through textContent. We assert textContent appears
2207 // in the hints-rendering path by checking it is used at all (the existing
2208 // invariant covers the negative case).
2209 assert!(
2210 core.contains("textContent"),
2211 "lesson-runner-core.js must use textContent for hints text"
2212 );
2213
2214 // Clause 5: the details is inserted after the prompt element. The runner
2215 // core holds a `promptEl` reference (document.getElementById("lesson-prompt"));
2216 // the hints details must be inserted relative to it.
2217 assert!(
2218 core.contains("promptEl"),
2219 "lesson-runner-core.js must reference promptEl for hints insertion"
2220 );
2221
2222 // Clause 6: the element is removed when hints is absent. A renderLesson
2223 // that only creates but never removes would leave a stale hints panel
2224 // from a previous lesson visible after switching.
2225 assert!(
2226 core.contains(".remove()"),
2227 "lesson-runner-core.js must remove the hints element when absent"
2228 );
2229 }
2230
2231 #[test]
2232 fn plan_site_styles_css_has_hints_panel_rules() {
2233 // The #lesson-hints <details> panel must be styled via var(--bt-*)
2234 // tokens, with a pointer cursor on the <summary> toggle. Dark mode is
2235 // handled by the existing :root token overrides in the @media block —
2236 // no hardcoded hex literals in the hints rules.
2237 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2238 let css = &file(&webr, "styles.css").contents;
2239
2240 // Clause 1: #lesson-hints selector exists.
2241 assert!(
2242 css.contains("#lesson-hints"),
2243 "styles.css must contain a #lesson-hints rule"
2244 );
2245
2246 // Clause 2: the summary toggle has cursor: pointer.
2247 let hints_pos = css
2248 .find("#lesson-hints")
2249 .expect("#lesson-hints rule exists");
2250 let after_hints = &css[hints_pos..];
2251 assert!(
2252 after_hints.contains("cursor: pointer") || after_hints.contains("cursor:pointer"),
2253 "styles.css must set cursor: pointer on the hints summary toggle"
2254 );
2255
2256 // Clause 3: the hints rules use var(--bt-*) tokens (no hardcoded hex).
2257 // Extract the #lesson-hints rule block and check for token usage.
2258 let brace = after_hints
2259 .find('{')
2260 .expect("#lesson-hints must have an opening brace");
2261 let body_start = hints_pos + brace + 1;
2262 let body_slice = &css[body_start..];
2263 let close = body_slice
2264 .find('}')
2265 .expect("#lesson-hints block must close");
2266 let hints_body = &css[body_start..body_start + close];
2267 assert!(
2268 hints_body.contains("var(--bt-"),
2269 "#lesson-hints must use var(--bt-*) tokens, got: {hints_body}"
2270 );
2271 }
2272
2273 #[test]
2274 fn plan_site_runner_core_splits_hints_and_gotchas() {
2275 // AC-2 (issue #88): Split hints and gotchas into separate expandable UI
2276 // sections. The lesson runner core must have TWO separate functions —
2277 // renderHints and renderGotchas — each creating its own <details> with a
2278 // unique ID, parsing bullet lines into <ul><li>, using textContent (never
2279 // innerHTML), and independently removing stale DOM. renderLesson must
2280 // call both. The combined "Hints & Gotchas" panel is gone.
2281 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2282 let core = &file(&webr, "lesson-runner-core.js").contents;
2283
2284 // Clause 1: renderGotchas function exists (new — the split).
2285 assert!(
2286 core.contains("function renderGotchas("),
2287 "lesson-runner-core.js must define a renderGotchas function"
2288 );
2289
2290 // Clause 2: renderHints function still exists (now hints-only).
2291 assert!(
2292 core.contains("function renderHints("),
2293 "lesson-runner-core.js must define a renderHints function"
2294 );
2295
2296 // Clause 3: lesson.gotchas is referenced (the field is consumed).
2297 assert!(
2298 core.contains("lesson.gotchas"),
2299 "lesson-runner-core.js must reference lesson.gotchas"
2300 );
2301
2302 // Clause 4: unique ID "lesson-gotchas" for the gotchas details.
2303 assert!(
2304 core.contains(r#""lesson-gotchas""#),
2305 "lesson-runner-core.js must set id=\"lesson-gotchas\" on the gotchas details"
2306 );
2307
2308 // Clause 5: the combined "Hints & Gotchas" label is gone.
2309 assert!(
2310 !core.contains("Hints & Gotchas"),
2311 "lesson-runner-core.js must NOT use the combined 'Hints & Gotchas' label"
2312 );
2313
2314 // Clause 6: bullet parsing creates <ul> and <li> elements.
2315 assert!(
2316 core.contains(r#""ul""#),
2317 "lesson-runner-core.js must create <ul> elements for bullet parsing"
2318 );
2319 assert!(
2320 core.contains(r#""li""#),
2321 "lesson-runner-core.js must create <li> elements for bullet parsing"
2322 );
2323
2324 // Clause 7: bullet marker detection uses startsWith (for "- " or "* ").
2325 assert!(
2326 core.contains("startsWith"),
2327 "lesson-runner-core.js must use startsWith for bullet marker detection"
2328 );
2329
2330 // Clause 8: textContent is used for <li> text (not innerHTML).
2331 // The existing !innerHTML invariant (plan_site_shells_load_codemirror_as_import
2332 // clause 6) covers the negative case.
2333 assert!(
2334 core.contains("textContent"),
2335 "lesson-runner-core.js must use textContent for bullet text"
2336 );
2337
2338 // Clause 9: each function independently removes stale DOM (>= 2 .remove()).
2339 let remove_count = core.matches(".remove()").count();
2340 assert!(
2341 remove_count >= 2,
2342 "lesson-runner-core.js must have >= 2 .remove() calls (one per section), got {remove_count}"
2343 );
2344
2345 // Clause 10: renderLesson calls both renderHints and renderGotchas.
2346 assert!(
2347 core.contains("renderHints(lesson.hints)"),
2348 "renderLesson must call renderHints(lesson.hints)"
2349 );
2350 assert!(
2351 core.contains("renderGotchas(lesson.gotchas)"),
2352 "renderLesson must call renderGotchas(lesson.gotchas)"
2353 );
2354 }
2355
2356 #[test]
2357 fn plan_site_styles_css_has_gotchas_panel_rules() {
2358 // AC-2 (issue #88): The #lesson-gotchas <details> panel must be styled
2359 // via var(--bt-*) tokens, parallel to #lesson-hints. Dark mode is
2360 // handled by the existing :root token overrides in the @media block —
2361 // no hardcoded hex literals in the gotchas rules.
2362 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2363 let css = &file(&webr, "styles.css").contents;
2364
2365 // Clause 1: #lesson-gotchas selector exists.
2366 assert!(
2367 css.contains("#lesson-gotchas"),
2368 "styles.css must contain a #lesson-gotchas rule"
2369 );
2370
2371 // Clause 2: the gotchas summary toggle has cursor: pointer.
2372 let gotchas_pos = css
2373 .find("#lesson-gotchas")
2374 .expect("#lesson-gotchas rule exists");
2375 let after_gotchas = &css[gotchas_pos..];
2376 assert!(
2377 after_gotchas.contains("cursor: pointer") || after_gotchas.contains("cursor:pointer"),
2378 "styles.css must set cursor: pointer on the gotchas summary toggle"
2379 );
2380
2381 // Clause 3: the gotchas rules use var(--bt-*) tokens (no hardcoded hex).
2382 let brace = after_gotchas
2383 .find('{')
2384 .expect("#lesson-gotchas must have an opening brace");
2385 let body_start = gotchas_pos + brace + 1;
2386 let body_slice = &css[body_start..];
2387 let close = body_slice
2388 .find('}')
2389 .expect("#lesson-gotchas block must close");
2390 let gotchas_body = &css[body_start..body_start + close];
2391 assert!(
2392 gotchas_body.contains("var(--bt-"),
2393 "#lesson-gotchas must use var(--bt-*) tokens, got: {gotchas_body}"
2394 );
2395 }
2396
2397 #[test]
2398 fn plan_site_emits_csp_sri_and_referrer_policy() {
2399 // AC-1 (issue #96): CSP + SRI + Referrer Policy on all built sites.
2400 // Free hardening: a Content-Security-Policy meta tag restricts what
2401 // origins may serve scripts/styles/connections, SRI pins CDN scripts to
2402 // a known hash, and a referrer policy prevents leaking the site URL to
2403 // cross-origin LLM providers.
2404 //
2405 // 11 clauses pin the security envelope (§1.5 — predicates, not
2406 // coincident shape):
2407 // 1. Exactly one CSP meta tag in head, before first script tag
2408 // 2. CSP content = single CSP_POLICY const (no wildcard schemes in
2409 // script-src: no https:, http:, *, 'unsafe-inline')
2410 // 3. script-src includes both CDN origins (cdn.jsdelivr.net +
2411 // webr.r-wasm.org) in BOTH targets
2412 // 4. connect-src includes self, cdn.jsdelivr.net, webr.r-wasm.org,
2413 // repo.r-wasm.org, api.fireworks.ai, api.anthropic.com
2414 // 5. style-src includes 'unsafe-inline' (CM6 StyleModule injects
2415 // style tags)
2416 // 6. worker-src includes 'self' blob: (WebR channel workers)
2417 // 7. Exactly one referrer meta tag, content="no-referrer"
2418 // 8. Pyodide CDN script has non-empty integrity (sha256/384/512
2419 // prefix) + crossorigin="anonymous"
2420 // 9. WebR target: NO integrity attributes on any HTML tag (ES module
2421 // import can't have SRI — gap accepted, documented)
2422 // 10. eval-results.html has same CSP + referrer meta
2423 // 11. CSP_POLICY defined as single const in site/mod.rs
2424
2425 // Clause 11: CSP_POLICY is a single const — referencing it here proves
2426 // it exists at compile time. A CSP string duplicated per target would
2427 // not have a single named source the test can pin.
2428 let csp = CSP_POLICY;
2429 assert!(
2430 !csp.is_empty(),
2431 "CSP_POLICY must be a non-empty const, not a placeholder"
2432 );
2433
2434 for (target, course) in [
2435 (BuildTarget::Webr, r_course()),
2436 (BuildTarget::Pyodide, python_course()),
2437 ] {
2438 let site = plan(&course, target).expect("plans");
2439 let html = &file(&site, "index.html").contents;
2440
2441 // --- Clause 1: exactly one CSP meta tag in head, before first script
2442 let csp_meta = r#"<meta http-equiv="Content-Security-Policy""#;
2443 let csp_count = html.matches(csp_meta).count();
2444 assert_eq!(
2445 csp_count, 1,
2446 "{target}: expected exactly one CSP meta tag, got {csp_count}"
2447 );
2448 let csp_pos = html.find(csp_meta).expect("CSP meta exists");
2449 let head_close = html.find("</head>").expect("head closes");
2450 assert!(
2451 csp_pos < head_close,
2452 "{target}: CSP meta must be inside <head>"
2453 );
2454 let first_script_pos = html.find("<script").expect("at least one script tag");
2455 assert!(
2456 csp_pos < first_script_pos,
2457 "{target}: CSP meta must appear before first <script> tag"
2458 );
2459
2460 // --- Clause 2: CSP content matches CSP_POLICY const, no wildcards
2461 let csp_content = extract_meta_content(html, "Content-Security-Policy")
2462 .expect("CSP content attribute is present");
2463 assert_eq!(
2464 csp_content, csp,
2465 "{target}: CSP content must match CSP_POLICY const exactly"
2466 );
2467 let script_src = extract_csp_directive(&csp_content, "script-src");
2468 assert!(
2469 !script_src.is_empty(),
2470 "{target}: CSP must have a script-src directive"
2471 );
2472 let script_sources: Vec<&str> = script_src.split_whitespace().collect();
2473 for wildcard in ["https:", "http:", "*", "'unsafe-inline'"] {
2474 assert!(
2475 !script_sources.contains(&wildcard),
2476 "{target}: script-src must not contain wildcard `{wildcard}`, \
2477 sources: {script_src}"
2478 );
2479 }
2480
2481 // --- Clause 3: script-src includes both CDN origins in BOTH targets
2482 assert!(
2483 script_src.contains("https://cdn.jsdelivr.net"),
2484 "{target}: script-src must include cdn.jsdelivr.net"
2485 );
2486 assert!(
2487 script_src.contains("https://webr.r-wasm.org"),
2488 "{target}: script-src must include webr.r-wasm.org"
2489 );
2490
2491 // --- Clause 4: connect-src includes all required origins
2492 let connect_src = extract_csp_directive(&csp_content, "connect-src");
2493 for origin in [
2494 "https://cdn.jsdelivr.net",
2495 "https://webr.r-wasm.org",
2496 "https://repo.r-wasm.org",
2497 "https://api.fireworks.ai",
2498 "https://api.anthropic.com",
2499 ] {
2500 assert!(
2501 connect_src.contains(origin),
2502 "{target}: connect-src must include {origin}, got: {connect_src}"
2503 );
2504 }
2505
2506 // --- Clause 5: style-src includes 'unsafe-inline'
2507 let style_src = extract_csp_directive(&csp_content, "style-src");
2508 assert!(
2509 style_src.contains("'unsafe-inline'"),
2510 "{target}: style-src must include 'unsafe-inline' \
2511 (CM6 StyleModule injects style tags), got: {style_src}"
2512 );
2513
2514 // --- Clause 6: worker-src includes 'self' blob:
2515 let worker_src = extract_csp_directive(&csp_content, "worker-src");
2516 assert!(
2517 worker_src.contains("'self'") && worker_src.contains("blob:"),
2518 "{target}: worker-src must include 'self' blob: \
2519 (WebR channel workers), got: {worker_src}"
2520 );
2521
2522 // --- Clause 7: exactly one referrer meta, content="no-referrer"
2523 let referrer_meta = r#"<meta name="referrer""#;
2524 let referrer_count = html.matches(referrer_meta).count();
2525 assert_eq!(
2526 referrer_count, 1,
2527 "{target}: expected exactly one referrer meta tag, got {referrer_count}"
2528 );
2529 let referrer_content =
2530 extract_meta_content(html, "referrer").expect("referrer content");
2531 assert_eq!(
2532 referrer_content, "no-referrer",
2533 "{target}: referrer meta must have content=\"no-referrer\""
2534 );
2535 let referrer_pos = html.find(referrer_meta).expect("referrer meta exists");
2536 assert!(
2537 referrer_pos < first_script_pos,
2538 "{target}: referrer meta must appear before first <script> tag"
2539 );
2540
2541 // --- Clauses 8/9: SRI per target
2542 match target {
2543 BuildTarget::Pyodide => {
2544 // Clause 8: Pyodide CDN script has non-empty integrity +
2545 // crossorigin="anonymous"
2546 let cdn_pos = html
2547 .find(r#"src="https://cdn.jsdelivr.net/pyodide"#)
2548 .expect("pyodide CDN script exists");
2549 // Find the closing > of this script tag
2550 let tag_end = html[cdn_pos..]
2551 .find('>')
2552 .map(|p| cdn_pos + p)
2553 .expect("pyodide CDN script tag closes");
2554 let script_tag = &html[cdn_pos..=tag_end];
2555
2556 // integrity attribute with sha256/384/512 prefix
2557 let integrity = extract_attr_value(script_tag, "integrity")
2558 .expect("pyodide CDN script must have integrity attribute");
2559 assert!(
2560 integrity.starts_with("sha256-")
2561 || integrity.starts_with("sha384-")
2562 || integrity.starts_with("sha512-"),
2563 "pyodide CDN integrity must start with sha256/384/512 prefix, \
2564 got: {integrity}"
2565 );
2566 assert!(
2567 integrity.len() > 10,
2568 "pyodide CDN integrity must be non-empty (not just the prefix), \
2569 got: {integrity}"
2570 );
2571
2572 // crossorigin="anonymous"
2573 let crossorigin = extract_attr_value(script_tag, "crossorigin")
2574 .expect("pyodide CDN script must have crossorigin attribute");
2575 assert_eq!(
2576 crossorigin, "anonymous",
2577 "pyodide CDN script must have crossorigin=\"anonymous\""
2578 );
2579 }
2580 BuildTarget::Webr => {
2581 // Clause 9: WebR target has NO integrity attributes on any
2582 // HTML tag. webR is loaded as an ES module import, which
2583 // cannot carry an HTML integrity attribute — the SRI gap is
2584 // accepted and documented in code comments.
2585 assert!(
2586 !html.contains("integrity="),
2587 "webr index.html must NOT contain any integrity attributes \
2588 (ES module import SRI gap — accepted, documented)"
2589 );
2590 }
2591 }
2592
2593 // --- Clause 10: eval-results.html has same CSP + referrer meta
2594 let eval_html = &file(&site, "eval-results.html").contents;
2595 let eval_csp = extract_meta_content(eval_html, "Content-Security-Policy")
2596 .expect("eval-results.html must have CSP meta");
2597 assert_eq!(
2598 eval_csp, csp,
2599 "{target}: eval-results.html CSP must match CSP_POLICY const"
2600 );
2601 let eval_referrer =
2602 extract_meta_content(eval_html, "referrer").expect("eval-results referrer");
2603 assert_eq!(
2604 eval_referrer, "no-referrer",
2605 "{target}: eval-results.html must have referrer content=\"no-referrer\""
2606 );
2607 }
2608 }
2609
2610 /// Extract the `content` attribute value from a `<meta>` tag identified by
2611 /// its `http-equiv` or `name` attribute. Returns `None` if the tag or its
2612 /// content attribute is not found.
2613 fn extract_meta_content(html: &str, key: &str) -> Option<String> {
2614 for prefix in [format!(r#"http-equiv="{key}""#), format!(r#"name="{key}""#)] {
2615 if let Some(pos) = html.find(&prefix) {
2616 let after = &html[pos + prefix.len()..];
2617 if let Some(content_pos) = after.find(r#"content=""#) {
2618 let start = content_pos + r#"content=""#.len();
2619 let rest = &after[start..];
2620 if let Some(end) = rest.find('"') {
2621 return Some(rest[..end].to_string());
2622 }
2623 }
2624 }
2625 }
2626 None
2627 }
2628
2629 /// Extract a CSP directive's value (the sources after the directive name,
2630 /// up to the next `;` or end of string).
2631 fn extract_csp_directive<'a>(csp: &'a str, directive: &str) -> &'a str {
2632 let needle = format!("{directive} ");
2633 if let Some(pos) = csp.find(&needle) {
2634 let start = pos + needle.len();
2635 let rest = &csp[start..];
2636 let end = rest.find(';').unwrap_or(rest.len());
2637 &rest[..end]
2638 } else {
2639 ""
2640 }
2641 }
2642
2643 /// Extract an attribute value from an HTML tag fragment. The `tag` slice
2644 /// should start at or before the attribute and end at or after the closing
2645 /// `>`. Returns `None` if the attribute is not found.
2646 fn extract_attr_value(tag: &str, attr: &str) -> Option<String> {
2647 let needle = format!(r#"{attr}=""#);
2648 let pos = tag.find(&needle)?;
2649 let start = pos + needle.len();
2650 let rest = &tag[start..];
2651 let end = rest.find('"')?;
2652 Some(rest[..end].to_string())
2653 }
2654
2655 /// Detect whether a `.cm-gutters` rule sets display:none or visibility:hidden.
2656 fn gutter_hidden(css: &str) -> bool {
2657 let mut rest = css;
2658 while let Some(pos) = rest.find(".cm-gutters") {
2659 let after = &rest[pos..];
2660 let Some(brace) = after.find('{') else {
2661 rest = &rest[pos + ".cm-gutters".len()..];
2662 continue;
2663 };
2664 let body_start = pos + brace + 1;
2665 let body_slice = &rest[body_start..];
2666 let Some(close) = body_slice.find('}') else {
2667 rest = &rest[pos + ".cm-gutters".len()..];
2668 continue;
2669 };
2670 let body = &rest[body_start..body_start + close];
2671 if body.contains("display: none") || body.contains("visibility: hidden") {
2672 return true;
2673 }
2674 rest = &rest[body_start + close..];
2675 }
2676 false
2677 }
2678
2679 // --- AC-4: client-side rate limiting — config.js emission (predicates 4-5) --
2680
2681 #[test]
2682 fn feedback_rate_limit_plan_site_emits_config_js() {
2683 // Predicate 4: plan_site emits config.js with "maxFeedbackPerSession": 5.
2684 // The config.js file is the Rust→JS contract for site-level configuration
2685 // (§3.2): it carries window.__btConfig = { maxFeedbackPerSession: N },
2686 // which feedback.js reads to enforce the per-session rate limit.
2687 let site_config = SiteConfig {
2688 max_feedback_per_session: 5,
2689 };
2690 let site = plan_site(
2691 &r_course(),
2692 BuildTarget::Webr,
2693 &EvalSummary::NotValidated,
2694 &site_config,
2695 )
2696 .expect("plans");
2697 let config = file(&site, "config.js");
2698 assert!(
2699 config.contents.contains("window.__btConfig"),
2700 "config.js must set window.__btConfig; got: {}",
2701 config.contents
2702 );
2703 assert!(
2704 config.contents.contains("maxFeedbackPerSession"),
2705 "config.js must contain maxFeedbackPerSession; got: {}",
2706 config.contents
2707 );
2708 assert!(
2709 config.contents.contains("maxFeedbackPerSession: 5"),
2710 "config.js must carry the configured max (5); got: {}",
2711 config.contents
2712 );
2713
2714 // The default SiteConfig (max=20) emits 20 — the default the feedback
2715 // rate limiter reads when no [site] section is present.
2716 let default_site = plan(&r_course(), BuildTarget::Webr).expect("plans");
2717 let default_config = file(&default_site, "config.js");
2718 assert!(
2719 default_config
2720 .contents
2721 .contains("maxFeedbackPerSession: 20"),
2722 "config.js must carry the default max (20); got: {}",
2723 default_config.contents
2724 );
2725
2726 // config.js is byte-identical across targets (shared contract, §4.2).
2727 let pyodide = plan_site(
2728 &python_course(),
2729 BuildTarget::Pyodide,
2730 &EvalSummary::NotValidated,
2731 &site_config,
2732 )
2733 .expect("plans");
2734 assert_eq!(
2735 file(&site, "config.js").contents,
2736 file(&pyodide, "config.js").contents,
2737 "config.js must be byte-identical across targets for the same SiteConfig"
2738 );
2739 }
2740
2741 #[test]
2742 fn feedback_rate_limit_shells_load_config_before_feedback() {
2743 // Predicate 5: both targets' index.html load config.js BEFORE feedback.js.
2744 // config.js sets window.__btConfig synchronously (classic script, not a
2745 // deferred module) so the global is available when feedback.js (a deferred
2746 // module) reads it. A shell that loads feedback.js first would leave
2747 // __btConfig undefined at rate-limit check time.
2748 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2749 let pyodide = plan(&python_course(), BuildTarget::Pyodide).expect("plans");
2750 for (target, site) in [(BuildTarget::Webr, &webr), (BuildTarget::Pyodide, &pyodide)] {
2751 let html = &file(site, "index.html").contents;
2752 let config_pos = html
2753 .find(r#"src="config.js""#)
2754 .unwrap_or_else(|| panic!("{target}: index.html must reference config.js"));
2755 let feedback_pos = html
2756 .find(r#"src="feedback.js""#)
2757 .unwrap_or_else(|| panic!("{target}: index.html must reference feedback.js"));
2758 assert!(
2759 config_pos < feedback_pos,
2760 "{target}: config.js must load before feedback.js (so __btConfig is set \
2761 before the rate limiter reads it)"
2762 );
2763 }
2764 }
2765
2766 // --- AC-4: client-side rate limiting — feedback.js source scan (6,7) -------
2767
2768 #[test]
2769 fn feedback_rate_limit_feedback_js_has_rate_limiting() {
2770 // Predicate 6: feedback.js contains the rate-limiting patterns. No JS
2771 // harness exists, so this is a static source scan of the emitted feedback.js
2772 // (verification: code). 6 sub-clauses pin the invariant (§1.5):
2773 // 1. "bt_feedback_count" sessionStorage key
2774 // 2. window.__btConfig.maxFeedbackPerSession read
2775 // 3. limit-reached message string
2776 // 4. counter increment AFTER try/catch (failed requests count)
2777 // 5. textContent for limit message (NOT innerHTML — XSS defense)
2778 // 6. parseInt guard on stored counter
2779 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2780 let feedback = &file(&webr, "feedback.js").contents;
2781
2782 // Clause 1: the bt_feedback_count sessionStorage key.
2783 assert!(
2784 feedback.contains("bt_feedback_count"),
2785 "feedback.js must use the bt_feedback_count sessionStorage key; feedback={feedback}"
2786 );
2787
2788 // Clause 2: window.__btConfig.maxFeedbackPerSession read.
2789 assert!(
2790 feedback.contains("__btConfig"),
2791 "feedback.js must read window.__btConfig; feedback={feedback}"
2792 );
2793 assert!(
2794 feedback.contains("maxFeedbackPerSession"),
2795 "feedback.js must read maxFeedbackPerSession from __btConfig; feedback={feedback}"
2796 );
2797
2798 // Clause 3: a limit-reached message string (the user-facing copy).
2799 assert!(
2800 feedback.to_lowercase().contains("limit"),
2801 "feedback.js must contain a limit-reached message; feedback={feedback}"
2802 );
2803
2804 // Clause 5: textContent for the limit message (NOT innerHTML). The
2805 // existing !innerHTML invariant (plan_site_shells_load_codemirror_as_import
2806 // clause 6) already guarantees no innerHTML in lesson-runner-core.js;
2807 // here we pin that feedback.js also never uses .innerHTML (property
2808 // access) — the comment at line ~473 mentions `innerHTML` in backticks
2809 // (no dot), so checking `.innerHTML` catches only real property access.
2810 // This covers the limit message AND all other rendering (verdict,
2811 // error, pending) — feedback.js handles untrusted model output, so
2812 // innerHTML is never safe.
2813 assert!(
2814 feedback.contains("textContent"),
2815 "feedback.js must use textContent for the limit message; feedback={feedback}"
2816 );
2817 assert!(
2818 !feedback.contains(".innerHTML"),
2819 "feedback.js must NOT use .innerHTML (XSS defense — untrusted model output \
2820 and limit messages use textContent); feedback={feedback}"
2821 );
2822
2823 // Clause 6: parseInt guard on the stored counter. Without parseInt, a
2824 // corrupt or missing sessionStorage value yields a string comparison
2825 // (or NaN), silently disabling limiting. We check for `parseInt(`
2826 // (the call, with opening paren) rather than bare `parseInt` — the
2827 // word appears in explanatory comments but only the call enforces the
2828 // guard at runtime.
2829 assert!(
2830 feedback.contains("parseInt("),
2831 "feedback.js must call parseInt() to guard the stored counter; feedback={feedback}"
2832 );
2833
2834 // Clause 4: counter increment AFTER try/catch (failed requests count).
2835 // The increment call must NOT be inside the try block (which would skip
2836 // it on error) and must appear after the catch block's renderError call
2837 // (the last statement in the catch). We scope the search to handleSubmit
2838 // to avoid matching the try/catch in listModels.
2839 let handle_submit_pos = feedback
2840 .find("async function handleSubmit")
2841 .expect("feedback.js must define handleSubmit");
2842 let handle_submit_body = &feedback[handle_submit_pos..];
2843
2844 let try_pos = handle_submit_body
2845 .find("try {")
2846 .expect("handleSubmit must have a try block");
2847 let catch_pos = handle_submit_body
2848 .find("} catch (error) {")
2849 .expect("handleSubmit must have a catch block");
2850 let increment_pos = handle_submit_body
2851 .find("incrementFeedbackCount()")
2852 .expect("feedback.js must call incrementFeedbackCount in handleSubmit");
2853 let render_error_pos = handle_submit_body
2854 .find("renderError(container, error)")
2855 .expect("handleSubmit must call renderError in the catch block");
2856
2857 // The increment must NOT be inside the try block (between try { and } catch).
2858 assert!(
2859 !(increment_pos > try_pos && increment_pos < catch_pos),
2860 "incrementFeedbackCount must NOT be inside the try block \
2861 (failed requests count — the negative case: increment only inside try \
2862 skips failures); feedback={feedback}"
2863 );
2864 // The increment must be after renderError (the last statement in the catch),
2865 // so it is reached on both success and failure paths.
2866 assert!(
2867 increment_pos > render_error_pos,
2868 "incrementFeedbackCount must be called AFTER the try/catch block \
2869 (after renderError in the catch — both paths reach it); feedback={feedback}"
2870 );
2871 }
2872
2873 #[test]
2874 fn feedback_rate_limit_feedback_js_does_not_increment_around_list_models() {
2875 // Predicate 7: feedback.js does NOT increment the counter around
2876 // listModels (model discovery is not feedback — it doesn't count).
2877 // We verify the increment call does not appear in the listModels or
2878 // renderModelPicker function bodies.
2879 let webr = plan(&r_course(), BuildTarget::Webr).expect("plans");
2880 let feedback = &file(&webr, "feedback.js").contents;
2881
2882 // Check listModels: find its body (from the function definition to the
2883 // next function definition) and assert no increment call within.
2884 let listmodels_start = feedback
2885 .find("async function listModels")
2886 .expect("feedback.js must define listModels");
2887 let after_listmodels = &feedback[listmodels_start..];
2888 let listmodels_end = after_listmodels[1..]
2889 .find("\nfunction ")
2890 .or_else(|| after_listmodels[1..].find("\nasync function "))
2891 .map(|p| p + 1)
2892 .unwrap_or(after_listmodels.len());
2893 let listmodels_body = &after_listmodels[..listmodels_end];
2894 assert!(
2895 !listmodels_body.contains("incrementFeedbackCount()"),
2896 "listModels must NOT call incrementFeedbackCount \
2897 (model discovery doesn't count); feedback={feedback}"
2898 );
2899
2900 // Check renderModelPicker: the function that calls listModels. The
2901 // increment must not appear here either — the guard and increment live
2902 // in handleSubmit, after the model picker phase.
2903 let picker_start = feedback
2904 .find("async function renderModelPicker")
2905 .expect("feedback.js must define renderModelPicker");
2906 let after_picker = &feedback[picker_start..];
2907 let picker_end = after_picker[1..]
2908 .find("\nfunction ")
2909 .or_else(|| after_picker[1..].find("\nasync function "))
2910 .map(|p| p + 1)
2911 .unwrap_or(after_picker.len());
2912 let picker_body = &after_picker[..picker_end];
2913 assert!(
2914 !picker_body.contains("incrementFeedbackCount()"),
2915 "renderModelPicker must NOT call incrementFeedbackCount \
2916 (model discovery doesn't count); feedback={feedback}"
2917 );
2918 }
2919
2920 // --- AC-2 (issue #97): password protection — pure Rust AES-256-GCM -------
2921
2922 /// The password used in encrypt_site_files tests — distinctive enough that a
2923 /// false-positive substring match in any emitted file is unlikely.
2924 const TEST_PASSWORD: &str = "test-password-12345";
2925
2926 #[test]
2927 fn encrypt_site_files_password_protects_all_content_files() {
2928 // AC-2 (issue #97): encrypt_site_files produces a SiteFiles where every
2929 // content file is encrypted and every infrastructure file is unchanged.
2930 // 17 clauses pin the invariant, tested for BOTH targets (clause 16).
2931 //
2932 // The probe: cargo test -p blendtutor-core --lib \
2933 // site::tests::encrypt_site_files_password_protects_all_content_files
2934 //
2935 // Note: PBKDF2 with 600k iterations is intentionally slow. This test
2936 // minimizes encrypt calls: one full encrypt_site_files per target, plus
2937 // a few crypto::encrypt calls for the salt/nonce uniqueness clauses.
2938
2939 for (target, course) in [
2940 (BuildTarget::Webr, r_course()),
2941 (BuildTarget::Pyodide, python_course()),
2942 ] {
2943 let planned = plan(&course, target).expect("plans");
2944 let mut rng = rand_core::OsRng;
2945 let encrypted = encrypt_site_files(&planned, TEST_PASSWORD, None, &mut rng);
2946 let index = &file(&encrypted, "index.html").contents;
2947
2948 // --- Clause 1: index.html is decrypt shell with data-encrypted-payload/
2949 // salt/iv base64 attrs ---
2950 assert!(
2951 index.contains("data-encrypted-payload="),
2952 "{target}: index.html must have data-encrypted-payload attr"
2953 );
2954 assert!(
2955 index.contains("data-salt="),
2956 "{target}: index.html must have data-salt attr"
2957 );
2958 assert!(
2959 index.contains("data-iv="),
2960 "{target}: index.html must have data-iv attr"
2961 );
2962 let payload_val = extract_data_attr(index, "data-encrypted-payload")
2963 .unwrap_or_else(|| panic!("{target}: data-encrypted-payload has a value"));
2964 let salt_val = extract_data_attr(index, "data-salt")
2965 .unwrap_or_else(|| panic!("{target}: data-salt has a value"));
2966 let iv_val = extract_data_attr(index, "data-iv")
2967 .unwrap_or_else(|| panic!("{target}: data-iv has a value"));
2968 assert!(!payload_val.is_empty(), "{target}: payload is non-empty");
2969 assert!(!salt_val.is_empty(), "{target}: salt is non-empty");
2970 assert!(!iv_val.is_empty(), "{target}: iv is non-empty");
2971
2972 // --- Clause 2: password input + decrypt button present ---
2973 assert!(
2974 index.contains(r#"type="password""#) && index.contains("decrypt-password"),
2975 "{target}: index.html must have a password input"
2976 );
2977 assert!(
2978 index.contains(r#"id="decrypt-button""#),
2979 "{target}: index.html must have a decrypt button"
2980 );
2981
2982 // --- Clause 3: inline crypto.subtle.deriveKey (PBKDF2) +
2983 // crypto.subtle.decrypt (AES-GCM) JS ---
2984 assert!(
2985 index.contains("crypto.subtle.deriveKey"),
2986 "{target}: inline JS must call crypto.subtle.deriveKey (PBKDF2)"
2987 );
2988 assert!(
2989 index.contains("crypto.subtle.decrypt"),
2990 "{target}: inline JS must call crypto.subtle.decrypt (AES-GCM)"
2991 );
2992
2993 // --- Clause 4: no plaintext lesson content in index.html ---
2994 for plaintext_marker in [
2995 r#"<header class="site-header">"#,
2996 r#"<main class="workspace">"#,
2997 r#"<footer class="site-footer">"#,
2998 "lesson-select",
2999 "Run checks",
3000 "Submit for feedback",
3001 "Booting webR",
3002 "Booting Pyodide",
3003 ] {
3004 assert!(
3005 !index.contains(plaintext_marker),
3006 "{target}: encrypted index.html must NOT contain plaintext \
3007 page-shell content `{plaintext_marker}`"
3008 );
3009 }
3010
3011 // --- Clause 5: every lessons/*.json is encrypted ciphertext
3012 // (serde_json::from_str fails) ---
3013 let lesson_files: Vec<_> = encrypted
3014 .files()
3015 .iter()
3016 .filter(|f| {
3017 f.path.starts_with("lessons/") && f.path.to_string_lossy().ends_with(".json")
3018 })
3019 .collect();
3020 assert!(
3021 !lesson_files.is_empty(),
3022 "{target}: encrypted site must still have lessons/*.json files"
3023 );
3024 for lf in &lesson_files {
3025 assert!(
3026 serde_json::from_str::<Value>(&lf.contents).is_err(),
3027 "{target}: lessons/{} must be encrypted ciphertext (serde_json::from_str \
3028 fails), got: {}",
3029 lf.path.display(),
3030 &lf.contents
3031 );
3032 }
3033
3034 // --- Clause 6: lessons.json is encrypted ciphertext ---
3035 let lessons_json = &file(&encrypted, "lessons.json").contents;
3036 assert!(
3037 serde_json::from_str::<Value>(lessons_json).is_err(),
3038 "{target}: lessons.json must be encrypted ciphertext (serde_json::from_str \
3039 fails), got: {lessons_json}"
3040 );
3041
3042 // --- Clause 7: password string not in any emitted file ---
3043 for f in encrypted.files() {
3044 assert!(
3045 !f.contents.contains(TEST_PASSWORD),
3046 "{target}: password string must not appear in any emitted file; found in {}",
3047 f.path.display()
3048 );
3049 }
3050
3051 // --- Clause 10: IV decodes to 12 bytes, not all zeros ---
3052 let iv_bytes = base64::engine::general_purpose::STANDARD
3053 .decode(&iv_val)
3054 .unwrap_or_else(|e| panic!("{target}: data-iv is valid base64: {e}"));
3055 assert_eq!(
3056 iv_bytes.len(),
3057 12,
3058 "{target}: IV must decode to 12 bytes, got {}",
3059 iv_bytes.len()
3060 );
3061 assert!(
3062 iv_bytes.iter().any(|&b| b != 0),
3063 "{target}: IV must not be all zeros"
3064 );
3065
3066 // --- Clause 11: inline JS contains literal 600000 (PBKDF2 iterations) ---
3067 assert!(
3068 index.contains("600000"),
3069 "{target}: inline JS must contain literal 600000 (PBKDF2 iterations)"
3070 );
3071
3072 // --- Clause 12: coi-serviceworker.js script before inline decrypt
3073 // script (BOTH targets) ---
3074 let coi_pos = index
3075 .find(r#"src="coi-serviceworker.js""#)
3076 .unwrap_or_else(|| panic!("{target}: coi-serviceworker.js must be referenced"));
3077 let inline_script_pos = index
3078 .rfind("<script>")
3079 .unwrap_or_else(|| panic!("{target}: must have an inline <script> block"));
3080 assert!(
3081 coi_pos < inline_script_pos,
3082 "{target}: coi-serviceworker.js must appear before the inline decrypt script"
3083 );
3084
3085 // --- Clause 13: decrypt JS has catch block with error message ---
3086 assert!(
3087 index.contains("catch"),
3088 "{target}: decrypt JS must have a catch block"
3089 );
3090 assert!(
3091 index.to_lowercase().contains("decryption failed")
3092 || index.to_lowercase().contains("wrong password"),
3093 "{target}: decrypt JS catch block must have an error message"
3094 );
3095
3096 // --- Clause 14: shared assets still emitted unchanged ---
3097 for infra in [
3098 "lesson-runner.js",
3099 "lesson-runner-core.js",
3100 "coi-serviceworker.js",
3101 "config.js",
3102 "feedback.js",
3103 "styles.css",
3104 "codemirror.js",
3105 ] {
3106 assert_eq!(
3107 file(&planned, infra).contents,
3108 file(&encrypted, infra).contents,
3109 "{target}: {infra} must be byte-identical before and after encryption"
3110 );
3111 }
3112
3113 // --- Clause 15: eval-results.html also encrypted ---
3114 let eval_html = &file(&encrypted, "eval-results.html").contents;
3115 assert!(
3116 eval_html.contains("data-encrypted-payload="),
3117 "{target}: eval-results.html must be a decrypt shell with data-encrypted-payload"
3118 );
3119 assert!(
3120 eval_html.contains("data-salt=") && eval_html.contains("data-iv="),
3121 "{target}: eval-results.html must have data-salt and data-iv attrs"
3122 );
3123 assert!(
3124 !eval_html.contains("Eval results") || eval_html.contains("password required"),
3125 "{target}: eval-results.html must not contain plaintext eval content"
3126 );
3127
3128 // --- Clause 16: both Webr and Pyodide targets work ---
3129 // (This loop runs for both targets — reaching here for both proves it.)
3130
3131 // --- Clause 18: fetch monkeypatch has try/catch fallback for
3132 // non-encrypted responses (404 HTML, third-party resources with
3133 // "lessons/" in URL). Without this, atob() throws
3134 // InvalidCharacterError on non-base64 content and the fetch
3135 // promise rejects with no fallback. response.clone() is
3136 // required because response.text() consumes the body — without
3137 // clone, the catch block would return an empty response. ---
3138 {
3139 let fetch_start = index
3140 .find("window.fetch =")
3141 .unwrap_or_else(|| panic!("{target}: must have fetch monkeypatch"));
3142 let rest = &index[fetch_start..];
3143 let fetch_end = rest
3144 .find("};")
3145 .unwrap_or_else(|| panic!("{target}: fetch monkeypatch must be closed"));
3146 let fetch_code = &rest[..fetch_end];
3147 assert!(
3148 fetch_code.contains("try") && fetch_code.contains("catch"),
3149 "{target}: fetch monkeypatch must have try/catch for non-encrypted responses"
3150 );
3151 assert!(
3152 fetch_code.contains("response.clone()"),
3153 "{target}: fetch monkeypatch must clone response before reading (body consumption guard)"
3154 );
3155 assert!(
3156 fetch_code.contains("return response"),
3157 "{target}: fetch monkeypatch catch block must fall back to returning original response"
3158 );
3159 }
3160
3161 // --- Clause 19: fetch monkeypatch derives a PER-FILE key using
3162 // each file's own salt, not the index.html-derived key.
3163 // encrypt_site_files generates a fresh salt per file, so
3164 // reusing derivedKey (derived from index.html's salt) fails
3165 // the GCM auth tag on every lesson JSON. The monkeypatch must
3166 // call crypto.subtle.deriveKey with the file's salt and use
3167 // that per-file key for decryption. ---
3168 {
3169 let fetch_start = index
3170 .find("window.fetch =")
3171 .unwrap_or_else(|| panic!("{target}: must have fetch monkeypatch"));
3172 let rest = &index[fetch_start..];
3173 let fetch_end = rest
3174 .find("};")
3175 .unwrap_or_else(|| panic!("{target}: fetch monkeypatch must be closed"));
3176 let fetch_code = &rest[..fetch_end];
3177 assert!(
3178 fetch_code.contains("deriveKey"),
3179 "{target}: fetch monkeypatch must derive a per-file key (deriveKey call)"
3180 );
3181 assert!(
3182 fetch_code.contains("salt: salt"),
3183 "{target}: fetch monkeypatch must derive key from the file's own salt, not index.html's salt"
3184 );
3185 assert!(
3186 !fetch_code.contains("derivedKey"),
3187 "{target}: fetch monkeypatch must NOT reuse derivedKey (index.html key) for lesson decryption"
3188 );
3189 }
3190
3191 // --- Clause 20: password stored at top level so the fetch
3192 // monkeypatch can derive per-file keys. The monkeypatch runs
3193 // outside the click handler, so it needs access to the password
3194 // via a top-level variable. ---
3195 assert!(
3196 index.contains("userPassword"),
3197 "{target}: decrypt shell must store password in a top-level userPassword variable for per-file key derivation"
3198 );
3199 assert!(
3200 index.contains("userPassword = password"),
3201 "{target}: decrypt shell must capture the password into userPassword in the click handler"
3202 );
3203 }
3204
3205 // --- Clause 8: two calls with same password+content yield different
3206 // salts (uses crypto::encrypt directly to avoid full-site re-encrypt) ---
3207 let mut rng_a = rand_core::OsRng;
3208 let mut rng_b = rand_core::OsRng;
3209 let p1 = crate::crypto::encrypt("same content", TEST_PASSWORD, &mut rng_a);
3210 let p2 = crate::crypto::encrypt("same content", TEST_PASSWORD, &mut rng_b);
3211 assert_ne!(p1.salt, p2.salt, "two calls must yield different salts");
3212
3213 // --- Clause 9: two calls yield different nonces ---
3214 assert_ne!(
3215 p1.nonce, p2.nonce,
3216 "two calls must yield different nonces (GCM catastrophic-failure guard)"
3217 );
3218
3219 // --- Clause 17: roundtrip — crypto::decrypt(crypto::encrypt(...)) ---
3220 let mut rng = rand_core::OsRng;
3221 let plaintext = "hello, encrypted world!";
3222 let payload = crate::crypto::encrypt(plaintext, "secret", &mut rng);
3223 let decrypted = crate::crypto::decrypt(&payload, "secret")
3224 .expect("roundtrip with correct password must succeed");
3225 assert_eq!(
3226 decrypted, plaintext,
3227 "crypto roundtrip must recover the original plaintext"
3228 );
3229
3230 // Negative: wrong password = Err
3231 assert!(
3232 crate::crypto::decrypt(&payload, "wrong").is_err(),
3233 "wrong password must fail decryption"
3234 );
3235
3236 // Negative: base64-encoding-as-encryption caught — lesson JSONs are not
3237 // valid JSON (they are base64 ciphertext, not JSON). Reuses the Webr
3238 // encrypted site from the loop above.
3239 let webr_planned = plan(&r_course(), BuildTarget::Webr).expect("plans");
3240 let mut rng = rand_core::OsRng;
3241 let enc = encrypt_site_files(&webr_planned, TEST_PASSWORD, None, &mut rng);
3242 let lesson0 = &file(&enc, "lessons/0.json").contents;
3243 assert!(
3244 serde_json::from_str::<Value>(lesson0).is_err(),
3245 "lesson JSON must not be valid JSON (base64-encoding-as-encryption guard)"
3246 );
3247
3248 // Negative: JSON-bypass caught — ALL lesson JSONs are encrypted, not
3249 // just index.html.
3250 let lesson_count = enc
3251 .files()
3252 .iter()
3253 .filter(|f| {
3254 f.path.starts_with("lessons/") && f.path.to_string_lossy().ends_with(".json")
3255 })
3256 .count();
3257 for i in 0..lesson_count {
3258 let path = format!("lessons/{i}.json");
3259 let content = &file(&enc, &path).contents;
3260 assert!(
3261 serde_json::from_str::<Value>(content).is_err(),
3262 "JSON-bypass guard: {path} must be encrypted (not plaintext JSON)"
3263 );
3264 }
3265
3266 // Negative: hardcoded-key JS caught — the decrypt shell must read the
3267 // password from the user input, not contain a hardcoded key.
3268 let index = &file(&enc, "index.html").contents;
3269 assert!(
3270 index.contains("decrypt-password"),
3271 "decrypt shell must read password from user input (no hardcoded key)"
3272 );
3273 assert!(
3274 !index.contains("hardcoded") && !index.contains("HARDCODED"),
3275 "decrypt shell must not reference a hardcoded key"
3276 );
3277 }
3278
3279 /// Extract the value of a `data-*` attribute from an HTML string.
3280 /// Returns the attribute value (between the quotes), or None if not found.
3281 fn extract_data_attr(html: &str, attr: &str) -> Option<String> {
3282 let needle = format!(r#"{attr}=""#);
3283 let pos = html.find(&needle)?;
3284 let start = pos + needle.len();
3285 let rest = &html[start..];
3286 let end = rest.find('"')?;
3287 Some(rest[..end].to_string())
3288 }
3289
3290 /// Extract an EncryptedPayload from a decrypt shell's data-* attributes.
3291 /// Reconstructs the salt, nonce, and ciphertext from the separate base64
3292 /// attributes the decrypt shell emits.
3293 fn extract_encrypted_payload(html: &str) -> crypto::EncryptedPayload {
3294 let ciphertext = base64::engine::general_purpose::STANDARD
3295 .decode(
3296 extract_data_attr(html, "data-encrypted-payload")
3297 .expect("decrypt shell must have data-encrypted-payload"),
3298 )
3299 .expect("ciphertext is valid base64");
3300 let salt_bytes = base64::engine::general_purpose::STANDARD
3301 .decode(
3302 extract_data_attr(html, "data-salt").expect("decrypt shell must have data-salt"),
3303 )
3304 .expect("salt is valid base64");
3305 let nonce_bytes = base64::engine::general_purpose::STANDARD
3306 .decode(extract_data_attr(html, "data-iv").expect("decrypt shell must have data-iv"))
3307 .expect("iv is valid base64");
3308 let mut salt = [0u8; 16];
3309 salt.copy_from_slice(&salt_bytes);
3310 let mut nonce = [0u8; 12];
3311 nonce.copy_from_slice(&nonce_bytes);
3312 crypto::EncryptedPayload {
3313 ciphertext,
3314 salt,
3315 nonce,
3316 }
3317 }
3318
3319 #[test]
3320 fn is_content_file_classifies_index_and_eval_results_as_content() {
3321 // index.html and eval-results.html are content files that get encrypted.
3322 // Kills the mutant that replaces || with && in the first two conditions:
3323 // `path == "index.html" && path == "eval-results.html"` is always false
3324 // (a path can't equal both), so both would be misclassified as non-content.
3325 assert!(
3326 is_content_file("index.html"),
3327 "index.html must be classified as a content file"
3328 );
3329 assert!(
3330 is_content_file("eval-results.html"),
3331 "eval-results.html must be classified as a content file"
3332 );
3333 }
3334
3335 #[test]
3336 fn is_content_file_rejects_non_content_files_matching_one_condition() {
3337 // A file that starts with "lessons/" but doesn't end in ".json" is NOT
3338 // a content file. Kills the mutant that replaces && with || in the
3339 // last condition: `starts_with("lessons/") || ends_with(".json")`
3340 // would classify "lessons/readme.txt" as content (matching starts_with
3341 // alone) and "config.json" as content (matching ends_with alone).
3342 assert!(
3343 !is_content_file("lessons/readme.txt"),
3344 "lessons/readme.txt must NOT be a content file (not a .json)"
3345 );
3346 assert!(
3347 !is_content_file("config.json"),
3348 "config.json must NOT be a content file (not under lessons/)"
3349 );
3350 }
3351
3352 #[test]
3353 fn encrypt_site_files_embeds_key_only_in_index_html() {
3354 // When an embed_key is provided, index.html's plaintext is JSON-wrapped
3355 // ({"html":"...","embeddedKey":{...}}) so the decrypt shell can extract
3356 // the key. eval-results.html does NOT receive the JSON wrapping — it
3357 // stays as plain encrypted HTML.
3358 //
3359 // Kills the mutant that replaces == with != in
3360 // `if path_str == "index.html"`: the mutant swaps the behavior,
3361 // JSON-wrapping eval-results.html instead of index.html.
3362 let planned = plan(&r_course(), BuildTarget::Webr).expect("plans");
3363 let mut rng = rand_core::OsRng;
3364 let embed_key = EmbeddedKey {
3365 provider: "fireworks".to_string(),
3366 key: "fw_testkey123".to_string(),
3367 };
3368 let encrypted = encrypt_site_files(&planned, TEST_PASSWORD, Some(&embed_key), &mut rng);
3369
3370 // index.html: decrypt and verify the plaintext contains embeddedKey JSON.
3371 let index_html = &file(&encrypted, "index.html").contents;
3372 let index_payload = extract_encrypted_payload(index_html);
3373 let index_plaintext = crypto::decrypt(&index_payload, TEST_PASSWORD)
3374 .expect("index.html decrypts with the correct password");
3375 assert!(
3376 index_plaintext.contains("embeddedKey"),
3377 "index.html plaintext must contain embeddedKey JSON wrapping, got: {index_plaintext}"
3378 );
3379 assert!(
3380 index_plaintext.contains("fw_testkey123"),
3381 "index.html plaintext must contain the embedded API key, got: {index_plaintext}"
3382 );
3383
3384 // eval-results.html: decrypt and verify the plaintext does NOT contain
3385 // embeddedKey JSON — it stays as plain HTML.
3386 let eval_html = &file(&encrypted, "eval-results.html").contents;
3387 let eval_payload = extract_encrypted_payload(eval_html);
3388 let eval_plaintext = crypto::decrypt(&eval_payload, TEST_PASSWORD)
3389 .expect("eval-results.html decrypts with the correct password");
3390 assert!(
3391 !eval_plaintext.contains("embeddedKey"),
3392 "eval-results.html plaintext must NOT contain embeddedKey JSON wrapping, got: {eval_plaintext}"
3393 );
3394 }
3395}