ocre-cli 0.2.0

Command-line tool for Ocre: create, generate, migrate, run and deploy apps.
//! `ocre g scaffold ... --realtime`, run through the real binary.

#[path = "../support/mod.rs"]
mod support;

use std::fs;

use serde_json::json;
use support::Sandbox;

#[test]
fn realtime_scaffold_sets_up_the_channel_once() {
    let sandbox = Sandbox::new();
    let root = sandbox.new_app("live", &[]);
    let (report, ok) = sandbox.json(&["g", "scaffold", "Post", "title:string", "--realtime"], &root);
    assert!(ok, "{report}");
    let created = report["created"].as_array().unwrap();
    assert!(
        created.contains(&json!("templates/posts/_row.html")) && created.contains(&json!("src/realtime.rs")),
        "{report}"
    );
    assert_eq!(report["updated"], json!(["src/lib.rs", "Cargo.toml", "cloudflare.config.ts", "templates/layout.html"]));
    assert_eq!(report["next"][3], "open http://localhost:8787/posts in a second window, then create a post");

    let cargo = fs::read_to_string(root.join("Cargo.toml")).unwrap();
    assert!(cargo.contains("features = [\"realtime\"] }"), "{cargo}");
    let config = fs::read_to_string(root.join("cloudflare.config.ts")).unwrap();
    assert!(
        config.contains("CHANNELS: bindings.durableObject({ worker: \"live\", exportName: \"OcreChannel\" }),\n"),
        "{config}"
    );
    assert!(config.contains("OcreChannel: exports.durableObject({ storage: \"sqlite\" }),\n"), "{config}");
    let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
    assert!(lib.contains("mod realtime;") && lib.contains(".merge(realtime::routes())"), "{lib}");
    let module = fs::read_to_string(root.join("src/realtime.rs")).unwrap();
    assert!(module.contains("Generated by `ocre g scaffold Post title:string --realtime`."), "{module}");
    assert!(
        module.contains("        // ocre:channels\n        \"posts\" => {}\n        _ => return Err(Error::NotFound),")
    );

    let controller = fs::read_to_string(root.join("src/posts.rs")).unwrap();
    assert!(
        controller
            .contains("broadcast(&ctx, &realtime::prepend(\"posts\", &row(&record)?)).await;\n            session")
    );
    assert!(controller.contains("broadcast(&ctx, &row(&record)?).await;"), "{controller}");
    assert!(controller.contains("broadcast(&ctx, &realtime::remove(&format!(\"post_{id}\"))).await;"));
    assert!(controller.contains("struct RowView<'a> {\n    post: &'a Post,\n}"), "{controller}");
    let index = fs::read_to_string(root.join("templates/posts/index.html")).unwrap();
    assert!(index.contains("<div hx-ext=\"ws\" ws-connect=\"/realtime/posts\">\n<table>"), "{index}");
    assert!(!index.contains("<script"), "the layout loads the extension: {index}");
    let layout = fs::read_to_string(root.join("templates/layout.html")).unwrap();
    assert!(
        layout.contains("crossorigin=\"anonymous\"></script>\n  <script src=\"https://unpkg.com/htmx-ext-ws@2.0.4/dist/ws.js\" crossorigin=\"anonymous\"></script>\n"),
        "{layout}"
    );
    assert!(index.contains("<tbody id=\"posts\">\n    {% for post in posts %}\n    {% include \"posts/_row.html\" %}"));
    assert!(index.contains("</table>\n</div>\n\n<nav class=\"pagination\">"), "{index}");
    let row = fs::read_to_string(root.join("templates/posts/_row.html")).unwrap();
    assert!(row.starts_with("<tr id=\"post_{{ post.id }}\"><td>{{ post.title }}</td>"), "{row}");

    let (routes, ok) = sandbox.json(&["routes", "realtime"], &root);
    assert!(ok, "{routes}");
    assert_eq!(routes["routes"][0]["path"], "/realtime/{channel}", "{routes}");

    // A second live resource only adds its channel; one already listed by hand is kept.
    let (report, ok) = sandbox.json(&["g", "scaffold", "Comment", "body:text", "--realtime"], &root);
    assert!(ok, "{report}");
    assert_eq!(
        report["updated"],
        json!(["src/models/mod.rs", "tests/factories/mod.rs", "src/lib.rs", "src/realtime.rs"])
    );
    let module = fs::read_to_string(root.join("src/realtime.rs")).unwrap();
    assert!(module.contains("// ocre:channels\n        \"comments\" => {}\n        \"posts\" => {}\n"), "{module}");
    fs::write(root.join("src/realtime.rs"), module.replace("\"comments\" => {}", "\"tags\" => {}")).unwrap();
    let (report, ok) = sandbox.json(&["g", "scaffold", "Tag", "name:string", "--realtime"], &root);
    assert!(ok, "{report}");
    assert_eq!(report["updated"], json!(["src/models/mod.rs", "tests/factories/mod.rs", "src/lib.rs"]));
    let module = fs::read_to_string(root.join("src/realtime.rs")).unwrap();
    assert_eq!(module.matches("\"tags\" => {}").count(), 1, "{module}");
    let config = fs::read_to_string(root.join("cloudflare.config.ts")).unwrap();
    assert_eq!(config.matches("bindings.durableObject(").count(), 1, "{config}");
    assert_eq!(config.matches("exports.durableObject(").count(), 1, "{config}");
    let cargo = fs::read_to_string(root.join("Cargo.toml")).unwrap();
    assert_eq!(cargo.matches("\"realtime\"").count(), 1, "{cargo}");
    let lib = fs::read_to_string(root.join("src/lib.rs")).unwrap();
    assert_eq!(lib.matches("mod realtime;").count(), 1, "{lib}");
    let layout = fs::read_to_string(root.join("templates/layout.html")).unwrap();
    assert_eq!(layout.matches("htmx-ext-ws").count(), 1, "{layout}");
}

