Skip to content

TESTBOX-448 MockBox $args() relies on unordered struct order#194

Open
zspitzer wants to merge 4 commits into
Ortus-Solutions:developmentfrom
zspitzer:TESTBOX-448-struct-arg-matching
Open

TESTBOX-448 MockBox $args() relies on unordered struct order#194
zspitzer wants to merge 4 commits into
Ortus-Solutions:developmentfrom
zspitzer:TESTBOX-448-struct-arg-matching

Conversation

@zspitzer

@zspitzer zspitzer commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • TESTBOX-448$args() arg matching fails for nested struct arguments because normalizeArguments() falls through to struct.toString(), whose output depends on HashMap iteration order. Two structurally-equal structs built in different insertion order hash differently, so the mock silently returns null.
  • Fix: introduces a private normalizeValue() helper that walks struct/array values recursively, sorting struct keys via java.util.TreeMap so iteration order can't affect the hash. Nested CFCs are serialized via metadata to match the top-level-arg behaviour and avoid Adobe's serializeJSON cycle on component metadata.
  • Surfaced by Lucee 7.1 (LDEV-5098), which replaced ConcurrentHashMapNullSupport with a thinner wrapper over java.util.concurrent.ConcurrentHashMap. Different bucket layout = different iteration order = latent fragility becomes consistently visible. Reproduces deterministically on Lucee 7.0.3.43 too (using structNew("ordered") to force insertion order).

Details

normalizeArguments() at system/MockBox.cfc sorts top-level arg keys via TreeMap, but for non-simple values it fell through to argOrderedTree[ arg ].toString(). That's Java's HashMap.toString() for a struct — output is not stable across iteration orders. Regular structs have never guaranteed iteration order in CFML, so this was always latent; it rarely hit in practice because tests usually build setup and call-site structs the same way. LDEV-5098 just made the fragility consistently visible.

The new normalizeValue() handles:

  • Simple values → toString() (unchanged fast path, preserves the integer ++/-- workaround).
  • CFCs → metadata via serializeJSON( getMetadata( value ) ) (mirrors the top-level-arg branch; must run before the struct branch because on Adobe CFCs are both isStruct and isObject).
  • Structs → TreeMap-sorted key/value pairs, recursing on each value.
  • Arrays → position-preserving, recursing on each element.
  • Fallback → .toString() with serializeJSON catch.

Test plan

  • New testMockArgsStructOrderIndependence — struct args built in different insertion order match.
  • New testMockArgsStructContainingCFC — structs containing CFC values match and don't trigger Adobe's JSON serializer cycle.
  • New testMockArgsDeepNesting — struct → array → struct canonicalises all the way down.
  • Full CI matrix green: Lucee 5/6/7, BoxLang 1/be/cfml@1, Adobe 2023/2025, format check.
  • Local full suite on Lucee 7.0.3.43 and 7.1.0.93-SNAPSHOT: 361/0/0/22.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request addresses nondeterministic $args() matching in MockBox when arguments include nested structs (and other deep structures), where argument hashing could differ purely due to struct key iteration order. It introduces recursive value normalization to produce a stable, deterministic string representation for hashing, and adds tests to prevent regressions across nesting and CFC-containing structures.

Changes:

  • Replaced non-simple argument hashing fallback with a deterministic normalizeValue() that canonicalizes nested structs (sorted keys) and arrays (stable positional traversal).
  • Preserved existing CFC handling by serializing component metadata, now extended to components nested inside structs/arrays.
  • Added new MockBox specs to validate order-independence, CFC-in-struct handling, and deep nesting behavior.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
system/MockBox.cfc Adds normalizeValue() and routes complex argument hashing through it to make $args() matching deterministic for nested structs/arrays and CFCs.
tests/specs/mockbox/MockBoxTest.cfc Adds regression tests covering struct insertion-order independence, CFC-in-struct cases, and deep nesting canonicalization.

Comment thread system/MockBox.cfc
Comment on lines +676 to +684
// Struct - sort keys
if ( isStruct( arguments.value ) && !isObject( arguments.value ) ) {
var sorted = createObject( "java", "java.util.TreeMap" ).init( arguments.value );
var parts = [];
for ( var key in sorted ) {
arrayAppend(
parts,
key & "=" & ( isNull( sorted[ key ] ) ? "null" : normalizeValue( sorted[ key ] ) )
);
Comment thread system/MockBox.cfc
Comment on lines +665 to +678
if (
isObject( arguments.value ) and
(
isInstanceOf( arguments.value, "Component" ) or structKeyExists(
getMetadata( arguments.value ),
"extends"
)
)
) {
return serializeJSON( getMetadata( arguments.value ) );
}
// Struct - sort keys
if ( isStruct( arguments.value ) && !isObject( arguments.value ) ) {
var sorted = createObject( "java", "java.util.TreeMap" ).init( arguments.value );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants