diff --git a/crates/socket-patch-cli/src/commands/vex_consumed.rs b/crates/socket-patch-cli/src/commands/vex_consumed.rs index b57d475f..cb0c6802 100644 --- a/crates/socket-patch-cli/src/commands/vex_consumed.rs +++ b/crates/socket-patch-cli/src/commands/vex_consumed.rs @@ -715,8 +715,11 @@ mod tests { None, ) .await; - assert_eq!(installed_again, installed); - let (paths, calls) = tracked_npm_hosted(&common, &installed_again).await; + // Since #605 the name-keyed resolver probes bundled trees itself, so + // it already returns the aliases and the nested store's peers. Feed + // the earlier, alias-free set to keep exercising alias expansion; + // the resolver's own set is checked against the same result below. + let (paths, calls) = tracked_npm_hosted(&common, &installed).await; assert_eq!(calls.len(), 1); let mut inputs = calls[0].clone(); inputs.sort(); @@ -738,6 +741,9 @@ mod tests { .len(), paths.len() ); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed_again).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] @@ -768,14 +774,19 @@ mod tests { None, ) .await; - assert!(installed.is_empty(), "{installed:?}"); - let (mut paths, calls) = tracked_npm_hosted(&common, &installed).await; + // Since #605 the name-keyed resolver reaches the alias and its + // sibling peers on its own. An alias-only set (what an alias-blind + // resolver returns) must still expand to the same copies. + let (mut paths, calls) = tracked_npm_hosted(&common, &HashMap::new()).await; assert_eq!(calls, vec![vec![alias.clone()]]); let mut expected = peers; expected.push(alias); paths.sort(); expected.sort(); assert_eq!(paths, expected); + let (mut resolved, _) = tracked_npm_hosted(&common, &installed).await; + resolved.sort(); + assert_eq!(resolved, expected, "the resolver's own copy set"); } #[cfg(unix)] diff --git a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs index 1a2d84ca..7370290e 100644 --- a/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs +++ b/crates/socket-patch-core/src/patch/redirect/upstream/vlt.rs @@ -28,9 +28,9 @@ use serde_json::{Map, Value}; use super::npm::{by_uuid, fetch_dists, read_or_refuse, refuse_all_in}; use super::{Ctx, FormatResult, HostedPin, View}; use crate::vendor::vlt_lock_text::{ - default_registry_alias, entry_text, is_default_registry, nodes_block, parse_node_line, - registry_base, render_entry_line, render_tuple_with_slots, sniff_lock, split_dep_id, - split_lines, DepIdEra, DepIdKind, LockSniff, + brotli_for_slot3, default_registry_alias, entry_text, is_default_registry, nodes_block, + parse_node_line, registry_base, render_entry_line, render_tuple_with_slots, sniff_lock, + split_dep_id, split_lines, DepIdEra, DepIdKind, LockSniff, }; /// One hosted node line to restore. @@ -269,6 +269,7 @@ pub(crate) async fn restore( }; let tuple = render_tuple_with_slots( &line.entry.elems, + brotli_for_slot3(slot3.as_deref()), Some(&json(&integrity)), slot3.as_deref(), ); diff --git a/crates/socket-patch-core/src/patch/redirect/vlt.rs b/crates/socket-patch-core/src/patch/redirect/vlt.rs index 14986852..81d74688 100644 --- a/crates/socket-patch-core/src/patch/redirect/vlt.rs +++ b/crates/socket-patch-core/src/patch/redirect/vlt.rs @@ -17,10 +17,10 @@ use crate::constants::npm_family::{ BUN_LOCK, BUN_LOCKB, NPM_LOCKS, PNPM_LOCK, VLT_CONFIG, VLT_HIDDEN_LOCK_REL, VLT_LOCK, }; use crate::vendor::vlt_lock_text::{ - entry_text, installs_outside_registry, is_default_registry, is_registry_url_segment, - nodes_block, parse_node_entry_text, parse_node_line, parse_vendored_path, render_entry_line, - render_tuple_with_slots, sniff_lock, split_dep_id, split_lines, DepId, DepIdKind, LockSniff, - NodeEntry, ParsedLock, SectionSpan, + brotli_for_slot3, entry_text, has_brotli_flag, installs_outside_registry, is_default_registry, + is_registry_url_segment, nodes_block, parse_node_entry_text, parse_node_line, + parse_vendored_path, render_entry_line, render_tuple_with_slots, sniff_lock, split_dep_id, + split_lines, DepId, DepIdKind, LockSniff, NodeEntry, ParsedLock, SectionSpan, }; /// The ledger kind of a hosted vlt node splice. @@ -490,10 +490,18 @@ fn rewrite_dep( for &(idx, id) in &located { let line = parse_node_line(&lines[idx]).expect("instance_line parsed this line"); let elems = &line.entry.elems; - if elems.len() >= 4 && elems[2] == s2 && elems[3] == s3 { + // The hosted artifact is a `.tgz`, so a node that resolved vlt + // 1.3's `.tar.br` alternate drops the brotli bit with its URL, as + // `vlt install` would save it (#372). + let brotli = brotli_for_slot3(Some(&s3)); + if elems.len() >= 4 + && elems[2] == s2 + && elems[3] == s3 + && has_brotli_flag(elems[0]) == brotli + { continue; } - let tuple = render_tuple_with_slots(elems, Some(&s2), Some(&s3)); + let tuple = render_tuple_with_slots(elems, brotli, Some(&s2), Some(&s3)); let new_text = entry_text(id, &tuple); let extra = split_dep_id(id).and_then(|dep_id| dep_id.extra); splices.push(Splice { @@ -668,6 +676,7 @@ pub fn carried_pin_original(fresh: &FileEdit, old: &FileEdit) -> Option { } let tuple = render_tuple_with_slots( &fresh_original.elems, + has_brotli_flag(old_original.elems[0]), old_original.slot(2).filter(|s| *s != "null"), old_original.slot(3).filter(|s| *s != "null"), ); @@ -986,4 +995,52 @@ mod tests { assert_eq!(carried_pin_original(&relocked, &old), None); } + /// #372: vlt 1.3 records the brotli bit (4) on a node that resolved + /// the registry's `.tar.br` alternate. Such a lock is canonical; the + /// pin points the node at the hosted `.tgz`, so it clears bit 4 (and + /// keeps dev/optional), which is what `vlt install` saves for it. + #[test] + fn a_vlt_1_3_brotli_lock_is_pinned_with_the_brotli_bit_cleared() { + const BR_URL: &str = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tar.br"; + let peer = "~npm~left-pad@1.3.0~peer.1"; + let lock = lock_with(&[ + &format!("\"{ID}\": [4,\"left-pad\",\"{REG_SHA}\",\"{BR_URL}\"]"), + &format!("\"{peer}\": [6,\"left-pad\",\"{REG_SHA}\",\"{BR_URL}\"]"), + "\"~npm~other@1.0.0\": [5,\"other\",\"sha512-O==\"]", + ]); + assert!(preflight_vlt_hosted(&files(&[(VLT_LOCK, &lock)])).is_ok()); + + let result = rewrite(&lock, &[dep("left-pad", "1.3.0", Some(SHA))]); + assert!(codes(&result).is_empty(), "{:?}", result.warnings); + assert!(result.confirmed_vlt_uuids.contains("uuid-left-pad")); + let written = &result.files[VLT_LOCK]; + assert!(written.contains(&format!("\"{ID}\": [0,\"left-pad\",\"{SHA}\",\"{URL}\"]"))); + assert!(written.contains(&format!("\"{peer}\": [2,\"left-pad\",\"{SHA}\",\"{URL}\"]"))); + assert!(written.contains("\"~npm~other@1.0.0\": [5,\"other\",\"sha512-O==\"]")); + let originals: Vec<_> = result.edits.iter().map(|e| e.original.clone()).collect(); + assert!(originals.contains(&Some(Value::String(format!( + "\"{ID}\": [4,\"left-pad\",\"{REG_SHA}\",\"{BR_URL}\"]" + ))))); + + // A re-run over its own pin is a no-op. + let again = rewrite(written, &[dep("left-pad", "1.3.0", Some(SHA))]); + assert!(again.edits.is_empty(), "{:?}", again.edits); + + // Superseding a carried brotli pin restores the brotli bit with + // the pristine `.tar.br` slots. + let old = vlt_edit( + &format!("\"{peer}\": [6,\"left-pad\",\"{REG_SHA}\",\"{BR_URL}\"]"), + &format!("\"{peer}\": [2,\"left-pad\",\"{SHA}\",\"{URL}\"]"), + ); + let carried = vlt_edit( + &format!("\"{ID}\": [2,\"left-pad\",\"{SHA}\",\"{URL}\"]"), + &format!("\"{ID}\": [2,\"left-pad\",\"sha512-P2==\",\"u2\"]"), + ); + assert_eq!( + carried_pin_original(&carried, &old), + Some(Value::String(format!( + "\"{ID}\": [6,\"left-pad\",\"{REG_SHA}\",\"{BR_URL}\"]" + ))) + ); + } } diff --git a/crates/socket-patch-core/src/patch/redirect/vlt_heal.rs b/crates/socket-patch-core/src/patch/redirect/vlt_heal.rs index 96645fd4..caf0f475 100644 --- a/crates/socket-patch-core/src/patch/redirect/vlt_heal.rs +++ b/crates/socket-patch-core/src/patch/redirect/vlt_heal.rs @@ -194,9 +194,11 @@ pub fn lock_flags(lock_text: &str, dep_id: &str) -> Option { /// (flags 1 or 3) as a skipped optional dependency, so every release from /// 0.0.0-32 to 1.2.0 leaves it missing (its importer link dangling) unless /// the same install also reinstalls a non-optional node; `vlt ci` restores -/// it. Unknown flags are not guaranteed either. +/// it. Unknown flags are not guaranteed either. vlt 1.3's brotli bit (4) +/// only says which artifact the node fetches, so a brotli prod or dev node +/// (4 or 6) is reinstalled like any other (#372). pub fn reinstalls_after_removal(flags: Option) -> bool { - matches!(flags, Some(0 | 2)) + flags.is_some_and(|flags| flags <= 7 && flags & 1 == 0) } /// vlt's `isDepID` path-safety rule: the id is used as one path segment. @@ -1080,7 +1082,10 @@ mod tests { fn only_prod_and_dev_nodes_are_reinstalled_after_removal() { assert!(reinstalls_after_removal(Some(0))); assert!(reinstalls_after_removal(Some(2))); - for flags in [Some(1), Some(3), Some(4), None] { + // #372: vlt 1.3's brotli bit leaves a prod or dev node reinstalled. + assert!(reinstalls_after_removal(Some(4))); + assert!(reinstalls_after_removal(Some(6))); + for flags in [Some(1), Some(3), Some(5), Some(7), Some(8), None] { assert!(!reinstalls_after_removal(flags), "{flags:?}"); } } diff --git a/crates/socket-patch-core/src/vendor/vlt_lock.rs b/crates/socket-patch-core/src/vendor/vlt_lock.rs index 89a80051..9f44da40 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock.rs @@ -40,12 +40,12 @@ use super::state::{ WiringRecord, }; use super::vlt_lock_text::{ - edges_block, entry_text, file_dep_id, installs_outside_registry, is_default_registry, - is_importer_dep_id, is_registry_url_segment, nodes_block, parse_edge_entry_text, - parse_edge_line, parse_node_entry_text, parse_node_line, parse_vendored_dir_path, - render_entry_line, render_tuple_with_slots, sniff_lock, split_dep_id, split_lines, - vendored_dir_rel, vlt_collate, vlt_edge_cmp, DepIdEra, DepIdKind, LockSniff, ParsedLock, - SectionSpan, + brotli_for_slot3, edges_block, entry_text, file_dep_id, has_brotli_flag, + installs_outside_registry, is_default_registry, is_importer_dep_id, is_registry_url_segment, + nodes_block, parse_edge_entry_text, parse_edge_line, parse_node_entry_text, parse_node_line, + parse_vendored_dir_path, render_entry_line, render_tuple_with_slots, sniff_lock, split_dep_id, + split_lines, vendored_dir_rel, vlt_collate, vlt_edge_cmp, DepIdEra, DepIdKind, LockSniff, + ParsedLock, SectionSpan, }; use super::{RevertOpts, RevertOutcome, VendorOutcome, VendorWarning}; @@ -855,8 +855,13 @@ fn plan_wiring( NOT_CANONICAL.to_string(), ) })?; - let file_tuple = - render_tuple_with_slots(&node_entry.elems, Some("null"), Some(&json_string(rel))); + let rel_slot = json_string(rel); + let file_tuple = render_tuple_with_slots( + &node_entry.elems, + brotli_for_slot3(Some(&rel_slot)), + Some("null"), + Some(&rel_slot), + ); let new_node = Entry { key: file_id.clone(), value: file_tuple, @@ -1645,6 +1650,7 @@ fn revert_node(staged: &mut Staged, rec: &WiringRecord) -> Step { } let tuple = render_tuple_with_slots( &live.elems, + has_brotli_flag(original.elems[0]), original.slot(2).filter(|s| *s != "null"), original.slot(3).filter(|s| *s != "null"), ); @@ -2981,6 +2987,35 @@ mod tests { assert!(fx.root.join(&entry.artifact.path).exists()); } + /// #372: vlt 1.3 sets the brotli bit (4) on nodes that resolved the + /// registry's `.tar.br` alternate. Vendoring wires such a lock (the + /// vendored directory is no Brotli tarball, so the wired node drops + /// bit 4) and the revert puts the pristine brotli entry back. + #[tokio::test] + async fn a_vlt_1_3_brotli_lock_is_vendored_and_reverted_exactly() { + let brotli = basic_lock() + .replace("[0,\"a\",", "[6,\"a\",") + .replace( + "[0,\"left-pad\",\"sha512-REG==\",\"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz\"]", + "[4,\"left-pad\",\"sha512-REG==\",\"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tar.br\"]", + ); + assert!(brotli.contains("[4,\"left-pad\"")); + let fx = fx(&brotli, &[(PACKAGE_JSON, ROOT_PKG)]).await; + let (entry, _) = entry_of(run(&fx, UUID, false).await); + let rel = format!(".socket/vendor/npm/{UUID}/left-pad-1.3.0/node_modules/left-pad"); + let wired = read(&fx, VLT_LOCK).await; + assert!( + wired.contains(&format!("[0,\"left-pad\",null,\"{rel}\"]")), + "{wired}" + ); + assert!(wired.contains("[6,\"a\",\"sha512-A==\"]"), "{wired}"); + + let out = revert_vlt_opts(&entry, &fx.root, RevertOpts::new(false)).await; + assert!(out.success && out.warnings.is_empty(), "{out:?}"); + assert_eq!(read(&fx, VLT_LOCK).await, brotli); + assert_eq!(read(&fx, PACKAGE_JSON).await, ROOT_PKG); + } + #[tokio::test] async fn revert_after_the_user_already_undid_the_wiring_only_removes_the_artifact() { let fx = fx(&basic_lock(), &[(PACKAGE_JSON, ROOT_PKG)]).await; diff --git a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs index ae57f920..63e0c140 100644 --- a/crates/socket-patch-core/src/vendor/vlt_lock_text.rs +++ b/crates/socket-patch-core/src/vendor/vlt_lock_text.rs @@ -851,13 +851,51 @@ pub(crate) fn parse_node_entry_text(text: &str) -> Option> { let is_string = |raw: &str| raw.starts_with('"') && serde_json::from_str::(raw).is_ok(); let string_or_null = |raw: &&str| *raw == "null" || is_string(raw); let well_formed = elems.len() >= 2 - && matches!(elems[0], "0" | "1" | "2" | "3") + && node_flags(elems[0]).is_some() && is_string(elems[1]) && elems.get(2).is_none_or(string_or_null) && elems.get(3).is_none_or(string_or_null); well_formed.then_some(NodeEntry { key, tuple, elems }) } +/// Slot [0] bit vlt 1.3 sets when a node's artifact is the registry's +/// Brotli (`.tar.br`) alternate, making slot [2] that artifact's hash. vlt +/// derives it from slot [3] whenever slot [3] is written, and only reads +/// the stored bit to rebuild an omitted URL with the right extension. +pub(crate) const NODE_FLAG_BROTLI: u64 = 4; + +/// Slot [0] as vlt's flag bitset: optional (1), dev (2) and brotli (4). +/// `None` for anything vlt does not write, so a lock from a vlt with more +/// flag bits is refused rather than misread. +pub(crate) fn node_flags(raw: &str) -> Option { + match raw.as_bytes() { + [digit @ b'0'..=b'7'] => Some(u64::from(digit - b'0')), + _ => None, + } +} + +/// vlt's `tarballFormat`: does `url` name a Brotli tarball? The extension +/// is read before any `?query` or `#fragment`. +pub(crate) fn is_brotli_artifact(url: &str) -> bool { + let end = url.find(['?', '#']).unwrap_or(url.len()); + url[..end].ends_with(".tar.br") +} + +/// The brotli bit for a node whose slot [3] is now `slot3` (a raw JSON +/// slice or absent), as vlt would save it. An absent slot [3] means the +/// registry's default `.tgz`: every writer that drops the URL also writes +/// that artifact's hash. +pub(crate) fn brotli_for_slot3(slot3: Option<&str>) -> bool { + slot3 + .and_then(|raw| serde_json::from_str::(raw).ok()) + .is_some_and(|url| is_brotli_artifact(&url)) +} + +/// Does a node's slot [0] carry the brotli bit? +pub(crate) fn has_brotli_flag(raw_flags: &str) -> bool { + node_flags(raw_flags).is_some_and(|flags| flags & NODE_FLAG_BROTLI != 0) +} + /// Raw top-level elements of a tuple `[e0,e1,…]`: the text must parse as a /// JSON array, and elements are separated by exactly one `,` with no /// surrounding whitespace. @@ -904,16 +942,34 @@ pub(crate) fn split_tuple_elements(tuple: &str) -> Option> { (elems.len() == expected && elems.iter().all(tight)).then_some(elems) } -/// A tuple with slots [2] and [3] replaced and `E0`, `E1`, `E4…` kept raw. -/// An absent slot is `null` when a later element follows and is dropped -/// otherwise, which is how vlt lays out unused slots. +/// A tuple with slots [2] and [3] replaced, slot [0]'s brotli bit set to +/// `brotli`, and the other flag bits, `E1` and `E4…` kept raw. Slots [2] +/// and [3] name one artifact and its hash, so a caller replacing them also +/// says which kind of artifact that is ([`brotli_for_slot3`] for a fresh +/// one, the recorded bit when restoring an entry). An absent slot is +/// `null` when a later element follows and is dropped otherwise, which is +/// how vlt lays out unused slots. pub(crate) fn render_tuple_with_slots( elems: &[&str], + brotli: bool, slot2: Option<&str>, slot3: Option<&str>, ) -> String { let tail = elems.get(4..).unwrap_or_default(); - let mut out: Vec<&str> = elems.iter().take(2).copied().collect(); + let flags = elems.first().and_then(|raw| node_flags(raw)).map(|flags| { + let flags = if brotli { + flags | NODE_FLAG_BROTLI + } else { + flags & !NODE_FLAG_BROTLI + }; + flags.to_string() + }); + let mut out: Vec<&str> = match &flags { + Some(flags) => std::iter::once(flags.as_str()) + .chain(elems.get(1).copied()) + .collect(), + None => elems.iter().take(2).copied().collect(), + }; let slots = [slot2, slot3]; let kept = if tail.is_empty() { slots.iter().rposition(Option::is_some).map_or(0, |i| i + 1) @@ -2098,6 +2154,53 @@ mod tests { assert_eq!(escaped.entry.name().as_deref(), Some("a\"b")); } + /// #372: vlt 1.3 sets the brotli bit (4) in slot [0] when a node + /// resolved the registry's `.tar.br` alternate, so flags 4–7 are + /// canonical vlt output. + #[test] + fn node_line_grammar_accepts_vlt_1_3_brotli_flags() { + for flags in 4..=7 { + let line = format!( + " \"~npm~left-pad@1.3.0\": [{flags},\"left-pad\",\"sha512-p3Lt==\",\ + \"http://127.0.0.1:18555/left-pad/-/left-pad-1.3.0.tar.br\"]," + ); + let parsed = node(&line); + assert_eq!(parsed.entry.elems[0], flags.to_string()); + assert_eq!(node_flags(parsed.entry.elems[0]), Some(flags)); + assert!(has_brotli_flag(parsed.entry.elems[0])); + } + assert!(!has_brotli_flag("3")); + } + + #[test] + fn brotli_bit_follows_the_artifact_slot_three_names() { + assert!(brotli_for_slot3(Some("\"https://r/a/-/a-1.0.0.tar.br\""))); + assert!(brotli_for_slot3(Some("\"https://r/a-1.0.0.tar.br?x=1#y\""))); + assert!(!brotli_for_slot3(Some("\"https://r/a/-/a-1.0.0.tgz\""))); + assert!(!brotli_for_slot3(Some("\"https://r/a.tgz?f=.tar.br\""))); + assert!(!brotli_for_slot3(Some("\".socket/vendor/npm/u/a.tgz\""))); + assert!(!brotli_for_slot3(Some("null"))); + assert!(!brotli_for_slot3(None)); + + let brotli = ["6", "\"a\"", "\"sha512-br\"", "\"https://r/a.tar.br\""]; + assert_eq!( + render_tuple_with_slots( + &brotli, + false, + Some("\"sha512-p\""), + Some("\"https://h/a.tgz\"") + ), + "[2,\"a\",\"sha512-p\",\"https://h/a.tgz\"]", + "pointing a brotli node at a .tgz clears bit 4 and keeps dev" + ); + let pinned = ["1", "\"a\"", "\"sha512-p\"", "\"https://h/a.tgz\""]; + assert_eq!( + render_tuple_with_slots(&pinned, true, Some("\"sha512-br\""), None), + "[5,\"a\",\"sha512-br\"]", + "restoring a brotli entry puts bit 4 back and keeps optional" + ); + } + #[test] fn node_line_grammar_rejects_deviations() { for line in [ @@ -2117,7 +2220,9 @@ mod tests { " \"~npm~a@1.0.0\": [0,,\"a\"]", " \"~npm~a@1.0.0\": []", " \"~npm~a@1.0.0\": [0]", - " \"~npm~a@1.0.0\": [4,\"a\"]", + " \"~npm~a@1.0.0\": [8,\"a\"]", + " \"~npm~a@1.0.0\": [07,\"a\"]", + " \"~npm~a@1.0.0\": [-1,\"a\"]", " \"~npm~a@1.0.0\": [\"0\",\"a\"]", " \"~npm~a@1.0.0\": [0.0,\"a\"]", " \"~npm~a@1.0.0\": [0,null]", @@ -2172,33 +2277,36 @@ mod tests { let s2 = Some("\"sha512-new\""); let s3 = Some("\"https://h/a.tgz\""); assert_eq!( - render_tuple_with_slots(&["0", "\"a\""], s2, s3), + render_tuple_with_slots(&["0", "\"a\""], false, s2, s3), "[0,\"a\",\"sha512-new\",\"https://h/a.tgz\"]" ); assert_eq!( - render_tuple_with_slots(&three, s2, s3), + render_tuple_with_slots(&three, false, s2, s3), "[0,\"a\",\"sha512-new\",\"https://h/a.tgz\"]" ); assert_eq!( - render_tuple_with_slots(&five[..6], Some("\"sha512-x\""), Some("\"u\"")), + render_tuple_with_slots(&five[..6], false, Some("\"sha512-x\""), Some("\"u\"")), "[2,\"a\",\"sha512-x\",\"u\",null,null]" ); let old = Some("\"sha512-old\""); assert_eq!( - render_tuple_with_slots(&four, old, None), + render_tuple_with_slots(&four, false, old, None), "[0,\"a\",\"sha512-old\"]" ); assert_eq!( - render_tuple_with_slots(&five, old, None), + render_tuple_with_slots(&five, false, old, None), "[2,\"a\",\"sha512-old\",null,null,null,null,null,{ \"a\": \"cli.js\"}]" ); - assert_eq!(render_tuple_with_slots(&four, None, None), "[0,\"a\"]"); assert_eq!( - render_tuple_with_slots(&four, None, Some("\"rel\"")), + render_tuple_with_slots(&four, false, None, None), + "[0,\"a\"]" + ); + assert_eq!( + render_tuple_with_slots(&four, false, None, Some("\"rel\"")), "[0,\"a\",null,\"rel\"]" ); assert_eq!( - render_tuple_with_slots(&three, Some("null"), Some("\"rel\"")), + render_tuple_with_slots(&three, false, Some("null"), Some("\"rel\"")), "[0,\"a\",null,\"rel\"]" ); }