use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::{Arc, Mutex, MutexGuard};
use devpage_design::fonts::FontFile;
use devpage_design::proto::{Problems, Update, WatchRequest, update::Body};
use devpage_design::resolve::ImageFile;
use devpage_design::{Files, Problem, Renderer, Scene, Theme, wire};
use tokio::sync::broadcast;
use tokio_stream::Stream;
use tonic::{Request, Response, Status};
#[derive(Clone)]
struct Resolved {
result: Result<Scene, Vec<Problem>>,
asked: Vec<String>,
missing: bool,
}
struct Source {
bytes: Option<Vec<u8>>,
read: u64,
canonical: Option<String>,
image: Option<Result<Arc<ImageFile>, String>>,
font: Option<Result<Arc<FontFile>, String>>,
}
struct Store {
served: PathBuf,
renderer: Mutex<Renderer>,
sources: Mutex<HashMap<String, Source>>,
reads: std::sync::atomic::AtomicU64,
resolved: Mutex<HashMap<String, Resolved>>,
updates: broadcast::Sender<String>,
#[cfg(test)]
resolutions: std::sync::atomic::AtomicUsize,
#[cfg(test)]
after_read: Mutex<Option<AfterRead>>,
}
#[cfg(test)]
type AfterRead = Box<dyn Fn(&str) + Send + Sync>;
fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
struct Reading<'a> {
store: &'a Store,
asked: RefCell<Vec<String>>,
seen: RefCell<Vec<(String, u64)>>,
}
impl Reading<'_> {
fn source(&self, key: &str) -> Option<Vec<u8>> {
self.asked.borrow_mut().push(key.to_string());
let mut sources = lock(&self.store.sources);
if let Some(source) = sources.get(key) {
self.seen.borrow_mut().push((key.to_string(), source.read));
return source.bytes.clone();
}
let source = match key.strip_suffix('/') {
Some(folder) => {
let found = inside(&self.store.served, folder).filter(|f| f.is_dir());
Source {
bytes: found
.as_ref()
.and_then(|f| names(f))
.map(|names| names.join("\n").into_bytes()),
canonical: found
.as_ref()
.map(|f| format!("{}/", self.store.relative(f))),
read: self.store.next_read(),
image: None,
font: None,
}
}
None => {
let found = inside(&self.store.served, key).filter(|f| f.is_file());
Source {
bytes: found.as_ref().and_then(|f| std::fs::read(f).ok()),
canonical: found.as_ref().map(|f| self.store.relative(f)),
read: self.store.next_read(),
image: None,
font: None,
}
}
};
self.seen.borrow_mut().push((key.to_string(), source.read));
let bytes = source.bytes.clone();
sources.insert(key.to_string(), source);
drop(sources);
#[cfg(test)]
if let Some(after) = lock(&self.store.after_read).as_ref() {
after(key);
}
bytes
}
}
fn inside(served: &Path, path: &str) -> Option<PathBuf> {
served
.join(path)
.canonicalize()
.ok()
.filter(|found| found.starts_with(served))
}
fn names(folder: &Path) -> Option<Vec<String>> {
let mut names: Vec<String> = std::fs::read_dir(folder)
.ok()?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_file())
.filter_map(|entry| entry.file_name().into_string().ok())
.collect();
names.sort();
Some(names)
}
impl Files for Reading<'_> {
fn bytes(&self, path: &str) -> Option<Vec<u8>> {
self.source(path)
}
fn list(&self, path: &str) -> Option<Vec<String>> {
let listing = self.source(&format!("{path}/"))?;
let text = String::from_utf8(listing).ok()?;
Some(text.lines().map(str::to_string).collect())
}
fn font(&self, path: &str) -> Option<Result<Arc<FontFile>, String>> {
if let Some(source) = lock(&self.store.sources).get(path)
&& let Some(font) = &source.font
{
self.asked.borrow_mut().push(path.to_string());
self.seen.borrow_mut().push((path.to_string(), source.read));
return Some(font.clone());
}
let bytes = self.bytes(path)?;
let read = self.seen.borrow().last().map(|(_, read)| *read);
let font = FontFile::new(bytes).map(Arc::new);
if let Some(source) = lock(&self.store.sources).get_mut(path)
&& Some(source.read) == read
{
source.font = Some(font.clone());
}
Some(font)
}
fn image(&self, path: &str) -> Option<Result<Arc<ImageFile>, String>> {
if let Some(source) = lock(&self.store.sources).get(path)
&& let Some(image) = &source.image
{
self.asked.borrow_mut().push(path.to_string());
self.seen.borrow_mut().push((path.to_string(), source.read));
return Some(image.clone());
}
let bytes = self.bytes(path)?;
let read = self.seen.borrow().last().map(|(_, read)| *read);
let image = ImageFile::new(bytes).map(Arc::new);
if let Some(source) = lock(&self.store.sources).get_mut(path)
&& Some(source.read) == read
{
source.image = Some(image.clone());
}
Some(image)
}
}
impl Store {
fn next_read(&self) -> u64 {
1 + self
.reads
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
fn relative(&self, file: &Path) -> String {
file.strip_prefix(&self.served)
.unwrap_or(file)
.to_string_lossy()
.into_owned()
}
fn refresh(&self, path: &str) -> Resolved {
loop {
let (resolved, seen) = self.resolve(path);
let mut kept = lock(&self.resolved);
let sources = lock(&self.sources);
let current = seen
.iter()
.all(|(file, read)| sources.get(file).is_some_and(|s| s.read == *read));
if current {
kept.insert(path.to_string(), resolved.clone());
return resolved;
}
}
}
fn resolve(&self, path: &str) -> (Resolved, Vec<(String, u64)>) {
#[cfg(test)]
self.resolutions
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let reading = Reading {
store: self,
asked: RefCell::new(Vec::new()),
seen: RefCell::new(Vec::new()),
};
let result = guarded(
path,
|| devpage_design::resolve(&reading, path),
|p| vec![p],
)
.and_then(|scene| {
drawable(&mut lock(&self.renderer), path, &scene)?;
Ok(scene)
});
let missing = lock(&self.sources)
.get(path)
.is_none_or(|source| source.bytes.is_none());
let resolved = Resolved {
result,
asked: reading.asked.into_inner(),
missing,
};
(resolved, reading.seen.into_inner())
}
fn get(&self, path: &str) -> Resolved {
if let Some(resolved) = lock(&self.resolved).get(path) {
return resolved.clone();
}
self.refresh(path)
}
}
#[derive(Clone)]
pub struct Designs(Arc<Store>);
fn address(path: &str) -> &str {
path.trim_start_matches('/')
}
impl Designs {
pub fn new(served: PathBuf) -> Designs {
Designs(Arc::new(Store {
served,
renderer: Mutex::new(Renderer::new()),
sources: Mutex::default(),
reads: Default::default(),
resolved: Mutex::default(),
updates: broadcast::channel(64).0,
#[cfg(test)]
resolutions: Default::default(),
#[cfg(test)]
after_read: Mutex::default(),
}))
}
pub fn resolve(&self, path: &str) -> Result<Scene, Vec<Problem>> {
self.0.get(address(path)).result
}
pub fn changed(&self, file: &Path) {
let store = &self.0;
let relative = store.relative(file);
let folder = match relative.rsplit_once('/') {
Some((folder, _)) => format!("{folder}/"),
None => "/".to_string(),
};
let affected: Vec<(String, Resolved)> = {
let resolved = lock(&store.resolved);
let mut sources = lock(&store.sources);
let forgotten = sources
.iter()
.filter(|(key, source)| {
[relative.as_str(), folder.as_str()].iter().any(|changed| {
key == changed || source.canonical.as_deref() == Some(changed)
})
})
.map(|(key, _)| key.clone())
.collect::<Vec<_>>();
if forgotten.is_empty() {
return;
}
for key in &forgotten {
sources.remove(key);
}
resolved
.iter()
.filter(|(_, resolved)| resolved.asked.iter().any(|a| forgotten.contains(a)))
.map(|(path, resolved)| (path.clone(), resolved.clone()))
.collect()
};
for (path, old) in affected {
let new = store.refresh(&path);
let differs = new.result != old.result || new.missing != old.missing;
if differs {
let _ = store.updates.send(path);
}
}
}
pub async fn etag(&self, path: &str, theme: Theme) -> Option<String> {
let designs = self.clone();
let path = path.to_string();
tokio::task::spawn_blocking(move || {
let scene = designs.resolve(&path).ok()?;
let mut text = format!("{}\0{theme:?}\0{:?}", crate::addresses::BUILD, scene.root);
for id in scene.images.keys() {
text.push_str(&format!("\0{id:?}"));
}
for id in scene.fonts.keys() {
text.push_str(&format!("\0{id:?}"));
}
Some(crate::addresses::hash(text.as_bytes()))
})
.await
.ok()
.flatten()
}
pub async fn png(&self, path: String, theme: Theme) -> Result<Vec<u8>, String> {
let designs = self.clone();
tokio::task::spawn_blocking(move || {
let scene = designs
.resolve(&path)
.map_err(|problems| lines(&problems))?;
let mut renderer = lock(&designs.0.renderer);
guarded(
&path,
|| devpage_design::png(&mut renderer, &scene, theme),
|p| p.message,
)
.map_err(|e| format!("{}: {e}", address(&path)))
})
.await
.expect("painting")
}
}
pub fn problems(served: &Path, path: &str) -> Vec<Problem> {
struct Disk<'a>(&'a Path);
impl Files for Disk<'_> {
fn bytes(&self, path: &str) -> Option<Vec<u8>> {
std::fs::read(inside(self.0, path)?).ok()
}
fn list(&self, path: &str) -> Option<Vec<String>> {
names(&inside(self.0, path).filter(|f| f.is_dir())?)
}
}
let path = address(path);
guarded(
path,
|| devpage_design::resolve(&Disk(served), path),
|p| vec![p],
)
.and_then(|scene| drawable(&mut Renderer::new(), path, &scene))
.err()
.unwrap_or_default()
}
fn drawable(renderer: &mut Renderer, path: &str, scene: &Scene) -> Result<(), Vec<Problem>> {
let themes: &[Theme] = if scene.themed {
&[Theme::Light, Theme::Dark]
} else {
&[Theme::Light]
};
guarded(
path,
|| {
themes
.iter()
.try_for_each(|&theme| renderer.drawing(scene, theme, None).map(|_| ()))
},
|p| devpage_design::render::Undrawable {
message: p.message,
at: None,
},
)
.map_err(|undrawable| {
let (file, pos) = match undrawable.at {
Some(at) => (at.file, at.pos),
None => (
path.to_string(),
devpage_design::parse::Pos { line: 1, col: 1 },
),
};
vec![Problem {
file,
pos,
message: undrawable.message,
}]
})
}
fn guarded<T, E>(
path: &str,
f: impl FnOnce() -> Result<T, E>,
failed: impl FnOnce(Problem) -> E,
) -> Result<T, E> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|panic| {
let message = panic
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_default();
Err(failed(Problem {
file: path.to_string(),
pos: devpage_design::parse::Pos { line: 1, col: 1 },
message: format!("the design engine failed on this design: {message}"),
}))
})
}
fn lines(problems: &[Problem]) -> String {
problems
.iter()
.map(Problem::to_string)
.collect::<Vec<_>>()
.join("\n")
}
pub struct Watch {
pub designs: Designs,
}
enum Encoded {
Whole(Box<devpage_design::proto::Scene>),
Changed(devpage_design::proto::Patch),
}
#[derive(Default)]
struct Sent {
scene: Option<Scene>,
problems: Option<Vec<Problem>>,
}
impl Sent {
fn next(&mut self, path: &str, resolved: Resolved) -> Option<Body> {
let result = resolved.result.and_then(|scene| {
let old = self.scene.as_ref();
let body = guarded(
path,
|| {
Ok(match old {
None => Encoded::Whole(Box::new(wire::scene(&scene))),
Some(old) => Encoded::Changed(wire::patch(old, &scene)),
})
},
|problem| vec![problem],
)?;
Ok((scene, body))
});
match result {
Ok((scene, body)) => {
let fixed = self.problems.take().is_some();
let themed = self.scene.as_ref().map(|s| s.themed);
let body = match body {
Encoded::Whole(whole) => Some(Body::Scene(*whole)),
Encoded::Changed(patch) => {
(fixed || !patch.replace.is_empty() || themed != Some(patch.themed))
.then_some(Body::Patch(patch))
}
};
self.scene = Some(scene);
body
}
Err(problems) if self.problems.as_ref() != Some(&problems) => {
self.problems = Some(problems.clone());
Some(Body::Problems(Problems {
problems: problems.iter().map(Problem::to_string).collect(),
}))
}
Err(_) => None,
}
}
}
#[tonic::async_trait]
impl devpage_design::proto::designs_server::Designs for Watch {
type WatchStream = Pin<Box<dyn Stream<Item = Result<Update, Status>> + Send>>;
async fn watch(
&self,
request: Request<WatchRequest>,
) -> Result<Response<Self::WatchStream>, Status> {
let mut watched: Vec<(String, String, Sent)> = Vec::new();
for named in request.into_inner().paths {
let path = address(&named).to_string();
if !watched.iter().any(|(p, _, _)| *p == path) {
watched.push((path, named, Sent::default()));
}
}
let store = self.designs.0.clone();
let mut updates = store.updates.subscribe();
let (tx, rx) = tokio::sync::mpsc::channel(16);
tokio::spawn(async move {
let mut due: Vec<String> = watched.iter().map(|(p, _, _)| p.clone()).collect();
loop {
for path in due.drain(..) {
let Some(index) = watched.iter().position(|(p, _, _)| *p == path) else {
continue;
};
let resolved = store.get(&path);
let (_, named, sent) = &mut watched[index];
if let Some(body) = sent.next(&path, resolved) {
let update = Update {
body: Some(body),
path: named.clone(),
};
if tx.send(Ok(update)).await.is_err() {
return;
}
}
}
if watched.is_empty() {
return;
}
match updates.recv().await {
Ok(changed) => due.push(changed),
Err(broadcast::error::RecvError::Lagged(_)) => {
due = watched.iter().map(|(p, _, _)| p.clone()).collect();
}
Err(broadcast::error::RecvError::Closed) => return,
}
}
});
Ok(Response::new(Box::pin(
tokio_stream::wrappers::ReceiverStream::new(rx),
)))
}
}
pub fn page(path: &str, theme: Option<Theme>) -> String {
let path = path.trim_start_matches('/');
let file = path.rsplit('/').next().unwrap_or(path);
let name = file.strip_suffix(".design").unwrap_or(file);
format!(
r#"<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>{name}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{stylesheet}">
{theme}{chosen}
</head><body class="design-page">
<canvas class="design" src="/{path}" role="img" aria-label="{name}"></canvas>
<div id="devpage-corner">{toggle}</div>
<script type="module" src="{design_script}"></script>
</body></html>"#,
stylesheet = crate::addresses::address(crate::addresses::Owned::Asset("style.css")),
design_script = crate::addresses::address(crate::addresses::Owned::Asset("design.js")),
name = crate::render::escape(name),
path = crate::render::escape(path),
theme = crate::navbar::theme_script(true),
chosen = match theme {
Some(Theme::Dark) =>
r#"<script>document.documentElement.dataset.theme="dark"</script>"#,
Some(Theme::Light) =>
r#"<script>document.documentElement.dataset.theme="light"</script>"#,
None => "",
},
toggle = crate::navbar::theme_toggle(),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{Document, Entry};
use crate::index::Index;
use crate::server::{Identity, router_with_index};
use prost::Message;
use std::time::Duration;
const BUTTON: &str = r##"<Frame props={{ label: "Button" }} layout="row" padding={[12, 20]} fill="#2563eb">
<Text color="#ffffff">{label}</Text>
</Frame>
"##;
const SCREEN: &str = r##"import Button from "./button.design"
<Frame width={200} height={120} layout="column" gap={8} padding={16} fill="#f8fafc">
<Text>Cart</Text>
<Button label="Pay" />
</Frame>
"##;
const OTHER: &str = r##"<Frame width={50} height={50} fill="#000000" />
"##;
fn served(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("devpage-designs-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("app")).unwrap();
std::fs::write(dir.join("app/button.design"), BUTTON).unwrap();
std::fs::write(dir.join("app/screen.design"), SCREEN).unwrap();
std::fs::write(dir.join("app/other.design"), OTHER).unwrap();
dir.canonicalize().unwrap()
}
fn save(designs: &Designs, file: &Path, text: &str) {
std::fs::write(file, text).unwrap();
designs.changed(file);
}
#[test]
fn a_drawing_too_big_to_paint_is_a_problem_everywhere() {
let dir = served("too-big");
std::fs::write(
dir.join("app/huge.design"),
r##"<Frame width={20000} height={10} fill="#000000" />"##,
)
.unwrap();
let expected = "app/huge.design:1:1: the drawing is 20000×10 pixels; a design draws at most 16384 a side";
let designs = Designs::new(dir.clone());
let found = designs.resolve("app/huge.design").unwrap_err();
assert_eq!(found[0].to_string(), expected);
let found = problems(&dir, "/app/huge.design");
assert_eq!(found.len(), 1);
assert_eq!(found[0].to_string(), expected);
}
#[test]
fn images_too_many_for_the_gpu_are_a_problem_at_their_src() {
struct Sized(String);
impl Files for Sized {
fn bytes(&self, path: &str) -> Option<Vec<u8>> {
(path == "app/a.design").then(|| self.0.clone().into_bytes())
}
fn list(&self, _: &str) -> Option<Vec<String>> {
None
}
fn image(&self, path: &str) -> Option<Result<Arc<ImageFile>, String>> {
let (size, _) = path.strip_prefix("app/")?.split_once('-')?;
let (w, h) = size.split_once('x')?;
let (w, h): (u32, u32) = (w.parse().ok()?, h.parse().ok()?);
Some(Ok(Arc::new(ImageFile {
id: devpage_design::resolve::ImageId::of(path.as_bytes()),
bytes: Vec::new(),
width: w,
height: h,
pixels: Arc::new(vec![0; 4 * w as usize * h as usize]),
pixels_width: w,
pixels_height: h,
})))
}
}
let images: String = "abcde"
.chars()
.map(|c| format!("\n <Image src=\"4096x4096-{c}.png\" width={{40}} height={{40}} />"))
.collect();
let design = Sized(format!(r#"<Frame layout="row">{images}</Frame>"#));
let scene = devpage_design::resolve(&design, "app/a.design").unwrap();
let found = drawable(&mut Renderer::new(), "app/a.design", &scene).unwrap_err();
assert_eq!(found.len(), 1);
assert!(
found[0]
.to_string()
.starts_with("app/a.design:6:10: this doesn't fit: "),
"{found:?}"
);
}
#[test]
fn a_save_decodes_only_the_image_that_changed() {
let dir = served("decode-once");
let shirt = std::fs::read(
Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/designs/checkout/shirt.jpg"),
)
.unwrap();
std::fs::write(dir.join("app/p.jpg"), &shirt).unwrap();
let photo = "<Frame>\n <Image src=\"./p.jpg\" />\n <Image src=\"./p.jpg\" />\n</Frame>\n";
std::fs::write(dir.join("app/photo.design"), photo).unwrap();
let designs = Designs::new(dir.clone());
let first = designs.resolve("app/photo.design").unwrap();
assert_eq!(first.images.len(), 1, "one file shown twice");
save(
&designs,
&dir.join("app/photo.design"),
&photo.replace("<Frame>", "<Frame fill=\"#ffffff\">"),
);
let second = designs.resolve("app/photo.design").unwrap();
assert_ne!(first.root, second.root);
let file = |s: &Scene| s.images.values().next().unwrap().clone();
assert!(Arc::ptr_eq(&file(&first), &file(&second)));
std::fs::write(dir.join("app/p.jpg"), &shirt).unwrap();
designs.changed(&dir.join("app/p.jpg"));
let third = designs.resolve("app/photo.design").unwrap();
assert!(!Arc::ptr_eq(&file(&second), &file(&third)));
}
#[tokio::test]
async fn an_image_that_does_not_decode_is_a_problem_everywhere() {
let dir = served("broken-image");
let mut broken = b"\x89PNG\r\n\x1a\n".to_vec();
broken.extend(b"garbage");
std::fs::write(dir.join("app/broken.png"), broken).unwrap();
std::fs::write(
dir.join("app/photo.design"),
"<Frame>\n <Image src=\"./broken.png\" width={50} height={50} />\n</Frame>\n",
)
.unwrap();
let expected = "app/photo.design:2:10: src: ./broken.png is a PNG that doesn't decode";
let found = problems(&dir, "app/photo.design");
assert_eq!(found.len(), 1, "{found:?}");
assert!(found[0].to_string().starts_with(expected), "{found:?}");
let designs = Designs::new(dir.clone());
let png = designs
.png("app/photo.design".into(), Theme::Light)
.await
.unwrap_err();
assert!(png.starts_with(expected), "{png}");
}
#[test]
fn only_the_designs_made_from_a_changed_file_are_announced() {
let dir = served("store");
let designs = Designs::new(dir.clone());
let mut updates = designs.0.updates.subscribe();
assert!(designs.resolve("app/screen.design").is_ok());
assert!(designs.resolve("/app/other.design").is_ok());
let resolutions = || {
designs
.0
.resolutions
.load(std::sync::atomic::Ordering::Relaxed)
};
assert_eq!(resolutions(), 2);
assert!(designs.resolve("app/screen.design").is_ok());
assert_eq!(resolutions(), 2, "kept in memory");
save(
&designs,
&dir.join("app/button.design"),
&BUTTON.replace("#2563eb", "#16a34a"),
);
assert_eq!(updates.try_recv().unwrap(), "app/screen.design");
assert!(
updates.try_recv().is_err(),
"other.design doesn't use the button"
);
assert_eq!(resolutions(), 3, "only screen.design was resolved again");
save(
&designs,
&dir.join("app/button.design"),
&BUTTON.replace("#2563eb", "#16a34a"),
);
assert!(updates.try_recv().is_err());
save(&designs, &dir.join("app/notes.txt"), "hi");
assert!(updates.try_recv().is_err());
}
#[test]
fn a_save_while_a_design_resolves_is_not_lost() {
let dir = served("race");
let designs = Designs::new(dir.clone());
let other = dir.join("app/other.design");
let saver = designs.clone();
let saved = std::sync::atomic::AtomicBool::new(false);
*lock(&designs.0.after_read) = Some(Box::new(move |path: &str| {
if path == "app/other.design" && !saved.swap(true, std::sync::atomic::Ordering::SeqCst)
{
save(&saver, &other, &OTHER.replace("{50}", "{20}"));
}
}));
let width =
|designs: &Designs| match designs.resolve("app/other.design").unwrap().root.width {
devpage_design::resolve::Size::Px(w) => w,
other => panic!("{other:?}"),
};
assert_eq!(width(&designs), 20.0, "resolved from the saved file");
*lock(&designs.0.after_read) = None;
assert_eq!(width(&designs), 20.0, "and kept so");
}
#[tokio::test]
async fn no_design_file_stops_the_store() {
let dir = served("hostile");
let designs = Designs::new(dir.clone());
let other = dir.join("app/other.design");
assert!(
designs
.png("app/other.design".into(), Theme::Light)
.await
.is_ok()
);
save(&designs, &other, &OTHER.replace("#000000", "#000\u{200b}"));
let problems = designs
.png("app/other.design".into(), Theme::Light)
.await
.unwrap_err();
assert!(
problems.contains("fill: #000\u{200b} is not a color"),
"{problems}"
);
for text in [
"<Frame>".repeat(5000),
"<Frame width={99999999999} />".to_string(),
"<Frame fill=\"#ff".to_string(),
] {
save(&designs, &other, &text);
assert!(
designs
.png("app/other.design".into(), Theme::Light)
.await
.is_err(),
"{text:.40}"
);
}
save(&designs, &other, &OTHER.replace("{50}", "{20}"));
let png = designs
.png("app/other.design".into(), Theme::Light)
.await
.unwrap();
let (_, w, h) = devpage_design::render::pixels(&png).unwrap();
assert_eq!((w, h), (20, 20));
}
#[test]
fn creating_a_missing_import_resolves_the_design_again() {
let dir = served("create");
let designs = Designs::new(dir.clone());
std::fs::remove_file(dir.join("app/button.design")).unwrap();
let problems = designs.resolve("app/screen.design").unwrap_err();
assert!(problems[0].message.contains("no such file"), "{problems:?}");
save(&designs, &dir.join("app/button.design"), BUTTON);
assert!(designs.resolve("app/screen.design").is_ok());
}
const THEMES: &str = "kind: themes\nlight:\n bg: \"#ffffff\"\ndark:\n bg: \"#000000\"\n";
#[test]
fn a_themes_file_resolves_again_only_the_designs_that_read_it() {
let dir = served("themes");
std::fs::write(
dir.join("app/themed.design"),
"<Frame width={10} height={10} fill={theme.bg} />",
)
.unwrap();
let designs = Designs::new(dir.clone());
let mut updates = designs.0.updates.subscribe();
let problems = designs.resolve("app/themed.design").unwrap_err();
assert!(
problems[0].message.contains("no themes.yml"),
"{problems:?}"
);
assert!(designs.resolve("app/other.design").is_ok());
let fill = |designs: &Designs| designs.resolve("app/themed.design").unwrap().root.fill;
save(&designs, &dir.join("themes.yml"), THEMES);
assert_eq!(updates.try_recv().unwrap(), "app/themed.design");
assert!(updates.try_recv().is_err(), "other.design reads no themes");
let top = fill(&designs);
save(
&designs,
&dir.join("app/themes.yml"),
&THEMES.replace("#000000", "#111111"),
);
assert_eq!(updates.try_recv().unwrap(), "app/themed.design");
assert_ne!(fill(&designs), top);
std::fs::remove_file(dir.join("app/themes.yml")).unwrap();
designs.changed(&dir.join("app/themes.yml"));
assert_eq!(updates.try_recv().unwrap(), "app/themed.design");
assert_eq!(fill(&designs), top);
assert!(updates.try_recv().is_err());
}
#[test]
fn a_font_change_resolves_again_only_the_designs_set_in_it() {
let dir = served("fonts");
let regular = std::fs::read(
Path::new(env!("CARGO_MANIFEST_DIR")).join("design/fonts/Inter-Regular.ttf"),
)
.unwrap();
std::fs::create_dir_all(dir.join("app/fonts/Body")).unwrap();
std::fs::write(dir.join("app/fonts/Body/Body-Regular.ttf"), ®ular).unwrap();
std::fs::write(dir.join("app/fonts/Body/OFL.txt"), "licence").unwrap();
std::fs::write(
dir.join("app/themes.yml"),
"kind: themes\nfonts:\n body: ./fonts/Body\nlight: {}\ndark: {}\n",
)
.unwrap();
std::fs::write(
dir.join("app/set.design"),
r#"<Frame><Text font="body">Hello</Text></Frame>"#,
)
.unwrap();
let designs = Designs::new(dir.clone());
let mut updates = designs.0.updates.subscribe();
let scene = designs.resolve("app/set.design").unwrap();
assert_eq!(scene.fonts.len(), 1);
assert!(designs.resolve("app/other.design").is_ok());
let resolutions = || {
designs
.0
.resolutions
.load(std::sync::atomic::Ordering::Relaxed)
};
let before = resolutions();
save(&designs, &dir.join("app/fonts/Body/OFL.txt"), "licence, v2");
assert_eq!(resolutions(), before + 1, "only set.design");
assert!(updates.try_recv().is_err());
let mut copy = regular.clone();
copy.push(0);
std::fs::write(dir.join("app/fonts/Body/Copy.ttf"), ©).unwrap();
designs.changed(&dir.join("app/fonts/Body/Copy.ttf"));
assert_eq!(updates.try_recv().unwrap(), "app/set.design");
assert!(updates.try_recv().is_err());
let expected = "app/themes.yml:3:3: fonts.body: ./fonts/Body holds two files of weight 400: Body-Regular.ttf and Copy.ttf";
let problems = designs.resolve("app/set.design").unwrap_err();
assert_eq!(problems[0].to_string(), expected);
assert_eq!(
super::problems(&dir, "/app/set.design")[0].to_string(),
expected
);
std::fs::remove_file(dir.join("app/fonts/Body/Copy.ttf")).unwrap();
designs.changed(&dir.join("app/fonts/Body/Copy.ttf"));
assert_eq!(updates.try_recv().unwrap(), "app/set.design");
let bold = std::fs::read(
Path::new(env!("CARGO_MANIFEST_DIR")).join("design/fonts/Inter-Bold.ttf"),
)
.unwrap();
std::fs::write(dir.join("app/fonts/Body/Body-Regular.ttf"), &bold).unwrap();
designs.changed(&dir.join("app/fonts/Body/Body-Regular.ttf"));
assert_eq!(updates.try_recv().unwrap(), "app/set.design");
assert_eq!(
designs.resolve("app/set.design").unwrap_err()[0].message,
"weight: Inter has no 400; it has 700"
);
assert!(updates.try_recv().is_err());
}
#[test]
fn a_scene_that_becomes_themed_is_sent() {
let files: HashMap<String, String> = [
(
"themes.yml".to_string(),
THEMES.replace("#000000", "#ffffff"),
),
(
"a.design".to_string(),
"<Frame fill=\"#ffffff\" />".to_string(),
),
(
"b.design".to_string(),
"<Frame fill={theme.bg} />".to_string(),
),
]
.into();
let resolved = |path: &str| Resolved {
result: devpage_design::resolve(&files, path).map(|mut scene| {
scene.files.clear();
scene
}),
asked: Vec::new(),
missing: false,
};
let mut sent = Sent::default();
assert!(matches!(
sent.next("a", resolved("a.design")),
Some(Body::Scene(_))
));
let Some(Body::Patch(patch)) = sent.next("a", resolved("b.design")) else {
panic!("a patch")
};
assert!(patch.replace.is_empty() && patch.themed);
assert!(sent.next("a", resolved("b.design")).is_none());
}
#[test]
fn a_design_outside_the_served_folder_is_never_read() {
let dir = served("outside");
std::fs::write(dir.join("../outside.design"), OTHER).unwrap();
let designs = Designs::new(dir.join("app"));
assert!(designs.resolve("../outside.design").is_err());
}
async fn start(dir: &Path, entry: &str) -> (String, Designs) {
let document = Arc::new(Document::load(&dir.join(entry), dir).unwrap());
let index = Arc::new(Index::build(dir));
let designs = Designs::new(dir.to_path_buf());
let changes = broadcast::channel(16).0;
let router = router_with_index(
Arc::new(Entry::new(document)),
index,
designs.clone(),
changes.clone(),
Some(Identity::Local("T".into())),
crate::slide_png::Shots::new(&changes),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = format!("http://{}", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, router).await.ok() });
(address, designs)
}
#[tokio::test]
async fn a_png_answers_304_from_its_scene_without_painting() {
let dir = served("png-etag");
let (address, designs) = start(&dir, "app/screen.design").await;
let url = format!("{address}/app/screen.design.png");
let client = reqwest::Client::new();
let first = client.get(&url).send().await.unwrap();
assert_eq!(first.status(), 200);
assert_eq!(first.headers()["cache-control"], "no-cache");
let tag = first.headers()["etag"].to_str().unwrap().to_string();
let again = client
.get(&url)
.header("if-none-match", &tag)
.send()
.await
.unwrap();
assert_eq!(again.status(), 304);
assert_eq!(again.headers()["etag"], tag.as_str());
let button = std::fs::read_to_string(dir.join("app/button.design")).unwrap();
save(
&designs,
&dir.join("app/button.design"),
&button.replace("#2563eb", "#16a34a"),
);
let changed = client
.get(&url)
.header("if-none-match", &tag)
.send()
.await
.unwrap();
assert_eq!(changed.status(), 200);
assert_ne!(changed.headers()["etag"], tag.as_str());
}
#[tokio::test]
async fn a_png_or_the_problems() {
let dir = served("png");
let (address, _) = start(&dir, "app/screen.design").await;
let response = reqwest::get(format!("{address}/app/screen.design.png"))
.await
.unwrap();
assert_eq!(response.status(), 200);
assert_eq!(response.headers()["content-type"], "image/png");
let png = response.bytes().await.unwrap();
assert_eq!(&png[1..4], b"PNG");
assert_eq!(u32::from_be_bytes(png[16..20].try_into().unwrap()), 200);
std::fs::write(
dir.join("app/broken.design"),
"import Button from \"./button.design\"\n<Frame>\n <Button lable=\"x\" />\n <Text size=\"big\">a</Text>\n</Frame>\n",
)
.unwrap();
let response = reqwest::get(format!("{address}/app/broken.design.png"))
.await
.unwrap();
assert_eq!(response.status(), 422);
let text = response.text().await.unwrap();
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines.len(), 2, "{text}");
assert!(lines[0].starts_with("app/broken.design:3:"), "{text}");
assert!(lines[1].starts_with("app/broken.design:4:"), "{text}");
}
#[tokio::test]
async fn a_png_in_either_theme() {
let dir = served("png-theme");
std::fs::write(dir.join("themes.yml"), THEMES).unwrap();
std::fs::write(
dir.join("app/themed.design"),
"<Frame width={10} height={10} fill={theme.bg} />",
)
.unwrap();
let (address, _) = start(&dir, "app/screen.design").await;
let get = |query: &str| reqwest::get(format!("{address}/app/themed.design.png{query}"));
let mut tags = Vec::new();
for (query, value) in [("", 255), ("?theme=light", 255), ("?theme=dark", 0)] {
let response = get(query).await.unwrap();
assert_eq!(response.status(), 200, "{query}");
tags.push(response.headers()["etag"].to_str().unwrap().to_string());
let png = response.bytes().await.unwrap();
let (pixels, _, _) = devpage_design::render::pixels(&png).unwrap();
assert_eq!(pixels[..4], [value, value, value, 255], "{query}");
}
assert_eq!(tags[0], tags[1]);
assert_ne!(tags[0], tags[2], "each theme is its own PNG");
let wrong = get("?theme=blue").await.unwrap();
assert_eq!(wrong.status(), 400);
assert_eq!(
wrong.text().await.unwrap(),
"theme: expected light or dark, found \"blue\""
);
}
#[tokio::test]
async fn the_canvas_page_shows_the_theme_its_address_names() {
let dir = served("page-theme");
let (address, _) = start(&dir, "app/screen.design").await;
let page = |query: &str| {
let url = format!("{address}/app/screen.design{query}");
async move { reqwest::get(url).await.unwrap().text().await.unwrap() }
};
let chosen = r#"<script>document.documentElement.dataset.theme="dark"</script>"#;
assert!(page("?theme=dark").await.contains(chosen));
assert!(!page("").await.contains("dataset.theme=\"dark\""));
assert!(!page("?theme=blue").await.contains("dataset.theme=\"blue\""));
}
#[tokio::test]
async fn no_png_of_a_design_outside_the_served_folder() {
let dir = served("png-outside");
let outside = dir.join("../png-outside-secret.design");
std::fs::write(&outside, "<Frame width={10} height={10} />").unwrap();
std::os::unix::fs::symlink(&outside, dir.join("app/escape.design")).unwrap();
let (address, _) = start(&dir, "app/screen.design").await;
for path in [
"/../png-outside-secret.design.png",
"/%2E%2E/png-outside-secret.design.png",
"/app/escape.design.png",
"/app/missing.design.png",
] {
let response = reqwest::get(format!("{address}{path}")).await.unwrap();
assert_eq!(response.status(), 404, "{path}");
let body = response.text().await.unwrap();
assert!(
body.ends_with("is not part of the served folder"),
"{path}: {body}"
);
}
}
#[tokio::test]
async fn the_design_service_answers_only_watch() {
let dir = served("only-watch");
let (address, _) = start(&dir, "app/screen.design").await;
let client = reqwest::Client::new();
let other = client
.post(format!("{address}/devpage.design.v1.Designs/Other"))
.header("content-type", "application/grpc-web+proto")
.body(vec![0u8, 0, 0, 0, 0])
.send()
.await
.unwrap();
assert!(other.status().is_client_error(), "{}", other.status());
}
#[tokio::test]
async fn a_real_file_named_like_a_designs_png_wins() {
let dir = served("png-real");
std::fs::write(dir.join("app/screen.design.png"), b"not a drawing").unwrap();
let (address, _) = start(&dir, "app/screen.design").await;
let real = reqwest::get(format!("{address}/app/screen.design.png"))
.await
.unwrap();
assert_eq!(real.status(), 200);
assert_eq!(real.bytes().await.unwrap().as_ref(), b"not a drawing");
let drawn = reqwest::get(format!("{address}/app/other.design.png"))
.await
.unwrap();
assert_eq!(drawn.headers()["content-type"], "image/png");
}
#[tokio::test]
async fn a_designs_png_is_a_link_check_accepts() {
let dir = served("png-link");
std::fs::write(
dir.join("app/broken.design"),
SCREEN.replace("<Button label", "<Button lable"),
)
.unwrap();
std::fs::write(
dir.join("page.md"),
"# Page\n\n\n\n\n\n\n",
)
.unwrap();
let document = Document::load(&dir.join("page.md"), &dir).unwrap();
let problems: Vec<String> = crate::check::document(&document, false)
.await
.iter()
.map(|p| p.to_string())
.collect();
assert_eq!(problems.len(), 2, "{problems:?}");
assert!(
problems[0].starts_with(
"page.md:5: broken design app/broken.design.png — app/broken.design:5:"
),
"{problems:?}"
);
assert_eq!(
problems[1],
"page.md:7: broken link app/nothing.design.png — no such file"
);
}
#[tokio::test]
async fn a_design_is_an_entry_document() {
let dir = served("entry");
let (address, _) = start(&dir, "app/screen.design").await;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let home = client.get(format!("{address}/")).send().await.unwrap();
assert_eq!(home.status(), 307);
assert_eq!(home.headers()["location"], "/app/screen.design");
let page = client
.get(format!("{address}/app/screen.design"))
.send()
.await
.unwrap();
assert_eq!(page.status(), 200);
assert!(page.text().await.unwrap().contains("<canvas"));
}
struct Stream {
response: reqwest::Response,
buffer: Vec<u8>,
}
impl Stream {
async fn open(address: &str, paths: &[&str]) -> Stream {
let request = WatchRequest {
paths: paths.iter().map(|p| p.to_string()).collect(),
}
.encode_to_vec();
let mut body = vec![0];
body.extend((request.len() as u32).to_be_bytes());
body.extend(request);
let response = reqwest::Client::new()
.post(format!("{address}/devpage.design.v1.Designs/Watch"))
.header("content-type", "application/grpc-web+proto")
.header("x-grpc-web", "1")
.body(body)
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
Stream {
response,
buffer: Vec::new(),
}
}
async fn next(&mut self) -> Option<Result<Body, String>> {
self.update()
.await
.map(|update| update.map(|update| update.body.unwrap()))
}
async fn update(&mut self) -> Option<Result<Update, String>> {
loop {
if self.buffer.len() >= 5 {
let length = u32::from_be_bytes(self.buffer[1..5].try_into().unwrap()) as usize;
if self.buffer.len() >= 5 + length {
let flag = self.buffer[0];
let payload: Vec<u8> = self.buffer.drain(..5 + length).skip(5).collect();
return Some(if flag & 0x80 != 0 {
Err(String::from_utf8_lossy(&payload).into_owned())
} else {
Ok(Update::decode(&payload[..]).unwrap())
});
}
}
let chunk =
tokio::time::timeout(Duration::from_secs(1), self.response.chunk()).await;
match chunk {
Ok(Ok(Some(bytes))) => self.buffer.extend_from_slice(&bytes),
Ok(_) | Err(_) => return None,
}
}
}
}
#[test]
fn the_canvas_page_is_titled_without_the_extension() {
let page = page("/app/checkout.design", None);
assert!(page.contains("<title>checkout</title>"), "{page}");
assert!(page.contains(r#"src="/app/checkout.design""#), "{page}");
}
#[tokio::test]
async fn the_watch_stream_sends_the_scene_then_only_what_changed() {
let dir = served("watch");
let (address, designs) = start(&dir, "app/screen.design").await;
let mut stream = Stream::open(&address, &["/app/screen.design"]).await;
let Some(Ok(Body::Scene(scene))) = stream.next().await else {
panic!("the first update is the scene")
};
assert_eq!(scene.root.unwrap().children.len(), 2);
save(
&designs,
&dir.join("app/button.design"),
&BUTTON.replace("#2563eb", "#16a34a"),
);
let Some(Ok(Body::Patch(patch))) = stream.next().await else {
panic!("a patch")
};
assert_eq!(
patch
.replace
.iter()
.map(|r| r.id.as_str())
.collect::<Vec<_>>(),
vec!["0.1"]
);
save(
&designs,
&dir.join("app/button.design"),
&format!("// a comment\n{}", BUTTON.replace("#2563eb", "#16a34a")),
);
assert!(stream.next().await.is_none());
save(
&designs,
&dir.join("app/screen.design"),
&SCREEN.replace("<Button label", "<Button lable"),
);
let Some(Ok(Body::Problems(problems))) = stream.next().await else {
panic!("problems")
};
assert!(problems.problems[0].contains("lable"), "{problems:?}");
save(&designs, &dir.join("app/screen.design"), SCREEN);
let Some(Ok(Body::Patch(patch))) = stream.next().await else {
panic!("an empty patch after the undo")
};
assert!(patch.replace.is_empty(), "{patch:?}");
save(
&designs,
&dir.join("app/screen.design"),
&SCREEN.replace("<Button label", "<Button lable"),
);
assert!(matches!(stream.next().await, Some(Ok(Body::Problems(_)))));
save(
&designs,
&dir.join("app/screen.design"),
&SCREEN.replace("Cart", "Basket"),
);
let Some(Ok(Body::Patch(patch))) = stream.next().await else {
panic!("a patch after the fix")
};
assert_eq!(patch.replace[0].id, "0.0");
std::fs::remove_file(dir.join("app/screen.design")).unwrap();
designs.changed(&dir.join("app/screen.design"));
let Some(Ok(Body::Problems(problems))) = stream.next().await else {
panic!("the deleted design's problem")
};
assert!(
problems.problems[0].contains("no such file"),
"{problems:?}"
);
save(&designs, &dir.join("app/screen.design"), SCREEN);
assert!(
matches!(stream.next().await, Some(Ok(Body::Patch(_)))),
"the design back"
);
}
#[tokio::test]
async fn one_stream_watches_every_design_a_page_shows() {
let dir = served("many");
std::fs::write(
dir.join("app/other.design"),
SCREEN.replace("Cart", "Other"),
)
.unwrap();
let (address, designs) = start(&dir, "app/screen.design").await;
let mut stream = Stream::open(
&address,
&[
"/app/screen.design",
"app/other.design",
"app/screen.design",
],
)
.await;
let mut first = Vec::new();
for _ in 0..2 {
let Some(Ok(update)) = stream.update().await else {
panic!("a scene for each design")
};
assert!(matches!(update.body, Some(Body::Scene(_))), "{update:?}");
first.push(update.path);
}
assert_eq!(first, ["/app/screen.design", "app/other.design"]);
assert!(stream.update().await.is_none(), "no third scene");
save(
&designs,
&dir.join("app/other.design"),
&SCREEN.replace("Cart", "Elsewhere"),
);
let Some(Ok(update)) = stream.update().await else {
panic!("a patch")
};
assert_eq!(update.path, "app/other.design");
assert!(matches!(update.body, Some(Body::Patch(_))));
save(
&designs,
&dir.join("app/button.design"),
&BUTTON.replace("#2563eb", "#16a34a"),
);
let mut patched = Vec::new();
for _ in 0..2 {
let Some(Ok(update)) = stream.update().await else {
panic!("a patch for each design")
};
assert!(matches!(update.body, Some(Body::Patch(_))));
patched.push(update.path);
}
patched.sort();
assert_eq!(patched, ["/app/screen.design", "app/other.design"]);
std::fs::remove_file(dir.join("app/other.design")).unwrap();
designs.changed(&dir.join("app/other.design"));
let Some(Ok(update)) = stream.update().await else {
panic!("the deleted design's problem")
};
assert_eq!(update.path, "app/other.design");
let Some(Body::Problems(problems)) = update.body else {
panic!("its problem")
};
assert!(
problems.problems[0].contains("no such file"),
"{problems:?}"
);
save(
&designs,
&dir.join("app/screen.design"),
&SCREEN.replace("Cart", "Basket"),
);
let Some(Ok(update)) = stream.update().await else {
panic!("the stream goes on for the design left")
};
assert_eq!(update.path, "/app/screen.design");
save(&designs, &dir.join("app/other.design"), OTHER);
let Some(Ok(update)) = stream.update().await else {
panic!("the re-created design")
};
assert_eq!(update.path, "app/other.design");
assert!(matches!(update.body, Some(Body::Patch(_))), "{update:?}");
}
#[tokio::test]
async fn check_reports_a_broken_embedded_design_where_it_is_wrong() {
let dir = served("check");
std::fs::write(
dir.join("app/screen.design"),
SCREEN.replace("<Button label", "<Button lable"),
)
.unwrap();
std::fs::write(
dir.join("page.md"),
"# Page\n\n\n\nEmbed with ``:\n\n```markdown\n\n```\n",
)
.unwrap();
let document = Document::load(&dir.join("page.md"), &dir).unwrap();
let problems = crate::check::document(&document, false).await;
assert_eq!(problems.len(), 1, "{problems:?}");
let shown = problems[0].to_string();
assert!(
shown.starts_with("page.md:3: broken design app/screen.design — app/screen.design:5:"),
"{shown}"
);
assert!(shown.contains("lable"), "{shown}");
let design = Document::load(&dir.join("app/screen.design"), &dir).unwrap();
let problems = crate::check::document(&design, false).await;
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(
problems[0].to_string().starts_with("app/screen.design:5:"),
"{problems:?}"
);
}
}
#[cfg(test)]
mod canvas_tests;