From 950a5df26851b820d7f6f4c16a3dcb9c361d2b91 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 4 Aug 2026 06:05:18 -0700 Subject: [PATCH 1/3] fix(model): read a join's type from association metadata, not from the SQL text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #3334, raised by wheels-bot on PR #3354 and correct. The grouping decision that PR introduced still asked "is this an INNER join?" by searching the generated SQL: FindNoCase("INNER", local.joins[local.parentPosition]) That misclassifies every table whose name contains the substring — `winners`, `spinners`, `beginners`. A parent LEFT OUTER JOIN on `c_o_r_e_winners` reads as INNER, so its nested child is emitted flat instead of grouped, which silently drops the parent rows #3334 exists to preserve. Not a regression: the pre-fix code scanned the same way, and no fixture table hits it. Worth fixing anyway, because it is the exact anti-pattern that PR argues against — its whole thesis is that structure should come from the association tree rather than from re-parsing the SQL string, and this was ten lines of new code doing the opposite. `$associationJoinsInner()` reads the association's declared `joinType`, which is the value `$expandedAssociations` turns into the leading `INNER JOIN` / `LEFT OUTER JOIN` text in the first place — so it is the authoritative source by construction. It falls back to the text scan only when an entry carries no `joinType`, keeping it total for a hand-built struct. Red-first: with the helper reverted to the bare text scan, the new spec fails `Expected [true] to be false`. A note on that red-check, because it nearly fooled me: run against a WARM server the reverted build still reported green — `tools/test-local.sh` reuses a running server and the reload did not recompile the changed CFC. The failure only appears after killing the server first. Any red-check on this suite needs a cold start to mean anything. lucee7 + sqlite, full core suite: develop 2c9864434 4755 pass / 0 fail / 0 error this branch 4756 pass / 0 fail / 0 error Exactly +1, the new spec. Refs #3334 Signed-off-by: Peter Amiri --- vendor/wheels/model/sql.cfc | 31 ++++++++++++++++++-- vendor/wheels/tests/specs/model/crudSpec.cfc | 25 ++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/vendor/wheels/model/sql.cfc b/vendor/wheels/model/sql.cfc index 9799d565e..fc4f1c666 100644 --- a/vendor/wheels/model/sql.cfc +++ b/vendor/wheels/model/sql.cfc @@ -136,6 +136,13 @@ component { // to every OUTER join, which only looked correct because issues #449 and #3245 both // exercise a single OUTER join. A root-level INNER join (`parentPosition` 0) has no // enclosing group and stays flat, keeping the root FROM table in scope for its ON. + // + // Join type comes from the association's `joinType`, not from scanning the + // generated SQL for "INNER". The text scan the pre-fix code used misreads any + // table whose name contains the substring — `winners`, `spinners`, `beginners` + // — as an inner join, which would emit its nested child flat and silently drop + // the parent rows this fix exists to preserve. `joinType` is the authoritative + // source: it is what the join string is built from a few hundred lines below. local.nestedJoins = {}; local.isNested = {}; for (local.i = 1; local.i <= local.iEnd; local.i++) { @@ -143,9 +150,9 @@ component { ? local.associations[local.i].parentPosition : 0; if ( - FindNoCase("INNER", local.joins[local.i]) + $associationJoinsInner(local.associations[local.i], local.joins[local.i]) && local.parentPosition > 0 - && !FindNoCase("INNER", local.joins[local.parentPosition]) + && !$associationJoinsInner(local.associations[local.parentPosition], local.joins[local.parentPosition]) ) { if (!StructKeyExists(local.nestedJoins, local.parentPosition)) { local.nestedJoins[local.parentPosition] = []; @@ -1284,6 +1291,26 @@ component { /** * Internal function. */ + /** + * Internal function. + * Whether an association contributes an INNER JOIN. + * + * Reads the association's declared `joinType` — the same value `$expandedAssociations` + * turns into the leading `INNER JOIN` / `LEFT OUTER JOIN` text — rather than searching the + * built SQL for "INNER". A substring search misclassifies every table whose name contains + * it (`winners`, `spinners`, `beginners`), and in `$fromClause` that would demote a nested + * group to a flat join and silently drop parent rows. + * + * Falls back to the text scan only if an entry somehow carries no `joinType`, which keeps + * this total for any caller assembling association structs by hand. + */ + public boolean function $associationJoinsInner(required struct association, required string join) { + if (StructKeyExists(arguments.association, "joinType") && Len(arguments.association.joinType)) { + return arguments.association.joinType == "inner"; + } + return FindNoCase("INNER", arguments.join) > 0; + } + public array function $expandedAssociations(required string include, boolean includeSoftDeletes = "false") { local.rv = []; diff --git a/vendor/wheels/tests/specs/model/crudSpec.cfc b/vendor/wheels/tests/specs/model/crudSpec.cfc index 1b66e6ef7..337adb7de 100644 --- a/vendor/wheels/tests/specs/model/crudSpec.cfc +++ b/vendor/wheels/tests/specs/model/crudSpec.cfc @@ -1335,6 +1335,31 @@ component extends="wheels.WheelsTest" { ) }) + // The grouping decision used to read the join TYPE by searching the generated SQL + // for "INNER". That misclassifies every table whose name contains the substring — + // `winners`, `spinners`, `beginners` — and in $fromClause a parent misread as + // INNER emits its nested child flat instead of grouped, silently dropping the + // parent rows this fix exists to preserve. `joinType` is the authoritative source: + // it is what the join text is built from. Unit-tested here rather than through a + // fixture because adding a `c_o_r_e_winners` table would mean DDL on all seven + // databases to pin one boolean. + it("reads the join type from metadata, not from the table name (issue ##3334)", () => { + m = g.model("post") + + // the trap: an OUTER join whose table name contains "inner" + outerWithInnerInName = {joinType = "outer"} + outerSql = "LEFT OUTER JOIN #qi('c_o_r_e_winners')# ON #qi('c_o_r_e_posts')#.#qi('id')# = #qi('c_o_r_e_winners')#.#qi('postid')#" + expect(FindNoCase("INNER", outerSql)).toBeGT(0) + expect(m.$associationJoinsInner(outerWithInnerInName, outerSql)).toBeFalse() + + // and it still recognises a genuine inner join + expect(m.$associationJoinsInner({joinType = "inner"}, "INNER JOIN #qi('c_o_r_e_tags')# ON 1=1")).toBeTrue() + + // no joinType at all (a hand-built struct) falls back to the text scan + expect(m.$associationJoinsInner({}, "INNER JOIN #qi('c_o_r_e_tags')# ON 1=1")).toBeTrue() + expect(m.$associationJoinsInner({}, "LEFT OUTER JOIN #qi('c_o_r_e_tags')# ON 1=1")).toBeFalse() + }) + // An unbalanced include reaches the level-tracking loop with an empty parent // stack. `ListDeleteAt` on a one-element list tolerates that; `ArrayDeleteAt(x, 0)` // throws. Malformed includes resolved to a plain join before the issue #3334 From c5961885e58afd6fa0987200ff5b1504ea9a9da6 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 4 Aug 2026 07:16:29 -0700 Subject: [PATCH 2/3] test(job): scope JobClassRoundTripSpec locals with local. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second wheels-bot review follow-up from the same batch, on PR #3358. Every sibling in vendor/wheels/tests/specs/jobs/ declares spec variables with `local.` (e.g. `local.bootstrapJob = new wheels.Job();` in JobRobustnessSpec). JobClassRoundTripSpec assigned them unscoped, which runs green but leaks the writes into the spec's `variables` scope. Purely a convention fix — no behaviour change. Folded in here rather than opened as a third PR, since it is the same review round on the same merged batch. lucee7 + sqlite, full core suite: 4756 pass / 0 fail / 0 error, unchanged. Signed-off-by: Peter Amiri --- .../specs/jobs/JobClassRoundTripSpec.cfc | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/vendor/wheels/tests/specs/jobs/JobClassRoundTripSpec.cfc b/vendor/wheels/tests/specs/jobs/JobClassRoundTripSpec.cfc index ee129ee24..6b4b995f3 100644 --- a/vendor/wheels/tests/specs/jobs/JobClassRoundTripSpec.cfc +++ b/vendor/wheels/tests/specs/jobs/JobClassRoundTripSpec.cfc @@ -17,13 +17,13 @@ component extends="wheels.WheelsTest" { describe("Tests that the persisted jobClass round-trips", () => { it("reports a metadata name whose last segment matches the .cfc file name exactly", () => { - job = CreateObject("component", "wheels.tests._assets.jobs.ProbeJob") - meta = GetMetadata(job) + local.job = CreateObject("component", "wheels.tests._assets.jobs.ProbeJob") + local.meta = GetMetadata(local.job) - fileName = ListFirst(ListLast(Replace(meta.path, "\", "/", "all"), "/"), ".") + local.fileName = ListFirst(ListLast(Replace(local.meta.path, "\", "/", "all"), "/"), ".") // case-sensitive comparison — Compare(), not CompareNoCase() - expect(Compare(ListLast(meta.name, "."), fileName)).toBe(0) + expect(Compare(ListLast(local.meta.name, "."), local.fileName)).toBe(0) }) it("never persists a caller's miscased path", () => { @@ -43,74 +43,74 @@ component extends="wheels.WheelsTest" { // // Asserting only the first would fail on Adobe for a reason that is *safer* // than the one being tested, so assert the property both satisfy. - canonical = GetMetadata(CreateObject("component", "wheels.tests._assets.jobs.ProbeJob")).name - resolved = {miscasedConstructed = false, name = ""} + local.canonical = GetMetadata(CreateObject("component", "wheels.tests._assets.jobs.ProbeJob")).name + local.resolved = {miscasedConstructed = false, name = ""} try { - resolved.name = GetMetadata(CreateObject("component", "wheels.tests._assets.jobs.probejob")).name - resolved.miscasedConstructed = true + local.resolved.name = GetMetadata(CreateObject("component", "wheels.tests._assets.jobs.probejob")).name + local.resolved.miscasedConstructed = true } catch (any e) { // case-sensitive resolver — the stronger guarantee } - if (resolved.miscasedConstructed) { - expect(Compare(resolved.name, canonical)).toBe(0) + if (local.resolved.miscasedConstructed) { + expect(Compare(local.resolved.name, local.canonical)).toBe(0) } else { - expect(resolved.name).toBe("") + expect(local.resolved.name).toBe("") } }) it("re-instantiates from its own persisted metadata name", () => { // the actual enqueue -> drain round trip, without touching the queue table - original = CreateObject("component", "wheels.tests._assets.jobs.ProbeJob") - persisted = GetMetadata(original).name + local.original = CreateObject("component", "wheels.tests._assets.jobs.ProbeJob") + local.persisted = GetMetadata(local.original).name // Hoisted receiver. A parenthesized `new` in receiver position — `(new X()).m()` // — is rejected by Adobe's parser with `Invalid construct: Either argument or // name is missing`, the same MissingNameException family as cross-engine // invariant 16. Adobe blames the enclosing describe() line and the whole engine // leg reports tests=0. Caught by the compat matrix; Lucee and BoxLang accept it. - bridge = new wheels.Job() - revived = bridge.$instantiateJobClass(jobClass = persisted) + local.bridge = new wheels.Job() + local.revived = local.bridge.$instantiateJobClass(jobClass = local.persisted) - expect(Compare(GetMetadata(revived).name, persisted)).toBe(0) + expect(Compare(GetMetadata(local.revived).name, local.persisted)).toBe(0) }) }) describe("Tests that an unresolvable jobClass", () => { it("throws Wheels.JobClassNotFound naming the row and the class", () => { - thrown = {type: "", message: ""} + local.thrown = {type: "", message: ""} - bridge = new wheels.Job() + local.bridge = new wheels.Job() try { - bridge.$instantiateJobClass(jobClass = "app.jobs.NoSuchJob", jobId = "abc-123") + local.bridge.$instantiateJobClass(jobClass = "app.jobs.NoSuchJob", jobId = "abc-123") } catch (any e) { - thrown.type = e.type - thrown.message = e.message + local.thrown.type = e.type + local.thrown.message = e.message } // the raw engine error is "component not found" for a class that plainly // exists, which points investigators at mappings and deployment - expect(thrown.type).toBe("Wheels.JobClassNotFound") - expect(thrown.message).toInclude("app.jobs.NoSuchJob") - expect(thrown.message).toInclude("abc-123") + expect(local.thrown.type).toBe("Wheels.JobClassNotFound") + expect(local.thrown.message).toInclude("app.jobs.NoSuchJob") + expect(local.thrown.message).toInclude("abc-123") }) it("throws Wheels.InvalidJobClass when the path resolves to something that is not a job", () => { - thrown = {type: ""} + local.thrown = {type: ""} - bridge = new wheels.Job() + local.bridge = new wheels.Job() try { // a real component with no perform() - bridge.$instantiateJobClass(jobClass = "wheels.tests._assets.models.Post") + local.bridge.$instantiateJobClass(jobClass = "wheels.tests._assets.models.Post") } catch (any e) { - thrown.type = e.type + local.thrown.type = e.type } - expect(thrown.type).toBe("Wheels.InvalidJobClass") + expect(local.thrown.type).toBe("Wheels.InvalidJobClass") }) }) } From 7ecbf0cdee9a8083acdb37b34b3bf69ba52a32f4 Mon Sep 17 00:00:00 2001 From: Peter Amiri Date: Tue, 4 Aug 2026 08:00:11 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(test):=20keep=20the=20catch-block=20str?= =?UTF-8?q?uct=20unscoped=20=E2=80=94=20local.=20breaks=20it=20on=20BoxLan?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compat matrix for this branch came back +24 tests on all 28 legs, which is correct, and two NEW failures on boxlang for every database: Failed | throws Wheels.JobClassNotFound naming the row and the class | Expected [Wheels.JobClassNotFound] but received [] Failed | throws Wheels.InvalidJobClass when the path resolves to something that is not a job | Expected [Wheels.InvalidJobClass] but received [] Both are mine, and both were caused by the previous commit — the `local.`-scoping convention nit from the bot review. `thrown` is written from inside a catch block, and on BoxLang the catch body runs under a nested `local` that is discarded on exit. Prefixing the struct made `local.thrown.type = e.type` land on that discarded copy instead of mutating the outer struct, so the assertion read an empty type. Cross-engine invariant 11 already covers the scalar case. What it did not say is that the struct workaround it recommends only works when the struct is accessed WITHOUT the prefix — `local.state.flag = true` fails exactly like `local.X = ...`. The prefix is what breaks it, not the assignment shape. Widened the invariant with that, plus a worked example, because `local.`-scoping spec variables IS the house style everywhere else, which makes tidying a catch-using spec to match an easy and completely invisible way to break it. So the original unscoped form was correct and the "nit" was wrong. Reverted for `thrown` only — every other variable in the file stays `local.`-scoped, since those are written from try bodies and are unaffected — with a comment at both sites explaining why, so it does not get tidied back. Worth noting the failure mode: green on Lucee, green on Adobe, wrong only on BoxLang, and silent rather than an error. Nothing local would have caught it. lucee7 + sqlite, full core suite: 4756 pass / 0 fail / 0 error, unchanged. Signed-off-by: Peter Amiri --- CLAUDE.md | 11 ++- .../139184C0543CCDB338AEFB643110CB89.cfm | 52 ++++++++++++++ .../16CA394252B074478BEBD2B3A9C8EA82.cfm | 52 ++++++++++++++ .../30942F4D0BCB6139072EF27C66218715.cfm | 70 +++++++++++++++++++ .../754BEF48E30B63BC11E518FA73C07785.cfm | 52 ++++++++++++++ .../B7681C49C5125395612F24D97559A4C2.cfm | 70 +++++++++++++++++++ .../C383511C024245809F1F73E0CA52E220.cfm | 70 +++++++++++++++++++ .../CDCB73D6FD17909D6519A49548FD6769.cfm | 70 +++++++++++++++++++ .../specs/jobs/JobClassRoundTripSpec.cfc | 34 ++++++--- 9 files changed, 471 insertions(+), 10 deletions(-) create mode 100644 public/testbox/system/stubs/139184C0543CCDB338AEFB643110CB89.cfm create mode 100644 public/testbox/system/stubs/16CA394252B074478BEBD2B3A9C8EA82.cfm create mode 100644 public/testbox/system/stubs/30942F4D0BCB6139072EF27C66218715.cfm create mode 100644 public/testbox/system/stubs/754BEF48E30B63BC11E518FA73C07785.cfm create mode 100644 public/testbox/system/stubs/B7681C49C5125395612F24D97559A4C2.cfm create mode 100644 public/testbox/system/stubs/C383511C024245809F1F73E0CA52E220.cfm create mode 100644 public/testbox/system/stubs/CDCB73D6FD17909D6519A49548FD6769.cfm diff --git a/CLAUDE.md b/CLAUDE.md index 7bdf4b7ef..47e04f0c0 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,16 @@ The framework must run on Lucee 5/6/7, Adobe CF 2018/2021/2023/2025, and BoxLang 8. **`Left(str, 0)` crashes Lucee 7.** Guard: `len > 0 ? Left(str, len) : ""`. 9. **`toBeInstanceOf("component")` fails on BoxLang** — returns the FQN, not the literal `"component"`. Use `toBeWheelsModel()` for finder results. 10. **Adobe CF 2023 and 2025 reject the `arguments` scope as `attributeCollection` on *any* built-in CFML tag.** Affects every `cfheader` / `cfcache` / `cfcontent` / `cfmail` / `cfdirectory` / `cffile` / `cflocation` / `cfhtmlhead` / `cfimage` / `cfdbinfo` / `cfinvoke` / `cfwddx` / `cfzip` wrapper. Covers both the string-interpolated (`attributeCollection = "#arguments#"`) and direct-struct (`attributeCollection = arguments`) forms. Adobe 2023/2025 throw — `cfheader`'s message is `"Failed to add HTML header"`; other tags surface their own — and `$header()` is catastrophic because it runs on every request. Copy to a plain struct first: `local.args = {}; for (local.key in arguments) { local.args[local.key] = arguments[local.key]; }`. Lucee 6/7, BoxLang, and Adobe 2018/2021 accept both forms; Adobe 2023/2025 require the plain struct. The 13 sites in `vendor/wheels/Global.cfc` were patched uniformly in [#2750](https://github.com/wheels-dev/wheels/pull/2750). -11. **`local.X = ...` inside `catch` doesn't persist on BoxLang.** Catch body runs under a nested `local` that gets discarded on exit, so `expect(local.X)` after the catch reads the un-touched outer value. Use a struct field: `var state = {flag = false}; ... state.flag = true;`. Bare `var bareName` + unscoped `bareName = true` also works but the struct form mirrors `TenantResolverSpec` and is the prior-art pattern. +11. **Anything written through `local.` inside `catch` doesn't persist on BoxLang.** Catch body runs under a nested `local` that gets discarded on exit, so `expect(local.X)` after the catch reads the un-touched outer value. Use a struct field: `var state = {flag = false}; ... state.flag = true;`. Bare `var bareName` + unscoped `bareName = true` also works but the struct form mirrors `TenantResolverSpec` and is the prior-art pattern. + + **The struct form only works if you access it WITHOUT the `local.` prefix.** `local.state.flag = true` inside a catch fails exactly like a scalar `local.X = ...` — the nested `local` shadows `local.state`, so the write lands on a discarded copy rather than mutating the outer struct. The prefix is what breaks it, not the assignment shape: + ```cfm + var state = {type = ""}; // RIGHT + try { ... } catch (any e) { state.type = e.type; } + local.state = {type = ""}; // WRONG — silently empty after the catch + try { ... } catch (any e) { local.state.type = e.type; } + ``` + This matters because `local.`-scoping spec variables is the house style everywhere else, so "tidying" a catch-using spec to match is an easy and invisible way to break it. Doing exactly that to `JobClassRoundTripSpec` cost two BoxLang failures on every database (`Expected [Wheels.JobClassNotFound] but received []`) — green on Lucee, caught only by the compat matrix. 12. **`for (local.i = ...)` inside `finally` miscompiles on Lucee 7.** Lucee 7.0.1+100 throws `variable [local] doesn't exist` at runtime when a `for` loop declares or iterates `local`-/`var`-scoped variables inside a `finally` block (one probe shape even produced a JVM `Expecting a stackmap frame` verifier error). Bare assignments and function calls in `finally` are fine; loops are not. Hoist the loop into a `public` `$`-prefixed helper and call it from `finally` — reference: `$restoreEmailViewVariables()` in `vendor/wheels/controller/miscellaneous.cfc` ([#2922](https://github.com/wheels-dev/wheels/pull/2922)). 13. **Bare tag-in-script statements without parentheses (e.g. `cfabort;`) are Lucee-only.** Adobe CF compiles the bare token as a reference to an undefined VARIABLE and throws `Variable CFABORT is undefined` at runtime (every Adobe engine, not just one release). Use the script keyword (`abort;`) or the parenthesized call form (`cfheader(...)`-style) instead. The `enablePublicComponent=false` 404 branch in `vendor/wheels/Dispatch.cfc` shipped a bare `cfabort;`, which turned `GET /` on every stock Adobe install in `testing`/`production` into an HTTP 500 ([#3029](https://github.com/wheels-dev/wheels/issues/3029)). Structural guard: `vendor/wheels/tests/specs/security/BareCfabortGuardSpec.cfc` fails the suite if any bare script-context `cfabort` statement reappears under `vendor/wheels/**/*.cfc` (tag-context `` in `.cfm`/tag-based CFCs stays legal). 14. **Adobe 2025's JVM rejects member calls on JDK-internal classes (JPMS).** Calling any member on an object whose runtime class lives in an unexported package (`com.sun.*`, `jdk.internal.*`) — e.g. the `com.sun.crypto.provider.PBKDF2KeyImpl` returned by `SecretKeyFactory.generateSecret()` — throws `java.lang.reflect.InaccessibleObjectException` on Adobe 2025 (its reflection layer bulk-`setAccessible`s the concrete class's methods; Lucee, BoxLang, and Adobe ≤2023 tolerate the same call, so **local Adobe 2023 green does NOT cover this**). Route the call through the exported interface's `Method` object instead: `CreateObject("java","java.lang.Class").forName("javax.crypto.SecretKey").getMethod("getEncoded", JavaCast("null","")).invoke(keyObj, JavaCast("null",""))` — `getMethod`/`invoke` treat the null varargs as empty. Hit by `PasswordHasher.$deriveKey()` ([#3300](https://github.com/wheels-dev/wheels/issues/3300)); watch for it with any Java factory API that returns internal implementation types. diff --git a/public/testbox/system/stubs/139184C0543CCDB338AEFB643110CB89.cfm b/public/testbox/system/stubs/139184C0543CCDB338AEFB643110CB89.cfm new file mode 100644 index 000000000..d313ccd44 --- /dev/null +++ b/public/testbox/system/stubs/139184C0543CCDB338AEFB643110CB89.cfm @@ -0,0 +1,52 @@ + + variables[ "closeSSEStream" ] = variables[ "tmp_closeSSEStream_139184C0543CCDB338AEFB643110CB89" ]; + this[ "closeSSEStream" ] = variables[ "tmp_closeSSEStream_139184C0543CCDB338AEFB643110CB89" ]; + + // Clean up + structDelete( variables, "tmp_closeSSEStream_139184C0543CCDB338AEFB643110CB89" ); + structDelete( this, "tmp_closeSSEStream_139184C0543CCDB338AEFB643110CB89" ); + public void function tmp_closeSSEStream_139184C0543CCDB338AEFB643110CB89( + + ) output=true { + + var results = this._mockResults; + var resultsKey = "closeSSEStream"; + var resultsCounter = 0; + var internalCounter = 0; + var resultsLen = 0; + var callbackLen = 0; + var argsHashKey = resultsKey & "|" & this.mockBox.normalizeArguments( arguments ); + var fCallBack = ""; + + // If Method & argument Hash Results, switch the results struct +if (structKeyExists( this._mockArgResults, argsHashKey) ) { + // Check if it is a callback +if (isStruct( this._mockArgResults[ argsHashKey ]) && + structKeyExists( this._mockArgResults[ argsHashKey ], "type" ) && + structKeyExists( this._mockArgResults[ argsHashKey ], "target" ) ) { + fCallBack = this._mockArgResults[ argsHashKey ].target; +} else { + // switch context and key + results = this._mockArgResults; + resultsKey = argsHashKey; + } + } + + // Get the statemachine counter +if (isSimpleValue( fCallBack) ) { + resultsLen = arrayLen( results[ resultsKey ] ); + } + + // Get the callback counter, if it exists +if (structKeyExists( this._mockCallbacks, resultsKey) ) { + callbackLen = arrayLen( this._mockCallbacks[ resultsKey ] ); + } + + // Log the Method Call + this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] = this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] + 1; + + // Get the CallCounter Reference + internalCounter = this._mockMethodCallCounters[listFirst(resultsKey,"|")]; + arrayAppend( this._mockCallLoggers["closeSSEStream"], arguments ); +} + \ No newline at end of file diff --git a/public/testbox/system/stubs/16CA394252B074478BEBD2B3A9C8EA82.cfm b/public/testbox/system/stubs/16CA394252B074478BEBD2B3A9C8EA82.cfm new file mode 100644 index 000000000..b7e93ee8a --- /dev/null +++ b/public/testbox/system/stubs/16CA394252B074478BEBD2B3A9C8EA82.cfm @@ -0,0 +1,52 @@ + + variables[ "sendSSEComment" ] = variables[ "tmp_sendSSEComment_16CA394252B074478BEBD2B3A9C8EA82" ]; + this[ "sendSSEComment" ] = variables[ "tmp_sendSSEComment_16CA394252B074478BEBD2B3A9C8EA82" ]; + + // Clean up + structDelete( variables, "tmp_sendSSEComment_16CA394252B074478BEBD2B3A9C8EA82" ); + structDelete( this, "tmp_sendSSEComment_16CA394252B074478BEBD2B3A9C8EA82" ); + public void function tmp_sendSSEComment_16CA394252B074478BEBD2B3A9C8EA82( + + ) output=true { + + var results = this._mockResults; + var resultsKey = "sendSSEComment"; + var resultsCounter = 0; + var internalCounter = 0; + var resultsLen = 0; + var callbackLen = 0; + var argsHashKey = resultsKey & "|" & this.mockBox.normalizeArguments( arguments ); + var fCallBack = ""; + + // If Method & argument Hash Results, switch the results struct +if (structKeyExists( this._mockArgResults, argsHashKey) ) { + // Check if it is a callback +if (isStruct( this._mockArgResults[ argsHashKey ]) && + structKeyExists( this._mockArgResults[ argsHashKey ], "type" ) && + structKeyExists( this._mockArgResults[ argsHashKey ], "target" ) ) { + fCallBack = this._mockArgResults[ argsHashKey ].target; +} else { + // switch context and key + results = this._mockArgResults; + resultsKey = argsHashKey; + } + } + + // Get the statemachine counter +if (isSimpleValue( fCallBack) ) { + resultsLen = arrayLen( results[ resultsKey ] ); + } + + // Get the callback counter, if it exists +if (structKeyExists( this._mockCallbacks, resultsKey) ) { + callbackLen = arrayLen( this._mockCallbacks[ resultsKey ] ); + } + + // Log the Method Call + this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] = this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] + 1; + + // Get the CallCounter Reference + internalCounter = this._mockMethodCallCounters[listFirst(resultsKey,"|")]; + arrayAppend( this._mockCallLoggers["sendSSEComment"], arguments ); +} + \ No newline at end of file diff --git a/public/testbox/system/stubs/30942F4D0BCB6139072EF27C66218715.cfm b/public/testbox/system/stubs/30942F4D0BCB6139072EF27C66218715.cfm new file mode 100644 index 000000000..e0cfd43b5 --- /dev/null +++ b/public/testbox/system/stubs/30942F4D0BCB6139072EF27C66218715.cfm @@ -0,0 +1,70 @@ + + variables[ "checkError" ] = variables[ "tmp_checkError_30942F4D0BCB6139072EF27C66218715" ]; + this[ "checkError" ] = variables[ "tmp_checkError_30942F4D0BCB6139072EF27C66218715" ]; + + // Clean up + structDelete( variables, "tmp_checkError_30942F4D0BCB6139072EF27C66218715" ); + structDelete( this, "tmp_checkError_30942F4D0BCB6139072EF27C66218715" ); + public any function tmp_checkError_30942F4D0BCB6139072EF27C66218715( + + ) output=true { + + var results = this._mockResults; + var resultsKey = "checkError"; + var resultsCounter = 0; + var internalCounter = 0; + var resultsLen = 0; + var callbackLen = 0; + var argsHashKey = resultsKey & "|" & this.mockBox.normalizeArguments( arguments ); + var fCallBack = ""; + + // If Method & argument Hash Results, switch the results struct +if (structKeyExists( this._mockArgResults, argsHashKey) ) { + // Check if it is a callback +if (isStruct( this._mockArgResults[ argsHashKey ]) && + structKeyExists( this._mockArgResults[ argsHashKey ], "type" ) && + structKeyExists( this._mockArgResults[ argsHashKey ], "target" ) ) { + fCallBack = this._mockArgResults[ argsHashKey ].target; +} else { + // switch context and key + results = this._mockArgResults; + resultsKey = argsHashKey; + } + } + + // Get the statemachine counter +if (isSimpleValue( fCallBack) ) { + resultsLen = arrayLen( results[ resultsKey ] ); + } + + // Get the callback counter, if it exists +if (structKeyExists( this._mockCallbacks, resultsKey) ) { + callbackLen = arrayLen( this._mockCallbacks[ resultsKey ] ); + } + + // Log the Method Call + this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] = this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] + 1; + + // Get the CallCounter Reference + internalCounter = this._mockMethodCallCounters[listFirst(resultsKey,"|")]; + arrayAppend( this._mockCallLoggers["checkError"], arguments ); + + if (resultsLen neq 0) { + if (internalCounter gt resultsLen) { + resultsCounter = internalCounter - ( resultsLen * fix( ( internalCounter - 1 ) / resultsLen ) ); + return results[ resultsKey ][ resultsCounter ]; + } else { + return results[ resultsKey ][ internalCounter ]; + } + } + + if ( callbackLen neq 0 ) { + fCallBack = this._mockCallbacks[ resultsKey ].first(); + return fCallBack( argumentCollection : arguments ); + } + + if ( not isSimpleValue( fCallBack ) ){ + return fCallBack( argumentCollection : arguments ); + } + } + \ No newline at end of file diff --git a/public/testbox/system/stubs/754BEF48E30B63BC11E518FA73C07785.cfm b/public/testbox/system/stubs/754BEF48E30B63BC11E518FA73C07785.cfm new file mode 100644 index 000000000..2ea84360a --- /dev/null +++ b/public/testbox/system/stubs/754BEF48E30B63BC11E518FA73C07785.cfm @@ -0,0 +1,52 @@ + + variables[ "sendSSEEvent" ] = variables[ "tmp_sendSSEEvent_754BEF48E30B63BC11E518FA73C07785" ]; + this[ "sendSSEEvent" ] = variables[ "tmp_sendSSEEvent_754BEF48E30B63BC11E518FA73C07785" ]; + + // Clean up + structDelete( variables, "tmp_sendSSEEvent_754BEF48E30B63BC11E518FA73C07785" ); + structDelete( this, "tmp_sendSSEEvent_754BEF48E30B63BC11E518FA73C07785" ); + public void function tmp_sendSSEEvent_754BEF48E30B63BC11E518FA73C07785( + + ) output=true { + + var results = this._mockResults; + var resultsKey = "sendSSEEvent"; + var resultsCounter = 0; + var internalCounter = 0; + var resultsLen = 0; + var callbackLen = 0; + var argsHashKey = resultsKey & "|" & this.mockBox.normalizeArguments( arguments ); + var fCallBack = ""; + + // If Method & argument Hash Results, switch the results struct +if (structKeyExists( this._mockArgResults, argsHashKey) ) { + // Check if it is a callback +if (isStruct( this._mockArgResults[ argsHashKey ]) && + structKeyExists( this._mockArgResults[ argsHashKey ], "type" ) && + structKeyExists( this._mockArgResults[ argsHashKey ], "target" ) ) { + fCallBack = this._mockArgResults[ argsHashKey ].target; +} else { + // switch context and key + results = this._mockArgResults; + resultsKey = argsHashKey; + } + } + + // Get the statemachine counter +if (isSimpleValue( fCallBack) ) { + resultsLen = arrayLen( results[ resultsKey ] ); + } + + // Get the callback counter, if it exists +if (structKeyExists( this._mockCallbacks, resultsKey) ) { + callbackLen = arrayLen( this._mockCallbacks[ resultsKey ] ); + } + + // Log the Method Call + this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] = this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] + 1; + + // Get the CallCounter Reference + internalCounter = this._mockMethodCallCounters[listFirst(resultsKey,"|")]; + arrayAppend( this._mockCallLoggers["sendSSEEvent"], arguments ); +} + \ No newline at end of file diff --git a/public/testbox/system/stubs/B7681C49C5125395612F24D97559A4C2.cfm b/public/testbox/system/stubs/B7681C49C5125395612F24D97559A4C2.cfm new file mode 100644 index 000000000..81f5d1340 --- /dev/null +++ b/public/testbox/system/stubs/B7681C49C5125395612F24D97559A4C2.cfm @@ -0,0 +1,70 @@ + + variables[ "initSSEStream" ] = variables[ "tmp_initSSEStream_B7681C49C5125395612F24D97559A4C2" ]; + this[ "initSSEStream" ] = variables[ "tmp_initSSEStream_B7681C49C5125395612F24D97559A4C2" ]; + + // Clean up + structDelete( variables, "tmp_initSSEStream_B7681C49C5125395612F24D97559A4C2" ); + structDelete( this, "tmp_initSSEStream_B7681C49C5125395612F24D97559A4C2" ); + public any function tmp_initSSEStream_B7681C49C5125395612F24D97559A4C2( + + ) output=true { + + var results = this._mockResults; + var resultsKey = "initSSEStream"; + var resultsCounter = 0; + var internalCounter = 0; + var resultsLen = 0; + var callbackLen = 0; + var argsHashKey = resultsKey & "|" & this.mockBox.normalizeArguments( arguments ); + var fCallBack = ""; + + // If Method & argument Hash Results, switch the results struct +if (structKeyExists( this._mockArgResults, argsHashKey) ) { + // Check if it is a callback +if (isStruct( this._mockArgResults[ argsHashKey ]) && + structKeyExists( this._mockArgResults[ argsHashKey ], "type" ) && + structKeyExists( this._mockArgResults[ argsHashKey ], "target" ) ) { + fCallBack = this._mockArgResults[ argsHashKey ].target; +} else { + // switch context and key + results = this._mockArgResults; + resultsKey = argsHashKey; + } + } + + // Get the statemachine counter +if (isSimpleValue( fCallBack) ) { + resultsLen = arrayLen( results[ resultsKey ] ); + } + + // Get the callback counter, if it exists +if (structKeyExists( this._mockCallbacks, resultsKey) ) { + callbackLen = arrayLen( this._mockCallbacks[ resultsKey ] ); + } + + // Log the Method Call + this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] = this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] + 1; + + // Get the CallCounter Reference + internalCounter = this._mockMethodCallCounters[listFirst(resultsKey,"|")]; + arrayAppend( this._mockCallLoggers["initSSEStream"], arguments ); + + if (resultsLen neq 0) { + if (internalCounter gt resultsLen) { + resultsCounter = internalCounter - ( resultsLen * fix( ( internalCounter - 1 ) / resultsLen ) ); + return results[ resultsKey ][ resultsCounter ]; + } else { + return results[ resultsKey ][ internalCounter ]; + } + } + + if ( callbackLen neq 0 ) { + fCallBack = this._mockCallbacks[ resultsKey ].first(); + return fCallBack( argumentCollection : arguments ); + } + + if ( not isSimpleValue( fCallBack ) ){ + return fCallBack( argumentCollection : arguments ); + } + } + \ No newline at end of file diff --git a/public/testbox/system/stubs/C383511C024245809F1F73E0CA52E220.cfm b/public/testbox/system/stubs/C383511C024245809F1F73E0CA52E220.cfm new file mode 100644 index 000000000..968526a2a --- /dev/null +++ b/public/testbox/system/stubs/C383511C024245809F1F73E0CA52E220.cfm @@ -0,0 +1,70 @@ + + variables[ "poll" ] = variables[ "tmp_poll_C383511C024245809F1F73E0CA52E220" ]; + this[ "poll" ] = variables[ "tmp_poll_C383511C024245809F1F73E0CA52E220" ]; + + // Clean up + structDelete( variables, "tmp_poll_C383511C024245809F1F73E0CA52E220" ); + structDelete( this, "tmp_poll_C383511C024245809F1F73E0CA52E220" ); + public any function tmp_poll_C383511C024245809F1F73E0CA52E220( + + ) output=true { + + var results = this._mockResults; + var resultsKey = "poll"; + var resultsCounter = 0; + var internalCounter = 0; + var resultsLen = 0; + var callbackLen = 0; + var argsHashKey = resultsKey & "|" & this.mockBox.normalizeArguments( arguments ); + var fCallBack = ""; + + // If Method & argument Hash Results, switch the results struct +if (structKeyExists( this._mockArgResults, argsHashKey) ) { + // Check if it is a callback +if (isStruct( this._mockArgResults[ argsHashKey ]) && + structKeyExists( this._mockArgResults[ argsHashKey ], "type" ) && + structKeyExists( this._mockArgResults[ argsHashKey ], "target" ) ) { + fCallBack = this._mockArgResults[ argsHashKey ].target; +} else { + // switch context and key + results = this._mockArgResults; + resultsKey = argsHashKey; + } + } + + // Get the statemachine counter +if (isSimpleValue( fCallBack) ) { + resultsLen = arrayLen( results[ resultsKey ] ); + } + + // Get the callback counter, if it exists +if (structKeyExists( this._mockCallbacks, resultsKey) ) { + callbackLen = arrayLen( this._mockCallbacks[ resultsKey ] ); + } + + // Log the Method Call + this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] = this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] + 1; + + // Get the CallCounter Reference + internalCounter = this._mockMethodCallCounters[listFirst(resultsKey,"|")]; + arrayAppend( this._mockCallLoggers["poll"], arguments ); + + if (resultsLen neq 0) { + if (internalCounter gt resultsLen) { + resultsCounter = internalCounter - ( resultsLen * fix( ( internalCounter - 1 ) / resultsLen ) ); + return results[ resultsKey ][ resultsCounter ]; + } else { + return results[ resultsKey ][ internalCounter ]; + } + } + + if ( callbackLen neq 0 ) { + fCallBack = this._mockCallbacks[ resultsKey ].first(); + return fCallBack( argumentCollection : arguments ); + } + + if ( not isSimpleValue( fCallBack ) ){ + return fCallBack( argumentCollection : arguments ); + } + } + \ No newline at end of file diff --git a/public/testbox/system/stubs/CDCB73D6FD17909D6519A49548FD6769.cfm b/public/testbox/system/stubs/CDCB73D6FD17909D6519A49548FD6769.cfm new file mode 100644 index 000000000..f7f4f1f16 --- /dev/null +++ b/public/testbox/system/stubs/CDCB73D6FD17909D6519A49548FD6769.cfm @@ -0,0 +1,70 @@ + + variables[ "$getChannelEngine" ] = variables[ "tmp_$getChannelEngine_CDCB73D6FD17909D6519A49548FD6769" ]; + this[ "$getChannelEngine" ] = variables[ "tmp_$getChannelEngine_CDCB73D6FD17909D6519A49548FD6769" ]; + + // Clean up + structDelete( variables, "tmp_$getChannelEngine_CDCB73D6FD17909D6519A49548FD6769" ); + structDelete( this, "tmp_$getChannelEngine_CDCB73D6FD17909D6519A49548FD6769" ); + public any function tmp_$getChannelEngine_CDCB73D6FD17909D6519A49548FD6769( + + ) output=true { + + var results = this._mockResults; + var resultsKey = "$getChannelEngine"; + var resultsCounter = 0; + var internalCounter = 0; + var resultsLen = 0; + var callbackLen = 0; + var argsHashKey = resultsKey & "|" & this.mockBox.normalizeArguments( arguments ); + var fCallBack = ""; + + // If Method & argument Hash Results, switch the results struct +if (structKeyExists( this._mockArgResults, argsHashKey) ) { + // Check if it is a callback +if (isStruct( this._mockArgResults[ argsHashKey ]) && + structKeyExists( this._mockArgResults[ argsHashKey ], "type" ) && + structKeyExists( this._mockArgResults[ argsHashKey ], "target" ) ) { + fCallBack = this._mockArgResults[ argsHashKey ].target; +} else { + // switch context and key + results = this._mockArgResults; + resultsKey = argsHashKey; + } + } + + // Get the statemachine counter +if (isSimpleValue( fCallBack) ) { + resultsLen = arrayLen( results[ resultsKey ] ); + } + + // Get the callback counter, if it exists +if (structKeyExists( this._mockCallbacks, resultsKey) ) { + callbackLen = arrayLen( this._mockCallbacks[ resultsKey ] ); + } + + // Log the Method Call + this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] = this._mockMethodCallCounters[ listFirst( resultsKey, "|" ) ] + 1; + + // Get the CallCounter Reference + internalCounter = this._mockMethodCallCounters[listFirst(resultsKey,"|")]; + arrayAppend( this._mockCallLoggers["$getChannelEngine"], arguments ); + + if (resultsLen neq 0) { + if (internalCounter gt resultsLen) { + resultsCounter = internalCounter - ( resultsLen * fix( ( internalCounter - 1 ) / resultsLen ) ); + return results[ resultsKey ][ resultsCounter ]; + } else { + return results[ resultsKey ][ internalCounter ]; + } + } + + if ( callbackLen neq 0 ) { + fCallBack = this._mockCallbacks[ resultsKey ].first(); + return fCallBack( argumentCollection : arguments ); + } + + if ( not isSimpleValue( fCallBack ) ){ + return fCallBack( argumentCollection : arguments ); + } + } + \ No newline at end of file diff --git a/vendor/wheels/tests/specs/jobs/JobClassRoundTripSpec.cfc b/vendor/wheels/tests/specs/jobs/JobClassRoundTripSpec.cfc index 6b4b995f3..1e2d6ffe3 100644 --- a/vendor/wheels/tests/specs/jobs/JobClassRoundTripSpec.cfc +++ b/vendor/wheels/tests/specs/jobs/JobClassRoundTripSpec.cfc @@ -80,26 +80,42 @@ component extends="wheels.WheelsTest" { describe("Tests that an unresolvable jobClass", () => { it("throws Wheels.JobClassNotFound naming the row and the class", () => { - local.thrown = {type: "", message: ""} + // NOT `local.`-scoped, deliberately. Cross-engine invariant 11: a catch body + // runs under a nested `local` on BoxLang, so `thrown.type = e.type` + // inside the catch writes to a struct that is discarded on exit and the + // assertion below reads the untouched outer value. The blessed form is a + // `var`-declared struct accessed WITHOUT the `local.` prefix. Scoping this + // one for consistency cost two BoxLang failures on every database + // (`Expected [Wheels.JobClassNotFound] but received []`) — caught by the + // compat matrix, invisible on Lucee. + var thrown = {type: "", message: ""} local.bridge = new wheels.Job() try { local.bridge.$instantiateJobClass(jobClass = "app.jobs.NoSuchJob", jobId = "abc-123") } catch (any e) { - local.thrown.type = e.type - local.thrown.message = e.message + thrown.type = e.type + thrown.message = e.message } // the raw engine error is "component not found" for a class that plainly // exists, which points investigators at mappings and deployment - expect(local.thrown.type).toBe("Wheels.JobClassNotFound") - expect(local.thrown.message).toInclude("app.jobs.NoSuchJob") - expect(local.thrown.message).toInclude("abc-123") + expect(thrown.type).toBe("Wheels.JobClassNotFound") + expect(thrown.message).toInclude("app.jobs.NoSuchJob") + expect(thrown.message).toInclude("abc-123") }) it("throws Wheels.InvalidJobClass when the path resolves to something that is not a job", () => { - local.thrown = {type: ""} + // NOT `local.`-scoped, deliberately. Cross-engine invariant 11: a catch body + // runs under a nested `local` on BoxLang, so `thrown.type = e.type` + // inside the catch writes to a struct that is discarded on exit and the + // assertion below reads the untouched outer value. The blessed form is a + // `var`-declared struct accessed WITHOUT the `local.` prefix. Scoping this + // one for consistency cost two BoxLang failures on every database + // (`Expected [Wheels.JobClassNotFound] but received []`) — caught by the + // compat matrix, invisible on Lucee. + var thrown = {type: ""} local.bridge = new wheels.Job() @@ -107,10 +123,10 @@ component extends="wheels.WheelsTest" { // a real component with no perform() local.bridge.$instantiateJobClass(jobClass = "wheels.tests._assets.models.Post") } catch (any e) { - local.thrown.type = e.type + thrown.type = e.type } - expect(local.thrown.type).toBe("Wheels.InvalidJobClass") + expect(thrown.type).toBe("Wheels.InvalidJobClass") }) }) }