#[test]
fn realtime_scaffold_without_the_channels_marker_writes_nothing() {
    let sandbox = Sandbox::new();
    let root = sandbox.new_app("live", &[]);
    fs::write(root.join("src/realtime.rs"), "// hand-written\n").unwrap();
    let (report, ok) = sandbox.json(&["g", "scaffold", "Post", "title:string", "--realtime"], &root);
    assert!(!ok);
    assert_eq!(report["error"], "src/realtime.rs is missing the `// ocre:channels` marker");
    assert!(report["hint"].as_str().unwrap().contains("add `\"posts\" => {}` there yourself"), "{report}");
    assert!(!root.join("src/posts.rs").exists(), "nothing written on failure");
    assert!(!fs::read_to_string(root.join("Cargo.toml")).unwrap().contains("realtime"));
}

#[test]
fn realtime_scaffold_without_the_exports_marker_writes_nothing() {
    let sandbox = Sandbox::new();
    let root = sandbox.new_app("live", &[]);
    let config = fs::read_to_string(root.join("cloudflare.config.ts")).unwrap().replace("// ocre:exports", "");
    fs::write(root.join("cloudflare.config.ts"), &config).unwrap();
    let (report, ok) = sandbox.json(&["g", "scaffold", "Post", "title:string", "--realtime"], &root);
    assert!(!ok);
    assert_eq!(report["error"], "cloudflare.config.ts is missing the `// ocre:exports` marker");
    assert!(report["hint"].as_str().unwrap().contains("inside `worker.exports: { ... }`"), "{report}");
    assert_eq!(fs::read_to_string(root.join("cloudflare.config.ts")).unwrap(), config);
    assert!(!root.join("src/posts.rs").exists() && !root.join("src/realtime.rs").exists(), "nothing written");
}

#[test]
fn realtime_scaffold_puts_the_extension_in_the_head_of_any_layout() {
    let sandbox = Sandbox::new();
    let root = sandbox.new_app("live", &[]);
    // htmx served by the app: the extension goes before </head>.
    fs::write(
        root.join("templates/layout.html"),
        "<html><head>\n<script src=\"/htmx.js\"></script>\n</head><body></body></html>\n",
    )
    .unwrap();
    let (report, ok) = sandbox.json(&["g", "scaffold", "Post", "title:string", "--realtime"], &root);
    assert!(ok, "{report}");
    let layout = fs::read_to_string(root.join("templates/layout.html")).unwrap();
    assert!(layout.contains("<script src=\"/htmx.js\"></script>\n  <script src=\"https://unpkg.com/htmx-ext-ws@2.0.4/dist/ws.js\" crossorigin=\"anonymous\"></script>\n</head>"), "{layout}");

    // No <head> at all: an error with the line to add, and nothing written.
    let root = sandbox.new_app("bare", &[]);
    fs::write(root.join("templates/layout.html"), "{% block content %}{% endblock %}\n").unwrap();
    let (report, ok) = sandbox.json(&["g", "scaffold", "Post", "title:string", "--realtime"], &root);
    assert!(!ok);
    assert_eq!(report["error"], "templates/layout.html has no </head>");
    assert!(report["hint"].as_str().unwrap().contains("htmx-ext-ws@2.0.4/dist/ws.js"), "{report}");
    assert!(!root.join("src/posts.rs").exists() && !root.join("src/realtime.rs").exists(), "nothing written");
}

#[test]
fn realtime_scaffold_needs_html_pages() {
    let sandbox = Sandbox::new();
    let root = sandbox.new_app("api", &["--api"]);
    let (report, ok) = sandbox.json(&["g", "scaffold", "Post", "title:string", "--realtime"], &root);
    assert!(!ok);
    assert_eq!(report["error"], "--realtime updates HTML pages; this app is API-only");
    assert!(report["hint"].as_str().unwrap().starts_with("run `ocre g scaffold` without --realtime"));
    assert!(!root.join("src/posts_api.rs").exists());
}