diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b40685c8..45b3de5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -342,6 +342,7 @@ jobs: # - 27 after review_pr_anchor (cute-dbt#303) # - 28 after agent_skill (cute-dbt#304) # - 29 after macro_perspective (cute-dbt#265) + # - 30 after explore_macro_view (cute-dbt#345) # The README is not a feature. The job name deliberately carries no # number (cute-dbt#68 — the old `==6` display label went stale across # several bumps): the enforced count lives ONLY in the `expected=` @@ -359,7 +360,7 @@ jobs: - name: features/*.feature count matches expected run: | set -euo pipefail - expected=29 + expected=30 count=$(find features -maxdepth 1 -name '*.feature' | wc -l | tr -d ' ') echo "feature-count: expected ${expected}, found ${count}" if [ "$count" != "$expected" ]; then diff --git a/features/explore_macro_view.feature b/features/explore_macro_view.feature new file mode 100644 index 00000000..d63725be --- /dev/null +++ b/features/explore_macro_view.feature @@ -0,0 +1,53 @@ +# Maps: cute-dbt#345 Slice 1 — the explorer macro view walking skeleton +# (epic cute-dbt#99). The explore verb gains a THIRD sub-page, +# `macro.html`, emitted ONLY when the `--pr-diff` changed a root-project +# macro. Slice 1 proves the seam end-to-end: the conditional emission, +# the nav round-trip, and the negative path that keeps the two-page +# output (and its byte-identity goldens) unchanged. +# +# The focused macro DAG (cute-dbt#345 Slice 3) and the filtered model + +# test directory (Slice 4) render INTO this shell in later slices; here +# the page is an honest empty placeholder. +# +# Founder respec pins inherited from cute-dbt#106: the explorer takes NO +# baseline manifest, ever — the changed-macro signal on `explore` is the +# `--pr-diff`. The `@no-baseline-usage-error` tag is belt-and-braces (the +# baseline-required-grep gate already allows explore invocations). +Feature: explore emits a macro-focus sub-page only when a root macro changed + + Background: + Given an explore scenario + + @no-baseline-usage-error + Scenario: a PR diff that changes a root-project macro emits macro.html + Given the explore manifest declares the model "stg_claims" + And the explore model "stg_claims" has source path "models/staging/stg_claims.sql" + And the explore manifest carries the root-project macro "add_dq_flags" at "macros/add_dq_flags.sql" + And the PR diff changes the explore file "macros/add_dq_flags.sql" + When I run cute-dbt explore on the macro manifest with the PR diff + Then the exit code is 0 + And the explore out directory contains "macro.html" + And dag.html links to the macro-focus page + And tests.html links to the macro-focus page + And macro.html carries the macro-focus heading + + @no-baseline-usage-error + Scenario: a PR diff that changes no root-project macro emits no macro.html + Given the explore manifest declares the model "stg_claims" + And the explore model "stg_claims" has source path "models/staging/stg_claims.sql" + And the explore manifest carries the root-project macro "add_dq_flags" at "macros/add_dq_flags.sql" + And the PR diff changes the explore file "models/staging/stg_claims.sql" + When I run cute-dbt explore on the macro manifest with the PR diff + Then the exit code is 0 + And no macro.html is written + And dag.html does not link to the macro-focus page + And tests.html does not link to the macro-focus page + + @no-baseline-usage-error + Scenario: explore without a PR diff emits no macro.html + Given the explore manifest declares the model "stg_claims" + And the explore manifest carries the root-project macro "add_dq_flags" at "macros/add_dq_flags.sql" + When I run cute-dbt explore on the macro manifest + Then the exit code is 0 + And no macro.html is written + And dag.html does not link to the macro-focus page diff --git a/lefthook.yml b/lefthook.yml index c4fb453f..c23ef92a 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -89,7 +89,9 @@ pre-push: feature-count: # Asserts the .feature-count invariant; the enforced count lives ONLY # in the `expected=` line below (cute-dbt#68 — numbers in labels and - # comments go stale). History: bumped to 29 in cute-dbt#265 when + # comments go stale). History: bumped to 30 in cute-dbt#345 when + # explore_macro_view.feature joined the contract; previously + # bumped to 29 in cute-dbt#265 when # macro_perspective.feature joined the contract; previously # bumped to 28 in cute-dbt#304 when # agent_skill.feature joined the contract; previously @@ -113,7 +115,7 @@ pre-push: # Mirrors the `feature-count` CI job; the README is not a feature. run: | set -euo pipefail - expected=29 + expected=30 count=$(find features -maxdepth 1 -name '*.feature' | wc -l | tr -d ' ') if [ "$count" != "$expected" ]; then echo "feature-count: expected ${expected} .feature files, found ${count}" diff --git a/src/adapters/explore.rs b/src/adapters/explore.rs index c9e29565..ff324ef3 100644 --- a/src/adapters/explore.rs +++ b/src/adapters/explore.rs @@ -976,6 +976,11 @@ struct ExploreDagTemplate<'a> { /// Number of lineage nodes the change context marked (0 is honest: /// a diff touching no model files still renders the count). changed_count: usize, + /// `true` iff the run emitted `macro.html` (cute-dbt#345 — a + /// `--pr-diff` that changed a root-project macro). Gates the third + /// nav anchor so the no-macro goldens stay byte-identical (the + /// conditional-anchor contract, mirrored on every explore page). + has_macro_focus: bool, } /// askama binding for `templates/explore-tests.html`. @@ -998,6 +1003,27 @@ struct ExploreTestsTemplate<'a> { /// Pre-escaped JSON for the `cute-dbt-data` carrier (the full /// [`ReportPayload`] — the `build_payload` reuse seam). payload_json: &'a str, + /// `true` iff the run emitted `macro.html` (cute-dbt#345). Gates the + /// third nav anchor — see [`ExploreDagTemplate::has_macro_focus`]. + has_macro_focus: bool, +} + +/// askama binding for `templates/explore-macro.html`. +/// +/// The third explore sub-page (cute-dbt#345, epic cute-dbt#99). Slice 1 +/// is the walking skeleton: an empty, real, zero-egress-clean shell that +/// only the `--pr-diff`-changed-root-macro path emits, carrying the page +/// chassis and the nav round-trip back to `dag.html`/`tests.html`. The +/// focused macro DAG (Slice 3) and the filtered model+test directory +/// (Slice 4) render into this shell in later slices. +#[derive(Template)] +#[template(path = "explore-macro.html", escape = "html")] +struct ExploreMacroTemplate<'a> { + sakura_css: &'a str, + /// SHARED appearance engine (cute-dbt#242) — the page honors the + /// saved `cute-dbt.appearance.v1` appearance (read-only). + appearance_js: &'a str, + favicon_data_uri: &'a str, } /// Serialize `value` for safe embedding inside an HTML @@ -1072,13 +1098,14 @@ fn explore_models( .collect() } -/// Render the two explore pages into `out_dir` (created if absent). +/// Render the explore pages into `out_dir` (created if absent). /// -/// Writes `dag.html` then `tests.html`; a failure on either write (or -/// on directory creation) surfaces the underlying [`io::Error`] — -/// the cli layer names `--out-dir` in the operator message. Template -/// rendering itself is compile-time-checked askama (the same -/// infallible-at-runtime posture as the report renderer). +/// Writes `dag.html` then `tests.html`, and — when `has_macro_focus` — +/// the third sub-page `macro.html` (cute-dbt#345). A failure on any +/// write (or on directory creation) surfaces the underlying +/// [`io::Error`] — the cli layer names `--out-dir` in the operator +/// message. Template rendering itself is compile-time-checked askama +/// (the same infallible-at-runtime posture as the report renderer). /// /// `changed` is the optional `--pr-diff` change context (cute-dbt#106): /// `Some(set)` marks the member lineage nodes and renders the header @@ -1086,16 +1113,25 @@ fn explore_models( /// page. Either way the full `models` set renders — context never /// narrows scope. /// +/// `has_macro_focus` (cute-dbt#345) is the resolved signal that the run +/// carried a `--pr-diff` changing a root-project macro: `true` emits +/// `macro.html` and renders the third nav anchor on every page; `false` +/// keeps the two-page output (and its byte-identical goldens) unchanged. +/// The renderer takes the pre-resolved flag, not the raw changed-macro +/// id set — scope resolution stays in `cli/mod.rs`, so this layer is a +/// pure renderer (the explore-lane posture). +/// /// # Errors /// /// Returns the underlying [`io::Error`] when `out_dir` cannot be -/// created or either page cannot be written. +/// created or any page cannot be written. pub fn render_explore( out_dir: &Path, current: &Manifest, models: &ModelInScopeSet, changed: Option<&ModelInScopeSet>, payload: &ReportPayload, + has_macro_focus: bool, ) -> io::Result<()> { fs::create_dir_all(out_dir)?; @@ -1136,6 +1172,7 @@ pub fn render_explore( contract_version: EXPLORE_CONTRACT_VERSION, has_change_context: changed.is_some(), changed_count, + has_macro_focus, } .render() .map_err(|err| io::Error::other(format!("render dag.html: {err}")))?; @@ -1154,11 +1191,29 @@ pub fn render_explore( test_count, explore_tests_js: EXPLORE_TESTS_JS, payload_json: &payload_json, + has_macro_focus, } .render() .map_err(|err| io::Error::other(format!("render tests.html: {err}")))?; fs::write(out_dir.join("tests.html"), tests_html)?; + // cute-dbt#345 Slice 1 — the third explore sub-page. Emitted only + // when the run carried a `--pr-diff` changing a root-project macro; + // the no-macro path writes just dag.html/tests.html (the goldens' + // shape). Slice 1 renders the walking-skeleton empty shell — the + // focused macro DAG (Slice 3) and the filtered directory (Slice 4) + // fill it in later. + if has_macro_focus { + let macro_html = ExploreMacroTemplate { + sakura_css: SAKURA_CSS, + appearance_js: APPEARANCE_JS, + favicon_data_uri: FAVICON_DATA_URI, + } + .render() + .map_err(|err| io::Error::other(format!("render macro.html: {err}")))?; + fs::write(out_dir.join("macro.html"), macro_html)?; + } + Ok(()) } @@ -1817,7 +1872,7 @@ mod tests { let payload = payload_for(¤t, &models); let dir = tmp_dir("both-pages"); - render_explore(&dir, ¤t, &models, None, &payload).expect("explore renders"); + render_explore(&dir, ¤t, &models, None, &payload, false).expect("explore renders"); let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html written"); let tests = fs::read_to_string(dir.join("tests.html")).expect("tests.html written"); @@ -1870,8 +1925,8 @@ mod tests { let payload = payload_for(¤t, &models); let dir_a = tmp_dir("det-a"); let dir_b = tmp_dir("det-b"); - render_explore(&dir_a, ¤t, &models, None, &payload).expect("first render"); - render_explore(&dir_b, ¤t, &models, None, &payload).expect("second render"); + render_explore(&dir_a, ¤t, &models, None, &payload, false).expect("first render"); + render_explore(&dir_b, ¤t, &models, None, &payload, false).expect("second render"); for page in ["dag.html", "tests.html"] { let a = fs::read(dir_a.join(page)).expect("page a"); let b = fs::read(dir_b.join(page)).expect("page b"); @@ -1887,7 +1942,7 @@ mod tests { let models = all_models(¤t); let payload = payload_for(¤t, &models); let dir = tmp_dir("nested").join("deeper"); - render_explore(&dir, ¤t, &models, None, &payload).expect("creates out-dir"); + render_explore(&dir, ¤t, &models, None, &payload, false).expect("creates out-dir"); assert!(dir.join("dag.html").exists()); assert!(dir.join("tests.html").exists()); let _ = fs::remove_dir_all(dir.parent().expect("parent")); @@ -1899,7 +1954,8 @@ mod tests { let models = all_models(¤t); let payload = payload_for(¤t, &models); let dir = tmp_dir("empty"); - render_explore(&dir, ¤t, &models, None, &payload).expect("empty manifest renders"); + render_explore(&dir, ¤t, &models, None, &payload, false) + .expect("empty manifest renders"); let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html"); assert!( dag.contains("\"nodes\":[]"), @@ -1975,7 +2031,7 @@ mod tests { let models = all_models(¤t); let payload = payload_for(¤t, &models); let dir = tmp_dir("cte-toggle"); - render_explore(&dir, ¤t, &models, None, &payload).expect("explore renders"); + render_explore(&dir, ¤t, &models, None, &payload, false).expect("explore renders"); let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html"); // The carrier embeds the per-model CTE DAG map. assert!(dag.contains("\"cte_dags\":{"), "cte_dags carrier present"); @@ -2030,7 +2086,7 @@ mod tests { let models = all_models(¤t); let payload = payload_for(¤t, &models); let dir = tmp_dir("tests-viewer"); - render_explore(&dir, ¤t, &models, None, &payload).expect("explore renders"); + render_explore(&dir, ¤t, &models, None, &payload, false).expect("explore renders"); let tests = fs::read_to_string(dir.join("tests.html")).expect("tests.html"); // The shared askama partial (report.html's test card) renders. assert!( @@ -2601,7 +2657,7 @@ mod tests { let models = all_models(¤t); let payload = payload_for(¤t, &models); let dir = tmp_dir("contract-version"); - render_explore(&dir, ¤t, &models, None, &payload).expect("explore renders"); + render_explore(&dir, ¤t, &models, None, &payload, false).expect("explore renders"); let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html"); assert!( dag.contains(&format!( @@ -2706,7 +2762,8 @@ mod tests { let payload = payload_for(¤t, &models); let changed = changed_set(&["model.shop.dim_orders"]); let dir = tmp_dir("change-context"); - render_explore(&dir, ¤t, &models, Some(&changed), &payload).expect("explore renders"); + render_explore(&dir, ¤t, &models, Some(&changed), &payload, false) + .expect("explore renders"); let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html"); assert!( dag.contains("1 changed in this diff"), @@ -2739,7 +2796,8 @@ mod tests { let payload = payload_for(¤t, &models); let empty = ModelInScopeSet::new(); let dir = tmp_dir("change-context-zero"); - render_explore(&dir, ¤t, &models, Some(&empty), &payload).expect("explore renders"); + render_explore(&dir, ¤t, &models, Some(&empty), &payload, false) + .expect("explore renders"); let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html"); assert!(dag.contains("0 changed in this diff"), "honest zero"); assert!( @@ -2760,7 +2818,7 @@ mod tests { let models = all_models(¤t); let payload = payload_for(¤t, &models); let dir = tmp_dir("no-change-context"); - render_explore(&dir, ¤t, &models, None, &payload).expect("explore renders"); + render_explore(&dir, ¤t, &models, None, &payload, false).expect("explore renders"); let dag = fs::read_to_string(dir.join("dag.html")).expect("dag.html"); assert!( !dag.contains(""), diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 8b38a2fb..b79191a8 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -453,7 +453,7 @@ fn execute_explore(args: &ExploreArgs) -> Result<(), RunError> { ); let current = load_explore_manifest(args)?; let models = all_models(¤t); - let changed = resolve_change_context(args, ¤t); + let context = resolve_change_context(args, ¤t); // Stage-2 fail-OPEN: no preflight_compiled here, by design. let payload = build_payload( ¤t, @@ -466,26 +466,70 @@ fn execute_explore(args: &ExploreArgs) -> Result<(), RunError> { &HashMap::new(), "", ); - render_explore(&args.out_dir, ¤t, &models, changed.as_ref(), &payload) - .map_err(|err| RunError::output(&args.out_dir, err))?; + // cute-dbt#345 — the macro view emits its third sub-page only when the + // `--pr-diff` changed a root-project macro. The flag is RESOLVED here + // (the scope-resolution lane) and handed to the renderer, which stays + // a pure renderer (it never calls a domain walker). + let has_macro_focus = !context.changed_macros.is_empty(); + render_explore( + &args.out_dir, + ¤t, + &models, + context.changed_models.as_ref(), + &payload, + has_macro_focus, + ) + .map_err(|err| RunError::output(&args.out_dir, err))?; Ok(()) } -/// The `resolve_change_context` stage (cute-dbt#106): map the optional -/// `--pr-diff` onto the changed-model set. +/// The resolved `--pr-diff` change context for the `explore` verb +/// (cute-dbt#106 + cute-dbt#345): both the changed-model set (the +/// lineage decoration) and the changed-root-project-macro set (the +/// cute-dbt#345 macro-view trigger), derived from ONE +/// [`NormalizedDiffIndex`] build so the diff is parsed once. /// -/// `None` — no `--pr-diff` — means **no change context** (the renderer -/// emits the unchanged no-context page shape), which is distinct from -/// `Some(empty)` — a diff that touched no model files still renders the -/// honest "0 changed in this diff" banner. The [`NormalizedDiffIndex`] -/// is built exactly like the report arm's (`resolve_scope_input`): -/// the `--project-root` strip rebases the diff's repo-relative paths -/// onto the manifest's project-relative `original_file_path` entries. -fn resolve_change_context(args: &ExploreArgs, current: &Manifest) -> Option { - args.pr_diff.as_ref().map(|diff| { - let index = NormalizedDiffIndex::new(diff, args.project_root.as_deref()); - changed_models(current, &index) - }) +/// `changed_models` is `None` when no `--pr-diff` was supplied (the +/// renderer emits the pre-#106 no-context page shape) and `Some` — +/// possibly empty — when a diff was present. `changed_macros` is empty +/// without a diff and otherwise holds the root-project macro ids the +/// diff's hunks/paths resolved to (the macro-view emission gate). +struct ExploreChangeContext { + changed_models: Option, + changed_macros: BTreeSet, +} + +/// The `resolve_change_context` stage (cute-dbt#106 + cute-dbt#345): map +/// the optional `--pr-diff` onto the changed-model set AND the +/// changed-root-project-macro set, from one [`NormalizedDiffIndex`] +/// build (the diff is parsed once and both derivations read it). +/// +/// `changed_models` is `None` — no `--pr-diff` — meaning **no change +/// context** (the renderer emits the unchanged no-context page shape), +/// which is distinct from `Some(empty)` — a diff that touched no model +/// files still renders the honest "0 changed in this diff" banner. The +/// [`NormalizedDiffIndex`] is built exactly like the report arm's +/// (`resolve_scope_input`): the `--project-root` strip rebases the +/// diff's repo-relative paths onto the manifest's project-relative +/// `original_file_path` entries. +/// +/// `changed_macros` (cute-dbt#345) is the root-project macro ids the +/// same diff resolved to via [`changed_macros_pr_diff`] — empty without +/// a diff, the macro-view emission gate when present. +fn resolve_change_context(args: &ExploreArgs, current: &Manifest) -> ExploreChangeContext { + match args.pr_diff.as_ref() { + None => ExploreChangeContext { + changed_models: None, + changed_macros: BTreeSet::new(), + }, + Some(diff) => { + let index = NormalizedDiffIndex::new(diff, args.project_root.as_deref()); + ExploreChangeContext { + changed_models: Some(changed_models(current, &index)), + changed_macros: changed_macros_pr_diff(current, &index), + } + } + } } /// Stage-1 pre-flight for the `explore` verb: load `--manifest` through diff --git a/templates/explore-dag.html b/templates/explore-dag.html index 3bc14961..4673186e 100644 --- a/templates/explore-dag.html +++ b/templates/explore-dag.html @@ -143,7 +143,8 @@

