TESTBOX-448 MockBox $args() relies on unordered struct order - #194
Closed
zspitzer wants to merge 4 commits into
Closed
TESTBOX-448 MockBox $args() relies on unordered struct order#194zspitzer wants to merge 4 commits into
zspitzer wants to merge 4 commits into
Conversation
There was a problem hiding this comment.
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 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 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 ); |
lmajano
added a commit
that referenced
this pull request
Aug 30, 2026
…upport (#204) * TESTBOX-448: Fix MockBox $args() struct-order fragility + cross-engine value normalization normalizeArguments() built its arg hash from struct.toString()/Java Map.toString() for non-simple values, whose output depends on HashMap iteration order - never guaranteed in CFML. Two structurally-equal structs built in different insertion order could hash differently, so $args() would silently miss a match. Latent for years; Lucee 7.1's new ConcurrentHashMap-backed struct implementation (LDEV-5098) made the fragility consistently visible. Fix: normalizeValue() recursively canonicalizes composite argument values instead of relying on raw Map/struct toString(): - Structs: keys sorted via java.util.TreeMap (order-independent), values recursively normalized, then the whole thing is JSON-encoded rather than hand-joined with bare "," / "=" / "{}" - hand-joining let a string value containing those characters collide with a completely different struct that happened to serialize to the same raw text (e.g. {a:"1,b=2"} and {a:1,b:2} previously hashed identically). JSON escaping closes that off for both structs and arrays. - BoxLang Range: canonicalized via toString(), which fully captures bounds/step/exclusivity ("1..5" vs "1>..<5" vs "1..10.step(3)" all differ) - never iterated/materialized, since ranges can be huge or unbounded (an open-start range even throws if you try to iterate it). Checked before the array branch, since isArray() is true for a Range. - BoxLang Set: unordered by definition, so elements are normalized then sorted before JSON-encoding - the same order-independence struct keys get, applied to set elements. Type-tagged (as is Range) so a Set can never collide with an Array/string holding equivalent content. - CFC values: unchanged, still serialized via getMetadata() (moved into the same recursive helper so nested CFCs inside structs/arrays get the same treatment as top-level CFC args). Range/Set checks are gated behind a computed IS_BOXLANG flag so isRange()/isBoxSet() - which don't exist on Lucee/Adobe - are never even attempted on those engines; short-circuit evaluation keeps the whole branch inert there. Tests: cross-engine coverage (struct order-independence, CFC-in-struct, deep struct>array>struct nesting, and the delimiter-collision regression) added to tests/specs/mockbox/MockBoxTest.cfc. Set/Range coverage added as tests/specs/mockbox/MockBoxSetRangeTest.bx - a .bx file, since BoxLang's `..` range operator isn't valid CFML syntax at all on Lucee/Adobe (a parse-time failure, not just a missing-BIF one) and TestBox's own bundle discovery already skips *.bx files entirely on non-BoxLang engines, so this file is never compiled or run there. Verified against BoxLang v1.17.0+58 end-to-end via the real MockBox/$args()/createStub() API (not just the isolated normalization logic): struct order-independence, delimiter-collision non-match, Set order-independence across all three backing variants, Set-vs-Array non-collision, Set-of-structs, Range match/non-match (bounds, step, exclusivity), Range-vs-string non-collision, and unbounded-range normalization all pass. Self-ran the full MockBoxTest + new MockBoxSetRangeTest bundles (41 specs) - all new tests green; the only 2 errors are a pre-existing, unrelated MockGenerator interface-stub issue confirmed present on unmodified development too. Supersedes #194 (external contributor PR for the same ticket): fixes the delimiter-collision bug in that PR's normalizeValue() rewrite (verified reproducible there against the real code, confirmed absent on the current merged development baseline) and adds BoxLang Set/Range support, which #194 did not handle. * Fix cfformat alignment violation in testMockArgsNoDelimiterCollision Inline the struct literal directly into save() instead of a separate var declaration, avoiding a miscomputed manual alignment column against the adjacent var statement. --------- Co-authored-by: Claude <noreply@anthropic.com>
Contributor
|
Merged manually. Superseded by #204 due to other approaches |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
$args()arg matching fails for nested struct arguments becausenormalizeArguments()falls through tostruct.toString(), whose output depends on HashMap iteration order. Two structurally-equal structs built in different insertion order hash differently, so the mock silently returnsnull.normalizeValue()helper that walks struct/array values recursively, sorting struct keys viajava.util.TreeMapso iteration order can't affect the hash. Nested CFCs are serialized via metadata to match the top-level-arg behaviour and avoid Adobe'sserializeJSONcycle on component metadata.ConcurrentHashMapNullSupportwith a thinner wrapper overjava.util.concurrent.ConcurrentHashMap. Different bucket layout = different iteration order = latent fragility becomes consistently visible. Reproduces deterministically on Lucee 7.0.3.43 too (usingstructNew("ordered")to force insertion order).Details
normalizeArguments()atsystem/MockBox.cfcsorts top-level arg keys viaTreeMap, but for non-simple values it fell through toargOrderedTree[ arg ].toString(). That's Java'sHashMap.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:toString()(unchanged fast path, preserves the integer++/--workaround).serializeJSON( getMetadata( value ) )(mirrors the top-level-arg branch; must run before the struct branch because on Adobe CFCs are bothisStructandisObject)..toString()withserializeJSONcatch.Test plan
testMockArgsStructOrderIndependence— struct args built in different insertion order match.testMockArgsStructContainingCFC— structs containing CFC values match and don't trigger Adobe's JSON serializer cycle.testMockArgsDeepNesting— struct → array → struct canonicalises all the way down.