From 18d529562da51c6c30c9028b1e28d8c133c85efe Mon Sep 17 00:00:00 2001 From: Shinrai Date: Fri, 2 Oct 2026 13:04:20 -0700 Subject: [PATCH] test(lazy): guard that a lazy api.add loads only the shared subfolders it merges A lazy api.slothlet.api.add() into an existing namespace loads exactly the subfolders the collision merge has to compare: both sides of each subfolder the two modules share. Every subfolder only the added module contributes stays unloaded after the add, including once pending microtasks and timers have run, and loads only on first access, one at a time. Eager mode is the control. The background loading reported in #548 could not be reproduced on next across merge, merge-replace and replace, async and live runtimes, and hooks on and off. What does start a load is reading a lazy subfolder through its parent: the parent's get trap starts loading the child it hands out, with or without an add (unified-wrapper.mjs, cached-child branch of the get trap). A test that checks `api.plugins.kit.__materialized` therefore loads `kit` itself. This test reaches each subfolder through own-property descriptors so the check never triggers a load. Refs #548 --- .../shared/alpha.mjs | 25 +++ api_tests/api_test_lazy_add_comount_a/top.mjs | 25 +++ .../api_test_lazy_add_comount_b/gear/cog.mjs | 25 +++ .../gear/spring.mjs | 25 +++ .../kit/box/nail.mjs | 25 +++ .../api_test_lazy_add_comount_b/kit/tape.mjs | 25 +++ .../api_test_lazy_add_comount_b/net/dns.mjs | 25 +++ .../shared/beta.mjs | 25 +++ .../shared/inner/gamma.mjs | 25 +++ ...zy-comount-materialization.test.vitest.mjs | 161 ++++++++++++++++++ 10 files changed, 386 insertions(+) create mode 100644 api_tests/api_test_lazy_add_comount_a/shared/alpha.mjs create mode 100644 api_tests/api_test_lazy_add_comount_a/top.mjs create mode 100644 api_tests/api_test_lazy_add_comount_b/gear/cog.mjs create mode 100644 api_tests/api_test_lazy_add_comount_b/gear/spring.mjs create mode 100644 api_tests/api_test_lazy_add_comount_b/kit/box/nail.mjs create mode 100644 api_tests/api_test_lazy_add_comount_b/kit/tape.mjs create mode 100644 api_tests/api_test_lazy_add_comount_b/net/dns.mjs create mode 100644 api_tests/api_test_lazy_add_comount_b/shared/beta.mjs create mode 100644 api_tests/api_test_lazy_add_comount_b/shared/inner/gamma.mjs create mode 100644 tests/vitests/suites/addapi/addapi-lazy-comount-materialization.test.vitest.mjs diff --git a/api_tests/api_test_lazy_add_comount_a/shared/alpha.mjs b/api_tests/api_test_lazy_add_comount_a/shared/alpha.mjs new file mode 100644 index 000000000..bbb8101e7 --- /dev/null +++ b/api_tests/api_test_lazy_add_comount_a/shared/alpha.mjs @@ -0,0 +1,25 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lazy_add_comount_a/shared/alpha.mjs + * @Date: 2026-10-02 12:32:25 -07:00 (1790969545) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-02 12:37:59 -07:00 (1790969879) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Leaf the first module contributes to the shared subfolder (#548). + * @module api_test_lazy_add_comount_a.shared.alpha + */ + +/** + * @function alpha + * @returns {string} `"a:alpha"`. + */ +export function alpha() { + return "a:alpha"; +} diff --git a/api_tests/api_test_lazy_add_comount_a/top.mjs b/api_tests/api_test_lazy_add_comount_a/top.mjs new file mode 100644 index 000000000..1018e98d5 --- /dev/null +++ b/api_tests/api_test_lazy_add_comount_a/top.mjs @@ -0,0 +1,25 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lazy_add_comount_a/top.mjs + * @Date: 2026-10-02 12:32:25 -07:00 (1790969545) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-02 12:37:59 -07:00 (1790969879) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Leaf only the first module contributes (#548). + * @module api_test_lazy_add_comount_a.top + */ + +/** + * @function top + * @returns {string} `"a:top"`. + */ +export function top() { + return "a:top"; +} diff --git a/api_tests/api_test_lazy_add_comount_b/gear/cog.mjs b/api_tests/api_test_lazy_add_comount_b/gear/cog.mjs new file mode 100644 index 000000000..31b0b9ef1 --- /dev/null +++ b/api_tests/api_test_lazy_add_comount_b/gear/cog.mjs @@ -0,0 +1,25 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lazy_add_comount_b/gear/cog.mjs + * @Date: 2026-10-02 12:32:25 -07:00 (1790969545) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-02 12:37:59 -07:00 (1790969879) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Leaf in a subfolder only the second module contributes (#548). + * @module api_test_lazy_add_comount_b.gear.cog + */ + +/** + * @function cog + * @returns {string} `"b:cog"`. + */ +export function cog() { + return "b:cog"; +} diff --git a/api_tests/api_test_lazy_add_comount_b/gear/spring.mjs b/api_tests/api_test_lazy_add_comount_b/gear/spring.mjs new file mode 100644 index 000000000..bf151c6ed --- /dev/null +++ b/api_tests/api_test_lazy_add_comount_b/gear/spring.mjs @@ -0,0 +1,25 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lazy_add_comount_b/gear/spring.mjs + * @Date: 2026-10-02 12:32:25 -07:00 (1790969545) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-02 12:37:59 -07:00 (1790969879) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Second leaf in a subfolder only the second module contributes (#548). + * @module api_test_lazy_add_comount_b.gear.spring + */ + +/** + * @function spring + * @returns {string} `"b:spring"`. + */ +export function spring() { + return "b:spring"; +} diff --git a/api_tests/api_test_lazy_add_comount_b/kit/box/nail.mjs b/api_tests/api_test_lazy_add_comount_b/kit/box/nail.mjs new file mode 100644 index 000000000..a3114238b --- /dev/null +++ b/api_tests/api_test_lazy_add_comount_b/kit/box/nail.mjs @@ -0,0 +1,25 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lazy_add_comount_b/kit/box/nail.mjs + * @Date: 2026-10-02 12:32:25 -07:00 (1790969545) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-02 12:37:59 -07:00 (1790969879) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Leaf two levels into a subfolder only the second module contributes (#548). + * @module api_test_lazy_add_comount_b.kit.box.nail + */ + +/** + * @function nail + * @returns {string} `"b:nail"`. + */ +export function nail() { + return "b:nail"; +} diff --git a/api_tests/api_test_lazy_add_comount_b/kit/tape.mjs b/api_tests/api_test_lazy_add_comount_b/kit/tape.mjs new file mode 100644 index 000000000..578ed6f4c --- /dev/null +++ b/api_tests/api_test_lazy_add_comount_b/kit/tape.mjs @@ -0,0 +1,25 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lazy_add_comount_b/kit/tape.mjs + * @Date: 2026-10-02 12:32:25 -07:00 (1790969545) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-02 12:37:59 -07:00 (1790969879) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Leaf in a subfolder only the second module contributes (#548). + * @module api_test_lazy_add_comount_b.kit.tape + */ + +/** + * @function tape + * @returns {string} `"b:tape"`. + */ +export function tape() { + return "b:tape"; +} diff --git a/api_tests/api_test_lazy_add_comount_b/net/dns.mjs b/api_tests/api_test_lazy_add_comount_b/net/dns.mjs new file mode 100644 index 000000000..7e64df5ef --- /dev/null +++ b/api_tests/api_test_lazy_add_comount_b/net/dns.mjs @@ -0,0 +1,25 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lazy_add_comount_b/net/dns.mjs + * @Date: 2026-10-02 12:32:25 -07:00 (1790969545) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-02 12:37:59 -07:00 (1790969879) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Leaf in a subfolder only the second module contributes (#548). + * @module api_test_lazy_add_comount_b.net.dns + */ + +/** + * @function dns + * @returns {string} `"b:dns"`. + */ +export function dns() { + return "b:dns"; +} diff --git a/api_tests/api_test_lazy_add_comount_b/shared/beta.mjs b/api_tests/api_test_lazy_add_comount_b/shared/beta.mjs new file mode 100644 index 000000000..c867d525c --- /dev/null +++ b/api_tests/api_test_lazy_add_comount_b/shared/beta.mjs @@ -0,0 +1,25 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lazy_add_comount_b/shared/beta.mjs + * @Date: 2026-10-02 12:32:25 -07:00 (1790969545) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-02 12:38:03 -07:00 (1790969883) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Leaf the second module contributes to the shared subfolder (#548). + * @module api_test_lazy_add_comount_b.shared.beta + */ + +/** + * @function beta + * @returns {string} `"b:beta"`. + */ +export function beta() { + return "b:beta"; +} diff --git a/api_tests/api_test_lazy_add_comount_b/shared/inner/gamma.mjs b/api_tests/api_test_lazy_add_comount_b/shared/inner/gamma.mjs new file mode 100644 index 000000000..a07282bb7 --- /dev/null +++ b/api_tests/api_test_lazy_add_comount_b/shared/inner/gamma.mjs @@ -0,0 +1,25 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lazy_add_comount_b/shared/inner/gamma.mjs + * @Date: 2026-10-02 12:32:25 -07:00 (1790969545) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-02 12:38:03 -07:00 (1790969883) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Leaf in a subfolder only the second module contributes, inside the shared one (#548). + * @module api_test_lazy_add_comount_b.shared.inner.gamma + */ + +/** + * @function gamma + * @returns {string} `"b:gamma"`. + */ +export function gamma() { + return "b:gamma"; +} diff --git a/tests/vitests/suites/addapi/addapi-lazy-comount-materialization.test.vitest.mjs b/tests/vitests/suites/addapi/addapi-lazy-comount-materialization.test.vitest.mjs new file mode 100644 index 000000000..3c82fe939 --- /dev/null +++ b/tests/vitests/suites/addapi/addapi-lazy-comount-materialization.test.vitest.mjs @@ -0,0 +1,161 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/addapi/addapi-lazy-comount-materialization.test.vitest.mjs + * @Date: 2026-10-02 12:32:44 -07:00 (1790969564) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-02 12:43:14 -07:00 (1790970194) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview A lazy add into an existing namespace loads only what the merge inspects (#548). + * + * @description + * Module `a` is added at `plugins`, then module `b` is added at the same path. `plugins.shared` is a + * subfolder both modules contribute to, so the merge has to load both sides of it to combine their + * children. Every other subfolder `b` brings (`gear`, `kit`, `net`, and `shared.inner` inside the + * shared one) is `b`'s alone: nothing reads it during the add, so under lazy mode it must stay + * unmaterialized after the add — including once pending microtasks and timers have run — and load + * only on first access, one subfolder at a time. Eager mode is the control: there every subfolder + * is already loaded by the add. + * + * Materialization state is read through the public `__materialized` / `__inFlight` diagnostics on + * each subfolder, reached by own-property descriptors so the probe itself loads nothing. A plain + * property read (`api.plugins.kit`) would not do: handing out a reference to a lazy subfolder through + * its parent starts loading it, with or without an add. + * + * Runs across the full matrix (eager/lazy × async/live × hooks on/off). + * + * @module tests/vitests/suites/addapi/addapi-lazy-comount-materialization + */ + +import { describe, it, expect, afterEach, vi } from "vitest"; +import path from "node:path"; +import slothlet from "@cldmv/slothlet"; +import { getMatrixConfigs, TEST_DIRS } from "../../setup/vitest-helper.mjs"; + +// Instance creation plus two adds can exceed the default hook/test timeout on a loaded machine. +vi.setConfig({ hookTimeout: 60000, testTimeout: 60000 }); + +const API_TESTS_ROOT = path.dirname(TEST_DIRS.API_TEST); +const A_DIR = path.join(API_TESTS_ROOT, "api_test_lazy_add_comount_a"); +const B_DIR = path.join(API_TESTS_ROOT, "api_test_lazy_add_comount_b"); + +/** Subfolders only `b` contributes, relative to `api.plugins`. */ +const B_ONLY_FOLDERS = ["gear", "kit", "net", "shared.inner"]; + +/** Every leaf under `api.plugins` once both modules are added, with the value each returns. */ +const LEAVES = { + top: "a:top", + "shared.alpha": "a:alpha", + "shared.beta": "b:beta", + "shared.inner.gamma": "b:gamma", + "gear.cog": "b:cog", + "gear.spring": "b:spring", + "kit.tape": "b:tape", + "kit.box.nail": "b:nail", + "net.dns": "b:dns" +}; + +/** + * Resolve a dotted path under `api.plugins`. + * @param {object} api - Slothlet API proxy. + * @param {string} dotted - Path relative to `api.plugins`. + * @returns {*} The value at that path, or undefined. + */ +function at(api, dotted) { + let cur = api.plugins; + for (const part of dotted.split(".")) { + if (cur === undefined || cur === null) return undefined; + cur = cur[part]; + } + return cur; +} + +/** + * Whether the namespace at a path has been loaded or has started loading. + * @description + * Reading a lazy child through its parent (`api.plugins.gear`) counts as accessing it and starts + * loading it, so this walks own-property descriptors instead: a descriptor read hands back the child + * without touching it, and `__materialized` / `__inFlight` are answered without loading anything. + * @param {object} api - Slothlet API proxy. + * @param {string} dotted - Path relative to `api.plugins`. + * @returns {boolean} True once the namespace is materialized or its materialization is in flight. + */ +function loaded(api, dotted) { + let node = api.plugins; + for (const part of dotted.split(".")) node = Object.getOwnPropertyDescriptor(node, part)?.value; + return node.__materialized === true || node.__inFlight === true; +} + +/** + * Let pending microtasks, immediates and short timers run. + * @returns {Promise} + */ +async function settle() { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setTimeout(resolve, 50)); +} + +describe.each(getMatrixConfigs())("api.add into an existing namespace: materialization (#548) > Config: $name", ({ config }) => { + let api; + + afterEach(async () => { + await api?.shutdown(); + api = null; + }); + + /** + * Create an instance and add `a` then `b` at `plugins`. + * @returns {Promise} Slothlet API proxy. + */ + async function createComounted() { + const instance = await slothlet({ + ...config, + base: TEST_DIRS.API_TEST, + api: { ...config.api, mutations: { add: true, remove: true, reload: true } } + }); + await instance.slothlet.api.add("plugins", A_DIR, { moduleID: "a" }); + await instance.slothlet.api.add("plugins", B_DIR, { moduleID: "b" }); + return instance; + } + + it.skipIf(config.mode !== "lazy")("leaves the added module's untouched subfolders unmaterialized", async () => { + api = await createComounted(); + for (const folder of B_ONLY_FOLDERS) expect(loaded(api, folder), `plugins.${folder} right after the add`).toBe(false); + + await settle(); + for (const folder of B_ONLY_FOLDERS) expect(loaded(api, folder), `plugins.${folder} once the add has settled`).toBe(false); + }); + + it.skipIf(config.mode !== "lazy")("materializes only the subfolder that is accessed", async () => { + api = await createComounted(); + await settle(); + + expect(await api.plugins.gear.cog()).toBe("b:cog"); + await settle(); + + expect(loaded(api, "gear")).toBe(true); + for (const folder of B_ONLY_FOLDERS.filter((f) => f !== "gear")) { + expect(loaded(api, folder), `plugins.${folder} after reading plugins.gear`).toBe(false); + } + }); + + it.skipIf(config.mode !== "eager")("eager control: every subfolder is materialized by the add", async () => { + api = await createComounted(); + for (const folder of B_ONLY_FOLDERS) expect(at(api, folder).__materialized, `plugins.${folder}`).toBe(true); + }); + + it("keeps every leaf of both modules reachable", async () => { + api = await createComounted(); + for (const [dotted, expected] of Object.entries(LEAVES)) { + const fn = at(api, dotted); + expect(typeof fn, `api.plugins.${dotted}`).toBe("function"); + expect(await fn(), `api.plugins.${dotted}()`).toBe(expected); + } + }); +});