Model lineage

full manifest — {{ model_count }} model{% if model_count != 1 %}s{% endif %}{% if snapshot_count > 0 %}, {{ snapshot_count }} snapshot{% if snapshot_count != 1 %}s{% endif %}{% endif %}{% if seed_count > 0 %}, {{ seed_count }} seed{% if seed_count != 1 %}s{% endif %}{% endif %}{% if source_count > 0 %}, {{ source_count }} source{% if source_count != 1 %}s{% endif %}{% endif %}{% if exposure_count > 0 %}, {{ exposure_count }} exposure{% if exposure_count != 1 %}s{% endif %}{% endif %}, {{ edge_count }} edge{% if edge_count != 1 %}s{% endif %}{% if not_compiled_count > 0 %}, {{ not_compiled_count }} not compiled{% endif %}{% if has_change_context %}, {{ changed_count }} changed in this diff{% endif %}

diff --git a/templates/explore-macro.html b/templates/explore-macro.html new file mode 100644 index 00000000..899f1a40 --- /dev/null +++ b/templates/explore-macro.html @@ -0,0 +1,73 @@ + + + + + +cute-dbt explore — macro focus + + + + + + + +
+
+

Macro focus

+

focused macro blast radius

+
+ +
+ +{# cute-dbt#345 Slice 1 — the walking-skeleton placeholder. The focused + macro DAG + filtered directory land here in Slices 3–4; until then the + page renders only when --pr-diff carries a changed root-project macro + (the conditional emission in render_explore), and shows this honest + empty state. #} +

No focused macro view yet — this page is the +explorer macro-view walking skeleton (cute-dbt#345).

+ + + + diff --git a/templates/explore-tests.html b/templates/explore-tests.html index 264fe14f..2ee8923f 100644 --- a/templates/explore-tests.html +++ b/templates/explore-tests.html @@ -89,7 +89,8 @@

Unit tests

full manifest — {{ model_count }} model{% if model_count != 1 %}s{% endif %}, {{ test_count }} unit test{% if test_count != 1 %}s{% endif %}

diff --git a/tests/headless_toggle.rs b/tests/headless_toggle.rs index 64fb8555..0791323c 100644 --- a/tests/headless_toggle.rs +++ b/tests/headless_toggle.rs @@ -11398,7 +11398,7 @@ fn render_explore_theme_pages(stem: &str) -> PathBuf { ); let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(stem); let _ = std::fs::remove_dir_all(&dir); - render_explore(&dir, &m, &models, None, &payload).expect("explore renders"); + render_explore(&dir, &m, &models, None, &payload, false).expect("explore renders"); dir } diff --git a/tests/headless_zero_egress.rs b/tests/headless_zero_egress.rs index c7820dc3..2ffd1cd9 100644 --- a/tests/headless_zero_egress.rs +++ b/tests/headless_zero_egress.rs @@ -569,7 +569,7 @@ fn render_explore_pages( ); let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(stem); let _ = std::fs::remove_dir_all(&dir); - render_explore(&dir, &manifest, &models, changed, &payload).expect("explore renders"); + render_explore(&dir, &manifest, &models, changed, &payload, false).expect("explore renders"); dir } @@ -600,7 +600,7 @@ fn render_explore_dag_manifest(stem: &str, manifest: &Manifest) -> String { ); let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(stem); let _ = std::fs::remove_dir_all(&dir); - render_explore(&dir, manifest, &models, None, &payload).expect("explore renders"); + render_explore(&dir, manifest, &models, None, &payload, false).expect("explore renders"); let p = dir.join("dag.html"); format!("file://{}", p.to_str().expect("page path is valid UTF-8")) } @@ -1766,6 +1766,129 @@ fn explore_tests_viewer_renders_fixture_grids_offline() { let _ = tab.close(true); } +/// Render the explore pages with `has_macro_focus = true` (cute-dbt#345 +/// Slice 1 — the third sub-page emitted) and return the `file://` URL of +/// the emitted `macro.html`. The in-process render discipline matches +/// `render_explore_pages`, but threads the macro-focus flag so the walking +/// skeleton actually writes `macro.html`. +fn render_explore_macro_page(stem: &str, nodes: Vec) -> String { + let manifest = Manifest::new( + ManifestMetadata::new("v12"), + nodes.into_iter().map(|n| (n.id().clone(), n)).collect(), + HashMap::new(), + HashMap::new(), + ); + let models = all_models(&manifest); + let payload = build_payload( + &manifest, + &InScopeSet::new(), + &models, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + "", + ); + let dir = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(stem); + let _ = std::fs::remove_dir_all(&dir); + render_explore(&dir, &manifest, &models, None, &payload, true).expect("explore renders"); + let p = dir.join("macro.html"); + format!("file://{}", p.to_str().expect("page path is valid UTF-8")) +} + +#[test] +#[ignore = "requires Chrome; runs explicitly in the headless-zero-egress CI job via `-- --ignored`"] +fn explore_macro_page_makes_zero_external_requests_when_opened_via_file_url() { + // cute-dbt#345 Slice 1 — the explorer macro view walking skeleton. + // The third explore sub-page (`macro.html`) is opened via a real + // file:// origin with all DNS denied; it must make ZERO external + // requests and render its empty-state placeholder (the focused macro + // DAG + filtered directory land in Slices 3–4). Uniform with the + // dag.html/tests.html arms of the primary gate. + let url = render_explore_macro_page( + "explore-macro-skeleton", + vec![ + explore_model("model.shop.stg_orders", &[]), + explore_model("model.shop.dim_orders", &["model.shop.stg_orders"]), + ], + ); + assert!( + url.starts_with("file://"), + "zero-egress proof MUST run against a real file:// origin; got {url}", + ); + + let browser = launch_browser(); + let tab = browser.new_tab().expect("new tab"); + tab.call_method(Network::Enable { + max_total_buffer_size: None, + max_resource_buffer_size: None, + max_post_data_size: None, + report_direct_socket_traffic: None, + enable_durable_messages: None, + }) + .expect("enable Network domain"); + let external = Arc::new(Mutex::new(Vec::::new())); + let external_recorder = external.clone(); + tab.add_event_listener(Arc::new(move |event: &Event| { + if let Event::NetworkRequestWillBeSent(e) = event { + let req_url = e.params.request.url.clone(); + if scheme_is_external(&req_url) { + external_recorder.lock().unwrap().push(ExternalRequest { + url: req_url, + initiator_type: format!("{:?}", e.params.initiator.Type), + initiator_url: e.params.initiator.url.clone(), + initiator_line: e.params.initiator.line_number, + }); + } + } + })) + .expect("subscribe Network.requestWillBeSent"); + + tab.navigate_to(&url).expect("navigate to file:// URL"); + tab.wait_until_navigated().expect("await navigation"); + + // The page renders its heading and the walking-skeleton empty state. + assert_eq!( + eval(&tab, "document.querySelector('h1').textContent"), + serde_json::Value::String("Macro focus".to_owned()), + "macro.html renders the macro-focus heading", + ); + assert_eq!( + eval(&tab, "document.querySelector('.explore-empty') !== null"), + serde_json::Value::Bool(true), + "the Slice 1 skeleton renders its empty-state placeholder", + ); + // The nav round-trips back to the sibling pages. + assert_eq!( + eval( + &tab, + "document.querySelector('.explore-nav a[href=\"dag.html\"]') !== null" + ), + serde_json::Value::Bool(true), + "macro.html navigates back to dag.html", + ); + // No graph engine on the skeleton page. + assert_eq!( + eval(&tab, "typeof window.cytoscape"), + serde_json::Value::String("undefined".to_owned()), + "the Slice 1 macro.html skeleton ships no Cytoscape global", + ); + + let captured = external.lock().unwrap().clone(); + assert!( + captured.is_empty(), + "macro.html must make zero external requests; got:\n{}", + captured + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"), + ); + + let _ = tab.close(true); +} + /// Evaluate `expr` in the page; panic on a thrown JS exception so a /// missing selector can never silently pass the gate. fn eval(tab: &headless_chrome::Tab, expr: &str) -> serde_json::Value { diff --git a/tests/steps/explore_change_context.rs b/tests/steps/explore_change_context.rs index 7b363ada..9fe5844b 100644 --- a/tests/steps/explore_change_context.rs +++ b/tests/steps/explore_change_context.rs @@ -74,7 +74,7 @@ fn malformed_patch(world: &mut World) { /// ONLY the `similarity index 100%` + `rename from`/`rename to` /// extended headers (no `---`/`+++`, no hunks — the real `git diff` /// shape the cute-dbt#80 parser collects). -fn synthesize_explore_patch(world: &World) -> PathBuf { +pub fn synthesize_explore_patch(world: &World) -> PathBuf { let mut patch = String::new(); for p in &world.explore_changed_files { patch.push_str(&format!( diff --git a/tests/steps/explore_full_manifest.rs b/tests/steps/explore_full_manifest.rs index 9c4a62fb..2f09e6f1 100644 --- a/tests/steps/explore_full_manifest.rs +++ b/tests/steps/explore_full_manifest.rs @@ -268,6 +268,24 @@ pub fn serialize_plan_manifest(world: &World, stem: &str) -> std::path::PathBuf }), )); } + // cute-dbt#345 — root-project macros in the REAL fusion wire shape so + // the `--pr-diff` changed-macro detection (`macro_id_for_path`) + // resolves a changed file to the macro `unique_id` (the + // macro_perspective.rs `serialize_with_wire_macro` precedent). The + // `macros` top-level map keys on `macro..`. + for (bare, path) in &plan.macros { + top_map_entries.push(( + "macros".to_owned(), + format!("macro.jaffle_shop.{bare}"), + serde_json::json!({ + "macro_sql": format!("{{% macro {bare}() %}}select 1{{% endmacro %}}"), + "depends_on": { "macros": [] }, + "original_file_path": path, + "name": bare, + "package_name": "jaffle_shop", + }), + )); + } serialize_explore_to_tmp(&manifest, stem, &node_patches, &raw_nodes, &top_map_entries) } diff --git a/tests/steps/explore_macro_view.rs b/tests/steps/explore_macro_view.rs new file mode 100644 index 00000000..48beafea --- /dev/null +++ b/tests/steps/explore_macro_view.rs @@ -0,0 +1,133 @@ +//! Step definitions for `features/explore_macro_view.feature` — +//! cute-dbt#345 Slice 1, the explorer macro-view walking skeleton. +//! +//! The explore verb gains a THIRD sub-page, `macro.html`, emitted ONLY +//! when the `--pr-diff` changed a root-project macro (the +//! `changed_macros_pr_diff` detection on the explore arm). Slice 1 +//! proves the seam end-to-end through the real subprocess: +//! +//! - a diff touching a root-project macro's source path emits +//! `macro.html` and the third nav anchor on every page; +//! - a diff touching no macro (only a model) emits the two-page output +//! unchanged — no `macro.html`, no anchor (the byte-identity-golden +//! shape); +//! - no `--pr-diff` at all is the same negative path. +//! +//! Self-contained on the [`ExplorePlan`](super::world::ExplorePlan) +//! accumulator (the `explore_change_context.rs` pattern): the macro +//! Given splices a root-project macro into the wire manifest (so +//! `macro_id_for_path` resolves), the `When` synthesizes the patch and +//! runs `cute-dbt explore --pr-diff @`, and the Thens assert the +//! `macro.html` presence/absence and the nav-anchor round-trip. The +//! exit-code Then is the shared step (`report_generation.rs`). + +use std::path::PathBuf; + +use cucumber::{given, then, when}; + +use super::super::common; +use super::World; +use super::explore_change_context::synthesize_explore_patch; +use super::explore_cli::run_explore_with; +use super::explore_full_manifest::serialize_plan_manifest; + +// --- Given ------------------------------------------------------------ + +#[given(regex = r#"^the explore manifest carries the root-project macro "([^"]+)" at "([^"]+)"$"#)] +fn manifest_carries_macro(world: &mut World, bare: String, path: String) { + world.explore_plan.macros.push((bare, path)); +} + +// --- When ------------------------------------------------------------- + +/// Serialize the macro-bearing plan manifest and run +/// `cute-dbt explore --pr-diff @` against the synthesized patch +/// (the `explore_change_context` synthesizer, shared so the two +/// subprocess paths cannot drift). +#[when("I run cute-dbt explore on the macro manifest with the PR diff")] +fn run_explore_macro_with_diff(world: &mut World) { + let manifest_path = serialize_plan_manifest(world, "explore_macro_view"); + let out_dir = fresh_out_dir("explore_macro_view_pages"); + let patch = synthesize_explore_patch(world); + let extra = vec!["--pr-diff".to_owned(), format!("@{}", common::s(&patch))]; + run_explore_with(world, &manifest_path, out_dir, &extra, None); +} + +/// Serialize the macro-bearing plan manifest and run +/// `cute-dbt explore` with NO `--pr-diff` (the no-context negative +/// path). +#[when("I run cute-dbt explore on the macro manifest")] +fn run_explore_macro_no_diff(world: &mut World) { + let manifest_path = serialize_plan_manifest(world, "explore_macro_view_nodiff"); + let out_dir = fresh_out_dir("explore_macro_view_nodiff_pages"); + run_explore_with(world, &manifest_path, out_dir, &[], None); +} + +/// A collision-free explore out-dir under `CARGO_TARGET_TMPDIR`, cleared +/// before each use so a stale page from a previous run can never satisfy +/// a Then (the `explore_cli::fresh_out_dir` shape, re-expressed here so +/// this module stays self-contained). +fn fresh_out_dir(stem: &str) -> PathBuf { + let dir = common::tmp(stem); + let _ = std::fs::remove_dir_all(&dir); + dir +} + +// --- Then ------------------------------------------------------------- +// +// "the exit code is 0" and "the explore out directory contains " +// are shared steps (report_generation.rs / explore_cli.rs) — they read +// the same `world.last_exit_code` / `world.explore_out_dir` the macro +// Whens set, so redefining them here would be an ambiguous-step error. + +#[then("no macro.html is written")] +fn no_macro_html_written(world: &mut World) { + let dir = world.explore_out_dir.as_ref().expect("explore ran"); + assert!( + !dir.join("macro.html").exists(), + "the no-macro path must not emit macro.html (out dir: {}); stderr={}", + dir.display(), + world.last_stderr, + ); +} + +#[then(regex = r#"^(dag|tests)\.html links to the macro-focus page$"#)] +fn page_links_to_macro(world: &mut World, page: String) { + let html = page_html(world, &page); + assert!( + html.contains(r#"Macro focus →"#), + "{page}.html must carry the macro-focus nav anchor when macro.html is emitted", + ); +} + +#[then(regex = r#"^(dag|tests)\.html does not link to the macro-focus page$"#)] +fn page_does_not_link_to_macro(world: &mut World, page: String) { + let html = page_html(world, &page); + assert!( + !html.contains(r#"href="macro.html""#), + "{page}.html must carry NO macro-focus anchor on the no-macro path \ + (the byte-identity-golden shape)", + ); +} + +#[then("macro.html carries the macro-focus heading")] +fn macro_html_carries_heading(world: &mut World) { + let dir = world.explore_out_dir.as_ref().expect("explore ran"); + let html = std::fs::read_to_string(dir.join("macro.html")) + .unwrap_or_else(|_| panic!("macro.html must be readable; stderr={}", world.last_stderr)); + assert!( + html.contains("

