Skip to content

fix(core): make the whole internal API work from tests/page objects under tsx/cjs (#5635)#5636

Open
mirao wants to merge 4 commits into
codeceptjs:4.xfrom
mirao:fix/5635-config-tsx-cjs
Open

fix(core): make the whole internal API work from tests/page objects under tsx/cjs (#5635)#5636
mirao wants to merge 4 commits into
codeceptjs:4.xfrom
mirao:fix/5635-config-tsx-cjs

Conversation

@mirao

@mirao mirao commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #5635

Problem

The internal API (https://codecept.io/architecture#the-internal-api) is supposed to work from tests, page objects and fragments — not only helpers. In 4.x it works only from helpers:

import { config } from "codeceptjs";

Scenario("test something", ({ I }) => {
  // TypeError: Cannot read properties of undefined (reading 'Playwright')
  console.log(config.get().helpers.Playwright.browser);
});

config was just the reported symptom — container, recorder, event and store are affected the same way. Works fine in 3.x.

Root cause

Same dual-realm split as #5634, but for the public singletons.

CodeceptJS 4.x is native ESM, but Mocha loads test files through a synchronous require() (the CommonJS realm). Under a CJS loader such as tsx/cjs (the setup the official Quickstart generates), a test that imports the internal API gets a second, disconnected CJS copy of each lib module and its singleton — a copy the runner never populates:

  • config.get() returns {},
  • container has no helpers/support/plugins,
  • recorder is never started (so effects short-circuit),
  • the event dispatcher has no listeners.

Helpers don't hit this because they're loaded via import() (lib/container.js), i.e. the ESM realm, so they share the live singletons — which is why the bug looked like "helpers only".

Fix

Share each stateful singleton across realms through globalThis, the same mechanism used for the recorder/container bridges in #5634, generalized:

  • lib/realm.js (new) — realmSingleton(key, factory) stores the instance on globalThis so every realm resolves to the one the runner operates on. The ESM runner loads these modules first during bootstrap, so it wins the key; later CJS copies reuse it.
  • Applied to lib/recorder.js (recorder object), lib/container.js (state object), lib/event.js (dispatcher), lib/store.js and lib/output.js.
  • lib/config.js refactored to the same holder pattern.

Under pure ESM the modules load once, so there is no behavior change on the ESM path.

Tests

Adds a runner regression test driving a real tsx/cjs project:

  • test/data/internal-api-tsx-cjs/ — fixture (config with require: ["tsx/cjs"], a helper, and a test that imports the internal API through the CJS loader).
  • test/runner/internal_api_tsx_test.js — asserts config, container, recorder, event and store all resolve to the live singletons from a test, and that a helper (ESM realm) sees the same config.

It fails without the fix and passes with it. The full unit suite (747 passing) and runner suite continue to pass.

🤖 Generated with Claude Code

mirao and others added 2 commits June 14, 2026 08:41
…/cjs (codeceptjs#5635)

The internal API `config` imported via `import { config } from "codeceptjs"`
returned an empty object `{}` from tests, page objects and fragments, while it
worked from helpers.

Test files are loaded through Mocha's synchronous require() (the CommonJS realm)
under a CJS loader such as tsx/cjs, while the framework runs as native ESM. So a
test importing `config` gets a second, disconnected CJS copy of config.js whose
module-scoped `config` is an empty `{}` that the runner never populates. Helpers
don't hit this because they are loaded via import() (the ESM realm) and share the
live singleton.

Bridge the live config through globalThis, the same way recorder.js and
container.js were fixed in codeceptjs#5634: setConfig() mirrors the config onto
globalThis.__codeceptjs_config on every create/append/reset, and Config.get()
prefers it, falling back to the local module copy. Under pure ESM both point at
the same object, so there is no behavior change on the ESM path.

Adds a runner regression test driving a real tsx/cjs project that asserts a test
(CJS realm) and a helper (ESM realm) both read the live config. It fails without
the fix and passes with it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odeceptjs#5635)

Generalizes the config fix to every stateful internal-API singleton. The internal
API (config, container, recorder, event, store, output) is documented to work from
tests, page objects and fragments — not only helpers — but test files are loaded
through Mocha's synchronous require() (the CommonJS realm) under a CJS loader such as
tsx/cjs, while the framework runs as native ESM. So importing any of these from a test
loaded a second, disconnected CJS copy: config.get() returned {}, the container had no
helpers, the recorder was never started, the event dispatcher had no listeners. Helpers
were unaffected because they load via import() (the ESM realm).

Add lib/realm.js with realmSingleton(key, factory), which stores each singleton on
globalThis so every realm resolves to the one the runner operates on (the ESM runner
loads these modules first during bootstrap, so it wins the key; later CJS copies reuse
it). Apply it to recorder, container state, the event dispatcher, store and output, and
refactor config.js to the same holder pattern. Under pure ESM the modules load once, so
there is no behavior change.

Broadens the regression fixture to assert config/container/recorder/event/store all
resolve to the live singletons from a test imported via tsx/cjs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mirao mirao changed the title fix(config): make config.get() work from tests/page objects under tsx/cjs (#5635) fix(core): make the whole internal API work from tests/page objects under tsx/cjs (#5635) Jun 14, 2026
mirao and others added 2 commits June 14, 2026 09:37
…nt (codeceptjs#5635)

The previous commit wrapped the output/store/container singletons by inlining their
object literal into the realmSingleton() factory call. tsd-jsdoc (npm run def) couldn't
associate the `@namespace`/`@type` JSDoc with a literal nested in an arrow argument, so
the regenerated typings dropped the `output` and `store` namespaces and lost the Result
reference — breaking dtslint in CI.

Keep the plain object literal (so jsdoc still sees it) and share it at export time via
realmSingleton(() => literal), the same shape recorder.js already uses. Runtime behavior
is unchanged; `npm run def` now regenerates the committed typings unchanged and dtslint
passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… (codeceptjs#5635)

codeceptjs#5635 fixes the dual-realm split at the source (shared recorder/container), which makes
within/tryTo/hopeThat/retryTo work from a CJS-loaded test without the effects.js
indirection PR codeceptjs#5634 added — i.e. codeceptjs#5635 supersedes codeceptjs#5634's mechanism. To avoid losing
codeceptjs#5634's regression coverage, fold its effects assertions into this PR's fixture: extend
the helper with the effects-exercising methods and add effects_test.ts, then assert the
markers from the same single fixture run. retryTo is kept last and the child exec has a
timeout guard so a future regression that re-hangs it fails cleanly instead of hanging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DavertMik

DavertMik commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

what is realm? 🙄

@DavertMik DavertMik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think Claude took the wrong path here

First: what the actual problem is? What we are trying to achieve?
Which exact internal API should work from tests?

I don't think that most of internal APIs should work from tests, as tests are business logic driven, and internal APIs are implementation details. I'd rather not to explose implementatoin details into tests / page objetcs themselves and keeped correct level of abstractoin

@mirao

mirao commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

I think Claude took the wrong path here

First: what the actual problem is? What we are trying to achieve? Which exact internal API should work from tests?

I don't think that most of internal APIs should work from tests, as tests are business logic driven, and internal APIs are implementation details. I'd rather not to explose implementatoin details into tests / page objetcs themselves and keeped correct level of abstractoin

In 3.x I used import { container } from "codeceptjs" or import { config } from "codeceptjs" in tests, because I wanted to get some configuration stuff without calling a helper method for it, e.g. browser or environment.

After the migration to 4.x it stopped working 😞 .

Also the stuff like

import { within } from "codeceptjs";
import { tryTo } from "codeceptjs/effects";

stopped working (#5632) in 4.x

Therefore I needed some solution.

@DavertMik

Copy link
Copy Markdown
Contributor

Thanks! I will try to ask my claude about this
I think importing config is good point, while importing container into tests is not

@mirao

mirao commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

OK, thanks. Maybe https://codecept.io/architecture#the-internal-api should be then updated so that the scope of internal API is more clear for users (where and which methods can be used).

As for the issue with the effects such as tryTo(), within(), they should definitely work from tests or page objects (actually their usage in helpers doesn't make sense, but who knows). I reported it in the issue #5632 and this PR #5636 was intended to solve the issue as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4.x: Internal API over "import" works from helpers only, it doesn't work from tests/page objects

2 participants