Macro focus

"), + "macro.html must render the macro-focus page heading", + ); +} + +/// The rendered `dag.html`/`tests.html` captured into the `World`, +/// panicking with stderr context when absent. +fn page_html(world: &World, page: &str) -> String { + match page { + "dag" => world.explore_dag_html.clone(), + "tests" => world.explore_tests_html.clone(), + other => panic!("unknown explore page {other:?}"), + } + .unwrap_or_else(|| panic!("{page}.html was not written; stderr={}", world.last_stderr)) +} diff --git a/tests/steps/mod.rs b/tests/steps/mod.rs index 5adb5e19..24c402cf 100644 --- a/tests/steps/mod.rs +++ b/tests/steps/mod.rs @@ -27,6 +27,7 @@ pub mod explore_cli; pub mod explore_full_manifest; pub mod explore_js_contract; pub mod explore_lineage_dag; +pub mod explore_macro_view; pub mod explore_model_detail; pub mod explore_test_badges; pub mod explore_view_toggle; diff --git a/tests/steps/world.rs b/tests/steps/world.rs index 9ffc196e..04474bd6 100644 --- a/tests/steps/world.rs +++ b/tests/steps/world.rs @@ -307,6 +307,14 @@ pub struct ExplorePlan { /// Declared `exposures`-map entries (cute-dbt#253): /// `(name, depended-on bare model names)`. pub exposures: Vec<(String, Vec)>, + /// Declared root-project macros (cute-dbt#345 — the explorer + /// macro-view scenarios): `(bare macro name, original_file_path)`. + /// Spliced into the serialized manifest's `macros` map in the REAL + /// fusion wire shape (`macro_sql`, `depends_on.macros`, + /// `original_file_path`, `name`, `package_name`) so the + /// `--pr-diff` changed-macro detection (`macro_id_for_path`) + /// resolves the changed file to the macro's `unique_id`. + pub macros: Vec<(String, String)>, } /// One path-bearing unit test in an [`ExplorePlan`] (cute-dbt#105).