diff --git a/.changeset/dull-heads-fall.md b/.changeset/dull-heads-fall.md
new file mode 100644
index 0000000000..92d32cd985
--- /dev/null
+++ b/.changeset/dull-heads-fall.md
@@ -0,0 +1,7 @@
+---
+"@lynx-js/react": patch
+"@lynx-js/test-environment": patch
+"create-rspeedy": patch
+---
+
+Add testing library for ReactLynx
diff --git a/.changeset/yellow-wasps-brush.md b/.changeset/yellow-wasps-brush.md
new file mode 100644
index 0000000000..e003689738
--- /dev/null
+++ b/.changeset/yellow-wasps-brush.md
@@ -0,0 +1,5 @@
+---
+"@lynx-js/qrcode-rsbuild-plugin": patch
+---
+
+Fix the issue where QR code fails to print after initial compilation errors are fixed.
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index c5f11352af..af0e52d865 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -149,6 +149,11 @@ jobs:
cd create-rspeedy-regression
pnpm install --registry=http://localhost:4873
pnpm run build
+ npx --registry http://localhost:4873 create-rspeedy-canary@latest --template react-vitest-rltl --dir create-rspeedy-regression-vitest-rltl
+ cd create-rspeedy-regression-vitest-rltl
+ pnpm install --registry=http://localhost:4873
+ pnpm run build
+ pnpm run test
test-tools:
needs: build
uses: ./.github/workflows/workflow-test.yml
@@ -170,6 +175,26 @@ jobs:
--no-cache
--logHeapUsage
--silent
+ test-testing-library:
+ needs: build
+ uses: ./.github/workflows/workflow-test.yml
+ secrets:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+ with:
+ runs-on: lynx-ubuntu-24.04-medium
+ run: >
+ pnpm run test
+ --project 'react/testing-library'
+ --project 'testing-library/*'
+ --test-timeout=50000
+ --reporter=github-actions
+ --reporter=dot
+ --reporter=junit
+ --outputFile=test-report.junit.xml
+ --coverage
+ --no-cache
+ --logHeapUsage
+ --silent
test-type:
needs: build
uses: ./.github/workflows/workflow-test.yml
@@ -247,6 +272,7 @@ jobs:
- test-rust
- test-rspeedy
- test-tools
+ - test-testing-library
- test-type
- website
- zizmor
diff --git a/biome.jsonc b/biome.jsonc
index 20a6fb9397..425ee9dc5d 100644
--- a/biome.jsonc
+++ b/biome.jsonc
@@ -47,6 +47,9 @@
"packages/web-platform/**",
"packages/third-party/**",
+
+ "packages/testing-library/**",
+ "packages/react/testing-library/**",
],
"rules": {
// We are migrating from ESLint to Biome
diff --git a/cspell.jsonc b/cspell.jsonc
index 5399d973b8..45de5e0786 100644
--- a/cspell.jsonc
+++ b/cspell.jsonc
@@ -115,6 +115,22 @@
"parseable", // https://pnpm.io/cli/list
"debugids", // https://getsentry.github.io/debugids/
"zizmor", // https://github.com/woodruffw/zizmor
+ "wvid",
+ "bindscroll",
+ "deinit",
+ "layoutchange", // the "layoutchange" event of Lynx
+ "longtap",
+ "bgload",
+ "bgerror",
+ "mouseclick",
+ "mousedblclick",
+ "mouselongpress",
+ "mouselongpress",
+ "contentsizechanged",
+ "scrolltoupperedge",
+ "scrolltoloweredge",
+ "scrolltonormalstate",
+ "rltl", // ReactLynx Testing Library
// lighthouse CI
"lhci",
"lh",
diff --git a/eslint.config.js b/eslint.config.js
index f3cddec0f5..3c1ec347cf 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -78,6 +78,11 @@ export default tseslint.config(
// TODO: enable eslint for web-platform
// web-platform
'packages/web-platform/**',
+
+ // TODO: enable eslint for testing-library
+ // testing-library
+ 'packages/testing-library/**',
+ 'packages/react/testing-library/**',
],
},
js.configs.recommended,
diff --git a/packages/react/package.json b/packages/react/package.json
index 15369dadf2..dda6619f46 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -82,6 +82,18 @@
"lazy": "./runtime/lazy/legacy-react-runtime.js",
"default": "./runtime/lib/legacy-react-runtime/index.js"
},
+ "./testing-library": {
+ "types": "./testing-library/types/index.d.ts",
+ "default": "./testing-library/dist/index.js"
+ },
+ "./testing-library/pure": {
+ "types": "./testing-library/types/pure.d.ts",
+ "default": "./testing-library/dist/pure.js"
+ },
+ "./testing-library/vitest-config": {
+ "types": "./testing-library/types/vitest-config.d.ts",
+ "default": "./testing-library/dist/vitest.config.js"
+ },
"./package.json": "./package.json"
},
"types": "./types/react.d.ts",
@@ -139,6 +151,7 @@
"types",
"docs",
"worklet-runtime",
+ "testing-library",
"CHANGELOG.md",
"internal.js",
"README.md",
@@ -151,8 +164,11 @@
"preact": "npm:@hongzhiyuan/preact@10.24.0-319c684e"
},
"devDependencies": {
+ "@lynx-js/test-environment": "workspace:*",
"@lynx-js/types": "^3.2.1",
"@microsoft/api-extractor": "catalog:",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/jest-dom": "^6.6.3",
"@types/react": "^18.3.20"
},
"peerDependencies": {
diff --git a/packages/react/runtime/__test__/lifecycle.test.jsx b/packages/react/runtime/__test__/lifecycle.test.jsx
index 5f875993ec..95087ac645 100644
--- a/packages/react/runtime/__test__/lifecycle.test.jsx
+++ b/packages/react/runtime/__test__/lifecycle.test.jsx
@@ -52,10 +52,11 @@ describe('useEffect', () => {
expect(callback).toHaveBeenCalledTimes(0);
expect(cleanUp).toHaveBeenCalledTimes(0);
+ expect(callback).toHaveBeenCalledTimes(0);
expect(mtCallbacks.mock.calls.length).toBe(1);
mtCallbacks.mock.calls[0][2]();
lynx.getNativeApp().callLepusMethod.mockClear();
- expect(callback).toHaveBeenCalledTimes(0);
+ expect(callback).toHaveBeenCalledTimes(1);
expect(cleanUp).toHaveBeenCalledTimes(0);
await waitSchedule();
diff --git a/packages/react/runtime/__test__/snapshot/ref.test.jsx b/packages/react/runtime/__test__/snapshot/ref.test.jsx
index 138904a2a5..c0421f3848 100644
--- a/packages/react/runtime/__test__/snapshot/ref.test.jsx
+++ b/packages/react/runtime/__test__/snapshot/ref.test.jsx
@@ -7,7 +7,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite
import { Component, createRef, root, useState } from '../../src/index';
import { delayedLifecycleEvents } from '../../src/lifecycle/event/delayLifecycleEvents';
-import { replaceCommitHook } from '../../src/lifecycle/patch/commit';
+import { clearCommitTaskId, replaceCommitHook } from '../../src/lifecycle/patch/commit';
import { injectUpdateMainThread } from '../../src/lifecycle/patch/updateMainThread';
import { renderBackground as render } from '../../src/lifecycle/render';
import { __pendingListUpdates } from '../../src/list';
@@ -25,6 +25,7 @@ beforeAll(() => {
beforeEach(() => {
globalEnvManager.resetEnv();
+ clearCommitTaskId();
});
afterEach(() => {
@@ -137,7 +138,7 @@ describe('element ref', () => {
lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot(
- `"{"patchList":[{"snapshotPatch":[],"id":4}]}"`,
+ `"{"patchList":[{"snapshotPatch":[],"id":2}]}"`,
);
lynx.getNativeApp().callLepusMethod.mock.calls[0][2]();
await waitSchedule();
@@ -218,7 +219,7 @@ describe('element ref', () => {
render(, __root);
expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot(
- `"{"patchList":[{"id":7,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_4",2,4,2,[3,4],1,-1,2,null]}]}"`,
+ `"{"patchList":[{"id":3,"snapshotPatch":[0,"__Card__:__snapshot_a94a8_test_4",2,4,2,[3,4],1,-1,2,null]}]}"`,
);
}
@@ -228,6 +229,7 @@ describe('element ref', () => {
globalThis.__OnLifecycleEvent.mockClear();
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
globalThis[rLynxChange[0]](rLynxChange[1]);
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
rLynxChange[2]();
expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`
[
@@ -235,7 +237,7 @@ describe('element ref', () => {
[
"rLynxRef",
{
- "commitTaskId": 7,
+ "commitTaskId": 3,
"refPatch": "{"2:0:":7,"2:1:":8}",
},
],
@@ -247,7 +249,6 @@ describe('element ref', () => {
// ref
{
globalEnvManager.switchToBackground();
- lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
await waitSchedule();
expect(ref1.mock.calls).toMatchInlineSnapshot(`
[
@@ -275,7 +276,7 @@ describe('element ref', () => {
render(, __root);
expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot(
- `"{"patchList":[{"id":8,"snapshotPatch":[3,2,0,3,3,2,1,4]}]}"`,
+ `"{"patchList":[{"id":4,"snapshotPatch":[3,2,0,3,3,2,1,4]}]}"`,
);
}
});
@@ -329,7 +330,7 @@ describe('element ref', () => {
render(, __root);
expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot(
- `"{"patchList":[{"id":11,"snapshotPatch":[2,-1,-2]}]}"`,
+ `"{"patchList":[{"id":3,"snapshotPatch":[2,-1,-2]}]}"`,
);
}
@@ -339,26 +340,28 @@ describe('element ref', () => {
globalThis.__OnLifecycleEvent.mockClear();
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
globalThis[rLynxChange[0]](rLynxChange[1]);
- rLynxChange[2]();
expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`
[
[
[
"rLynxRef",
{
- "commitTaskId": 11,
+ "commitTaskId": 3,
"refPatch": "{"-2:0:":null,"-2:1:":null}",
},
],
],
]
`);
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
+ globalThis.__OnLifecycleEvent.mockClear();
+ rLynxChange[2]();
+ expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`[]`);
}
// ref patch
{
globalEnvManager.switchToBackground();
- lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
await waitSchedule();
expect(ref1.mock.calls).toMatchInlineSnapshot(`
[
@@ -421,7 +424,7 @@ describe('element ref', () => {
render(, __root);
expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot(
- `"{"patchList":[{"id":14,"snapshotPatch":[2,-1,-2]}]}"`,
+ `"{"patchList":[{"id":3,"snapshotPatch":[2,-1,-2]}]}"`,
);
}
@@ -431,6 +434,7 @@ describe('element ref', () => {
globalThis.__OnLifecycleEvent.mockClear();
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
globalThis[rLynxChange[0]](rLynxChange[1]);
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
rLynxChange[2]();
expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`
[
@@ -438,7 +442,7 @@ describe('element ref', () => {
[
"rLynxRef",
{
- "commitTaskId": 14,
+ "commitTaskId": 3,
"refPatch": "{"-2:0:":null}",
},
],
@@ -450,7 +454,6 @@ describe('element ref', () => {
// ref patch
{
globalEnvManager.switchToBackground();
- lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
await waitSchedule();
expect(ref1).not.toBeCalled();
expect(cleanup.mock.calls).toMatchInlineSnapshot(`
@@ -534,26 +537,26 @@ describe('element ref', () => {
globalThis.__OnLifecycleEvent.mockClear();
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
globalThis[rLynxChange[0]](rLynxChange[1]);
- rLynxChange[2]();
expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`
[
[
[
"rLynxRef",
{
- "commitTaskId": 17,
+ "commitTaskId": 3,
"refPatch": "{"-2:0:":null,"-3:0:":null,"-4:0:":null,"-5:0:":null}",
},
],
],
]
`);
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
+ rLynxChange[2]();
}
// ref patch
{
globalEnvManager.switchToBackground();
- lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
await waitSchedule();
ref1.forEach(ref => expect(ref).toHaveBeenCalledWith(null));
expect(ref2).toHaveBeenCalledWith(null);
@@ -622,7 +625,7 @@ describe('element ref', () => {
lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot(
- `"{"patchList":[{"snapshotPatch":[3,-2,0,null,3,-2,1,13,3,-2,2,14],"id":19}]}"`,
+ `"{"patchList":[{"snapshotPatch":[3,-2,0,null,3,-2,1,13,3,-2,2,14],"id":2}]}"`,
);
expect(ref1.current).toBeNull();
@@ -634,6 +637,7 @@ describe('element ref', () => {
globalThis.__OnLifecycleEvent.mockClear();
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
globalThis[rLynxChange[0]](rLynxChange[1]);
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
rLynxChange[2]();
expect(__root.__element_root).toMatchInlineSnapshot(`
{
// ref patch
{
globalEnvManager.switchToBackground();
- lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
await waitSchedule();
expect(ref1.current).toBeNull();
expect(ref2).toMatchInlineSnapshot(`
@@ -722,7 +725,7 @@ describe('element ref', () => {
lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot(
- `"{"patchList":[{"snapshotPatch":[3,-2,0,null,3,-2,1,16],"id":22}]}"`,
+ `"{"patchList":[{"snapshotPatch":[3,-2,0,null,3,-2,1,16],"id":3}]}"`,
);
globalThis.__OnLifecycleEvent.mockClear();
@@ -730,12 +733,12 @@ describe('element ref', () => {
globalEnvManager.switchToMainThread();
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
globalThis[rLynxChange[0]](rLynxChange[1]);
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
rLynxChange[2]();
// ref patch
{
globalEnvManager.switchToBackground();
- lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
await waitSchedule();
expect(ref1.current).toBeNull();
expect(ref2).toMatchInlineSnapshot(`
@@ -847,7 +850,7 @@ describe('element ref', () => {
render(, __root);
expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot(
- `"{"patchList":[{"id":25,"snapshotPatch":[3,-2,0,20,3,-2,1,21,3,-2,2,null]}]}"`,
+ `"{"patchList":[{"id":3,"snapshotPatch":[3,-2,0,20,3,-2,1,21,3,-2,2,null]}]}"`,
);
}
@@ -857,6 +860,7 @@ describe('element ref', () => {
globalThis.__OnLifecycleEvent.mockClear();
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
globalThis[rLynxChange[0]](rLynxChange[1]);
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
rLynxChange[2]();
expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`
[
@@ -864,7 +868,7 @@ describe('element ref', () => {
[
"rLynxRef",
{
- "commitTaskId": 25,
+ "commitTaskId": 3,
"refPatch": "{"-2:0:":32,"-2:1:":33}",
},
],
@@ -876,7 +880,6 @@ describe('element ref', () => {
// ref
{
globalEnvManager.switchToBackground();
- lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
await waitSchedule();
expect(ref1.mock.calls).toMatchInlineSnapshot(`
[
@@ -982,7 +985,7 @@ describe('element ref in spread', () => {
[
"rLynxRef",
{
- "commitTaskId": 27,
+ "commitTaskId": 2,
"refPatch": "{"-2:1:ref":38}",
},
],
@@ -1013,7 +1016,7 @@ describe('element ref in spread', () => {
render(, __root);
expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot(
- `"{"patchList":[{"id":28,"snapshotPatch":[3,-2,0,{"ref":23}]}]}"`,
+ `"{"patchList":[{"id":3,"snapshotPatch":[3,-2,0,{"ref":23}]}]}"`,
);
}
@@ -1023,6 +1026,7 @@ describe('element ref in spread', () => {
globalThis.__OnLifecycleEvent.mockClear();
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
globalThis[rLynxChange[0]](rLynxChange[1]);
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
rLynxChange[2]();
expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`
[
@@ -1030,7 +1034,7 @@ describe('element ref in spread', () => {
[
"rLynxRef",
{
- "commitTaskId": 28,
+ "commitTaskId": 3,
"refPatch": "{"-2:0:ref":37}",
},
],
@@ -1042,7 +1046,6 @@ describe('element ref in spread', () => {
// ref
{
globalEnvManager.switchToBackground();
- lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
await waitSchedule();
expect(ref1.mock.calls).toMatchInlineSnapshot(`
[
@@ -1133,7 +1136,7 @@ describe('element ref in spread', () => {
render(, __root);
expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot(
- `"{"patchList":[{"id":31,"snapshotPatch":[3,-2,0,{},2,-2,-3]}]}"`,
+ `"{"patchList":[{"id":3,"snapshotPatch":[3,-2,0,{},2,-2,-3]}]}"`,
);
}
@@ -1143,12 +1146,12 @@ describe('element ref in spread', () => {
globalThis.__OnLifecycleEvent.mockClear();
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
globalThis[rLynxChange[0]](rLynxChange[1]);
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
rLynxChange[2]();
}
// ref
{
- lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
await waitSchedule();
expect(ref1.mock.calls).toMatchInlineSnapshot(`
[
@@ -1259,7 +1262,7 @@ describe('element ref in spread', () => {
render(, __root);
expect(lynx.getNativeApp().callLepusMethod).toHaveBeenCalledTimes(1);
expect(lynx.getNativeApp().callLepusMethod.mock.calls[0][1].data).toMatchInlineSnapshot(
- `"{"patchList":[{"id":34,"snapshotPatch":[3,-2,0,{"ref":29},3,-2,1,{"ref":30},3,-2,2,{}]}]}"`,
+ `"{"patchList":[{"id":3,"snapshotPatch":[3,-2,0,{"ref":29},3,-2,1,{"ref":30},3,-2,2,{}]}]}"`,
);
}
@@ -1269,6 +1272,7 @@ describe('element ref in spread', () => {
globalThis.__OnLifecycleEvent.mockClear();
const rLynxChange = lynx.getNativeApp().callLepusMethod.mock.calls[0];
globalThis[rLynxChange[0]](rLynxChange[1]);
+ lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
rLynxChange[2]();
expect(globalThis.__OnLifecycleEvent.mock.calls).toMatchInlineSnapshot(`
[
@@ -1276,7 +1280,7 @@ describe('element ref in spread', () => {
[
"rLynxRef",
{
- "commitTaskId": 34,
+ "commitTaskId": 3,
"refPatch": "{"-2:0:ref":46,"-2:1:ref":47}",
},
],
@@ -1288,7 +1292,6 @@ describe('element ref in spread', () => {
// ref
{
globalEnvManager.switchToBackground();
- lynxCoreInject.tt.OnLifecycleEvent(...globalThis.__OnLifecycleEvent.mock.calls[0]);
await waitSchedule();
expect(ref1.mock.calls).toMatchInlineSnapshot(`
[
diff --git a/packages/react/runtime/__test__/utils/nativeMethod.ts b/packages/react/runtime/__test__/utils/nativeMethod.ts
index ac834702b0..fcb216487b 100644
--- a/packages/react/runtime/__test__/utils/nativeMethod.ts
+++ b/packages/react/runtime/__test__/utils/nativeMethod.ts
@@ -235,16 +235,6 @@ export const elementTree = new (class {
__UpdateListComponents(list: Element, components: string[]) {}
- __UpdateListActions(
- list: Element,
- removals: number[],
- insertions: number[],
- moveFrom: number[],
- moveTo: number[],
- updateFrom: number[],
- updateTo: number[],
- ) {}
-
__UpdateListCallbacks(
list: Element,
componentAtIndex: (
diff --git a/packages/react/runtime/src/lifecycle/patch/commit.ts b/packages/react/runtime/src/lifecycle/patch/commit.ts
index a5a6d88fb5..8590d4c582 100644
--- a/packages/react/runtime/src/lifecycle/patch/commit.ts
+++ b/packages/react/runtime/src/lifecycle/patch/commit.ts
@@ -31,6 +31,9 @@ let nextCommitTaskId = 1;
let globalBackgroundSnapshotInstancesToRemove: number[] = [];
let patchesToCommit: Patch[] = [];
+function clearPatchesToCommit(): void {
+ patchesToCommit = [];
+}
interface Patch {
id: number;
@@ -160,54 +163,61 @@ async function commitToMainThread(): Promise {
patchList.flushOptions = flushOptions;
}
- await commitPatchUpdate(patchList, {});
+ const obj = commitPatchUpdate(patchList, {});
- for (let i = 0; i < patchList.patchList.length; i++) {
- const patch = patchList.patchList[i]!;
- const commitTask = globalCommitTaskMap.get(patch.id);
- if (commitTask) {
- commitTask();
- globalCommitTaskMap.delete(patch.id);
+ lynx.getNativeApp().callLepusMethod(LifecycleConstant.patchUpdate, obj, () => {
+ for (let i = 0; i < patchList.patchList.length; i++) {
+ const patch = patchList.patchList[i]!;
+ const commitTask = globalCommitTaskMap.get(patch.id);
+ if (commitTask) {
+ commitTask();
+ globalCommitTaskMap.delete(patch.id);
+ }
}
- }
+ });
}
-function commitPatchUpdate(patchList: PatchList, patchOptions: Omit): Promise {
- return new Promise(resolve => {
- // console.debug('********** JS update:');
- // printSnapshotInstance(
- // (backgroundSnapshotInstanceManager.values.get(1) || backgroundSnapshotInstanceManager.values.get(-1))!,
- // );
- // console.debug('commitPatchUpdate: ', JSON.stringify(patchList));
- if (__PROFILE__) {
- console.profile('commitChanges');
- }
- markTiming(PerformanceTimingKeys.pack_changes_start);
- const obj: {
- data: string;
- patchOptions: PatchOptions;
- } = {
- data: JSON.stringify(patchList),
- patchOptions: {
- ...patchOptions,
- reloadVersion: getReloadVersion(),
- },
- };
- markTiming(PerformanceTimingKeys.pack_changes_end);
- if (globalPipelineOptions) {
- obj.patchOptions.pipelineOptions = globalPipelineOptions;
- setPipeline(undefined);
- }
- lynx.getNativeApp().callLepusMethod(LifecycleConstant.patchUpdate, obj, resolve);
- if (__PROFILE__) {
- console.profileEnd();
- }
- });
+function commitPatchUpdate(patchList: PatchList, patchOptions: Omit): {
+ data: string;
+ patchOptions: PatchOptions;
+} {
+ // console.debug('********** JS update:');
+ // printSnapshotInstance(
+ // (backgroundSnapshotInstanceManager.values.get(1) || backgroundSnapshotInstanceManager.values.get(-1))!,
+ // );
+ // console.debug('commitPatchUpdate: ', JSON.stringify(patchList));
+ if (__PROFILE__) {
+ console.profile('commitChanges');
+ }
+ markTiming(PerformanceTimingKeys.pack_changes_start);
+ const obj: {
+ data: string;
+ patchOptions: PatchOptions;
+ } = {
+ data: JSON.stringify(patchList),
+ patchOptions: {
+ ...patchOptions,
+ reloadVersion: getReloadVersion(),
+ },
+ };
+ markTiming(PerformanceTimingKeys.pack_changes_end);
+ if (globalPipelineOptions) {
+ obj.patchOptions.pipelineOptions = globalPipelineOptions;
+ setPipeline(undefined);
+ }
+ if (__PROFILE__) {
+ console.profileEnd();
+ }
+
+ return obj;
}
function genCommitTaskId(): number {
return nextCommitTaskId++;
}
+function clearCommitTaskId(): void {
+ nextCommitTaskId = 1;
+}
function replaceRequestAnimationFrame(): void {
// to make afterPaintEffects run faster
@@ -224,10 +234,13 @@ export {
commitPatchUpdate,
commitToMainThread,
genCommitTaskId,
+ clearCommitTaskId,
globalBackgroundSnapshotInstancesToRemove,
globalCommitTaskMap,
globalFlushOptions,
nextCommitTaskId,
+ patchesToCommit,
+ clearPatchesToCommit,
replaceCommitHook,
replaceRequestAnimationFrame,
type PatchOptions,
diff --git a/packages/react/runtime/src/lynx.ts b/packages/react/runtime/src/lynx.ts
index ca07d1e429..87f241e0a7 100644
--- a/packages/react/runtime/src/lynx.ts
+++ b/packages/react/runtime/src/lynx.ts
@@ -15,6 +15,7 @@ import { setupLynxEnv } from './lynx/env.js';
import { injectLepusMethods } from './lynx/injectLepusMethods.js';
import { initTimingAPI } from './lynx/performance.js';
import { injectTt } from './lynx/tt.js';
+export { runWithForce } from './lynx/runWithForce.js';
// @ts-expect-error Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature
if (__LEPUS__ && typeof globalThis.processEvalResult === 'undefined') {
diff --git a/packages/react/runtime/src/lynx/runWithForce.ts b/packages/react/runtime/src/lynx/runWithForce.ts
new file mode 100644
index 0000000000..53d429b5ba
--- /dev/null
+++ b/packages/react/runtime/src/lynx/runWithForce.ts
@@ -0,0 +1,52 @@
+import { options } from 'preact';
+import type { VNode } from 'preact';
+import { COMPONENT, DIFF, DIFFED, FORCE } from '../renderToOpcodes/constants.js';
+
+export function runWithForce(cb: () => void): void {
+ // save vnode and its `_component` in WeakMap
+ const m = new WeakMap();
+
+ const oldDiff = options[DIFF];
+
+ options[DIFF] = (vnode: VNode) => {
+ if (oldDiff) {
+ oldDiff(vnode);
+ }
+
+ // when `options[DIFF]` is called, a newVnode is passed in
+ // so its `vnode[COMPONENT]` should be null,
+ // but it will be set later
+ Object.defineProperty(vnode, COMPONENT, {
+ configurable: true,
+ set(c) {
+ m.set(vnode, c);
+ if (c) {
+ c[FORCE] = true;
+ }
+ },
+ get() {
+ return m.get(vnode);
+ },
+ });
+ };
+
+ const oldDiffed = options[DIFFED];
+
+ options[DIFFED] = (vnode: VNode) => {
+ if (oldDiffed) {
+ oldDiffed(vnode);
+ }
+
+ // delete is a reverse operation of previous `Object.defineProperty`
+ delete vnode[COMPONENT];
+ // restore
+ vnode[COMPONENT] = m.get(vnode);
+ };
+
+ try {
+ cb();
+ } finally {
+ options[DIFF] = oldDiff as (vnode: VNode) => void;
+ options[DIFFED] = oldDiffed as (vnode: VNode) => void;
+ }
+}
diff --git a/packages/react/runtime/src/lynx/tt.ts b/packages/react/runtime/src/lynx/tt.ts
index 552f1e846b..38d8cbe979 100644
--- a/packages/react/runtime/src/lynx/tt.ts
+++ b/packages/react/runtime/src/lynx/tt.ts
@@ -1,72 +1,29 @@
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
-import { options } from 'preact';
-import type { VNode } from 'preact';
-
import { LifecycleConstant, NativeUpdateDataType } from '../lifecycleConstant.js';
import { PerformanceTimingKeys, beginPipeline, markTiming } from './performance.js';
import { BackgroundSnapshotInstance, hydrate } from '../backgroundSnapshot.js';
import { destroyBackground } from '../lifecycle/destroy.js';
import { delayedEvents, delayedPublishEvent } from '../lifecycle/event/delayEvents.js';
import { delayLifecycleEvent, delayedLifecycleEvents } from '../lifecycle/event/delayLifecycleEvents.js';
-import { commitPatchUpdate, genCommitTaskId, globalCommitTaskMap } from '../lifecycle/patch/commit.js';
+import {
+ clearPatchesToCommit,
+ commitPatchUpdate,
+ genCommitTaskId,
+ globalCommitTaskMap,
+ patchesToCommit,
+ type PatchList,
+} from '../lifecycle/patch/commit.js';
import { reloadBackground } from '../lifecycle/reload.js';
import { renderBackground } from '../lifecycle/render.js';
-import { CHILDREN, COMPONENT, DIFF, DIFFED, FORCE } from '../renderToOpcodes/constants.js';
+import { CHILDREN } from '../renderToOpcodes/constants.js';
import { __root } from '../root.js';
import { globalRefsToSet, updateBackgroundRefs } from '../snapshot/ref.js';
import { backgroundSnapshotInstanceManager } from '../snapshot.js';
import { destroyWorklet } from '../worklet/destroy.js';
-
-export function runWithForce(cb: () => void): void {
- // save vnode and its `_component` in WeakMap
- const m = new WeakMap();
-
- const oldDiff = options[DIFF];
-
- options[DIFF] = (vnode: VNode) => {
- if (oldDiff) {
- oldDiff(vnode);
- }
-
- // when `options[DIFF]` is called, a newVnode is passed in
- // so its `vnode[COMPONENT]` should be null,
- // but it will be set later
- Object.defineProperty(vnode, COMPONENT, {
- configurable: true,
- set(c) {
- m.set(vnode, c);
- if (c) {
- c[FORCE] = true;
- }
- },
- get() {
- return m.get(vnode);
- },
- });
- };
-
- const oldDiffed = options[DIFFED];
-
- options[DIFFED] = (vnode: VNode) => {
- if (oldDiffed) {
- oldDiffed(vnode);
- }
-
- // delete is a reverse operation of previous `Object.defineProperty`
- delete vnode[COMPONENT];
- // restore
- vnode[COMPONENT] = m.get(vnode);
- };
-
- try {
- cb();
- } finally {
- options[DIFF] = oldDiff as (vnode: VNode) => void;
- options[DIFFED] = oldDiffed as (vnode: VNode) => void;
- }
-}
+import { runWithForce } from './runWithForce.js';
+export { runWithForce };
function injectTt(): void {
// @ts-ignore
@@ -100,7 +57,7 @@ function onLifecycleEvent([type, data]: [string, any]) {
}
try {
- void onLifecycleEventImpl(type, data);
+ onLifecycleEventImpl(type, data);
} catch (e) {
lynx.reportError(e as Error);
}
@@ -110,7 +67,7 @@ function onLifecycleEvent([type, data]: [string, any]) {
}
}
-async function onLifecycleEventImpl(type: string, data: any): Promise {
+function onLifecycleEventImpl(type: string, data: any): void {
switch (type) {
case LifecycleConstant.firstScreen: {
const { root: lepusSide, refPatch, jsReadyEventIdSwap } = data;
@@ -166,14 +123,23 @@ async function onLifecycleEventImpl(type: string, data: any): Promise {
console.profile('commitChanges');
}
const commitTaskId = genCommitTaskId();
- await commitPatchUpdate({ patchList: [{ snapshotPatch, id: commitTaskId }] }, { isHydration: true });
- updateBackgroundRefs(commitTaskId);
- globalCommitTaskMap.forEach((commitTask, id) => {
- if (id > commitTaskId) {
- return;
- }
- commitTask();
- globalCommitTaskMap.delete(id);
+ patchesToCommit.push(
+ { snapshotPatch, id: commitTaskId },
+ );
+ const patchList: PatchList = {
+ patchList: patchesToCommit,
+ };
+ clearPatchesToCommit();
+ const obj = commitPatchUpdate(patchList, { isHydration: true });
+ lynx.getNativeApp().callLepusMethod(LifecycleConstant.patchUpdate, obj, () => {
+ updateBackgroundRefs(commitTaskId);
+ globalCommitTaskMap.forEach((commitTask, id) => {
+ if (id > commitTaskId) {
+ return;
+ }
+ commitTask();
+ globalCommitTaskMap.delete(id);
+ });
});
break;
}
diff --git a/packages/react/testing-library/.npmignore b/packages/react/testing-library/.npmignore
new file mode 100644
index 0000000000..cb433b7a07
--- /dev/null
+++ b/packages/react/testing-library/.npmignore
@@ -0,0 +1,4 @@
+*
+!dist/*
+!dist/env/*
+!types/*
diff --git a/packages/react/testing-library/README.md b/packages/react/testing-library/README.md
new file mode 100644
index 0000000000..2e4f006ea3
--- /dev/null
+++ b/packages/react/testing-library/README.md
@@ -0,0 +1,70 @@
+# @lynx-js/react/testing-library
+
+ReactLynx Testing Library is a simple and complete ReactLynx unit testing library that encourages good testing practices.
+
+> Inspired completely by [react-testing-library](https://github.com/testing-library/react-testing-library)
+
+Similar to [react-testing-library](https://github.com/testing-library/react-testing-library), this library is designed to test your ReactLynx components in the same way you would test React components using react-testing-library.
+
+## Setup
+
+Setup vitest:
+
+```js
+// vitest.config.js
+import { defineConfig, mergeConfig } from 'vitest/config';
+import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config';
+
+const defaultConfig = createVitestConfig();
+const config = defineConfig({
+ test: {
+ // ...
+ },
+});
+
+export default mergeConfig(defaultConfig, config);
+```
+
+Then you can start writing tests and run them with vitest!
+
+## Usage
+
+```js
+import '@testing-library/jest-dom';
+import { test, expect } from 'vitest';
+import { render } from '@lynx-js/react/testing-library';
+
+test('renders options.wrapper around node', async () => {
+ const WrapperComponent = ({ children }) => (
+ {children}
+ );
+ const Comp = () => {
+ return ;
+ };
+ const { container, getByTestId } = render(, {
+ wrapper: WrapperComponent,
+ });
+ expect(getByTestId('wrapper')).toBeInTheDocument();
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+
+ `);
+});
+```
+
+💡 Since our testing environment (`@lynx-js/test-environment`) is based on jsdom, You may also be interested in installing `@testing-library/jest-dom` so you can use
+[the custom jest matchers](https://github.com/testing-library/jest-dom).
+
+## Examples
+
+See our [examples](https://github.com/lynx-family/lynx-stack/tree/main/packages/react/testing-library/src/__tests__) for more usage.
+
+## Credits
+
+- [Testing Library](https://testing-library.com/) for the testing utilities and good practices for React testing.
diff --git a/packages/react/testing-library/api-extractor.json b/packages/react/testing-library/api-extractor.json
new file mode 100644
index 0000000000..2d2d66ca84
--- /dev/null
+++ b/packages/react/testing-library/api-extractor.json
@@ -0,0 +1,15 @@
+/**
+ * Config file for API Extractor. For more info, please visit: https://api-extractor.com
+ */
+{
+ "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
+ "extends": "../../../api-extractor.json",
+ "mainEntryPointFilePath": "/types/index.d.ts",
+ "compiler": {
+ "overrideTsconfig": {
+ "compilerOptions": {
+ "skipLibCheck": true,
+ },
+ },
+ },
+}
diff --git a/packages/react/testing-library/etc/react-lynx-testing-library.api.md b/packages/react/testing-library/etc/react-lynx-testing-library.api.md
new file mode 100644
index 0000000000..4b6e4c9866
--- /dev/null
+++ b/packages/react/testing-library/etc/react-lynx-testing-library.api.md
@@ -0,0 +1,65 @@
+## API Report File for "@lynx-js/react-lynx-testing-library"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { BoundFunction } from '@testing-library/dom';
+import { ComponentChild } from 'preact';
+import { ComponentType } from 'preact';
+import { LynxElement } from '@lynx-js/test-environment';
+import { Queries } from '@testing-library/dom';
+import { queries } from '@testing-library/dom';
+
+// @public
+export function cleanup(): void;
+
+// @public
+export function render(
+ui: ComponentChild,
+options?: RenderOptions,
+): RenderResult;
+
+// @public
+export function renderHook(
+render: (initialProps: Props) => Result,
+options?: RenderHookOptions,
+): RenderHookResult;
+
+// @public
+export interface RenderHookOptions {
+ initialProps?: Props;
+ wrapper?: ComponentType<{ children: LynxElement }>;
+}
+
+// @public
+export interface RenderHookResult {
+ rerender: (props?: Props) => void;
+ result: {
+ current: Result;
+ };
+ unmount: () => void;
+}
+
+// @public
+export interface RenderOptions {
+ enableBackgroundThread?: boolean;
+ enableMainThread?: boolean;
+ queries?: Q;
+ wrapper?: ComponentChild;
+}
+
+// @public
+export type RenderResult = {
+ container: LynxElement;
+ rerender: (ui: ComponentChild) => void;
+ unmount: () => boolean;
+} & { [P in keyof Q]: BoundFunction };
+
+// @public
+export function waitSchedule(): Promise;
+
+
+export * from "@testing-library/dom";
+
+```
diff --git a/packages/react/testing-library/loaders/jsx-loader.js b/packages/react/testing-library/loaders/jsx-loader.js
new file mode 100644
index 0000000000..1b9446aa06
--- /dev/null
+++ b/packages/react/testing-library/loaders/jsx-loader.js
@@ -0,0 +1,49 @@
+import { fileURLToPath } from 'node:url';
+import { transformReactLynxSync } from '../../transform/main.js';
+import path from 'node:path';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+export default function jsxLoader(source) {
+ const runtimePkgName = '@lynx-js/react';
+ const sourcePath = this.resourcePath;
+ const basename = path.basename(sourcePath);
+ const relativePath = path.relative(
+ __dirname,
+ sourcePath,
+ );
+
+ const result = transformReactLynxSync(source, {
+ mode: 'test',
+ pluginName: '',
+ filename: basename,
+ sourcemap: true,
+ snapshot: {
+ preserveJsx: false,
+ runtimePkg: `${runtimePkgName}/internal`,
+ jsxImportSource: runtimePkgName,
+ filename: relativePath,
+ target: 'MIXED',
+ },
+ // snapshot: true,
+ directiveDCE: false,
+ defineDCE: false,
+ shake: false,
+ compat: false,
+ worklet: {
+ filename: relativePath,
+ runtimePkg: `${runtimePkgName}/internal`,
+ target: 'MIXED',
+ },
+ refresh: false,
+ cssScope: false,
+ });
+
+ if (result.errors.length > 0) {
+ console.error(result.errors);
+ throw new Error('transformReactLynxSync failed');
+ }
+
+ this.callback(null, result.code, result.map);
+}
diff --git a/packages/react/testing-library/package.json b/packages/react/testing-library/package.json
new file mode 100644
index 0000000000..5d68b93c95
--- /dev/null
+++ b/packages/react/testing-library/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@lynx-js/react-lynx-testing-library",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "api-extractor": "api-extractor run --verbose",
+ "build": "rslib build",
+ "dev": "rslib build --watch",
+ "test": "vitest",
+ "test:ui": "vitest --ui"
+ },
+ "devDependencies": {
+ "@lynx-js/react": "workspace:*"
+ }
+}
diff --git a/packages/react/testing-library/rslib.config.ts b/packages/react/testing-library/rslib.config.ts
new file mode 100644
index 0000000000..67225dc333
--- /dev/null
+++ b/packages/react/testing-library/rslib.config.ts
@@ -0,0 +1,57 @@
+import { defineConfig, type rsbuild } from '@rslib/core';
+
+export default defineConfig({
+ lib: [
+ {
+ format: 'esm',
+ syntax: 'es2022',
+ dts: false,
+ bundle: true,
+ source: {
+ entry: {
+ 'pure': './src/pure.jsx',
+ 'env/vitest': './src/env/vitest.ts',
+ },
+ },
+ output: {
+ externals: [
+ /^@lynx-js\/react/,
+ /^\.\.\/\.\.\/runtime\/lib/,
+ /^preact/,
+ /^vitest/,
+ ],
+ },
+ },
+ {
+ format: 'esm',
+ syntax: 'es2022',
+ dts: false,
+ bundle: false,
+ source: {
+ entry: {
+ 'index': './src/index.jsx',
+ 'vitest.config': './src/vitest.config.js',
+ 'vitest-global-setup': './src/vitest-global-setup.js',
+ },
+ },
+ output: {
+ externals: [
+ /@lynx-js\/react/,
+ /\.\.\/\.\.\/runtime\/lib/,
+ ],
+ },
+ },
+ ],
+ tools: {
+ rspack(config) {
+ config.module!.rules!.push({
+ test: /\.jsx$/,
+ use: [
+ {
+ loader: require.resolve('./loaders/jsx-loader'),
+ },
+ ],
+ });
+ },
+ },
+});
diff --git a/packages/react/testing-library/src/__tests__/act.test.jsx b/packages/react/testing-library/src/__tests__/act.test.jsx
new file mode 100644
index 0000000000..cda8c26b42
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/act.test.jsx
@@ -0,0 +1,289 @@
+import '@testing-library/jest-dom';
+import { vi } from 'vitest';
+import { test } from 'vitest';
+import { render, fireEvent } from '..';
+import { useEffect, useState } from 'preact/hooks';
+import { createRef } from 'preact';
+import { Component } from 'preact';
+import { expect } from 'vitest';
+import { __globalSnapshotPatch } from '../../../runtime/lib/lifecycle/patch/snapshotPatch.js';
+import { snapshotInstanceManager } from '../../../runtime/lib/snapshot.js';
+
+test('render calls useEffect immediately', async () => {
+ const cb = vi.fn();
+ function Comp() {
+ useEffect(() => {
+ cb(`__MAIN_THREAD__: ${__MAIN_THREAD__}`);
+ });
+ return ;
+ }
+ const { container } = render();
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+ `);
+
+ expect(cb).toBeCalledTimes(1);
+ expect(cb.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "__MAIN_THREAD__: false",
+ ],
+ ]
+ `);
+});
+
+test('render calls componentDidMount immediately', async () => {
+ const cb = vi.fn();
+ class Comp extends Component {
+ componentDidMount() {
+ cb(`__MAIN_THREAD__: ${__MAIN_THREAD__}`);
+ }
+ render() {
+ return ;
+ }
+ }
+ const { container } = render();
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+ `);
+ expect(cb).toBeCalledTimes(1);
+ expect(cb.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "__MAIN_THREAD__: false",
+ ],
+ ]
+ `);
+});
+
+test('findByTestId returns the element', async () => {
+ const ref = createRef();
+ class Comp extends Component {
+ render() {
+ return (
+
+ Hello world!
+
+ );
+ }
+ }
+ const { container, findByTestId } = render();
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+ Hello world!
+
+
+
+ `);
+ expect(await findByTestId('foo')).toMatchInlineSnapshot(`
+
+
+ Hello world!
+
+
+ `);
+ expect(ref.current).toMatchInlineSnapshot(`
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "1",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ }
+ `);
+});
+
+test('fireEvent triggers useEffect calls', async () => {
+ expect(__globalSnapshotPatch).toMatchInlineSnapshot(`undefined`);
+ // mock lynx.getNativeApp().callLepusMethod
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`[]`);
+ const cb = vi.fn();
+ const onTap = vi.fn();
+ function Counter() {
+ const [count, setCount] = useState(0);
+ useEffect(() => cb(count));
+ return (
+ {
+ onTap(...args);
+ setCount(count + 1);
+ }}
+ >
+ {count}
+
+ );
+ }
+ expect(snapshotInstanceManager.values).toMatchInlineSnapshot(`
+ Map {
+ -1 => {
+ "children": undefined,
+ "id": -1,
+ "type": "root",
+ "values": undefined,
+ },
+ }
+ `);
+ const { container } = render();
+ expect(container).toMatchInlineSnapshot(`
+
+
+ 0
+
+
+ `);
+ const buttonNode = container.firstChild;
+ expect(buttonNode).toMatchInlineSnapshot(`
+
+ 0
+
+ `);
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`
+ [
+ [
+ "rLynxChange",
+ {
+ "data": "{"patchList":[{"snapshotPatch":[0,"__Card__:__snapshot_e8d0a_test_4",2,0,null,3,4,3,[0],1,2,3,null,4,2,[1],1,-1,2,null],"id":2}]}",
+ "patchOptions": {
+ "isHydration": true,
+ "pipelineOptions": {
+ "needTimestamps": true,
+ "pipelineID": "pipelineID",
+ },
+ "reloadVersion": 0,
+ },
+ },
+ [Function],
+ ],
+ ]
+ `);
+ expect(snapshotInstanceManager.values).toMatchInlineSnapshot(`
+ Map {
+ -1 => {
+ "children": [
+ {
+ "children": [
+ {
+ "children": undefined,
+ "id": 3,
+ "type": null,
+ "values": [
+ 0,
+ ],
+ },
+ ],
+ "id": 2,
+ "type": "__Card__:__snapshot_e8d0a_test_4",
+ "values": [
+ "2:0:",
+ ],
+ },
+ ],
+ "id": -1,
+ "type": "root",
+ "values": undefined,
+ },
+ 2 => {
+ "children": [
+ {
+ "children": undefined,
+ "id": 3,
+ "type": null,
+ "values": [
+ 0,
+ ],
+ },
+ ],
+ "id": 2,
+ "type": "__Card__:__snapshot_e8d0a_test_4",
+ "values": [
+ "2:0:",
+ ],
+ },
+ 3 => {
+ "children": undefined,
+ "id": 3,
+ "type": null,
+ "values": [
+ 0,
+ ],
+ },
+ }
+ `);
+ expect(__globalSnapshotPatch).toMatchInlineSnapshot(`[]`);
+ fireEvent.tap(buttonNode);
+ expect(__globalSnapshotPatch).toMatchInlineSnapshot(`[]`);
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`
+ [
+ [
+ "rLynxChange",
+ {
+ "data": "{"patchList":[{"snapshotPatch":[0,"__Card__:__snapshot_e8d0a_test_4",2,0,null,3,4,3,[0],1,2,3,null,4,2,[1],1,-1,2,null],"id":2}]}",
+ "patchOptions": {
+ "isHydration": true,
+ "pipelineOptions": {
+ "needTimestamps": true,
+ "pipelineID": "pipelineID",
+ },
+ "reloadVersion": 0,
+ },
+ },
+ [Function],
+ ],
+ [
+ "rLynxChange",
+ {
+ "data": "{"patchList":[{"id":3,"snapshotPatch":[3,3,0,1]}]}",
+ "patchOptions": {
+ "pipelineOptions": {
+ "needTimestamps": true,
+ "pipelineID": "pipelineID",
+ },
+ "reloadVersion": 0,
+ },
+ },
+ [Function],
+ ],
+ ]
+ `);
+
+ expect(buttonNode).toHaveTextContent('1');
+ expect(cb).toHaveBeenCalledTimes(2);
+ expect(cb.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ 0,
+ ],
+ [
+ 1,
+ ],
+ ]
+ `);
+ expect(onTap.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ Event {
+ "eventName": "tap",
+ "eventType": "bindEvent",
+ "isTrusted": false,
+ },
+ ],
+ ]
+ `);
+
+ vi.clearAllMocks();
+});
diff --git a/packages/react/testing-library/src/__tests__/auto-cleanup-skip.test.jsx b/packages/react/testing-library/src/__tests__/auto-cleanup-skip.test.jsx
new file mode 100644
index 0000000000..ac369899e4
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/auto-cleanup-skip.test.jsx
@@ -0,0 +1,23 @@
+let render;
+
+beforeAll(async () => {
+ process.env.PTL_SKIP_AUTO_CLEANUP = 'true';
+ const rtl = await import('..');
+ render = rtl.render;
+});
+
+// This one verifies that if PTL_SKIP_AUTO_CLEANUP is set
+// then we DON'T auto-wire up the afterEach for folks
+test('first', () => {
+ render(hi);
+});
+
+test('second', () => {
+ expect(elementTree.root).toMatchInlineSnapshot(`
+
+
+ hi
+
+
+ `);
+});
diff --git a/packages/react/testing-library/src/__tests__/auto-cleanup.test.jsx b/packages/react/testing-library/src/__tests__/auto-cleanup.test.jsx
new file mode 100644
index 0000000000..b408434860
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/auto-cleanup.test.jsx
@@ -0,0 +1,12 @@
+import { render } from '..';
+
+// This just verifies that by importing PTL in an
+// environment which supports afterEach (like jest)
+// we'll get automatic cleanup between tests.
+test('first', () => {
+ render(hi);
+});
+
+test('second', () => {
+ expect(elementTree.root).toMatchInlineSnapshot(`undefined`);
+});
diff --git a/packages/react/testing-library/src/__tests__/button.test.jsx b/packages/react/testing-library/src/__tests__/button.test.jsx
new file mode 100644
index 0000000000..3c5a7001ef
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/button.test.jsx
@@ -0,0 +1,25 @@
+import '@testing-library/jest-dom';
+import { expect, it, vi } from 'vitest';
+import { render, fireEvent, screen } from '../pure';
+
+it('basic', async function() {
+ const Button = ({
+ children,
+ onClick,
+ }) => {
+ return {children};
+ };
+ const onClick = vi.fn(() => {
+ });
+
+ const { container } = render(
+ ,
+ );
+
+ expect(onClick).not.toHaveBeenCalled();
+ fireEvent.tap(container.firstChild);
+ expect(onClick).toBeCalledTimes(1);
+ expect(screen.getByTestId('text')).toHaveTextContent('Click me');
+});
diff --git a/packages/react/testing-library/src/__tests__/cleanup.test.jsx b/packages/react/testing-library/src/__tests__/cleanup.test.jsx
new file mode 100644
index 0000000000..00bd089395
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/cleanup.test.jsx
@@ -0,0 +1,29 @@
+import { expect } from 'vitest';
+import { render, cleanup } from '..';
+import { Component } from '@lynx-js/react';
+
+test('clean up the document', () => {
+ const spy = vi.fn();
+ const viewId = 'my-view';
+
+ class Test extends Component {
+ componentWillUnmount() {
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+ `);
+ spy();
+ }
+ render() {
+ return ;
+ }
+ }
+
+ render();
+ cleanup();
+ expect(elementTree).toMatchInlineSnapshot(`undefined`);
+ expect(spy).toHaveBeenCalledTimes(1);
+});
diff --git a/packages/react/testing-library/src/__tests__/end-to-end.test.jsx b/packages/react/testing-library/src/__tests__/end-to-end.test.jsx
new file mode 100644
index 0000000000..fb767449b5
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/end-to-end.test.jsx
@@ -0,0 +1,201 @@
+import '@testing-library/jest-dom';
+import { Component } from 'preact';
+import { expect } from 'vitest';
+import { render, screen, waitForElementToBeRemoved } from '..';
+import { snapshotInstanceManager } from '../../../runtime/lib/snapshot.js';
+
+const fetchAMessage = () =>
+ new Promise((resolve) => {
+ // we are using random timeout here to simulate a real-time example
+ // of an async operation calling a callback at a non-deterministic time
+ const randomTimeout = Math.floor(Math.random() * 100);
+
+ setTimeout(() => {
+ resolve({ returnedMessage: 'Hello World' });
+ }, randomTimeout);
+ });
+
+class ComponentWithLoader extends Component {
+ state = { loading: true };
+
+ componentDidMount() {
+ fetchAMessage().then(data => {
+ this.setState({ data, loading: false });
+ });
+ }
+
+ render() {
+ if (this.state.loading) {
+ return Loading...;
+ }
+
+ return (
+
+ Loaded this message: {this.state.data.returnedMessage}!
+
+ );
+ }
+}
+
+test('state change will cause re-render', async () => {
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ expect(snapshotInstanceManager.values).toMatchInlineSnapshot(`
+ Map {
+ -1 => {
+ "children": undefined,
+ "id": -1,
+ "type": "root",
+ "values": undefined,
+ },
+ }
+ `);
+ expect(snapshotInstanceManager.nextId).toMatchInlineSnapshot(`-1`);
+ render();
+ expect(elementTree.root).toMatchInlineSnapshot(`
+
+
+ Loading...
+
+
+ `);
+ expect(snapshotInstanceManager.values).toMatchInlineSnapshot(`
+ Map {
+ -1 => {
+ "children": [
+ {
+ "children": undefined,
+ "id": 2,
+ "type": "__Card__:__snapshot_354a3_test_1",
+ "values": undefined,
+ },
+ ],
+ "id": -1,
+ "type": "root",
+ "values": undefined,
+ },
+ 2 => {
+ "children": undefined,
+ "id": 2,
+ "type": "__Card__:__snapshot_354a3_test_1",
+ "values": undefined,
+ },
+ }
+ `);
+
+ await new Promise(resolve => {
+ setTimeout(resolve, 1000);
+ });
+
+ const isBackground = !__MAIN_THREAD__;
+
+ const callLepusMethod = lynxEnv.backgroundThread.lynx.getNativeApp().callLepusMethod;
+ // callLepusMethodCalls such as rLynxChange
+ globalThis.lynxEnv.switchToMainThread();
+ expect(callLepusMethod.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "rLynxChange",
+ {
+ "data": "{"patchList":[{"snapshotPatch":[0,"__Card__:__snapshot_354a3_test_1",2,1,-1,2,null],"id":2}]}",
+ "patchOptions": {
+ "isHydration": true,
+ "pipelineOptions": {
+ "needTimestamps": true,
+ "pipelineID": "pipelineID",
+ },
+ "reloadVersion": 0,
+ },
+ },
+ [Function],
+ ],
+ [
+ "rLynxChange",
+ {
+ "data": "{"patchList":[{"id":3,"snapshotPatch":[2,-1,2,0,"__Card__:__snapshot_354a3_test_2",3,0,null,4,3,4,0,"Hello World",1,3,4,null,1,-1,3,null]}]}",
+ "patchOptions": {
+ "pipelineOptions": {
+ "needTimestamps": true,
+ "pipelineID": "pipelineID",
+ },
+ "reloadVersion": 0,
+ },
+ },
+ [Function],
+ ],
+ ]
+ `);
+
+ // restore the original thread state
+ if (isBackground) {
+ globalThis.lynxEnv.switchToBackgroundThread();
+ }
+
+ expect(elementTree.root).toMatchInlineSnapshot(`
+
+
+ Loaded this message:
+
+ Hello World
+
+ !
+
+
+ `);
+});
+
+test('it waits for the data to be loaded', async () => {
+ expect(snapshotInstanceManager.values).toMatchInlineSnapshot(`
+ Map {
+ -1 => {
+ "children": undefined,
+ "id": -1,
+ "type": "root",
+ "values": undefined,
+ },
+ }
+ `);
+ expect(snapshotInstanceManager.nextId).toMatchInlineSnapshot(`-1`);
+ render();
+ expect(elementTree.root).toMatchInlineSnapshot(`
+
+
+ Loading...
+
+
+ `);
+ const loading = () => {
+ return screen.getByText('Loading...');
+ };
+ await waitForElementToBeRemoved(loading);
+ expect(document.body).toMatchInlineSnapshot(`
+
+
+
+ Loaded this message:
+
+ Hello World
+
+ !
+
+
+
+ `);
+ expect(screen.getByTestId('message')).toHaveTextContent(/Hello World/);
+ expect(elementTree.root).toMatchInlineSnapshot(`
+
+
+ Loaded this message:
+
+ Hello World
+
+ !
+
+
+ `);
+});
diff --git a/packages/react/testing-library/src/__tests__/events.test.jsx b/packages/react/testing-library/src/__tests__/events.test.jsx
new file mode 100644
index 0000000000..679c6f48f6
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/events.test.jsx
@@ -0,0 +1,220 @@
+// cspell:disable
+import '@testing-library/jest-dom';
+import { test } from 'vitest';
+import { fireEvent, render } from '..';
+import { createRef } from '@lynx-js/react';
+import { expect, vi } from 'vitest';
+
+const eventTypes = [
+ {
+ type: 'LynxBindCatchEvent',
+ events: [
+ 'tap',
+ 'longtap',
+ ],
+ init: {
+ key: 'value',
+ },
+ },
+ {
+ type: 'LynxEvent',
+ events: [
+ 'bgload',
+ 'bgerror',
+ 'touchstart',
+ 'touchmove',
+ 'touchcancel',
+ 'touchend',
+ 'longpress',
+ 'transitionstart',
+ 'transitioncancel',
+ 'transitionend',
+ 'animationstart',
+ 'animationiteration',
+ 'animationcancel',
+ 'animationend',
+ 'mousedown',
+ 'mouseup',
+ 'mousemove',
+ 'mouseclick',
+ 'mousedblclick',
+ 'mouselongpress',
+ 'wheel',
+ 'keydown',
+ 'keyup',
+ 'focus',
+ 'blur',
+ 'layoutchange',
+ ],
+ },
+];
+
+eventTypes.forEach(({ type, events, elementType, init }, eventTypeIdx) => {
+ describe(`${type} Events`, () => {
+ events.forEach((eventName, eventIdx) => {
+ const eventProp = `bind${eventName}`;
+
+ it(`triggers ${eventProp}`, async () => {
+ const ref = createRef();
+ const spy = vi.fn();
+
+ const Comp = () => {
+ return (
+
+ );
+ };
+
+ render();
+
+ if (eventTypeIdx === 0 && eventIdx === 0) {
+ expect(ref).toMatchInlineSnapshot(`
+ {
+ "current": NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "1",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ },
+ }
+ `);
+ expect(ref.current.constructor.name).toMatchInlineSnapshot(
+ `"NodesRef"`,
+ );
+ const element = __GetElementByUniqueId(
+ Number(ref.current._nodeSelectToken.identifier),
+ );
+ expect(element).toMatchInlineSnapshot(`
+
+ `);
+ expect(element.attributes).toMatchInlineSnapshot(`
+ NamedNodeMap {
+ "has-react-ref": "true",
+ }
+ `);
+ expect(element.eventMap).toMatchInlineSnapshot(`
+ {
+ "bindEvent:tap": [Function],
+ }
+ `);
+ expect(init).toMatchInlineSnapshot(`
+ {
+ "key": "value",
+ }
+ `);
+ }
+
+ expect(spy).toHaveBeenCalledTimes(0);
+ expect(fireEvent[eventName](ref.current, init)).toBe(true);
+ expect(spy).toHaveBeenCalledTimes(1);
+ if (init) {
+ expect(spy).toHaveBeenCalledWith(expect.objectContaining(init));
+ if (eventTypeIdx === 0 && eventIdx === 0) {
+ expect(spy.mock.calls[0][0]).toMatchInlineSnapshot(`
+ Event {
+ "eventName": "tap",
+ "eventType": "bindEvent",
+ "isTrusted": false,
+ "key": "value",
+ }
+ `);
+ }
+ }
+ });
+ });
+ });
+});
+
+test('calling `fireEvent` directly works too', () => {
+ const handler = vi.fn();
+
+ const Comp = () => {
+ return ;
+ };
+
+ const { container: { firstChild: button } } = render();
+
+ expect(handler).toHaveBeenCalledTimes(0);
+ const event = new Event('catchEvent:tap');
+ Object.assign(
+ event,
+ {
+ eventType: 'catchEvent',
+ eventName: 'tap',
+ key: 'value',
+ },
+ );
+ // Use fireEvent directly
+ expect(fireEvent(button, event)).toBe(true);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith(event);
+ expect(handler.mock.calls[0][0].type).toMatchInlineSnapshot(
+ `"catchEvent:tap"`,
+ );
+ expect(handler.mock.calls[0][0]).toMatchInlineSnapshot(`
+ Event {
+ "eventName": "tap",
+ "eventType": "catchEvent",
+ "isTrusted": false,
+ "key": "value",
+ }
+`);
+
+ // Use fireEvent.tap
+ fireEvent.tap(button, {
+ eventType: 'catchEvent',
+ });
+ expect(handler).toHaveBeenCalledTimes(2);
+ expect(handler.mock.calls[1][0]).toMatchInlineSnapshot(`
+ Event {
+ "eventName": "tap",
+ "eventType": "catchEvent",
+ "isTrusted": false,
+ }
+`);
+});
+
+test('customEvent not in internal eventMap', () => {
+ const handler = vi.fn();
+
+ const Comp = () => {
+ return ;
+ };
+
+ const { container: { firstChild: button } } = render();
+
+ expect(handler).toHaveBeenCalledTimes(0);
+ const event = new Event('catchEvent:customevent');
+ Object.assign(
+ event,
+ {
+ eventType: 'catchEvent',
+ eventName: 'customevent',
+ key: 'value',
+ },
+ );
+ // Use fireEvent directly
+ expect(fireEvent(button, event)).toBe(true);
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith(event);
+ expect(handler.mock.calls[0][0].type).toMatchInlineSnapshot(
+ `"catchEvent:customevent"`,
+ );
+ expect(handler.mock.calls[0][0]).toMatchInlineSnapshot(`
+ Event {
+ "eventName": "customevent",
+ "eventType": "catchEvent",
+ "isTrusted": false,
+ "key": "value",
+ }
+ `);
+});
diff --git a/packages/react/testing-library/src/__tests__/list.test.jsx b/packages/react/testing-library/src/__tests__/list.test.jsx
new file mode 100644
index 0000000000..ab980c35bb
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/list.test.jsx
@@ -0,0 +1,229 @@
+import { describe, expect } from 'vitest';
+import { render } from '..';
+import { useState } from '@lynx-js/react';
+import { __pendingListUpdates } from '../../../runtime/lib/list.js';
+
+describe('list', () => {
+ it('basic', async () => {
+ expect(__pendingListUpdates.values).toMatchInlineSnapshot(`{}`);
+ const Comp = () => {
+ const [list, setList] = useState([0, 1, 2]);
+ return (
+
+ {list.map((item) => (
+
+ {item}
+
+ ))}
+
+ );
+ };
+
+ const { container } = render();
+ expect(__pendingListUpdates.values).toMatchInlineSnapshot(`{}`);
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+ `);
+ const list = container.firstChild;
+ expect(list.props).toMatchInlineSnapshot(`undefined`);
+ const uid0 = elementTree.enterListItemAtIndex(list, 0);
+ expect(__pendingListUpdates.values).toMatchInlineSnapshot(`
+ {
+ "2": [
+ {
+ "insertAction": [],
+ "removeAction": [],
+ "updateAction": [
+ {
+ "flush": false,
+ "from": 0,
+ "item-key": 0,
+ "to": 0,
+ "type": "__Card__:__snapshot_a9e46_test_2",
+ },
+ ],
+ },
+ ],
+ }
+ `);
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ 0
+
+
+
+
+ `);
+ const uid1 = elementTree.enterListItemAtIndex(list, 1);
+ expect(__pendingListUpdates.values).toMatchInlineSnapshot(`
+ {
+ "2": [
+ {
+ "insertAction": [],
+ "removeAction": [],
+ "updateAction": [
+ {
+ "flush": false,
+ "from": 0,
+ "item-key": 0,
+ "to": 0,
+ "type": "__Card__:__snapshot_a9e46_test_2",
+ },
+ {
+ "flush": false,
+ "from": 1,
+ "item-key": 1,
+ "to": 1,
+ "type": "__Card__:__snapshot_a9e46_test_2",
+ },
+ ],
+ },
+ ],
+ }
+ `);
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ 0
+
+
+
+
+ 1
+
+
+
+
+ `);
+ expect(uid0).toMatchInlineSnapshot(`2`);
+ expect(uid1).toMatchInlineSnapshot(`5`);
+ elementTree.leaveListItem(list, uid0);
+ expect(__pendingListUpdates.values).toMatchInlineSnapshot(`
+ {
+ "2": [
+ {
+ "insertAction": [],
+ "removeAction": [],
+ "updateAction": [
+ {
+ "flush": false,
+ "from": 0,
+ "item-key": 0,
+ "to": 0,
+ "type": "__Card__:__snapshot_a9e46_test_2",
+ },
+ {
+ "flush": false,
+ "from": 1,
+ "item-key": 1,
+ "to": 1,
+ "type": "__Card__:__snapshot_a9e46_test_2",
+ },
+ ],
+ },
+ ],
+ }
+ `);
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ 0
+
+
+
+
+ 1
+
+
+
+
+ `);
+ const uid2 = elementTree.enterListItemAtIndex(list, 2);
+ expect(__pendingListUpdates.values).toMatchInlineSnapshot(
+ `
+ {
+ "2": [
+ {
+ "insertAction": [],
+ "removeAction": [],
+ "updateAction": [
+ {
+ "flush": false,
+ "from": 0,
+ "item-key": 0,
+ "to": 0,
+ "type": "__Card__:__snapshot_a9e46_test_2",
+ },
+ {
+ "flush": false,
+ "from": 1,
+ "item-key": 1,
+ "to": 1,
+ "type": "__Card__:__snapshot_a9e46_test_2",
+ },
+ {
+ "flush": false,
+ "from": 2,
+ "item-key": 2,
+ "to": 2,
+ "type": "__Card__:__snapshot_a9e46_test_2",
+ },
+ ],
+ },
+ ],
+ }
+ `,
+ );
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ 2
+
+
+
+
+ 1
+
+
+
+
+ `);
+ expect(uid2).toMatchInlineSnapshot(`2`);
+ expect(uid0).toBe(uid2);
+ });
+});
diff --git a/packages/react/testing-library/src/__tests__/lynx.test.jsx b/packages/react/testing-library/src/__tests__/lynx.test.jsx
new file mode 100644
index 0000000000..bf29bd4049
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/lynx.test.jsx
@@ -0,0 +1,27 @@
+import { describe } from 'vitest';
+
+describe('lynx global API', () => {
+ it('getJSModule should work', () => {
+ const cb = vi.fn();
+ lynx.getJSModule('GlobalEventEmitter')
+ .addListener('onDataChanged', cb);
+
+ lynx.getJSModule('GlobalEventEmitter').emit('onDataChanged', {
+ data: {
+ foo: 'bar',
+ },
+ });
+ expect(cb).toBeCalledTimes(1);
+ expect(cb.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ {
+ "data": {
+ "foo": "bar",
+ },
+ },
+ ],
+ ]
+ `);
+ });
+});
diff --git a/packages/react/testing-library/src/__tests__/ref.test.jsx b/packages/react/testing-library/src/__tests__/ref.test.jsx
new file mode 100644
index 0000000000..f618a0030e
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/ref.test.jsx
@@ -0,0 +1,463 @@
+import { createRef, Component, useState } from '@lynx-js/react';
+import { render } from '..';
+import { expect, vi } from 'vitest';
+import { act } from 'preact/test-utils';
+
+describe('component ref', () => {
+ it('basic', async () => {
+ const cleanup = vi.fn();
+ const ref1 = vi.fn(() => {
+ return cleanup;
+ });
+ const ref2 = createRef();
+ const ref3 = vi.fn();
+ const ref4 = createRef();
+ let _setShow;
+
+ class Child extends Component {
+ name = 'child';
+ render() {
+ return ;
+ }
+ }
+
+ function App() {
+ const [show, setShow] = useState(true);
+ _setShow = setShow;
+
+ return ;
+ }
+
+ class Comp extends Component {
+ name = 'comp';
+ render() {
+ return this.props.show && (
+
+
+
+
+
+
+ );
+ }
+ }
+
+ render();
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+
+
+
+
+
+
+
+ `);
+ expect(ref1).toBeCalledWith(expect.objectContaining({
+ name: 'child',
+ }));
+ expect(ref2.current).toHaveProperty('name', 'child');
+ expect(ref3.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "3",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ },
+ ],
+ ]
+ `);
+ expect(ref4.current).toMatchInlineSnapshot(`
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "4",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ }
+ `);
+ expect(cleanup).toBeCalledTimes(0);
+ act(() => {
+ _setShow(false);
+ });
+ expect(cleanup).toBeCalledTimes(1);
+ expect(cleanup.mock.calls).toMatchInlineSnapshot(`
+ [
+ [],
+ ]
+ `);
+ expect(ref3).toHaveBeenCalledWith(null);
+ expect(ref4.current).toBeNull();
+ });
+});
+
+describe('element ref', () => {
+ it('basic', async () => {
+ const ref1 = vi.fn();
+ const ref2 = createRef();
+
+ class Comp extends Component {
+ name = 'comp';
+ render() {
+ return (
+
+
+
+
+ );
+ }
+ }
+ render();
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+
+
+
+ `);
+ expect(ref1.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "2",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ },
+ ],
+ ]
+ `);
+ expect(ref2.current).toMatchInlineSnapshot(`
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "3",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ }
+ `);
+ });
+
+ it('insert', async () => {
+ const ref1 = vi.fn();
+ const ref2 = createRef();
+ let _setShow;
+
+ function App() {
+ const [show, setShow] = useState(false);
+ _setShow = setShow;
+ return ;
+ }
+ class Comp extends Component {
+ name = 'comp';
+ render() {
+ return this.props.show && (
+
+
+
+
+ );
+ }
+ }
+ render();
+ expect(elementTree).toMatchInlineSnapshot(``);
+ expect(ref1.mock.calls).toMatchInlineSnapshot(`[]`);
+ expect(ref2.current).toBeNull();
+ act(() => {
+ _setShow(true);
+ });
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+
+
+
+ `);
+ expect(ref1.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "2",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ },
+ ],
+ ]
+ `);
+ expect(ref2.current).toMatchInlineSnapshot(`
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "3",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ }
+ `);
+ });
+
+ it('remove', async () => {
+ const ref1 = vi.fn();
+ const ref2 = createRef();
+ let _setShow;
+
+ function App() {
+ const [show, setShow] = useState(true);
+ _setShow = setShow;
+ return ;
+ }
+
+ class Comp extends Component {
+ name = 'comp';
+ render() {
+ return this.props.show && (
+
+
+
+
+ );
+ }
+ }
+ render();
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+
+
+
+ `);
+ expect(ref1.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "2",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ },
+ ],
+ ]
+ `);
+ expect(ref2.current).toMatchInlineSnapshot(`
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "3",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ }
+ `);
+ act(() => {
+ _setShow(false);
+ });
+ expect(elementTree).toMatchInlineSnapshot(``);
+ expect(ref1.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "2",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ },
+ ],
+ [
+ null,
+ ],
+ ]
+ `);
+ expect(ref2.current).toBeNull();
+ });
+
+ it('remove with cleanup function', async () => {
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(0);
+
+ const cleanup = vi.fn();
+ const ref1 = vi.fn(() => {
+ return cleanup;
+ });
+ let _setShow;
+
+ function App() {
+ const [show, setShow] = useState(true);
+ _setShow = setShow;
+ return ;
+ }
+
+ class Comp extends Component {
+ name = 'comp';
+ render() {
+ return this.props.show && (
+
+
+
+ );
+ }
+ }
+ render();
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+
+
+ `);
+ expect(ref1.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "2",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ },
+ ],
+ ]
+ `);
+ expect(cleanup.mock.calls).toMatchInlineSnapshot(`[]`);
+ expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1);
+ act(() => {
+ _setShow(false);
+ });
+ expect(elementTree).toMatchInlineSnapshot(``);
+ expect(ref1.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "2",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ },
+ ],
+ ]
+ `);
+ expect(cleanup.mock.calls).toMatchInlineSnapshot(`
+ [
+ [],
+ ]
+ `);
+ expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(2);
+ vi.resetAllMocks();
+ });
+
+ it('unmount', async () => {
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(0);
+
+ const cleanup = vi.fn();
+ const ref1 = vi.fn(() => {
+ return cleanup;
+ });
+ const ref2 = createRef();
+
+ function App() {
+ return ;
+ }
+
+ class Comp extends Component {
+ name = 'comp';
+ render() {
+ return (
+ this.props.show && (
+
+
+
+
+ )
+ );
+ }
+ }
+ const { unmount } = render();
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+
+
+
+ `);
+ expect(ref1.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "2",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ },
+ ],
+ ]
+ `);
+ expect(ref2.current).toMatchInlineSnapshot(`
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "3",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ }
+ `);
+ expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(1);
+ unmount();
+ expect(ref1.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "2",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ },
+ ],
+ ]
+ `);
+ expect(ref2.current).toBeNull();
+ expect(cleanup.mock.calls).toMatchInlineSnapshot(`
+ [
+ [],
+ ]
+ `);
+ expect(lynx.getNativeApp().callLepusMethod).toBeCalledTimes(2);
+ vi.resetAllMocks();
+ });
+});
diff --git a/packages/react/testing-library/src/__tests__/render.test.jsx b/packages/react/testing-library/src/__tests__/render.test.jsx
new file mode 100644
index 0000000000..cfd62ca70a
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/render.test.jsx
@@ -0,0 +1,49 @@
+import '@testing-library/jest-dom';
+import { test, expect } from 'vitest';
+import { render } from '..';
+import { createRef } from '@lynx-js/react';
+
+test('renders view into page', async () => {
+ const ref = createRef();
+ const Comp = () => {
+ return (
+
+
+
+
+
+
+ );
+ };
+ render();
+ expect(ref.current).toMatchInlineSnapshot(`
+ NodesRef {
+ "_nodeSelectToken": {
+ "identifier": "1",
+ "type": 2,
+ },
+ "_selectorQuery": {},
+ }
+ `);
+});
+
+test('renders options.wrapper around node', async () => {
+ const WrapperComponent = ({ children }) => {children};
+ const Comp = () => {
+ return ;
+ };
+ const { container, getByTestId } = render(, {
+ wrapper: WrapperComponent,
+ });
+ expect(getByTestId('wrapper')).toBeInTheDocument();
+ expect(container.firstChild).toMatchInlineSnapshot(`
+
+
+
+ `);
+});
diff --git a/packages/react/testing-library/src/__tests__/renderHook.test.jsx b/packages/react/testing-library/src/__tests__/renderHook.test.jsx
new file mode 100644
index 0000000000..ad7a5c7c0f
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/renderHook.test.jsx
@@ -0,0 +1,61 @@
+import { useState, useEffect, createContext, useContext } from '@lynx-js/react';
+import { renderHook } from '..';
+
+test('gives committed result', async () => {
+ const { result } = renderHook(() => {
+ const [state, setState] = useState(1);
+
+ useEffect(() => {
+ setState(2);
+ }, []);
+
+ return [state, setState];
+ });
+
+ expect(result.current).toEqual([2, expect.any(Function)]);
+});
+
+test('allows rerendering', async () => {
+ const { result, rerender } = renderHook(
+ ({ branch }) => {
+ const [left, setLeft] = useState('left');
+ const [right, setRight] = useState('right');
+
+ switch (branch) {
+ case 'left':
+ return [left, setLeft];
+ case 'right':
+ return [right, setRight];
+
+ default:
+ throw new Error(
+ 'No Props passed. This is a bug in the implementation',
+ );
+ }
+ },
+ { initialProps: { branch: 'left' } },
+ );
+
+ expect(result.current).toEqual(['left', expect.any(Function)]);
+
+ rerender({ branch: 'right' });
+
+ expect(result.current).toEqual(['right', expect.any(Function)]);
+});
+
+test('allows wrapper components', async () => {
+ const Context = createContext('default');
+ function Wrapper({ children }) {
+ return {children};
+ }
+ const { result } = renderHook(
+ () => {
+ return useContext(Context);
+ },
+ {
+ wrapper: Wrapper,
+ },
+ );
+
+ expect(result.current).toEqual('provided');
+});
diff --git a/packages/react/testing-library/src/__tests__/rerender.test.jsx b/packages/react/testing-library/src/__tests__/rerender.test.jsx
new file mode 100644
index 0000000000..1a5bf23d44
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/rerender.test.jsx
@@ -0,0 +1,54 @@
+import '@testing-library/jest-dom';
+import { render } from '..';
+import { expect } from 'vitest';
+import { useEffect, useState } from '@lynx-js/react';
+
+test('rerender will re-render the element', async () => {
+ const Greeting = (props) => {props.message};
+ const { container, rerender } = render();
+ expect(container).toMatchInlineSnapshot(`
+
+
+ hi
+
+
+ `);
+ expect(container.firstChild).toHaveTextContent('hi');
+
+ {
+ const { container } = rerender();
+ expect(container.firstChild).toHaveTextContent('hey');
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+ hey
+
+
+ `);
+ }
+});
+
+test('rerender will flush pending hooks effects', async () => {
+ const Component = () => {
+ const [value, setValue] = useState(0);
+ useEffect(() => {
+ const timeoutId = setTimeout(() => {
+ setValue(1);
+ }, 0);
+ return () => clearTimeout(timeoutId);
+ });
+
+ return value;
+ };
+
+ const { rerender } = render();
+ const { findByText } = rerender();
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethod = lynxEnv.backgroundThread.lynx.getNativeApp().callLepusMethod;
+ expect(callLepusMethod.mock.calls).toMatchInlineSnapshot(`[]`);
+
+ await findByText('1');
+
+ vi.clearAllMocks();
+});
diff --git a/packages/react/testing-library/src/__tests__/stopwatch.test.jsx b/packages/react/testing-library/src/__tests__/stopwatch.test.jsx
new file mode 100644
index 0000000000..448e1f14d6
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/stopwatch.test.jsx
@@ -0,0 +1,80 @@
+import { Component } from '@lynx-js/react';
+import { fireEvent, render } from '..';
+import { expect, vi } from 'vitest';
+
+class StopWatch extends Component {
+ state = { lapse: 0, running: false };
+
+ handleRunClick = () => {
+ this.setState((state) => {
+ if (state.running) {
+ clearInterval(this.timer);
+ } else {
+ const startTime = Date.now() - this.state.lapse;
+ this.timer = setInterval(() => {
+ this.setState({ lapse: Date.now() - startTime });
+ });
+ }
+ return { running: !state.running };
+ });
+ };
+
+ handleClearClick = () => {
+ clearInterval(this.timer);
+ this.setState({ lapse: 0, running: false });
+ };
+
+ componentWillUnmount() {
+ clearInterval(this.timer);
+ }
+
+ render({ lapse, running }) {
+ return (
+
+ {lapse}ms
+
+ {running ? 'Stop' : 'Start'}
+
+ Clear
+
+ );
+ }
+}
+
+const wait = (time) => new Promise((resolve) => setTimeout(resolve, time));
+
+test('unmounts a component', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ const { unmount, container, getByText } = render();
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+
+ ms
+
+
+ Start
+
+
+ Clear
+
+
+
+ `);
+
+ fireEvent.tap(getByText('Start'));
+
+ unmount();
+
+ // Hey there reader! You don't need to have an assertion like this one
+ // this is just me making sure that the unmount function works.
+ // You don't need to do this in your apps. Just rely on the fact that this works.
+ expect(elementTree.root).toMatchInlineSnapshot(`undefined`);
+
+ // Just wait to see if the interval is cleared or not.
+ // If it's not, then we'll call setState on an unmounted component and get an error.
+ await wait(() => expect(console.error).not.toHaveBeenCalled());
+});
diff --git a/packages/react/testing-library/src/__tests__/worklet.test.jsx b/packages/react/testing-library/src/__tests__/worklet.test.jsx
new file mode 100644
index 0000000000..ca04e40f43
--- /dev/null
+++ b/packages/react/testing-library/src/__tests__/worklet.test.jsx
@@ -0,0 +1,458 @@
+import { describe, expect, vi } from 'vitest';
+import { fireEvent, render, waitSchedule } from '..';
+import { runOnBackground, useMainThreadRef, runOnMainThread } from '@lynx-js/react';
+describe('worklet', () => {
+ it('main-thread script should work', async () => {
+ const cb = vi.fn();
+ const Comp = () => {
+ return (
+ {
+ 'main thread';
+ cb(e);
+ }}
+ >
+ Hello Main Thread Script
+
+ );
+ };
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: false,
+ });
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+ Hello Main Thread Script
+
+
+
+ `);
+ fireEvent.tap(container.firstChild, {
+ key: 'value',
+ });
+ expect(cb).toBeCalledTimes(1);
+ expect(cb.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ {
+ "eventName": "tap",
+ "eventType": "bindEvent",
+ "isTrusted": false,
+ "key": "value",
+ },
+ ],
+ ]
+ `);
+ });
+ it('main-thread script should not throw when enable background thread', async () => {
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`[]`);
+
+ globalThis.cb = vi.fn();
+ const mainThreadFn = () => {
+ 'main thread';
+ console.log('main thread');
+ globalThis.cb();
+ };
+
+ const Comp = () => {
+ return (
+
+ Hello Main Thread Script
+
+ );
+ };
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`
+ [
+ [
+ "rLynxChange",
+ {
+ "data": "{"patchList":[{"snapshotPatch":[3,-2,0,{"_wkltId":"a45f:test:2","_workletType":"main-thread","_execId":1}],"id":2}]}",
+ "patchOptions": {
+ "isHydration": true,
+ "pipelineOptions": {
+ "needTimestamps": true,
+ "pipelineID": "pipelineID",
+ },
+ "reloadVersion": 0,
+ },
+ },
+ [Function],
+ ],
+ ]
+ `);
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+ Hello Main Thread Script
+
+
+
+ `);
+ fireEvent.tap(container.firstChild, {
+ key: 'value',
+ });
+ expect(globalThis.cb).toBeCalledTimes(1);
+ expect(globalThis.cb.mock.calls).toMatchInlineSnapshot(`
+ [
+ [],
+ ]
+ `);
+ vi.resetAllMocks();
+ });
+ it('main-thread script should not update MTS function when enable background', async () => {
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`[]`);
+
+ globalThis.cb = vi.fn();
+ const mainThreadFn = (e) => {
+ 'main thread';
+ console.log('main thread');
+ globalThis.cb(e);
+ };
+
+ const Comp = (props) => {
+ const {
+ onClick,
+ } = props;
+
+ return (
+ {
+ if (onClick) {
+ onClick(e);
+ }
+ }}
+ main-thread:bindtap={(e) => {
+ 'main thread';
+ if (props['main-thread:onClick']) {
+ props['main-thread:onClick'](e);
+ }
+ }}
+ >
+ Hello Main Thread Script
+
+ );
+ };
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`
+ [
+ [
+ "rLynxChange",
+ {
+ "data": "{"patchList":[{"snapshotPatch":[3,-2,1,{"_c":{"props":{"main-thread:onClick":{"_wkltId":"a45f:test:3"}}},"_wkltId":"a45f:test:4","_execId":1}],"id":2}]}",
+ "patchOptions": {
+ "isHydration": true,
+ "pipelineOptions": {
+ "needTimestamps": true,
+ "pipelineID": "pipelineID",
+ },
+ "reloadVersion": 0,
+ },
+ },
+ [Function],
+ ],
+ ]
+ `);
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+ Hello Main Thread Script
+
+
+
+ `);
+ fireEvent.tap(container.firstChild, {
+ key: 'value',
+ });
+ expect(globalThis.cb).toBeCalledTimes(1);
+ expect(globalThis.cb.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ {
+ "eventName": "tap",
+ "eventType": "bindEvent",
+ "isTrusted": false,
+ "key": "value",
+ },
+ ],
+ ]
+ `);
+ vi.resetAllMocks();
+ });
+
+ it('main thread script props', () => {
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`[]`);
+
+ globalThis.cb = vi.fn();
+ const mainThreadFn = (e) => {
+ 'main thread';
+ console.log('main thread');
+ globalThis.cb(e);
+ };
+
+ const List = (props) => {
+ return (
+ {
+ 'main thread';
+ if (props['main-thread:onScroll']) {
+ props['main-thread:onScroll'](e);
+ }
+ }}
+ >
+
+ );
+ };
+
+ const { container } = render(
+
,
+ {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ },
+ );
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+ `);
+
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`
+ [
+ [
+ "rLynxChange",
+ {
+ "data": "{"patchList":[{"snapshotPatch":[3,-2,0,{"_c":{"props":{"main-thread:onScroll":{"_wkltId":"a45f:test:5"}}},"_wkltId":"a45f:test:6","_execId":1}],"id":2}]}",
+ "patchOptions": {
+ "isHydration": true,
+ "pipelineOptions": {
+ "needTimestamps": true,
+ "pipelineID": "pipelineID",
+ },
+ "reloadVersion": 0,
+ },
+ },
+ [Function],
+ ],
+ ]
+ `);
+
+ const list = container.firstChild;
+ fireEvent.scroll(list, {
+ info: {
+ detail: {
+ scrollTop: 100,
+ scrollLeft: 0,
+ },
+ },
+ });
+ expect(globalThis.cb).toBeCalledTimes(1);
+ });
+
+ it('runOnMainThread works', async () => {
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`[]`);
+ const Comp = () => {
+ return (
+ {
+ const resp = await runOnMainThread(() => {
+ 'main thread';
+ console.log('run on main thread');
+ return 'Hello from main thread';
+ })();
+ expect(resp).toMatchInlineSnapshot(`"Hello from main thread"`);
+ }}
+ >
+ Hello Main Thread Script
+
+ );
+ };
+
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ fireEvent.tap(container.firstChild, {
+ key: 'value',
+ });
+ await waitSchedule();
+ });
+
+ it('runOnBackground works', async () => {
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`[]`);
+
+ const cb = vi.fn();
+ globalThis.receiveRunOnBackgroundResp = vi.fn();
+ const Comp = () => {
+ return (
+ {
+ 'main thread';
+ const resp = await runOnBackground(() => {
+ console.log('run on background');
+ cb();
+ return 'Hello from background';
+ })();
+ globalThis.receiveRunOnBackgroundResp(resp);
+ }}
+ >
+ Hello Main Thread Script
+
+ );
+ };
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`
+ [
+ [
+ "rLynxChange",
+ {
+ "data": "{"patchList":[{"snapshotPatch":[3,-2,0,{"_wkltId":"a45f:test:8","_jsFn":{"_jsFn1":{"_jsFnId":2}},"_execId":1}],"id":2}]}",
+ "patchOptions": {
+ "isHydration": true,
+ "pipelineOptions": {
+ "needTimestamps": true,
+ "pipelineID": "pipelineID",
+ },
+ "reloadVersion": 0,
+ },
+ },
+ [Function],
+ ],
+ ]
+ `);
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+ Hello Main Thread Script
+
+
+
+ `);
+ fireEvent.tap(container.firstChild, {
+ key: 'value',
+ });
+ expect(cb).toBeCalledTimes(1);
+ expect(cb.mock.calls).toMatchInlineSnapshot(`
+ [
+ [],
+ ]
+ `);
+ // wait for runOnBackground to finish
+ await waitSchedule();
+ expect(globalThis.receiveRunOnBackgroundResp).toBeCalledTimes(1);
+ expect(globalThis.receiveRunOnBackgroundResp.mock.calls)
+ .toMatchInlineSnapshot(`
+ [
+ [
+ "Hello from background",
+ ],
+ ]
+ `);
+ vi.resetAllMocks();
+ });
+
+ it('worklet ref should work', async () => {
+ vi.spyOn(lynx.getNativeApp(), 'callLepusMethod');
+ const callLepusMethodCalls = lynx.getNativeApp().callLepusMethod.mock.calls;
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`[]`);
+ globalThis.cb = vi.fn();
+ const Comp = () => {
+ const ref = useMainThreadRef(null);
+ const num = useMainThreadRef(0);
+
+ const handleTap = () => {
+ 'main thread';
+ ref.current?.setStyleProperty('background-color', 'blue');
+ num.current = 100;
+ globalThis.cb(num.current);
+ };
+
+ return (
+
+ Hello main thread ref
+
+ );
+ };
+
+ const { container } = render(, {
+ enableMainThread: true,
+ enableBackgroundThread: true,
+ });
+
+ expect(container).toMatchInlineSnapshot(`
+
+
+
+ Hello main thread ref
+
+
+
+ `);
+ expect(callLepusMethodCalls).toMatchInlineSnapshot(`
+ [
+ [
+ "rLynxChange",
+ {
+ "data": "{"patchList":[{"id":1,"workletRefInitValuePatch":[[1,null],[2,0]]},{"snapshotPatch":[3,-2,0,{"_wvid":1},3,-2,1,{"_c":{"ref":{"_wvid":1},"num":{"_wvid":2}},"_wkltId":"a45f:test:9","_execId":1}],"id":2}]}",
+ "patchOptions": {
+ "isHydration": true,
+ "pipelineOptions": {
+ "needTimestamps": true,
+ "pipelineID": "pipelineID",
+ },
+ "reloadVersion": 0,
+ },
+ },
+ [Function],
+ ],
+ ]
+ `);
+ fireEvent.tap(container.firstChild, {
+ key: 'value',
+ });
+ expect(globalThis.cb).toBeCalledTimes(1);
+ expect(globalThis.cb.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ 100,
+ ],
+ ]
+ `);
+ });
+});
diff --git a/packages/react/testing-library/src/env/vitest.ts b/packages/react/testing-library/src/env/vitest.ts
new file mode 100644
index 0000000000..9dcf051390
--- /dev/null
+++ b/packages/react/testing-library/src/env/vitest.ts
@@ -0,0 +1,3 @@
+import env from '@lynx-js/test-environment/env/vitest';
+
+export default env;
diff --git a/packages/react/testing-library/src/fire-event.ts b/packages/react/testing-library/src/fire-event.ts
new file mode 100644
index 0000000000..3c2212afd9
--- /dev/null
+++ b/packages/react/testing-library/src/fire-event.ts
@@ -0,0 +1,185 @@
+// @ts-nocheck
+import { fireEvent as domFireEvent, createEvent } from '@testing-library/dom';
+
+let NodesRef = lynx.createSelectorQuery().selectUniqueID(-1).constructor;
+function getElement(elemOrNodesRef) {
+ if (elemOrNodesRef instanceof NodesRef) {
+ return __GetElementByUniqueId(
+ Number(elemOrNodesRef._nodeSelectToken.identifier),
+ );
+ } else if (elemOrNodesRef?.constructor?.name === 'HTMLUnknownElement') {
+ return elemOrNodesRef;
+ } else {
+ throw new Error(
+ 'Invalid element, got: ' + elemOrNodesRef.constructor?.name,
+ );
+ }
+}
+// Similar to RTL we make are own fireEvent helper that just calls DTL's fireEvent with that
+// we can that any specific behaviors to the helpers we need
+export const fireEvent: any = (elemOrNodesRef, ...args) => {
+ const isMainThread = __MAIN_THREAD__;
+
+ // switch to background thread
+ lynxEnv.switchToBackgroundThread();
+
+ const elem = getElement(elemOrNodesRef);
+
+ let ans = domFireEvent(elem, ...args);
+
+ if (isMainThread) {
+ // switch back to main thread
+ lynxEnv.switchToMainThread();
+ }
+
+ return ans;
+};
+
+export const eventMap = {
+ // LynxBindCatchEvent Events
+ tap: {
+ defaultInit: {},
+ },
+ longtap: {
+ defaultInit: {},
+ },
+ // LynxEvent Events
+ bgload: {
+ defaultInit: {},
+ },
+ bgerror: {
+ defaultInit: {},
+ },
+ touchstart: {
+ defaultInit: {},
+ },
+ touchmove: {
+ defaultInit: {},
+ },
+ touchcancel: {
+ defaultInit: {},
+ },
+ touchend: {
+ defaultInit: {},
+ },
+ longpress: {
+ defaultInit: {},
+ },
+ transitionstart: {
+ defaultInit: {},
+ },
+ transitioncancel: {
+ defaultInit: {},
+ },
+ transitionend: {
+ defaultInit: {},
+ },
+ animationstart: {
+ defaultInit: {},
+ },
+ animationiteration: {
+ defaultInit: {},
+ },
+ animationcancel: {
+ defaultInit: {},
+ },
+ animationend: {
+ defaultInit: {},
+ },
+ mousedown: {
+ defaultInit: {},
+ },
+ mouseup: {
+ defaultInit: {},
+ },
+ mousemove: {
+ defaultInit: {},
+ },
+ mouseclick: {
+ defaultInit: {},
+ },
+ mousedblclick: {
+ defaultInit: {},
+ },
+ mouselongpress: {
+ defaultInit: {},
+ },
+ wheel: {
+ defaultInit: {},
+ },
+ keydown: {
+ defaultInit: {},
+ },
+ keyup: {
+ defaultInit: {},
+ },
+ focus: {
+ defaultInit: {},
+ },
+ blur: {
+ defaultInit: {},
+ },
+ layoutchange: {
+ defaultInit: {},
+ },
+
+ scrolltoupper: {
+ defaultInit: {},
+ },
+ scrolltolower: {
+ defaultInit: {},
+ },
+ scroll: {
+ defaultInit: {},
+ },
+ scrollend: {
+ defaultInit: {},
+ },
+ contentsizechanged: {
+ defaultInit: {},
+ },
+ scrolltoupperedge: {
+ defaultInit: {},
+ },
+ scrolltoloweredge: {
+ defaultInit: {},
+ },
+ scrolltonormalstate: {
+ defaultInit: {},
+ },
+};
+
+Object.keys(eventMap).forEach((key) => {
+ fireEvent[key] = (elemOrNodesRef, init = {}) => {
+ const isMainThread = __MAIN_THREAD__;
+ // switch to background thread
+ lynxEnv.switchToBackgroundThread();
+
+ const elem = getElement(elemOrNodesRef);
+ const eventType = init?.['eventType'] || 'bindEvent';
+ init = {
+ eventType,
+ eventName: key,
+ ...eventMap[key].defaultInit,
+ ...init,
+ };
+
+ const event = createEvent(
+ `${eventType}:${key}`,
+ elem,
+ init,
+ );
+ Object.assign(event, init);
+ const ans = domFireEvent(
+ elem,
+ event,
+ );
+
+ if (isMainThread) {
+ // switch back to main thread
+ lynxEnv.switchToMainThread();
+ }
+
+ return ans;
+ };
+});
diff --git a/packages/react/testing-library/src/index.jsx b/packages/react/testing-library/src/index.jsx
new file mode 100644
index 0000000000..86e73f671d
--- /dev/null
+++ b/packages/react/testing-library/src/index.jsx
@@ -0,0 +1,25 @@
+import { cleanup } from './pure.jsx';
+
+// If we're running in a test runner that supports afterEach
+// or teardown then we'll automatically run cleanup afterEach test
+// this ensures that tests run in isolation from each other.
+// If you don't like this then either import the `pure` module
+// or set the PTL_SKIP_AUTO_CLEANUP env variable to 'true'.
+if (
+ typeof process === 'undefined' || process.env.PTL_SKIP_AUTO_CLEANUP !== 'true'
+) {
+ if (typeof afterEach === 'function') {
+ afterEach(() => {
+ cleanup();
+ lynxEnv.resetLynxEnv();
+ });
+ } else if (typeof teardown === 'function') {
+ // eslint-disable-next-line no-undef
+ teardown(() => {
+ cleanup();
+ lynxEnv.resetLynxEnv();
+ });
+ }
+}
+
+export * from './pure.jsx';
diff --git a/packages/react/testing-library/src/pure.jsx b/packages/react/testing-library/src/pure.jsx
new file mode 100644
index 0000000000..cad285ac72
--- /dev/null
+++ b/packages/react/testing-library/src/pure.jsx
@@ -0,0 +1,138 @@
+// Copyright 2024 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import { configure as configureDTL, getQueriesForElement } from '@testing-library/dom';
+import { cloneElement, createRef, h, render as preactRender } from 'preact';
+import { useEffect } from 'preact/hooks';
+import { act } from 'preact/test-utils';
+
+import { __root } from '@lynx-js/react/internal';
+
+import { flushDelayedLifecycleEvents } from '../../runtime/lib/lynx/tt.js';
+import { clearPage } from '../../runtime/lib/snapshot.js';
+import { commitToMainThread } from '../../runtime/lib/lifecycle/patch/commit.js';
+
+export function waitSchedule() {
+ return new Promise(resolve => {
+ requestAnimationFrame(() => {
+ setTimeout(resolve);
+ });
+ });
+}
+
+configureDTL({
+ asyncWrapper: async cb => {
+ let result;
+ await act(() => {
+ result = cb();
+ });
+ return result;
+ },
+ eventWrapper: cb => {
+ let result;
+ act(() => {
+ result = cb();
+ });
+ return result;
+ },
+});
+
+export function render(
+ ui,
+ {
+ queries,
+ wrapper: WrapperComponent,
+ enableMainThread = false,
+ enableBackgroundThread = true,
+ } = {},
+) {
+ if (!enableMainThread && !enableBackgroundThread) {
+ throw new Error(
+ 'You must enable at least one thread for rendering (enableMainThread or enableBackgroundThread)',
+ );
+ }
+ const wrapUiIfNeeded = (innerElement) => (WrapperComponent
+ ? h(WrapperComponent, null, innerElement)
+ : innerElement);
+
+ const comp = wrapUiIfNeeded(ui);
+ const compMainThread = cloneElement(comp);
+ const compBackgroundThread = cloneElement(comp);
+
+ globalThis.lynxEnv.switchToMainThread();
+ __root.__jsx = enableMainThread ? compMainThread : null;
+ renderPage();
+ if (enableBackgroundThread) {
+ globalThis.lynxEnv.switchToBackgroundThread();
+ act(() => {
+ preactRender(compBackgroundThread, __root);
+ flushDelayedLifecycleEvents();
+ });
+ }
+
+ return {
+ container: lynxEnv.mainThread.elementTree.root,
+ unmount: cleanup,
+ rerender: (rerenderUi) => {
+ lynxEnv.resetLynxEnv();
+ return render(wrapUiIfNeeded(rerenderUi), {
+ queries,
+ wrapper: WrapperComponent,
+ enableMainThread,
+ enableBackgroundThread,
+ });
+ },
+ ...getQueriesForElement(lynxEnv.mainThread.elementTree.root, queries),
+ };
+}
+
+export function cleanup() {
+ const isMainThread = __MAIN_THREAD__;
+
+ // Ensure componentWillUnmount is called
+ globalThis.lynxEnv.switchToBackgroundThread();
+ act(() => {
+ preactRender(null, __root);
+ // This is needed to ensure that the ui updates are sent to the main thread
+ commitToMainThread();
+ });
+
+ lynxEnv.mainThread.elementTree.root = undefined;
+ clearPage();
+ lynxEnv.jsdom.window.document.body.innerHTML = '';
+
+ if (isMainThread) {
+ globalThis.lynxEnv.switchToMainThread();
+ }
+}
+
+export function renderHook(renderCallback, options) {
+ const { initialProps, wrapper } = options || {};
+ const result = createRef();
+
+ function TestComponent({ renderCallbackProps }) {
+ const pendingResult = renderCallback(renderCallbackProps);
+
+ useEffect(() => {
+ result.current = pendingResult;
+ });
+
+ return null;
+ }
+
+ const { rerender: baseRerender, unmount } = render(
+ ,
+ { wrapper },
+ );
+
+ function rerender(rerenderCallbackProps) {
+ return baseRerender(
+ ,
+ );
+ }
+
+ return { result, rerender, unmount };
+}
+
+export * from '@testing-library/dom';
+export { fireEvent } from './fire-event';
diff --git a/packages/react/testing-library/src/vitest-global-setup.js b/packages/react/testing-library/src/vitest-global-setup.js
new file mode 100644
index 0000000000..d8dc92bb52
--- /dev/null
+++ b/packages/react/testing-library/src/vitest-global-setup.js
@@ -0,0 +1,183 @@
+import { options } from 'preact';
+import { SnapshotInstance } from '../../runtime/lib/snapshot.js';
+import { snapshotInstanceManager } from '../../runtime/lib/snapshot.js';
+import { BackgroundSnapshotInstance } from '../../runtime/lib/backgroundSnapshot.js';
+import { backgroundSnapshotInstanceManager } from '../../runtime/lib/snapshot.js';
+import { injectCalledByNative } from '../../runtime/lib/lynx/calledByNative.js';
+import { injectUpdateMainThread } from '../../runtime/lib/lifecycle/patch/updateMainThread.js';
+import {
+ replaceCommitHook,
+ clearPatchesToCommit,
+ clearCommitTaskId,
+} from '../../runtime/lib/lifecycle/patch/commit.js';
+import { injectTt } from '../../runtime/lib/lynx/tt.js';
+import { setRoot } from '../../runtime/lib/root.js';
+import { deinitGlobalSnapshotPatch } from '../../runtime/lib/lifecycle/patch/snapshotPatch.js';
+import { initApiEnv } from '../../worklet-runtime/lib/api/lynxApi.js';
+import { initEventListeners } from '../../worklet-runtime/lib/listeners.js';
+import { initWorklet } from '../../worklet-runtime/lib/workletRuntime.js';
+import { destroyWorklet } from '../../runtime/lib/worklet/destroy.js';
+import { flushDelayedLifecycleEvents } from '../../runtime/lib/lynx/tt.js';
+
+const {
+ onInjectMainThreadGlobals,
+ onInjectBackgroundThreadGlobals,
+ onResetLynxEnv,
+ onSwitchedToMainThread,
+ onSwitchedToBackgroundThread,
+ onInitWorkletRuntime,
+} = globalThis;
+
+injectCalledByNative();
+injectUpdateMainThread();
+replaceCommitHook();
+
+globalThis.onInitWorkletRuntime = () => {
+ if (onInitWorkletRuntime) {
+ onInitWorkletRuntime();
+ }
+
+ if (process.env.DEBUG) {
+ console.log('initWorkletRuntime');
+ }
+ lynx.setTimeout = setTimeout;
+ lynx.setInterval = setInterval;
+ lynx.clearTimeout = clearTimeout;
+ lynx.clearInterval = clearInterval;
+
+ initWorklet();
+ initApiEnv();
+ initEventListeners();
+
+ return true;
+};
+
+globalThis.onInjectMainThreadGlobals = (target) => {
+ if (onInjectMainThreadGlobals) {
+ onInjectMainThreadGlobals();
+ }
+ if (process.env.DEBUG) {
+ console.log('onInjectMainThreadGlobals');
+ }
+
+ snapshotInstanceManager.clear();
+ snapshotInstanceManager.nextId = 0;
+ target.__root = new SnapshotInstance('root');
+
+ function setupDocument(document) {
+ document.createElement = function(type) {
+ return new SnapshotInstance(type);
+ };
+ document.createElementNS = function(_ns, type) {
+ return new SnapshotInstance(type);
+ };
+ document.createTextNode = function(text) {
+ const i = new SnapshotInstance(null);
+ i.setAttribute(0, text);
+ Object.defineProperty(i, 'data', {
+ set(v) {
+ i.setAttribute(0, v);
+ },
+ });
+ return i;
+ };
+ return document;
+ }
+
+ target._document = setupDocument({});
+
+ target.globalPipelineOptions = undefined;
+};
+globalThis.onInjectBackgroundThreadGlobals = (target) => {
+ if (onInjectBackgroundThreadGlobals) {
+ onInjectBackgroundThreadGlobals();
+ }
+ if (process.env.DEBUG) {
+ console.log('onInjectBackgroundThreadGlobals');
+ }
+
+ backgroundSnapshotInstanceManager.clear();
+ backgroundSnapshotInstanceManager.nextId = 0;
+ target.__root = new BackgroundSnapshotInstance('root');
+
+ function setupBackgroundDocument(document) {
+ document.createElement = function(type) {
+ return new BackgroundSnapshotInstance(type);
+ };
+ document.createElementNS = function(_ns, type) {
+ return new BackgroundSnapshotInstance(type);
+ };
+ document.createTextNode = function(text) {
+ const i = new BackgroundSnapshotInstance(null);
+ i.setAttribute(0, text);
+ Object.defineProperty(i, 'data', {
+ set(v) {
+ i.setAttribute(0, v);
+ },
+ });
+ return i;
+ };
+ return document;
+ }
+
+ target._document = setupBackgroundDocument({});
+ target.globalPipelineOptions = undefined;
+
+ // TODO: can we only inject to target(mainThread.globalThis) instead of globalThis?
+ // packages/react/runtime/src/lynx.ts
+ // intercept lynxCoreInject assignments to lynxEnv.backgroundThread.globalThis.lynxCoreInject
+ const oldLynxCoreInject = globalThis.lynxCoreInject;
+ globalThis.lynxCoreInject = target.lynxCoreInject;
+ injectTt();
+ globalThis.lynxCoreInject = oldLynxCoreInject;
+
+ // re-init global snapshot patch to undefined
+ deinitGlobalSnapshotPatch();
+ clearPatchesToCommit();
+ clearCommitTaskId();
+};
+globalThis.onResetLynxEnv = () => {
+ if (onResetLynxEnv) {
+ onResetLynxEnv();
+ }
+ if (process.env.DEBUG) {
+ console.log('onResetLynxEnv');
+ }
+
+ flushDelayedLifecycleEvents();
+ destroyWorklet();
+
+ lynxEnv.switchToMainThread();
+ initEventListeners();
+ lynxEnv.switchToBackgroundThread();
+};
+
+globalThis.onSwitchedToMainThread = () => {
+ if (onSwitchedToMainThread) {
+ onSwitchedToMainThread();
+ }
+ if (process.env.DEBUG) {
+ console.log('onSwitchedToMainThread');
+ }
+
+ setRoot(globalThis.__root);
+ options.document = globalThis._document;
+};
+globalThis.onSwitchedToBackgroundThread = () => {
+ if (onSwitchedToBackgroundThread) {
+ onSwitchedToBackgroundThread();
+ }
+ if (process.env.DEBUG) {
+ console.log('onSwitchedToBackgroundThread');
+ }
+
+ setRoot(globalThis.__root);
+ options.document = globalThis._document;
+};
+
+globalThis.onInjectMainThreadGlobals(
+ globalThis.lynxEnv.mainThread.globalThis,
+);
+globalThis.onInjectBackgroundThreadGlobals(
+ globalThis.lynxEnv.backgroundThread.globalThis,
+);
diff --git a/packages/react/testing-library/src/vitest.config.js b/packages/react/testing-library/src/vitest.config.js
new file mode 100644
index 0000000000..c679cede6f
--- /dev/null
+++ b/packages/react/testing-library/src/vitest.config.js
@@ -0,0 +1,125 @@
+import { defineConfig } from 'vitest/config';
+import { VitestPackageInstaller } from 'vitest/node';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { createRequire } from 'module';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const require = createRequire(import.meta.url);
+
+async function ensurePackagesInstalled() {
+ const installer = new VitestPackageInstaller();
+ const installed = await installer.ensureInstalled(
+ 'jsdom',
+ process.cwd(),
+ );
+ if (!installed) {
+ console.log('ReactLynx Testing Library requires jsdom to be installed.');
+ process.exit(1);
+ }
+}
+
+/**
+ * @returns {import('vitest/config').ViteUserConfig}
+ */
+export const createVitestConfig = async (options) => {
+ await ensurePackagesInstalled();
+
+ const runtimePkgName = options?.runtimePkgName ?? '@lynx-js/react';
+
+ function transformReactLynxPlugin() {
+ return {
+ name: 'transformReactLynxPlugin',
+ enforce: 'pre',
+ transform(sourceText, sourcePath) {
+ const id = sourcePath;
+ if (
+ id.endsWith('.css') || id.endsWith('.less') || id.endsWith('.scss')
+ ) {
+ if (process.env['DEBUG']) {
+ console.log('ignoring css file', id);
+ }
+ return '';
+ }
+
+ const { transformReactLynxSync } = require(
+ '@lynx-js/react/transform',
+ );
+ // relativePath should be stable between different runs with different cwd
+ const relativePath = path.relative(
+ __dirname,
+ sourcePath,
+ );
+ const basename = path.basename(sourcePath);
+ const result = transformReactLynxSync(sourceText, {
+ mode: 'test',
+ pluginName: '',
+ filename: basename,
+ sourcemap: true,
+ snapshot: {
+ preserveJsx: false,
+ runtimePkg: `${runtimePkgName}/internal`,
+ jsxImportSource: runtimePkgName,
+ filename: relativePath,
+ target: 'MIXED',
+ },
+ // snapshot: true,
+ directiveDCE: false,
+ defineDCE: false,
+ shake: false,
+ compat: false,
+ worklet: {
+ filename: relativePath,
+ runtimePkg: `${runtimePkgName}/internal`,
+ target: 'MIXED',
+ },
+ refresh: false,
+ cssScope: false,
+ });
+ if (result.errors.length > 0) {
+ // https://rollupjs.org/plugin-development/#this-error
+ result.errors.forEach(error => {
+ this.error(
+ error.text,
+ error.location,
+ );
+ });
+ }
+ if (result.warnings.length > 0) {
+ result.warnings.forEach(warning => {
+ this.warn(
+ warning.text,
+ warning.location,
+ );
+ });
+ }
+
+ return {
+ code: result.code,
+ map: result.map,
+ };
+ },
+ };
+ }
+
+ return defineConfig({
+ server: {
+ fs: {
+ allow: [
+ path.join(__dirname, '..'),
+ ],
+ },
+ },
+ plugins: [
+ transformReactLynxPlugin(),
+ ],
+ test: {
+ environment: require.resolve(
+ './env/vitest',
+ ),
+ globals: true,
+ setupFiles: [path.join(__dirname, 'vitest-global-setup')],
+ },
+ });
+};
diff --git a/packages/react/testing-library/tsconfig.json b/packages/react/testing-library/tsconfig.json
new file mode 100644
index 0000000000..aac4b8ccfb
--- /dev/null
+++ b/packages/react/testing-library/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "outDir": "lib",
+ "rootDir": "src",
+ "stripInternal": true,
+ "target": "ESNext",
+ "lib": ["es2021"],
+ "module": "Node16",
+ "moduleResolution": "Node16",
+ "resolveJsonModule": true,
+ "composite": true,
+ },
+ "include": ["src"],
+}
diff --git a/packages/react/testing-library/turbo.json b/packages/react/testing-library/turbo.json
new file mode 100644
index 0000000000..3e8e016168
--- /dev/null
+++ b/packages/react/testing-library/turbo.json
@@ -0,0 +1,16 @@
+{
+ "$schema": "https://turbo.build/schema.json",
+ "extends": ["//"],
+ "tasks": {
+ "build": {
+ "dependsOn": [
+ "@lynx-js/react-transform#build:wasm",
+ "@lynx-js/test-environment#build"
+ ],
+ "inputs": [
+ "src"
+ ],
+ "outputs": ["dist/**"]
+ }
+ }
+}
diff --git a/packages/react/testing-library/types/index.d.ts b/packages/react/testing-library/types/index.d.ts
new file mode 100644
index 0000000000..9e7ee2b7ac
--- /dev/null
+++ b/packages/react/testing-library/types/index.d.ts
@@ -0,0 +1,256 @@
+/**
+ * @packageDocumentation
+ *
+ * ReactLynx Testing Library is a simple and complete ReactLynx
+ * unit testing library that encourages good testing practices.
+ *
+ * Inspired by {@link https://testing-library.com/docs/react-testing-library/intro | React Testing Library} and {@link https://github.com/jsdom/jsdom | jsdom}.
+ */
+
+import { queries, Queries, BoundFunction } from '@testing-library/dom';
+import { LynxElement } from '@lynx-js/test-environment';
+import { ComponentChild, ComponentType } from 'preact';
+export * from '@testing-library/dom';
+
+/**
+ * The options for {@link render}.
+ *
+ * @public
+ */
+export interface RenderOptions {
+ /**
+ * Queries to bind. Overrides the default set from DOM Testing Library unless merged.
+ *
+ * @example
+ *
+ * ```ts
+ * // Example, a function to traverse table contents
+ * import * as tableQueries from 'my-table-query-library'
+ * import { queries } from '@lynx-js/react/testing-library'
+ *
+ * const { getByRowColumn, getByText } = render(, {
+ * queries: {...queries, ...tableQueries},
+ * })
+ *
+ * ```
+ */
+ queries?: Q;
+ /**
+ * Pass a React Component as the wrapper option to have it rendered around the inner element. This is most useful for creating
+ * reusable custom render functions for common data providers. See setup for examples.
+ *
+ * @example
+ *
+ * ```ts
+ * import { render } from '@lynx-js/react/testing-library'
+ * import { ThemeProvider } from 'my-ui-lib'
+ * import { TranslationProvider } from 'my-i18n-lib'
+ * import defaultStrings from 'i18n/en-x-default'
+ *
+ * const AllTheProviders = ({children}) => {
+ * return (
+ *
+ *
+ * {children}
+ *
+ *
+ * )
+ * }
+ *
+ * const customRender = (ui, options) =>
+ * render(ui, { wrapper: AllTheProviders, ...options })
+ *
+ * // re-export everything
+ * export * from '@lynx-js/react/testing-library'
+ *
+ * // override render method
+ * export { customRender as render }
+ * ```
+ */
+ wrapper?: ComponentChild;
+ /**
+ * Render your component in the main thread or not.
+ *
+ * It is recommended to use this option only when you need to test the {@link https://lynxjs.org/zh/guide/interaction/ifr.html | IFR} behavior.
+ *
+ * @defaultValue false
+ */
+ enableMainThread?: boolean;
+ /**
+ * Render your component in the background thread or not.
+ *
+ * Note that all user code in the top level will be executed in the background thread by default. (eg. `__BACKGROUND__` is `true` in the top level)
+ *
+ * @defaultValue true
+ */
+ enableBackgroundThread?: boolean;
+}
+
+/**
+ * The result of {@link render}
+ *
+ * @public
+ */
+export type RenderResult = {
+ container: LynxElement;
+ rerender: (ui: ComponentChild) => void;
+ unmount: () => boolean;
+} & { [P in keyof Q]: BoundFunction };
+
+/**
+ * Render into the page. It should be used with cleanup.
+ *
+ * @example
+ *
+ * ```ts
+ * import { render} from '@lynx-js/react/testing-library'
+ *
+ * const WrapperComponent = ({ children }) => (
+ * {children}
+ * );
+ * const Comp = () => {
+ * return ;
+ * };
+ * const { container, getByTestId } = render(, {
+ * wrapper: WrapperComponent,
+ * });
+ * expect(getByTestId('wrapper')).toBeInTheDocument();
+ * expect(container.firstChild).toMatchInlineSnapshot(`
+ *
+ *
+ *
+ * `);
+ * ```
+ *
+ * @public
+ */
+export function render(
+ ui: ComponentChild,
+ options?: RenderOptions,
+): RenderResult;
+/**
+ * Cleanup elements rendered to the page and Preact trees that were mounted with render.
+ *
+ * @public
+ */
+export function cleanup(): void;
+
+/**
+ * The result of {@link renderHook}
+ *
+ * @public
+ */
+
+export interface RenderHookResult {
+ /**
+ * Triggers a re-render. The props will be passed to your renderHook callback.
+ */
+ rerender: (props?: Props) => void;
+ /**
+ * This is a stable reference to the latest value returned by your renderHook
+ * callback
+ */
+ result: {
+ /**
+ * The value returned by your renderHook callback
+ */
+ current: Result;
+ };
+ /**
+ * Unmounts the test component. This is useful for when you need to test
+ * any cleanup your useEffects have.
+ */
+ unmount: () => void;
+}
+
+/**
+ * The options for {@link renderHook}
+ *
+ * @public
+ */
+export interface RenderHookOptions {
+ /**
+ * The argument passed to the renderHook callback. Can be useful if you plan
+ * to use the rerender utility to change the values passed to your hook.
+ */
+ initialProps?: Props;
+ /**
+ * Pass a React Component as the wrapper option to have it rendered around the inner element. This is most useful for creating
+ * reusable custom render functions for common data providers. See setup for examples.
+ *
+ * @example
+ *
+ * ```ts
+ * import { renderHook } from '@lynx-js/react/testing-library'
+ * import { ThemeProvider } from 'my-ui-lib'
+ * import { TranslationProvider } from 'my-i18n-lib'
+ * import defaultStrings from 'i18n/en-x-default'
+ *
+ * const AllTheProviders = ({children}) => {
+ * return (
+ *
+ *
+ * {children}
+ *
+ *
+ * )
+ * }
+ *
+ * const customRenderHook = (ui, options) =>
+ * renderHook(ui, { wrapper: AllTheProviders, ...options })
+ *
+ * // re-export everything
+ * export * from '@lynx-js/react/testing-library'
+ *
+ * // override renderHook method
+ * export { customRender as renderHook }
+ * ```
+ */
+ wrapper?: ComponentType<{ children: LynxElement }>;
+}
+
+/**
+ * Allows you to render a hook within a test React component without having to
+ * create that component yourself.
+ *
+ * @example
+ *
+ * ```ts
+ * import { renderHook } from '@lynx-js/react/testing-library'
+ *
+ * const Context = createContext('default');
+ * function Wrapper({ children }) {
+ * return {children};
+ * }
+ * const { result } = renderHook(
+ * () => {
+ * return useContext(Context);
+ * },
+ * {
+ * wrapper: Wrapper,
+ * },
+ * );
+ *
+ * expect(result.current).toEqual('provided');
+ * ```
+ *
+ * @public
+ */
+export function renderHook(
+ render: (initialProps: Props) => Result,
+ options?: RenderHookOptions,
+): RenderHookResult;
+
+/**
+ * Wait for the next event loop.
+ *
+ * It will be useful when you want to wait for the next event loop to finish.
+ *
+ * @public
+ */
+export function waitSchedule(): Promise;
diff --git a/packages/react/testing-library/types/pure.d.ts b/packages/react/testing-library/types/pure.d.ts
new file mode 100644
index 0000000000..53ceff783b
--- /dev/null
+++ b/packages/react/testing-library/types/pure.d.ts
@@ -0,0 +1,2 @@
+// @ts-nocheck
+export * from './index.d.ts';
diff --git a/packages/react/testing-library/types/vitest-config.d.ts b/packages/react/testing-library/types/vitest-config.d.ts
new file mode 100644
index 0000000000..472c2f22c7
--- /dev/null
+++ b/packages/react/testing-library/types/vitest-config.d.ts
@@ -0,0 +1,12 @@
+import type { ViteUserConfig } from 'vitest/config.js';
+
+export interface CreateVitestConfigOptions {
+ /**
+ * The package name of the ReactLynx runtime package.
+ *
+ * @default `@lynx-js/react`
+ */
+ runtimePkgName?: string;
+}
+
+export function createVitestConfig(options?: CreateVitestConfigOptions): Promise;
diff --git a/packages/react/testing-library/vitest.config.ts b/packages/react/testing-library/vitest.config.ts
new file mode 100644
index 0000000000..e196057133
--- /dev/null
+++ b/packages/react/testing-library/vitest.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig, mergeConfig } from 'vitest/config';
+import { createVitestConfig } from './dist/vitest.config';
+
+const defaultConfig = await createVitestConfig({
+ runtimePkgName: '@lynx-js/react',
+});
+const config = defineConfig({
+ test: {
+ name: 'react/testing-library',
+ },
+});
+
+export default mergeConfig(defaultConfig, config);
diff --git a/packages/react/turbo.json b/packages/react/turbo.json
index c187a06436..96a6732887 100644
--- a/packages/react/turbo.json
+++ b/packages/react/turbo.json
@@ -6,7 +6,9 @@
"dependsOn": [
"@lynx-js/react-refresh#build",
"@lynx-js/react-transform#build:wasm",
- "@lynx-js/react-worklet-runtime#build"
+ "@lynx-js/react-worklet-runtime#build",
+ "@lynx-js/react-lynx-testing-library#build",
+ "@lynx-js/test-environment#build"
]
}
}
diff --git a/packages/rspeedy/create-rspeedy/src/index.ts b/packages/rspeedy/create-rspeedy/src/index.ts
index 8d79e86bad..30d0018141 100644
--- a/packages/rspeedy/create-rspeedy/src/index.ts
+++ b/packages/rspeedy/create-rspeedy/src/index.ts
@@ -8,7 +8,7 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Argv } from 'create-rstack'
-import { checkCancel, create, select } from 'create-rstack'
+import { checkCancel, create, multiselect, select } from 'create-rstack'
type LANG = 'js' | 'ts'
@@ -29,13 +29,16 @@ interface Template {
const composeTemplateName = ({
template,
+ tools,
lang,
}: {
template: string
tools?: Record | undefined
lang: LANG
}) => {
- return `${template}-${lang}`
+ const toolsKeys = (tools ? Object.keys(tools) : []).sort()
+ const toolsStr = toolsKeys.length > 0 ? `-${toolsKeys.join('-')}` : ''
+ return `${template}${toolsStr}-${lang}`
}
const TEMPLATES: Template[] = [
@@ -64,10 +67,29 @@ async function getTemplateName({ template }: Argv) {
}),
)
- // TODO: support tools
+ const tools = checkCancel(
+ await multiselect({
+ message:
+ 'Select development tools (Use to select, to continue)',
+ required: false,
+ options: [
+ {
+ value: 'vitest-rltl',
+ label: 'Add ReactLynx Testing Library for unit testing',
+ },
+ ],
+ initialValues: [
+ 'vitest-rltl',
+ ],
+ }),
+ )
+
return composeTemplateName({
template: 'react',
lang: language,
+ tools: Object.fromEntries(
+ tools.map((tool) => [tool, tool]),
+ ),
})
}
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/lynx.config.js b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/lynx.config.js
new file mode 100644
index 0000000000..f4b90d3ab6
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/lynx.config.js
@@ -0,0 +1,16 @@
+import { defineConfig } from '@lynx-js/rspeedy'
+
+import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'
+import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'
+
+export default defineConfig({
+ plugins: [
+ pluginQRCode({
+ schema(url) {
+ // We use `?fullscreen=true` to open the page in LynxExplorer in full screen mode
+ return `${url}?fullscreen=true`
+ },
+ }),
+ pluginReactLynx(),
+ ],
+})
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/package.json b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/package.json
new file mode 100644
index 0000000000..947663e8cb
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "rspeedy-react-js",
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "build": "rspeedy build",
+ "dev": "rspeedy dev",
+ "preview": "rspeedy preview",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@lynx-js/react": "workspace:*"
+ },
+ "devDependencies": {
+ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*",
+ "@lynx-js/react-rsbuild-plugin": "workspace:*",
+ "@lynx-js/rspeedy": "workspace:*",
+ "@testing-library/jest-dom": "^6.6.3",
+ "jsdom": "^26.1.0",
+ "vitest": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/App.jsx b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/App.jsx
new file mode 100644
index 0000000000..84cb61483b
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/App.jsx
@@ -0,0 +1,46 @@
+import { useCallback, useEffect, useState } from '@lynx-js/react'
+
+import './App.css'
+import arrow from './assets/arrow.png'
+import lynxLogo from './assets/lynx-logo.png'
+import reactLynxLogo from './assets/react-logo.png'
+
+export function App(props) {
+ const [alterLogo, setAlterLogo] = useState(false)
+
+ useEffect(() => {
+ console.info('Hello, ReactLynx')
+ props.onMounted?.()
+ }, [])
+
+ const onTap = useCallback(() => {
+ 'background only'
+ setAlterLogo(!alterLogo)
+ }, [alterLogo])
+
+ return (
+
+
+
+
+
+ {alterLogo
+ ?
+ : }
+
+ React
+ on Lynx
+
+
+
+ Tap the logo and have fun!
+
+ Edit{' src/App.tsx '}
+ to see updates!
+
+
+
+
+
+ )
+}
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/__tests__/index.test.jsx b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/__tests__/index.test.jsx
new file mode 100644
index 0000000000..46bbd53182
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/__tests__/index.test.jsx
@@ -0,0 +1,102 @@
+// Copyright 2024 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import '@testing-library/jest-dom'
+import { expect, test, vi } from 'vitest'
+import { render, getQueriesForElement } from '@lynx-js/react/testing-library'
+
+import { App } from '../App.jsx'
+
+test('App', async () => {
+ const cb = vi.fn()
+
+ render(
+ {
+ cb(`__MAIN_THREAD__: ${__MAIN_THREAD__}`)
+ }}
+ />,
+ )
+ expect(cb).toBeCalledTimes(1)
+ expect(cb.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "__MAIN_THREAD__: false",
+ ],
+ ]
+ `)
+ expect(elementTree.root).toMatchInlineSnapshot(`
+
+
+
+
+
+
+
+
+
+ React
+
+
+ on Lynx
+
+
+
+
+
+ Tap the logo and have fun!
+
+
+ Edit
+
+ src/App.tsx
+
+ to see updates!
+
+
+
+
+
+
+ `)
+ const {
+ findByText,
+ } = getQueriesForElement(elementTree.root)
+ const element = await findByText('Tap the logo and have fun!')
+ expect(element).toBeInTheDocument()
+ expect(element).toMatchInlineSnapshot(`
+
+ Tap the logo and have fun!
+
+ `)
+})
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/index.js b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/index.js
new file mode 100644
index 0000000000..ab7f2c6da0
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/src/index.js
@@ -0,0 +1,9 @@
+import { root } from '@lynx-js/react'
+
+import { App } from './App.jsx'
+
+root.render()
+
+if (import.meta.webpackHot) {
+ import.meta.webpackHot.accept()
+}
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/vitest.config.js b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/vitest.config.js
new file mode 100644
index 0000000000..98425e53c0
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-js/vitest.config.js
@@ -0,0 +1,9 @@
+import { defineConfig, mergeConfig } from 'vitest/config'
+import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config'
+
+const defaultConfig = await createVitestConfig()
+const config = defineConfig({
+ test: {},
+})
+
+export default mergeConfig(defaultConfig, config)
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/lynx.config.ts b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/lynx.config.ts
new file mode 100644
index 0000000000..f4b90d3ab6
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/lynx.config.ts
@@ -0,0 +1,16 @@
+import { defineConfig } from '@lynx-js/rspeedy'
+
+import { pluginQRCode } from '@lynx-js/qrcode-rsbuild-plugin'
+import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin'
+
+export default defineConfig({
+ plugins: [
+ pluginQRCode({
+ schema(url) {
+ // We use `?fullscreen=true` to open the page in LynxExplorer in full screen mode
+ return `${url}?fullscreen=true`
+ },
+ }),
+ pluginReactLynx(),
+ ],
+})
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/package.json b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/package.json
new file mode 100644
index 0000000000..a18f96cc2c
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "rspeedy-react-ts",
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "build": "rspeedy build",
+ "dev": "rspeedy dev",
+ "preview": "rspeedy preview",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@lynx-js/react": "workspace:*"
+ },
+ "devDependencies": {
+ "@lynx-js/qrcode-rsbuild-plugin": "workspace:*",
+ "@lynx-js/react-rsbuild-plugin": "workspace:*",
+ "@lynx-js/rspeedy": "workspace:*",
+ "@lynx-js/types": "^3.2.1",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@types/react": "^18.3.20",
+ "jsdom": "^26.1.0",
+ "typescript": "~5.8.3",
+ "vitest": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+}
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/src/App.tsx b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/src/App.tsx
new file mode 100644
index 0000000000..1f0b116b69
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/src/App.tsx
@@ -0,0 +1,48 @@
+import { useCallback, useEffect, useState } from '@lynx-js/react'
+
+import './App.css'
+import arrow from './assets/arrow.png'
+import lynxLogo from './assets/lynx-logo.png'
+import reactLynxLogo from './assets/react-logo.png'
+
+export function App(props: {
+ onMounted?: () => void
+}) {
+ const [alterLogo, setAlterLogo] = useState(false)
+
+ useEffect(() => {
+ console.info('Hello, ReactLynx')
+ props.onMounted?.()
+ }, [])
+
+ const onTap = useCallback(() => {
+ 'background only'
+ setAlterLogo(!alterLogo)
+ }, [alterLogo])
+
+ return (
+
+
+
+
+
+ {alterLogo
+ ?
+ : }
+
+ React
+ on Lynx
+
+
+
+ Tap the logo and have fun!
+
+ Edit{' src/App.tsx '}
+ to see updates!
+
+
+
+
+
+ )
+}
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/src/__tests__/index.test.tsx b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/src/__tests__/index.test.tsx
new file mode 100644
index 0000000000..4b9e16d5df
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/src/__tests__/index.test.tsx
@@ -0,0 +1,102 @@
+// Copyright 2024 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import '@testing-library/jest-dom'
+import { expect, test, vi } from 'vitest'
+import { render, getQueriesForElement } from '@lynx-js/react/testing-library'
+
+import { App } from '../App.jsx'
+
+test('App', async () => {
+ const cb = vi.fn()
+
+ render(
+ {
+ cb(`__MAIN_THREAD__: ${__MAIN_THREAD__}`)
+ }}
+ />,
+ )
+ expect(cb).toBeCalledTimes(1)
+ expect(cb.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "__MAIN_THREAD__: false",
+ ],
+ ]
+ `)
+ expect(elementTree.root).toMatchInlineSnapshot(`
+
+
+
+
+
+
+
+
+
+ React
+
+
+ on Lynx
+
+
+
+
+
+ Tap the logo and have fun!
+
+
+ Edit
+
+ src/App.tsx
+
+ to see updates!
+
+
+
+
+
+
+ `)
+ const {
+ findByText,
+ } = getQueriesForElement(elementTree.root!)
+ const element = await findByText('Tap the logo and have fun!')
+ expect(element).toBeInTheDocument()
+ expect(element).toMatchInlineSnapshot(`
+
+ Tap the logo and have fun!
+
+ `)
+})
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/src/index.tsx b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/src/index.tsx
new file mode 100644
index 0000000000..ab7f2c6da0
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/src/index.tsx
@@ -0,0 +1,9 @@
+import { root } from '@lynx-js/react'
+
+import { App } from './App.jsx'
+
+root.render()
+
+if (import.meta.webpackHot) {
+ import.meta.webpackHot.accept()
+}
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/src/rspeedy-env.d.ts b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/src/rspeedy-env.d.ts
new file mode 100644
index 0000000000..1c813a68b0
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/src/rspeedy-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/tsconfig.json b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/tsconfig.json
new file mode 100644
index 0000000000..802b9e24d8
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "jsx": "preserve",
+
+ "module": "node16",
+ "moduleResolution": "node16",
+
+ "strict": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ },
+ "exclude": ["dist/"],
+}
diff --git a/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/vitest.config.ts b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/vitest.config.ts
new file mode 100644
index 0000000000..98425e53c0
--- /dev/null
+++ b/packages/rspeedy/create-rspeedy/template-react-vitest-rltl-ts/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig, mergeConfig } from 'vitest/config'
+import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config'
+
+const defaultConfig = await createVitestConfig()
+const config = defineConfig({
+ test: {},
+})
+
+export default mergeConfig(defaultConfig, config)
diff --git a/packages/rspeedy/plugin-qrcode/src/index.ts b/packages/rspeedy/plugin-qrcode/src/index.ts
index 8b38eff810..440010ccd0 100644
--- a/packages/rspeedy/plugin-qrcode/src/index.ts
+++ b/packages/rspeedy/plugin-qrcode/src/index.ts
@@ -105,19 +105,23 @@ export function pluginQRCode(
await main(environments['lynx'], port)
})
- api.onDevCompileDone(async ({ isFirstCompile, stats, environments }) => {
+ let printedQRCode = false
+
+ api.onDevCompileDone(async ({ stats, environments }) => {
if (!api.context.devServer) {
return
}
- if (!isFirstCompile) {
+ if (stats.hasErrors()) {
return
}
- if (stats.hasErrors()) {
+ if (printedQRCode) {
return
}
+ printedQRCode = true
+
await main(environments['lynx'], api.context.devServer.port)
})
diff --git a/packages/rspeedy/plugin-qrcode/test/fixtures/error/index.js b/packages/rspeedy/plugin-qrcode/test/fixtures/error/index.js
new file mode 100644
index 0000000000..19dfa14dab
--- /dev/null
+++ b/packages/rspeedy/plugin-qrcode/test/fixtures/error/index.js
@@ -0,0 +1 @@
+console.log('Hello, world!')
diff --git a/packages/rspeedy/plugin-qrcode/test/index.test.ts b/packages/rspeedy/plugin-qrcode/test/index.test.ts
index 1cd805926a..36641cc154 100644
--- a/packages/rspeedy/plugin-qrcode/test/index.test.ts
+++ b/packages/rspeedy/plugin-qrcode/test/index.test.ts
@@ -1,6 +1,7 @@
// Copyright 2024 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
+import { readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -11,7 +12,7 @@ import type {
RsbuildPlugin,
RsbuildPluginAPI,
} from '@rsbuild/core'
-import { beforeEach, describe, expect, test, vi } from 'vitest'
+import { beforeEach, describe, expect, onTestFinished, test, vi } from 'vitest'
import type { Config, ExposedAPI } from '@lynx-js/rspeedy'
@@ -415,13 +416,76 @@ describe('Plugins - Terminal', () => {
expect.stringContaining('example.com/foo'),
)
})
+
+ test('print qrcode when errors are fixed', async () => {
+ vi.stubEnv('NODE_ENV', 'development')
+
+ const entry = join(
+ dirname(fileURLToPath(import.meta.url)),
+ 'fixtures',
+ 'error',
+ 'index.js',
+ )
+ const source = await readFile(entry, 'utf-8')
+ onTestFinished(async () => {
+ // ensure rewrite when exit test
+ await writeFile(entry, source, 'utf-8')
+ })
+
+ const { selectKey, isCancel } = await import('@clack/prompts')
+ vi.mocked(selectKey).mockResolvedValue('foo')
+ vi.mocked(isCancel).mockReturnValueOnce(false)
+ const { renderUnicodeCompact } = await import('uqr')
+ vi.mocked(renderUnicodeCompact).mockReturnValueOnce('')
+ // write content which has a syntax error
+ await writeFile(entry, source.slice(0, source.length - 2), 'utf-8')
+
+ const rsbuild = await createRsbuild(
+ {
+ rsbuildConfig: {
+ dev: {
+ assetPrefix: 'http://example.com/foo/',
+ },
+ environments: {
+ lynx: {},
+ },
+ server: {
+ port: getRandomNumberInRange(3000, 60000),
+ },
+ source: {
+ entry: {
+ main: entry,
+ },
+ },
+ plugins: [
+ pluginStubRspeedyAPI(),
+ pluginQRCode(),
+ ],
+ },
+ },
+ )
+
+ await using server = await usingDevServer(rsbuild)
+
+ await server.waitDevCompileDone()
+
+ expect(renderUnicodeCompact).toBeCalledTimes(0)
+ // fix syntax error
+ await writeFile(entry, source, 'utf-8')
+
+ await server.waitDevCompileSuccess()
+
+ expect(renderUnicodeCompact).toBeCalledTimes(1)
+ })
})
})
async function usingDevServer(rsbuild: RsbuildInstance) {
let done = false
+ let hasErrors = false
rsbuild.onDevCompileDone({
- handler: () => {
+ handler: ({ stats }) => {
+ hasErrors = stats.hasErrors()
done = true
},
// We make sure this is run at the last
@@ -439,6 +503,10 @@ async function usingDevServer(rsbuild: RsbuildInstance) {
async waitDevCompileDone(timeout?: number) {
await vi.waitUntil(() => done, { timeout: timeout ?? 5000 })
},
+ async waitDevCompileSuccess(timeout?: number) {
+ await vi.waitUntil(() => !hasErrors, { timeout: timeout ?? 1000 })
+ },
+ hasErrors,
async [Symbol.asyncDispose]() {
return await server.close()
},
diff --git a/packages/testing-library/README.md b/packages/testing-library/README.md
new file mode 100644
index 0000000000..a236f046d5
--- /dev/null
+++ b/packages/testing-library/README.md
@@ -0,0 +1,19 @@
+# Testing Library for Lynx
+
+Unit testing library for lynx, same as https://github.com/testing-library.
+
+## Packages
+
+| Package | Description | Equivalent |
+| ---------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------ |
+| [@lynx-js/test-environment](./lynx-environment/) | Lynx equivalent of jsdom | [jsdom](https://github.com/jsdom/jsdom) |
+| [@lynx-js/react/testing-library](../react/testing-library) | Lynx equivalent of preact-testing-library | [@testing-library/preact](https://github.com/testing-library/preact-testing-library) |
+
+## Documentation
+
+Find the complete documentation for ReactLynx Testing Library on [lynxjs.org](https://lynxjs.org/react/react-lynx-testing-library.html).
+
+## Credits
+
+- [Testing Library](https://testing-library.com/) for the testing utilities and good practices for React testing.
+- [jsdom](https://github.com/jsdom/jsdom) for the pure-JavaScript implementation of DOM API.
diff --git a/packages/testing-library/examples/basic/.gitignore b/packages/testing-library/examples/basic/.gitignore
new file mode 100644
index 0000000000..35ff3006b1
--- /dev/null
+++ b/packages/testing-library/examples/basic/.gitignore
@@ -0,0 +1,7 @@
+/dist
+/node_modules
+
+*.log
+log
+output
+.DS_Store
diff --git a/packages/testing-library/examples/basic/lynx.config.ts b/packages/testing-library/examples/basic/lynx.config.ts
new file mode 100644
index 0000000000..dc711861a8
--- /dev/null
+++ b/packages/testing-library/examples/basic/lynx.config.ts
@@ -0,0 +1,13 @@
+import { pluginReactLynx } from '@lynx-js/react-rsbuild-plugin';
+import { defineConfig } from '@lynx-js/rspeedy';
+
+export default defineConfig({
+ source: {
+ entry: './src/index.tsx',
+ },
+ plugins: [
+ pluginReactLynx({
+ enableRemoveCSSScope: true,
+ }),
+ ],
+});
diff --git a/packages/testing-library/examples/basic/package.json b/packages/testing-library/examples/basic/package.json
new file mode 100644
index 0000000000..4be5305b58
--- /dev/null
+++ b/packages/testing-library/examples/basic/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@lynx-js/testing-library-example-basic",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "rspeedy build",
+ "dev": "rspeedy dev",
+ "test": "vitest"
+ },
+ "dependencies": {
+ "@lynx-js/react": "workspace:*"
+ },
+ "devDependencies": {
+ "@lynx-js/react-rsbuild-plugin": "workspace:*",
+ "@lynx-js/rspeedy": "workspace:*",
+ "@testing-library/jest-dom": "^6.6.3"
+ }
+}
diff --git a/packages/testing-library/examples/basic/src/App.tsx b/packages/testing-library/examples/basic/src/App.tsx
new file mode 100644
index 0000000000..15c69d1623
--- /dev/null
+++ b/packages/testing-library/examples/basic/src/App.tsx
@@ -0,0 +1,19 @@
+import { Component } from '@lynx-js/react';
+
+export interface IProps {
+ onMounted?: () => void;
+}
+
+export class App extends Component {
+ override componentDidMount(): void {
+ this.props?.onMounted?.();
+ }
+
+ override render(): JSX.Element {
+ return (
+
+ Hello World!
+
+ );
+ }
+}
diff --git a/packages/testing-library/examples/basic/src/__tests__/index.test.tsx b/packages/testing-library/examples/basic/src/__tests__/index.test.tsx
new file mode 100644
index 0000000000..3f54ebe518
--- /dev/null
+++ b/packages/testing-library/examples/basic/src/__tests__/index.test.tsx
@@ -0,0 +1,51 @@
+// Copyright 2024 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+import '@testing-library/jest-dom';
+import { expect, test, vi } from 'vitest';
+import { render, getQueriesForElement } from '@lynx-js/react/testing-library';
+
+import { App } from '../App.jsx';
+
+test('App', async () => {
+ const cb = vi.fn();
+
+ render(
+ {
+ cb(`__MAIN_THREAD__: ${__MAIN_THREAD__}`);
+ }}
+ />,
+ );
+ expect(cb).toBeCalledTimes(1);
+ expect(cb.mock.calls).toMatchInlineSnapshot(`
+ [
+ [
+ "__MAIN_THREAD__: false",
+ ],
+ ]
+ `);
+ expect(elementTree.root).toMatchInlineSnapshot(`
+
+
+
+ Hello World!
+
+
+
+ `);
+ const {
+ findByText,
+ } = getQueriesForElement(elementTree.root!);
+ const element = await findByText('Hello World!');
+ expect(element).toBeInTheDocument();
+ expect(element).toMatchInlineSnapshot(`
+
+ Hello World!
+
+ `);
+});
diff --git a/packages/testing-library/examples/basic/src/index.tsx b/packages/testing-library/examples/basic/src/index.tsx
new file mode 100644
index 0000000000..028f3b473b
--- /dev/null
+++ b/packages/testing-library/examples/basic/src/index.tsx
@@ -0,0 +1,4 @@
+import { App } from './App.jsx';
+import { root } from '@lynx-js/react';
+
+root.render();
diff --git a/packages/testing-library/examples/basic/src/rspeedy-env.d.ts b/packages/testing-library/examples/basic/src/rspeedy-env.d.ts
new file mode 100644
index 0000000000..1c813a68b0
--- /dev/null
+++ b/packages/testing-library/examples/basic/src/rspeedy-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/packages/testing-library/examples/basic/tsconfig.json b/packages/testing-library/examples/basic/tsconfig.json
new file mode 100644
index 0000000000..e2ec0132e2
--- /dev/null
+++ b/packages/testing-library/examples/basic/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../../../tsconfig.json",
+ "compilerOptions": {
+ /* Specify what JSX code is generated. */
+ "jsx": "preserve",
+
+ /* Disable emitting files from a compilation. */
+ "noEmit": true,
+ },
+ "include": ["src"],
+}
diff --git a/packages/testing-library/examples/basic/vitest.config.ts b/packages/testing-library/examples/basic/vitest.config.ts
new file mode 100644
index 0000000000..cbe326697f
--- /dev/null
+++ b/packages/testing-library/examples/basic/vitest.config.ts
@@ -0,0 +1,13 @@
+import { defineConfig, mergeConfig } from 'vitest/config';
+import { createVitestConfig } from '@lynx-js/react/testing-library/vitest-config';
+
+const defaultConfig = await createVitestConfig({
+ runtimePkgName: '@lynx-js/react',
+});
+const config = defineConfig({
+ test: {
+ name: 'testing-library/examples/basic',
+ },
+});
+
+export default mergeConfig(defaultConfig, config);
diff --git a/packages/testing-library/test-environment/README.md b/packages/testing-library/test-environment/README.md
new file mode 100644
index 0000000000..ad480507e8
--- /dev/null
+++ b/packages/testing-library/test-environment/README.md
@@ -0,0 +1,72 @@
+# @lynx-js/test-environment
+
+`@lynx-js/test-environment` is a pure-JavaScript implementation of the [Lynx Spec](https://lynxjs.org/api/engine/element-api), notably the [Element PAPI](https://lynxjs.org/api/engine/element-api) and [Dual-threaded Model](https://lynxjs.org/guide/spec#dual-threaded-model) for use with Node.js. In general, the goal of the project is to emulate enough of a subset of a Lynx environment to be useful for testing.
+
+The Element PAPI implementation is based on jsdom, for example `__CreateElement` will return a `LynxElement`, which extends `HTMLElement` from jsdom. You can reuse the testing utilities that are commonly used for DOM testing, such as [`@testing-library/dom`](https://github.com/testing-library/dom-testing-library) (for DOM querying) and [`@testing-library/jest-dom`](https://github.com/testing-library/jest-dom) (custom jest matchers for the DOM), etc.
+
+## Usage
+
+```js
+import { LynxEnv } from '@lynx-js/test-environment';
+
+const lynxEnv = new LynxEnv();
+```
+
+To use `@lynx-js/test-environment`, you will primarily use the `LynxEnv` constructor, which is a named export of the package. You will get back a `LynxEnv` instance, which has a number of methods of useful properties, notably `switchToMainThread` and `switchToBackgroundThread`, which allow you to switch between the main thread and background thread.
+
+Use the background thread API:
+
+```js
+lynxEnv.switchToBackgroundThread();
+// use the background thread global object
+globalThis.lynxCoreInject.tt.OnLifecycleEvent(...args);
+// or directly use `lynxCoreInject` since it's already injected to `globalThis`
+// lynxCoreInject.tt.OnLifecycleEvent(...args);
+```
+
+Use the main thread API:
+
+```js
+lynxEnv.switchToMainThread();
+// use the main thread Element PAPI
+const page = __CreatePage('0', 0);
+const view = __CreateView(0);
+__AppendElement(page, view);
+```
+
+Note that you can still access the other thread's globals without switching threads:
+
+```js
+lynxEnv.switchToMainThread();
+// use the `backgroundThread` global object even though we're on the main thread
+lynxEnv.backgroundThread.tt.OnLifecycleEvent(...args);
+```
+
+### Use in Vitest
+
+It is recommended to configure as Vitest's [test environment](https://vitest.dev/guide/environment), for example:
+
+```js
+// vitest.config.js
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: require.resolve(
+ '@lynx-js/test-environment/env/vitest',
+ ),
+ },
+});
+```
+
+After configuration, you can directly access the `lynxEnv` object globally in the test.
+
+If you want to use `@lynx-js/test-environment` for unit testing in ReactLynx, you usually don't need to specify this configuration manually.
+
+Please refer to [ReactLynx Testing Library](https://lynxjs.org/react/react-lynx-testing-library.html) to inherit the configuration from `@lynx-js/react/testing-library`.
+
+## Credits
+
+Thanks to:
+
+- [jsdom](https://github.com/jsdom/jsdom) for the pure-JavaScript implementation of DOM API.
diff --git a/packages/testing-library/test-environment/api-extractor.json b/packages/testing-library/test-environment/api-extractor.json
new file mode 100644
index 0000000000..0916d6c151
--- /dev/null
+++ b/packages/testing-library/test-environment/api-extractor.json
@@ -0,0 +1,7 @@
+/**
+ * Config file for API Extractor. For more info, please visit: https://api-extractor.com
+ */
+{
+ "extends": "../../../api-extractor.json",
+ "mainEntryPointFilePath": "/dist/index.d.ts",
+}
diff --git a/packages/testing-library/test-environment/etc/test-environment.api.md b/packages/testing-library/test-environment/etc/test-environment.api.md
new file mode 100644
index 0000000000..10b8e9dc0e
--- /dev/null
+++ b/packages/testing-library/test-environment/etc/test-environment.api.md
@@ -0,0 +1,111 @@
+## API Report File for "@lynx-js/test-environment"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { JSDOM } from 'jsdom';
+
+// @public
+export type ElementTree = ReturnType;
+
+// @public
+export type ElementTreeGlobals = PickUnderscoreKeys;
+
+// @public (undocumented)
+export type FilterUnderscoreKeys = {
+ [K in keyof T]: K extends `__${string}` ? K : never;
+}[keyof T];
+
+// @public (undocumented)
+export const initElementTree: () => {
+ root: LynxElement | undefined;
+ countElement(element: LynxElement, parentComponentUniqueId: number): void;
+ __CreatePage(_tag: string, parentComponentUniqueId: number): LynxElement;
+ __CreateRawText(text: string): LynxElement;
+ __GetElementUniqueID(e: LynxElement): number;
+ __SetClasses(e: LynxElement, cls: string): void;
+ __CreateElement(tag: string, parentComponentUniqueId: number): LynxElement;
+ __CreateView(parentComponentUniqueId: number): LynxElement;
+ __CreateScrollView(parentComponentUniqueId: number): LynxElement;
+ __FirstElement(e: LynxElement): LynxElement;
+ __CreateText(parentComponentUniqueId: number): LynxElement;
+ __CreateImage(parentComponentUniqueId: number): LynxElement;
+ __CreateWrapperElement(parentComponentUniqueId: number): LynxElement;
+ __AddInlineStyle(e: HTMLElement, key: number, value: string): void;
+ __AppendElement(parent: LynxElement, child: LynxElement): void;
+ __SetCSSId(e: LynxElement | LynxElement[], id: string, entryName?: string): void;
+ __SetAttribute(e: LynxElement, key: string, value: any): void;
+ __AddEvent(e: LynxElement, eventType: string, eventName: string, eventHandler: string | Record): void;
+ __GetEvent(e: LynxElement, eventType: string, eventName: string): {
+ type: string;
+ name: string;
+ jsFunction: any;
+ } | undefined;
+ __SetID(e: LynxElement, id: string): void;
+ __SetInlineStyles(e: LynxElement, styles: string | Record): void;
+ __AddDataset(e: LynxElement, key: string, value: string): void;
+ __SetDataset(e: LynxElement, dataset: any): void;
+ __SetGestureDetector(e: LynxElement, id: number, type: number, config: any, relationMap: Record): void;
+ __GetDataset(e: LynxElement): DOMStringMap;
+ __RemoveElement(parent: LynxElement, child: LynxElement): void;
+ __InsertElementBefore(parent: LynxElement, child: LynxElement, ref?: LynxElement): void;
+ __ReplaceElement(newElement: LynxElement, oldElement: LynxElement): void;
+ __FlushElementTree(): void;
+ __UpdateListComponents(_list: LynxElement, _components: string[]): void;
+ __UpdateListCallbacks(list: LynxElement, componentAtIndex: (list: LynxElement, listID: number, cellIndex: number, operationID: number, enable_reuse_notification: boolean) => void, enqueueComponent: (list: LynxElement, listID: number, sign: number) => void): void;
+ __CreateList(parentComponentUniqueId: number, componentAtIndex: any, enqueueComponent: any): LynxElement;
+ __GetTag(ele: LynxElement): string;
+ __GetAttributeByName(ele: LynxElement, name: string): string | null;
+ clear(): void;
+ toTree(): LynxElement | undefined;
+ enterListItemAtIndex(e: LynxElement, index: number, ...args: any[]): number;
+ leaveListItem(e: LynxElement, uiSign: number): void;
+ toJSON(): LynxElement | undefined;
+ __GetElementByUniqueId(uniqueId: number): LynxElement | undefined;
+};
+
+// @public
+export interface LynxElement extends HTMLElement {
+ cssId?: string;
+ eventMap?: {
+ [key: string]: any;
+ };
+ firstChild: LynxElement;
+ gesture?: {
+ [key: string]: any;
+ };
+ nextSibling: LynxElement;
+ parentNode: LynxElement;
+}
+
+// @public
+export class LynxEnv {
+ constructor();
+ backgroundThread: LynxGlobalThis;
+ // (undocumented)
+ clearGlobal(): void;
+ // (undocumented)
+ injectGlobals(): void;
+ // (undocumented)
+ jsdom: JSDOM;
+ mainThread: LynxGlobalThis & ElementTreeGlobals;
+ // (undocumented)
+ resetLynxEnv(): void;
+ // (undocumented)
+ switchToBackgroundThread(): void;
+ // (undocumented)
+ switchToMainThread(): void;
+}
+
+// @public
+export interface LynxGlobalThis {
+ // (undocumented)
+ [key: string]: any;
+ globalThis: LynxGlobalThis;
+}
+
+// @public (undocumented)
+export type PickUnderscoreKeys = Pick>;
+
+```
diff --git a/packages/testing-library/test-environment/package.json b/packages/testing-library/test-environment/package.json
new file mode 100644
index 0000000000..994b001a51
--- /dev/null
+++ b/packages/testing-library/test-environment/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@lynx-js/test-environment",
+ "version": "0.0.0",
+ "exports": {
+ ".": {
+ "default": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.js"
+ },
+ "./env/vitest": {
+ "default": "./dist/env/vitest/index.mjs",
+ "types": "./dist/env/vitest/index.d.ts",
+ "import": "./dist/env/vitest/index.mjs",
+ "require": "./dist/env/vitest/index.js"
+ }
+ },
+ "main": "./dist/index.js",
+ "module": "./dist/index.mjs",
+ "types": "./dist/index.d.ts",
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "api-extractor": "api-extractor run --verbose",
+ "build": "rslib build",
+ "dev": "rslib build --watch"
+ },
+ "devDependencies": {
+ "@testing-library/jest-dom": "^6.6.3",
+ "@types/jsdom": "^21.1.7"
+ }
+}
diff --git a/packages/testing-library/test-environment/rslib.config.ts b/packages/testing-library/test-environment/rslib.config.ts
new file mode 100644
index 0000000000..aaf377d7a0
--- /dev/null
+++ b/packages/testing-library/test-environment/rslib.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig } from '@rslib/core';
+
+export default defineConfig({
+ source: {
+ entry: {
+ index: [
+ './src/**',
+ '!./src/**/__tests__/**',
+ ],
+ },
+ },
+ lib: [
+ {
+ bundle: false,
+ format: 'esm',
+ syntax: 'es2021',
+ dts: true,
+ },
+ {
+ bundle: false,
+ format: 'cjs',
+ syntax: 'es2021',
+ },
+ ],
+});
diff --git a/packages/testing-library/test-environment/src/__tests__/basic.test.js b/packages/testing-library/test-environment/src/__tests__/basic.test.js
new file mode 100644
index 0000000000..de2b71c098
--- /dev/null
+++ b/packages/testing-library/test-environment/src/__tests__/basic.test.js
@@ -0,0 +1,183 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+beforeEach(() => {
+ lynxEnv.resetLynxEnv();
+ lynxEnv.switchToMainThread();
+});
+
+describe('test', () => {
+ it('basic element PAPI should work', () => {
+ const page = __CreatePage('0', 0);
+ expect(elementTree).toMatchInlineSnapshot(``);
+ const view0 = __CreateView(0);
+ expect(view0).toMatchInlineSnapshot(``);
+ expect(view0.$$uiSign).toMatchInlineSnapshot(`1`);
+ expect(elementTree).toMatchInlineSnapshot(``);
+ __AppendElement(page, view0);
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+ `);
+ __AddDataset(view0, 'testid', 'view-element');
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+ `);
+
+ const view1 = __CreateElement('svg', view0.$$uiSign);
+ __AddDataset(view1, 'testid', 'svg-element');
+ __AppendElement(page, view1);
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+
+ `);
+
+ const element0 = __CreateElement('custom-element', view0.$$uiSign);
+ __AddDataset(element0, 'testid', 'custom-element');
+ __AppendElement(page, element0);
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+
+
+ `);
+
+ const text0 = __CreateText(view0.$$uiSign);
+ const rawText0 = __CreateRawText('Text Element', text0.$$uiSign);
+ __AppendElement(text0, rawText0);
+ __AppendElement(view0, text0);
+
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+ Text Element
+
+
+
+
+
+ `);
+
+ const queryByTestId = testId =>
+ elementTree.root.querySelector(`[data-testid="${testId}"]`);
+
+ const viewElement = queryByTestId('view-element');
+ const svgElement = queryByTestId('svg-element');
+ const customElement = queryByTestId('custom-element');
+ const detachedElement = __CreateElement('custom-element', -1);
+ const fakeElement = { thisIsNot: 'a lynx element' };
+ const undefinedElement = undefined;
+ const nullElement = null;
+ expect(viewElement).toMatchInlineSnapshot(`
+
+
+ Text Element
+
+
+ `);
+ expect(svgElement).toMatchInlineSnapshot(`
+
+ `);
+ expect(customElement).toMatchInlineSnapshot(`
+
+ `);
+ expect(detachedElement).toMatchInlineSnapshot(``);
+ expect(fakeElement).toMatchInlineSnapshot(`
+ {
+ "thisIsNot": "a lynx element",
+ }
+ `);
+ expect(undefinedElement).toMatchInlineSnapshot(`undefined`);
+ expect(nullElement).toMatchInlineSnapshot(`null`);
+ });
+ it('event listener should work', () => {
+ const page = __CreatePage('0', 0);
+ expect(elementTree).toMatchInlineSnapshot(``);
+ const view0 = __CreateView(0);
+ expect(view0).toMatchInlineSnapshot(``);
+ __AppendElement(page, view0);
+ expect(page).toMatchInlineSnapshot(`
+
+
+
+ `);
+ __AddEvent(
+ view0,
+ 'bindEvent',
+ 'tap',
+ '2:0:bindtap',
+ );
+ expect(view0.eventMap).toMatchInlineSnapshot(`
+ {
+ "bindEvent:tap": [Function],
+ }
+ `);
+ lynxEnv.switchToBackgroundThread();
+ lynxCoreInject.tt.publishEvent = (eventHandler, data) => {
+ expect(eventHandler).toMatchInlineSnapshot(`"2:0:bindtap"`);
+ expect(data).toMatchInlineSnapshot(`
+ Event {
+ "eventName": "tap",
+ "eventType": "bindEvent",
+ "isTrusted": false,
+ "key": "value",
+ }
+ `);
+ };
+ const event = new Event('bindEvent:tap');
+ Object.assign(
+ event,
+ {
+ eventType: 'bindEvent',
+ eventName: 'tap',
+ key: 'value',
+ },
+ );
+ view0.dispatchEvent(event);
+ });
+ it('text should works', () => {
+ const page = __CreatePage('0', 0);
+ expect(elementTree).toMatchInlineSnapshot(``);
+ const text0 = __CreateText(0);
+ expect(text0).toMatchInlineSnapshot(``);
+ const rawText0 = __CreateElement('raw-text', text0.$$uiSign);
+ expect(rawText0).toMatchInlineSnapshot(``);
+ __AppendElement(text0, rawText0);
+ __SetAttribute(rawText0, 'text', 'Hello World');
+ expect(text0).toMatchInlineSnapshot(`
+
+ Hello World
+
+ `);
+ });
+});
diff --git a/packages/testing-library/test-environment/src/__tests__/to-have-text-content.test.js b/packages/testing-library/test-environment/src/__tests__/to-have-text-content.test.js
new file mode 100644
index 0000000000..c15d3a3d91
--- /dev/null
+++ b/packages/testing-library/test-environment/src/__tests__/to-have-text-content.test.js
@@ -0,0 +1,242 @@
+import '@testing-library/jest-dom';
+import { expect, test } from 'vitest';
+
+describe('.toHaveTextContent', () => {
+ test('handles positive test cases', () => {
+ lynxEnv.resetLynxEnv();
+ lynxEnv.switchToMainThread();
+
+ const page = __CreatePage('0', 0);
+ const text0 = __CreateText(0);
+ const rawText0 = __CreateRawText('2', text0.$$uiSign);
+ __AppendElement(text0, rawText0);
+ __AppendElement(page, text0);
+ __AddDataset(text0, 'testid', 'count-value');
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+ 2
+
+
+ `);
+
+ const queryByTestId = testId =>
+ elementTree.root.querySelector(`[data-testid="${testId}"]`);
+
+ expect(queryByTestId('count-value')).toHaveTextContent('2');
+ expect(queryByTestId('count-value')).toHaveTextContent(2);
+ expect(queryByTestId('count-value')).toHaveTextContent(/2/);
+ expect(queryByTestId('count-value')).not.toHaveTextContent('21');
+ });
+
+ test('handles text nodes', () => {
+ lynxEnv.resetLynxEnv();
+ lynxEnv.switchToMainThread();
+ const page = __CreatePage('0', 0);
+ const text0 = __CreateText(0);
+ const rawText0 = __CreateRawText('example', text0.$$uiSign);
+ __AppendElement(text0, rawText0);
+ __AppendElement(page, text0);
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+ example
+
+
+ `);
+ expect(elementTree.root.children[0]).toMatchInlineSnapshot(`
+
+ example
+
+ `);
+ expect(elementTree.root.children[0]).toHaveTextContent(
+ 'example',
+ );
+ });
+
+ test('handles negative test cases', () => {
+ lynxEnv.resetLynxEnv();
+ lynxEnv.switchToMainThread();
+ const page = __CreatePage('0', 0);
+ const text0 = __CreateText(0);
+ const rawText0 = __CreateRawText('2', text0.$$uiSign);
+ __AppendElement(text0, rawText0);
+ __AppendElement(page, text0);
+ __AddDataset(text0, 'testid', 'count-value');
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+ 2
+
+
+ `);
+ const queryByTestId = testId =>
+ elementTree.root.querySelector(`[data-testid="${testId}"]`);
+ expect(() => expect(queryByTestId('count-value2')).toHaveTextContent('2'))
+ .toThrowError();
+ expect(() => expect(queryByTestId('count-value')).toHaveTextContent('3'))
+ .toThrowError();
+ expect(() =>
+ expect(queryByTestId('count-value')).not.toHaveTextContent('2')
+ ).toThrowError();
+ });
+
+ test('normalizes whitespace by default', () => {
+ lynxEnv.resetLynxEnv();
+ lynxEnv.switchToMainThread();
+ const page = __CreatePage('0', 0);
+ const text0 = __CreateText(0);
+ const rawText0 = __CreateRawText('Step 1 of 4', text0.$$uiSign);
+ __AppendElement(text0, rawText0);
+ __AppendElement(page, text0);
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+ Step 1 of 4
+
+
+ `);
+ expect(elementTree.root.children[0]).toHaveTextContent('Step 1 of 4');
+ });
+
+ test('allows whitespace normalization to be turned off', () => {
+ lynxEnv.resetLynxEnv();
+ lynxEnv.switchToMainThread();
+ const page = __CreatePage('0', 0);
+ const text0 = __CreateText(0);
+ const rawText0 = __CreateRawText(' Step 1 of 4', text0.$$uiSign);
+ __AppendElement(text0, rawText0);
+ __AppendElement(page, text0);
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+ Step 1 of 4
+
+
+ `);
+ expect(elementTree.root.children[0]).toHaveTextContent(' Step 1 of 4', {
+ normalizeWhitespace: false,
+ });
+ });
+
+ test('can handle multiple levels', () => {
+ lynxEnv.resetLynxEnv();
+ lynxEnv.switchToMainThread();
+ const page = __CreatePage('0', 0);
+ const text0 = __CreateText(0);
+ const rawText0 = __CreateRawText('Step 1 of 4', text0.$$uiSign);
+ __AppendElement(text0, rawText0);
+ __AppendElement(page, text0);
+ __SetID(text0, 'parent');
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+ Step 1 of 4
+
+
+ `);
+ expect(elementTree.root.querySelector('#parent')).toHaveTextContent(
+ 'Step 1 of 4',
+ );
+ });
+
+ test('can handle multiple levels with content spread across descendants', () => {
+ lynxEnv.resetLynxEnv();
+ lynxEnv.switchToMainThread();
+ const page = __CreatePage('0', 0);
+ const view = __CreateView(0);
+ const text0 = __CreateText(view.$$uiSign);
+ const rawText0 = __CreateRawText('Step', text0.$$uiSign);
+ const text1 = __CreateText(view.$$uiSign);
+ const rawText1 = __CreateRawText('1', text1.$$uiSign);
+ const text2 = __CreateText(view.$$uiSign);
+ const rawText2 = __CreateRawText('of', text2.$$uiSign);
+ const text3 = __CreateText(view.$$uiSign);
+ const rawText3 = __CreateRawText('4', text3.$$uiSign);
+ __AppendElement(text0, rawText0);
+ __AppendElement(text1, rawText1);
+ __AppendElement(text2, rawText2);
+ __AppendElement(text3, rawText3);
+ __AppendElement(view, text0);
+ __AppendElement(view, text1);
+ __AppendElement(view, text2);
+ __AppendElement(view, text3);
+ __AppendElement(page, view);
+ __SetID(view, 'parent');
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+ Step
+
+
+ 1
+
+
+ of
+
+
+ 4
+
+
+
+ `);
+ expect(elementTree.root.querySelector('#parent')).toHaveTextContent(
+ 'Step1of4',
+ );
+ });
+
+ test('does not throw error with empty content', () => {
+ lynxEnv.resetLynxEnv();
+ lynxEnv.switchToMainThread();
+ const page = __CreatePage('0', 0);
+ const text0 = __CreateText(0);
+ __AppendElement(page, text0);
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+
+ `);
+ expect(elementTree.root.children[0]).toHaveTextContent('');
+ });
+
+ test('is case-sensitive', () => {
+ lynxEnv.resetLynxEnv();
+ lynxEnv.switchToMainThread();
+ const page = __CreatePage('0', 0);
+ const text0 = __CreateText(0);
+ const rawText0 = __CreateRawText('Sensitive text', text0.$$uiSign);
+ __AppendElement(text0, rawText0);
+ __AppendElement(page, text0);
+ expect(elementTree).toMatchInlineSnapshot(`
+
+
+ Sensitive text
+
+
+ `);
+ expect(elementTree.root.children[0]).toHaveTextContent('Sensitive text');
+ expect(elementTree.root.children[0]).not.toHaveTextContent(
+ 'sensitive text',
+ );
+ });
+
+ test('when matching with empty string and element with content, suggest using toBeEmptyDOMElement instead', () => {
+ const page = __CreatePage('0', 0);
+ const text0 = __CreateText(0);
+ const rawText0 = __CreateRawText('not empty', text0.$$uiSign);
+ __AppendElement(text0, rawText0);
+ __AppendElement(page, text0);
+ expect(() => expect(text0).toHaveTextContent('')).toThrowError(
+ /toBeEmptyDOMElement\(\)/,
+ );
+ });
+});
diff --git a/packages/testing-library/test-environment/src/env/vitest/index.ts b/packages/testing-library/test-environment/src/env/vitest/index.ts
new file mode 100644
index 0000000000..7c3054eca6
--- /dev/null
+++ b/packages/testing-library/test-environment/src/env/vitest/index.ts
@@ -0,0 +1,26 @@
+import { builtinEnvironments } from 'vitest/environments';
+import { LynxEnv } from '@lynx-js/test-environment';
+
+const env = {
+ name: 'lynxEnv',
+ transformMode: 'web',
+ async setup(global) {
+ const fakeGlobal: {
+ jsdom?: any;
+ } = {};
+ await builtinEnvironments.jsdom.setup(fakeGlobal, {});
+ global.jsdom = fakeGlobal.jsdom;
+
+ const lynxEnv = new LynxEnv();
+ global.lynxEnv = lynxEnv;
+
+ return {
+ teardown(global) {
+ delete global.lynxEnv;
+ delete global.jsdom;
+ },
+ };
+ },
+};
+
+export default env;
diff --git a/packages/testing-library/test-environment/src/index.ts b/packages/testing-library/test-environment/src/index.ts
new file mode 100644
index 0000000000..d0eb8349ba
--- /dev/null
+++ b/packages/testing-library/test-environment/src/index.ts
@@ -0,0 +1,492 @@
+/**
+ * @packageDocumentation
+ *
+ * A pure-JavaScript implementation of the {@link https://lynxjs.org/guide/spec.html | Lynx Spec},
+ * notably the {@link https://lynxjs.org/api/engine/element-api | Element PAPI} and {@link https://lynxjs.org/guide/spec#dual-threaded-model | Dual-threaded Model} for use with Node.js.
+ */
+
+import EventEmitter from 'events';
+import { JSDOM } from 'jsdom';
+import { createGlobalThis, LynxGlobalThis } from './lynx/GlobalThis';
+import { initElementTree } from './lynx/ElementPAPI';
+export { initElementTree } from './lynx/ElementPAPI';
+export type { LynxElement } from './lynx/ElementPAPI';
+export type { LynxGlobalThis } from './lynx/GlobalThis';
+/**
+ * @public
+ * The lynx element tree
+ */
+export type ElementTree = ReturnType;
+/**
+ * @public
+ */
+export type FilterUnderscoreKeys = {
+ [K in keyof T]: K extends `__${string}` ? K : never;
+}[keyof T];
+/**
+ * @public
+ */
+export type PickUnderscoreKeys = Pick>;
+/**
+ * The Element PAPI Types
+ * @public
+ */
+export type ElementTreeGlobals = PickUnderscoreKeys;
+
+declare global {
+ var lynxEnv: LynxEnv;
+ var elementTree: ElementTree;
+ var __JS__: boolean;
+ var __LEPUS__: boolean;
+ var __BACKGROUND__: boolean;
+ var __MAIN_THREAD__: boolean;
+
+ namespace lynxCoreInject {
+ var tt: any;
+ }
+
+ function onInjectBackgroundThreadGlobals(globals: any): void;
+ function onInjectMainThreadGlobals(globals: any): void;
+ function onSwitchedToBackgroundThread(): void;
+ function onSwitchedToMainThread(): void;
+ function onResetLynxEnv(): void;
+ function onInitWorkletRuntime(): void;
+}
+
+function __injectElementApi(target?: any) {
+ const elementTree = initElementTree();
+ target.elementTree = elementTree;
+
+ if (typeof target === 'undefined') {
+ target = globalThis;
+ }
+
+ for (
+ const k of Object.getOwnPropertyNames(elementTree.constructor.prototype)
+ ) {
+ if (k.startsWith('__')) {
+ // @ts-ignore
+ target[k] = elementTree[k].bind(elementTree);
+ }
+ }
+
+ target.$kTemplateAssembler = {};
+
+ target.registerDataProcessor = () => {
+ console.error('registerDataProcessor is not implemented');
+ };
+
+ target.__OnLifecycleEvent = (...args: any[]) => {
+ const isMainThread = __MAIN_THREAD__;
+
+ globalThis.lynxEnv.switchToBackgroundThread();
+ globalThis.lynxCoreInject.tt.OnLifecycleEvent(...args);
+
+ if (isMainThread) {
+ globalThis.lynxEnv.switchToMainThread();
+ }
+ };
+ target._ReportError = () => {};
+}
+
+function createPolyfills() {
+ const app = {
+ callLepusMethod: (...rLynxChange: any[]) => {
+ const isBackground = !__MAIN_THREAD__;
+
+ globalThis.lynxEnv.switchToMainThread();
+ globalThis[rLynxChange[0]](rLynxChange[1]);
+
+ globalThis.lynxEnv.switchToBackgroundThread();
+ rLynxChange[2]();
+ globalThis.lynxEnv.switchToMainThread();
+
+ // restore the original thread state
+ if (isBackground) {
+ globalThis.lynxEnv.switchToBackgroundThread();
+ }
+ },
+ markTiming: () => {},
+ createJSObjectDestructionObserver: (() => {
+ return {};
+ }),
+ };
+
+ const performance = {
+ __functionCallHistory: [] as any[],
+ _generatePipelineOptions: (() => {
+ performance.__functionCallHistory.push(['_generatePipelineOptions']);
+ return {
+ pipelineID: 'pipelineID',
+ needTimestamps: false,
+ };
+ }),
+ _onPipelineStart: ((id) => {
+ performance.__functionCallHistory.push(['_onPipelineStart', id]);
+ }),
+ _markTiming: ((id, key) => {
+ performance.__functionCallHistory.push(['_markTiming', id, key]);
+ }),
+ _bindPipelineIdWithTimingFlag: ((id, flag) => {
+ performance.__functionCallHistory.push([
+ '_bindPipelineIdWithTimingFlag',
+ id,
+ flag,
+ ]);
+ }),
+ };
+
+ const ee = new EventEmitter();
+ // @ts-ignore
+ ee.dispatchEvent = ({
+ type,
+ data,
+ }) => {
+ const isMainThread = __MAIN_THREAD__;
+ lynxEnv.switchToBackgroundThread();
+
+ // Ensure the code is running on the background thread
+ ee.emit(type, {
+ data: data,
+ });
+
+ if (isMainThread) {
+ lynxEnv.switchToMainThread();
+ }
+ };
+ // @ts-ignore
+ ee.addEventListener = ee.addListener;
+ // @ts-ignore
+ ee.removeEventListener = ee.removeListener;
+
+ const CoreContext = ee;
+
+ const JsContext = ee;
+
+ function __LoadLepusChunk(
+ chunkName: string,
+ options,
+ ) {
+ const isBackground = !__MAIN_THREAD__;
+ globalThis.lynxEnv.switchToMainThread();
+
+ if (process.env['DEBUG']) {
+ console.log('__LoadLepusChunk', chunkName, options);
+ }
+ let ans;
+ if (chunkName === 'worklet-runtime') {
+ ans = globalThis.onInitWorkletRuntime?.();
+ } else {
+ throw new Error(`__LoadLepusChunk: Unknown chunk name: ${chunkName}`);
+ }
+
+ // restore the original thread state
+ if (isBackground) {
+ globalThis.lynxEnv.switchToBackgroundThread();
+ }
+
+ return ans;
+ }
+
+ return {
+ app,
+ performance,
+ CoreContext,
+ JsContext,
+ __LoadLepusChunk,
+ };
+}
+
+function injectMainThreadGlobals(target?: any, polyfills?: any) {
+ __injectElementApi(target);
+
+ const {
+ performance,
+ JsContext,
+ __LoadLepusChunk,
+ } = polyfills || {};
+ if (typeof target === 'undefined') {
+ target = globalThis;
+ }
+
+ target.__DEV__ = true;
+ target.__PROFILE__ = true;
+ target.__JS__ = false;
+ target.__LEPUS__ = true;
+ target.__BACKGROUND__ = false;
+ target.__MAIN_THREAD__ = true;
+ target.__REF_FIRE_IMMEDIATELY__ = false;
+ target.__FIRST_SCREEN_SYNC_TIMING__ = 'immediately';
+ target.__TESTING_FORCE_RENDER_TO_OPCODE__ = false;
+ target.__ENABLE_SSR__ = false;
+ target.globDynamicComponentEntry = '__Card__';
+ target.lynx = {
+ performance,
+ getJSContext: (() => JsContext),
+ reportError: (e: Error) => {
+ throw e;
+ },
+ };
+ target.requestAnimationFrame = setTimeout;
+ target.cancelAnimationFrame = clearTimeout;
+
+ target.console.profile = () => {};
+ target.console.profileEnd = () => {};
+
+ target.__LoadLepusChunk = __LoadLepusChunk;
+
+ globalThis.onInjectMainThreadGlobals?.(target);
+}
+
+const IGNORE_LIST_GLOBALS = [
+ 'globalThis',
+ 'global',
+];
+
+class NodesRef {
+ // @ts-ignore
+ private readonly _nodeSelectToken: any;
+ // @ts-ignore
+ private readonly _selectorQuery: any;
+
+ constructor(selectorQuery: any, nodeSelectToken: any) {
+ this._nodeSelectToken = nodeSelectToken;
+ this._selectorQuery = selectorQuery;
+ }
+ invoke() {
+ throw new Error('not implemented');
+ }
+ path() {
+ throw new Error('not implemented');
+ }
+ fields() {
+ throw new Error('not implemented');
+ }
+ setNativeProps() {
+ throw new Error('not implemented');
+ }
+}
+
+function injectBackgroundThreadGlobals(target?: any, polyfills?: any) {
+ const {
+ app,
+ performance,
+ CoreContext,
+ __LoadLepusChunk,
+ } = polyfills || {};
+ if (typeof target === 'undefined') {
+ target = globalThis;
+ }
+
+ target.__DEV__ = true;
+ target.__PROFILE__ = true;
+ target.__JS__ = true;
+ target.__LEPUS__ = false;
+ target.__BACKGROUND__ = true;
+ target.__MAIN_THREAD__ = false;
+ target.__ENABLE_SSR__ = false;
+ target.globDynamicComponentEntry = '__Card__';
+ target.lynxCoreInject = {};
+ target.lynxCoreInject.tt = {
+ _params: {
+ initData: {},
+ updateData: {},
+ },
+ };
+
+ const enum IdentifierType {
+ ID_SELECTOR, // css selector
+ REF_ID, // for react ref
+ UNIQUE_ID, // element_id
+ }
+
+ const globalEventEmitter = new EventEmitter();
+ // @ts-ignore
+ globalEventEmitter.trigger = globalEventEmitter.emit;
+ // @ts-ignore
+ globalEventEmitter.toggle = globalEventEmitter.emit;
+ target.lynx = {
+ getNativeApp: () => app,
+ performance,
+ createSelectorQuery: (() => {
+ return {
+ selectUniqueID: function(uniqueId: number) {
+ return new NodesRef({}, {
+ type: IdentifierType.UNIQUE_ID,
+ identifier: uniqueId.toString(),
+ });
+ },
+ };
+ }),
+ getCoreContext: (() => CoreContext),
+ getJSModule: (moduleName) => {
+ if (moduleName === 'GlobalEventEmitter') {
+ return globalEventEmitter;
+ } else {
+ throw new Error(`getJSModule(${moduleName}) not implemented`);
+ }
+ },
+ reportError: (e: Error) => {
+ throw e;
+ },
+ };
+ target.requestAnimationFrame = setTimeout;
+ target.cancelAnimationFrame = clearTimeout;
+
+ target.console.profile = () => {};
+ target.console.profileEnd = () => {};
+
+ // TODO: user-configurable
+ target.SystemInfo = {
+ 'platform': 'iOS',
+ 'pixelRatio': 3,
+ 'pixelWidth': 1170,
+ 'pixelHeight': 2532,
+ 'osVersion': '17.0.2',
+ 'enableKrypton': true,
+ 'runtimeType': 'quickjs',
+ 'lynxSdkVersion': '3.0',
+ };
+
+ target.__LoadLepusChunk = __LoadLepusChunk;
+
+ globalThis.onInjectBackgroundThreadGlobals?.(target);
+}
+
+/**
+ * A pure-JavaScript implementation of the {@link https://lynxjs.org/guide/spec.html | Lynx Spec},
+ * notably the {@link https://lynxjs.org/api/engine/element-api | Element PAPI} and {@link https://lynxjs.org/guide/spec#dual-threaded-model | Dual-threaded Model} for use with Node.js.
+ *
+ * @example
+ *
+ * ```ts
+ * import { LynxEnv } from '@lynx-js/test-environment';
+ *
+ * const lynxEnv = new LynxEnv();
+ *
+ * lynxEnv.switchToMainThread();
+ * // use the main thread Element PAPI
+ * const page = __CreatePage('0', 0);
+ * const view = __CreateView(0);
+ * __AppendElement(page, view);
+ *
+ * ```
+ *
+ * @public
+ */
+export class LynxEnv {
+ private originals: Map = new Map();
+ /**
+ * The global object for the background thread.
+ *
+ * @example
+ *
+ * ```ts
+ * import { LynxEnv } from '@lynx-js/test-environment';
+ *
+ * const lynxEnv = new LynxEnv();
+ *
+ * lynxEnv.switchToBackgroundThread();
+ * // use the background thread global object
+ * globalThis.lynxCoreInject.tt.OnLifecycleEvent(...args);
+ * ```
+ */
+ backgroundThread: LynxGlobalThis;
+ /**
+ * The global object for the main thread.
+ *
+ * @example
+ *
+ * ```ts
+ * import { LynxEnv } from '@lynx-js/test-environment';
+ *
+ * const lynxEnv = new LynxEnv();
+ *
+ * lynxEnv.switchToMainThread();
+ * // use the main thread global object
+ * const page = globalThis.__CreatePage('0', 0);
+ * const view = globalThis.__CreateView(0);
+ * globalThis.__AppendElement(page, view);
+ * ```
+ */
+ mainThread: LynxGlobalThis & ElementTreeGlobals;
+ jsdom: JSDOM = global.jsdom;
+ constructor() {
+ this.backgroundThread = createGlobalThis() as any;
+ this.mainThread = createGlobalThis() as any;
+
+ const globalPolyfills = {
+ console: this.jsdom.window['console'],
+ // `Event` is required by `fireEvent` in `@testing-library/dom`
+ Event: this.jsdom.window.Event,
+ // `window` is required by `getDocument` in `@testing-library/dom`
+ window: this.jsdom.window,
+ // `document` is required by `screen` in `@testing-library/dom`
+ document: this.jsdom.window.document,
+ };
+
+ Object.assign(
+ this.mainThread.globalThis,
+ globalPolyfills,
+ );
+ Object.assign(
+ this.backgroundThread.globalThis,
+ globalPolyfills,
+ );
+
+ this.injectGlobals();
+
+ // we have to switch background thread first
+ // otherwise global import for @lynx-js/react will report error
+ // on __MAIN_THREAD__/__BACKGROUND__/lynx not defined etc.
+ this.switchToBackgroundThread();
+ }
+
+ injectGlobals() {
+ const polyfills = createPolyfills();
+ injectBackgroundThreadGlobals(this.backgroundThread.globalThis, polyfills);
+ injectMainThreadGlobals(this.mainThread.globalThis, polyfills);
+ }
+
+ switchToBackgroundThread() {
+ this.originals = new Map();
+ Object.getOwnPropertyNames(this.backgroundThread.globalThis).forEach(
+ (key) => {
+ if (IGNORE_LIST_GLOBALS.includes(key)) {
+ return;
+ }
+ this.originals.set(key, global[key]);
+ global[key] = this.backgroundThread.globalThis[key];
+ },
+ );
+
+ globalThis?.onSwitchedToBackgroundThread?.();
+ }
+ switchToMainThread() {
+ this.originals = new Map();
+ Object.getOwnPropertyNames(this.mainThread.globalThis).forEach((key) => {
+ if (IGNORE_LIST_GLOBALS.includes(key)) {
+ return;
+ }
+ this.originals.set(key, global[key]);
+ global[key] = this.mainThread.globalThis[key];
+ });
+
+ globalThis?.onSwitchedToMainThread?.();
+ }
+ // we do not use it because we have to keep background thread
+ // otherwise we will get error on __MAIN_THREAD__/__BACKGROUND__/lynx not defined etc.
+ clearGlobal() {
+ this.originals?.forEach((v, k) => {
+ global[k] = v;
+ });
+ this.originals?.clear();
+ }
+ resetLynxEnv() {
+ this.injectGlobals();
+ // ensure old globals are replaced with new globals
+ this.switchToMainThread();
+ this.switchToBackgroundThread();
+ globalThis.onResetLynxEnv?.();
+ }
+}
diff --git a/packages/testing-library/test-environment/src/lynx/ElementPAPI.ts b/packages/testing-library/test-environment/src/lynx/ElementPAPI.ts
new file mode 100644
index 0000000000..5d5ea24991
--- /dev/null
+++ b/packages/testing-library/test-environment/src/lynx/ElementPAPI.ts
@@ -0,0 +1,478 @@
+/*
+// Copyright 2024 The Lynx Authors. All rights reserved.
+// Licensed under the Apache License Version 2.0 that can be found in the
+// LICENSE file in the root directory of this source tree.
+*/
+
+/**
+ * Any Lynx Element, such as `view`, `text`, `image`, etc.
+ *
+ * [Lynx Spec Reference](https://lynxjs.org/living-spec/index.html?ts=1743416098203#element%E2%91%A0)
+ *
+ * @public
+ */
+export interface LynxElement extends HTMLElement {
+ // /**
+ // * The props of the element.
+ // */
+ // props: {
+ // cssId?: string;
+ // event?: {
+ // [key: string]: any;
+ // };
+ // gesture?: {
+ // [key: string]: any;
+ // };
+ // [key: string]: any;
+ // };
+ /**
+ * The unique id of the element.
+ *
+ * @internal
+ */
+ $$uiSign: number;
+ /**
+ * The unique id of the parent of the element.
+ *
+ * @internal
+ */
+ parentComponentUniqueId: number;
+ /**
+ * The map of events bound to the element.
+ */
+ eventMap?: {
+ [key: string]: any;
+ };
+ /**
+ * The gestures bound to the element.
+ */
+ gesture?: {
+ [key: string]: any;
+ };
+ /**
+ * The cssId of the element
+ */
+ cssId?: string;
+ /**
+ * Returns the first child.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/firstChild)
+ */
+ firstChild: LynxElement;
+ /**
+ * Returns the next sibling.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/nextSibling)
+ */
+ nextSibling: LynxElement;
+ /**
+ * Returns the parent.
+ *
+ * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Node/parentNode)
+ */
+ parentNode: LynxElement;
+}
+/**
+ * @public
+ */
+export const initElementTree = () => {
+ let uiSignNext = 0;
+ const uniqueId2Element = new Map();
+
+ return new (class ElementTree {
+ root: LynxElement | undefined;
+ countElement(
+ element: LynxElement,
+ parentComponentUniqueId: number,
+ ) {
+ element.$$uiSign = uiSignNext++;
+ uniqueId2Element.set(element.$$uiSign, element);
+ element.parentComponentUniqueId = parentComponentUniqueId;
+ }
+ __CreatePage(_tag: string, parentComponentUniqueId: number) {
+ const page = this.__CreateElement('page', parentComponentUniqueId);
+ this.root = page;
+ lynxEnv.jsdom.window.document.body.appendChild(page);
+ return page;
+ }
+
+ __CreateRawText(text: string): LynxElement {
+ const element = lynxEnv.jsdom.window.document.createTextNode(
+ text,
+ ) as unknown as LynxElement;
+ this.countElement(element, 0);
+
+ return element;
+ }
+
+ __GetElementUniqueID(e: LynxElement): number {
+ return e.$$uiSign;
+ }
+
+ __SetClasses(e: LynxElement, cls: string) {
+ e.className = cls;
+ }
+
+ __CreateElement(
+ tag: string,
+ parentComponentUniqueId: number,
+ ): LynxElement {
+ if (tag === 'raw-text') {
+ return this.__CreateRawText('');
+ }
+
+ const element = lynxEnv.jsdom.window.document.createElement(
+ tag,
+ ) as LynxElement;
+ this.countElement(element, parentComponentUniqueId);
+ return element;
+ }
+
+ __CreateView(parentComponentUniqueId: number) {
+ return this.__CreateElement('view', parentComponentUniqueId);
+ }
+ __CreateScrollView(parentComponentUniqueId: number) {
+ return this.__CreateElement('scroll-view', parentComponentUniqueId);
+ }
+ __FirstElement(e: LynxElement) {
+ return e.firstChild;
+ }
+
+ __CreateText(parentComponentUniqueId: number) {
+ return this.__CreateElement('text', parentComponentUniqueId);
+ }
+
+ __CreateImage(parentComponentUniqueId: number) {
+ return this.__CreateElement('image', parentComponentUniqueId);
+ }
+
+ __CreateWrapperElement(parentComponentUniqueId: number) {
+ return this.__CreateElement('wrapper', parentComponentUniqueId);
+ }
+
+ __AddInlineStyle(e: HTMLElement, key: number, value: string) {
+ e.style[key] = value;
+ }
+
+ __AppendElement(parent: LynxElement, child: LynxElement) {
+ parent.appendChild(child);
+ }
+
+ __SetCSSId(
+ e: LynxElement | LynxElement[],
+ id: string,
+ entryName?: string,
+ ) {
+ const cssId = `${entryName ?? '__Card__'}:${id}`;
+ if (Array.isArray(e)) {
+ e.forEach(item => {
+ item.cssId = cssId;
+ });
+ } else {
+ e.cssId = cssId;
+ }
+ }
+
+ __SetAttribute(e: LynxElement, key: string, value: any) {
+ if (
+ key === 'style'
+ || key === 'class'
+ || key === 'className'
+ || key === 'key'
+ || key === 'id'
+ || key === 'ref'
+ || (/^data-/.exec(key))
+ || (/^(bind|catch|global-bind|capture-bind|capture-catch)[A-Za-z]/.exec(
+ key,
+ ))
+ ) {
+ throw new Error(`Cannot use __SetAttribute for "${key}"`);
+ }
+
+ if (key === 'update-list-info') {
+ let listInfoStr = e.getAttribute(key);
+ let listInfo = listInfoStr ? JSON.parse(listInfoStr) : [];
+ listInfo.push(value);
+
+ e.setAttribute(key, JSON.stringify(listInfo));
+ return;
+ }
+
+ if (key === 'text') {
+ e.textContent = value;
+ return;
+ }
+
+ if (value === null) {
+ e.removeAttribute(key);
+ return;
+ }
+ if (typeof value === 'string') {
+ e.setAttribute(key, value);
+ return;
+ } else {
+ e.setAttribute(key, JSON.stringify(value));
+ return;
+ }
+ }
+
+ __AddEvent(
+ e: LynxElement,
+ eventType: string,
+ eventName: string,
+ eventHandler: string | Record,
+ ) {
+ if (e.eventMap?.[`${eventType}:${eventName}`]) {
+ e.removeEventListener(
+ `${eventType}:${eventName}`,
+ e.eventMap[`${eventType}:${eventName}`],
+ );
+ delete e.eventMap[`${eventType}:${eventName}`];
+ }
+ if (typeof eventHandler === 'undefined') {
+ return;
+ }
+ if (
+ typeof eventHandler !== 'string' && eventHandler['type'] === undefined
+ ) {
+ throw new Error(`event must be string, but got ${typeof eventHandler}`);
+ }
+
+ const listener: EventListenerOrEventListenerObject = (evt) => {
+ if (
+ typeof eventHandler === 'object' && eventHandler['type'] === 'worklet'
+ ) {
+ const isBackground = !__MAIN_THREAD__;
+ globalThis.lynxEnv.switchToMainThread();
+
+ // Use Object.assign to convert evt to plain object to avoid infinite transformWorkletInner
+ // @ts-ignore
+ runWorklet(eventHandler.value, [Object.assign({}, evt)]);
+
+ if (isBackground) {
+ globalThis.lynxEnv.switchToBackgroundThread();
+ }
+ } else {
+ // stop the propagation of the event
+ if (eventType === 'catchEvent' || eventType === 'capture-catch') {
+ evt.stopPropagation();
+ }
+ // @ts-ignore
+ globalThis.lynxCoreInject.tt.publishEvent(eventHandler, evt);
+ }
+ };
+ e.eventMap = e.eventMap ?? {};
+ e.eventMap[`${eventType}:${eventName}`] = listener;
+ e.addEventListener(
+ `${eventType}:${eventName}`,
+ listener,
+ {
+ // listening at capture stage
+ capture: eventType === 'capture-bind'
+ || eventType === 'capture-catch',
+ },
+ );
+ }
+
+ __GetEvent(e: LynxElement, eventType: string, eventName: string) {
+ const jsFunction = e.eventMap?.[`${eventType}:${eventName}`];
+ if (typeof jsFunction !== 'undefined') {
+ return {
+ type: eventType,
+ name: eventName,
+ jsFunction,
+ };
+ }
+ return undefined;
+ }
+
+ __SetID(e: LynxElement, id: string) {
+ e.id = id;
+ }
+
+ __SetInlineStyles(
+ e: LynxElement,
+ styles: string | Record,
+ ) {
+ if (typeof styles === 'string') {
+ e.style.cssText = styles;
+ } else {
+ Object.assign(e.style, styles);
+ }
+ }
+
+ __AddDataset(e: LynxElement, key: string, value: string) {
+ e.dataset[key] = value;
+ }
+
+ __SetDataset(e: LynxElement, dataset: any) {
+ Object.assign(e.dataset, dataset);
+ }
+
+ __SetGestureDetector(
+ e: LynxElement,
+ id: number,
+ type: number,
+ config: any,
+ relationMap: Record,
+ ) {
+ e.gesture = {
+ id,
+ type,
+ config,
+ relationMap,
+ };
+ }
+
+ __GetDataset(e: LynxElement) {
+ return e.dataset;
+ }
+
+ __RemoveElement(parent: LynxElement, child: LynxElement) {
+ let ch = parent.firstChild;
+ while (ch) {
+ if (ch === child) {
+ parent.removeChild(ch);
+ break;
+ }
+ }
+ }
+
+ __InsertElementBefore(
+ parent: LynxElement,
+ child: LynxElement,
+ ref?: LynxElement,
+ ) {
+ if (typeof ref === 'undefined') {
+ parent.appendChild(child);
+ } else {
+ parent.insertBefore(child, ref);
+ }
+ }
+
+ __ReplaceElement(
+ newElement: LynxElement,
+ oldElement: LynxElement,
+ ) {
+ const parent = oldElement.parentNode;
+ if (!parent) {
+ throw new Error('unreachable');
+ }
+ parent.replaceChild(newElement, oldElement);
+ }
+
+ __FlushElementTree(): void {}
+
+ __UpdateListComponents(_list: LynxElement, _components: string[]) {}
+
+ __UpdateListCallbacks(
+ list: LynxElement,
+ componentAtIndex: (
+ list: LynxElement,
+ listID: number,
+ cellIndex: number,
+ operationID: number,
+ enable_reuse_notification: boolean,
+ ) => void,
+ enqueueComponent: (
+ list: LynxElement,
+ listID: number,
+ sign: number,
+ ) => void,
+ ): void {
+ Object.defineProperties(list, {
+ componentAtIndex: {
+ enumerable: false,
+ configurable: true,
+ value: componentAtIndex,
+ },
+ enqueueComponent: {
+ enumerable: false,
+ configurable: true,
+ value: enqueueComponent,
+ },
+ });
+ }
+
+ __CreateList(
+ parentComponentUniqueId: number,
+ componentAtIndex: any,
+ enqueueComponent: any,
+ ) {
+ const e = this.__CreateElement('list', parentComponentUniqueId);
+
+ Object.defineProperties(e, {
+ componentAtIndex: {
+ enumerable: false,
+ configurable: true,
+ value: componentAtIndex,
+ },
+ enqueueComponent: {
+ enumerable: false,
+ configurable: true,
+ value: enqueueComponent,
+ },
+ });
+
+ return e;
+ }
+
+ __GetTag(ele: LynxElement) {
+ return ele.nodeName;
+ }
+
+ __GetAttributeByName(ele: LynxElement, name: string) {
+ // return ele.props[name];
+ return ele.getAttribute(name);
+ }
+
+ clear() {
+ this.root = undefined;
+ }
+
+ toTree() {
+ return this.root;
+ }
+
+ /**
+ * Enter a list-item element at the given index.
+ * It will load the list-item element using the `componentAtIndex` callback.
+ *
+ * @param e - The list element
+ * @param index - The index of the list-item element
+ * @param args - The arguments used to create the list-item element
+ * @returns The unique id of the list-item element
+ */
+ enterListItemAtIndex(
+ e: LynxElement,
+ index: number,
+ ...args: any[]
+ ): number {
+ // @ts-ignore
+ const { componentAtIndex, $$uiSign } = e;
+ return componentAtIndex(e, $$uiSign, index, ...args);
+ }
+
+ /**
+ * Leave a list-item element.
+ * It will mark the list-item element as unused using
+ * the `enqueueComponent` callback, and the list-item element
+ * will be reused in the future by other list-item elements.
+ *
+ * @param e - The list element
+ * @param uiSign - The unique id of the list-item element
+ */
+ leaveListItem(e: LynxElement, uiSign: number) {
+ // @ts-ignore
+ const { enqueueComponent, $$uiSign } = e;
+ enqueueComponent(e, $$uiSign, uiSign);
+ }
+
+ toJSON() {
+ return this.toTree();
+ }
+ __GetElementByUniqueId(uniqueId: number) {
+ return uniqueId2Element.get(uniqueId);
+ }
+ })();
+};
diff --git a/packages/testing-library/test-environment/src/lynx/GlobalThis.ts b/packages/testing-library/test-environment/src/lynx/GlobalThis.ts
new file mode 100644
index 0000000000..b7210dba4b
--- /dev/null
+++ b/packages/testing-library/test-environment/src/lynx/GlobalThis.ts
@@ -0,0 +1,41 @@
+import { define } from '../util';
+
+function installOwnProperties(globalThis: any) {
+ define(globalThis, {
+ get globalThis() {
+ return globalThis._globalProxy;
+ },
+ });
+}
+
+export const createGlobalThis = (): LynxGlobalThis => {
+ // @ts-ignore
+ const globalThis: LynxGlobalThis = {};
+
+ globalThis._globalObject = globalThis._globalProxy = globalThis;
+
+ installOwnProperties(globalThis);
+
+ return globalThis;
+};
+
+/**
+ * The `globalThis` object of Lynx dual thread environment.
+ *
+ * @public
+ */
+export interface LynxGlobalThis {
+ /**
+ * @internal
+ */
+ _globalObject: any;
+ /**
+ * @internal
+ */
+ _globalProxy: any;
+ /**
+ * The globalThis object.
+ */
+ globalThis: LynxGlobalThis;
+ [key: string]: any;
+}
diff --git a/packages/testing-library/test-environment/src/util.ts b/packages/testing-library/test-environment/src/util.ts
new file mode 100644
index 0000000000..563be216a1
--- /dev/null
+++ b/packages/testing-library/test-environment/src/util.ts
@@ -0,0 +1,14 @@
+/**
+ * Define a set of properties on an object, by copying the property descriptors
+ * from the original object.
+ *
+ * - `object` {Object} the target object
+ * - `properties` {Object} the source from which to copy property descriptors
+ */
+export function define(object: any, properties: any) {
+ for (const name of Object.getOwnPropertyNames(properties)) {
+ const propDesc = Object.getOwnPropertyDescriptor(properties, name);
+ // @ts-ignore
+ Object.defineProperty(object, name, propDesc);
+ }
+}
diff --git a/packages/testing-library/test-environment/tsconfig.json b/packages/testing-library/test-environment/tsconfig.json
new file mode 100644
index 0000000000..88f6924cbf
--- /dev/null
+++ b/packages/testing-library/test-environment/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../../tsconfig.json",
+ "compilerOptions": {
+ "verbatimModuleSyntax": false,
+ "noImplicitAny": false,
+ "isolatedDeclarations": false,
+ "rootDir": "src",
+ },
+
+ "include": [
+ "src",
+ ],
+}
diff --git a/packages/testing-library/test-environment/turbo.json b/packages/testing-library/test-environment/turbo.json
new file mode 100644
index 0000000000..3b4189eea4
--- /dev/null
+++ b/packages/testing-library/test-environment/turbo.json
@@ -0,0 +1,15 @@
+{
+ "$schema": "https://turbo.build/schema.json",
+ "extends": ["//"],
+ "tasks": {
+ "api-extractor": {
+ "dependsOn": [
+ "build"
+ ],
+ "cache": false
+ },
+ "build": {
+ "outputs": ["dist/**"]
+ }
+ }
+}
diff --git a/packages/testing-library/test-environment/vitest.config.mts b/packages/testing-library/test-environment/vitest.config.mts
new file mode 100644
index 0000000000..301228960f
--- /dev/null
+++ b/packages/testing-library/test-environment/vitest.config.mts
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config';
+import path from 'path';
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: path.posix.join(__dirname, './src/env/vitest/index.ts'),
+ name: 'testing-library/lynx-environment',
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index db991b64b6..7a3d131dc1 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -183,12 +183,21 @@ importers:
specifier: npm:@hongzhiyuan/preact@10.24.0-319c684e
version: '@hongzhiyuan/preact@10.24.0-319c684e'
devDependencies:
+ '@lynx-js/test-environment':
+ specifier: workspace:*
+ version: link:../testing-library/test-environment
'@lynx-js/types':
specifier: ^3.2.1
version: 3.2.1
'@microsoft/api-extractor':
specifier: 'catalog:'
version: 7.52.3(@types/node@22.14.1)
+ '@testing-library/dom':
+ specifier: ^10.4.0
+ version: 10.4.0
+ '@testing-library/jest-dom':
+ specifier: ^6.6.3
+ version: 6.6.3
'@types/react':
specifier: ^18.3.20
version: 18.3.20
@@ -238,6 +247,12 @@ importers:
specifier: ^3.1.1
version: 3.1.1(@types/debug@4.1.12)(@types/node@22.14.1)(@vitest/ui@3.1.1)(jsdom@26.0.0)(sass-embedded@1.86.0)(terser@5.31.6)
+ packages/react/testing-library:
+ devDependencies:
+ '@lynx-js/react':
+ specifier: workspace:*
+ version: link:..
+
packages/react/transform:
devDependencies:
'@emnapi/core':
@@ -294,7 +309,7 @@ importers:
version: 7.52.3(@types/node@22.14.1)
'@rollup/plugin-typescript':
specifier: ^12.1.2
- version: 12.1.2(patch_hash=926ba262ec682d27369f1a8648a0dfb657fb5f1b28539ca3628d292276c91c3d)(rollup@4.21.1)(tslib@2.8.1)(typescript@5.8.3)
+ version: 12.1.2(patch_hash=926ba262ec682d27369f1a8648a0dfb657fb5f1b28539ca3628d292276c91c3d)(rollup@4.34.9)(tslib@2.8.1)(typescript@5.8.3)
'@rsbuild/webpack':
specifier: catalog:rsbuild
version: 1.3.0(@rsbuild/core@1.3.8)(@rspack/core@1.3.5(@swc/helpers@0.5.17))
@@ -428,7 +443,7 @@ importers:
version: 7.52.3(@types/node@22.14.1)
'@rollup/plugin-typescript':
specifier: ^12.1.2
- version: 12.1.2(patch_hash=926ba262ec682d27369f1a8648a0dfb657fb5f1b28539ca3628d292276c91c3d)(rollup@4.21.1)(tslib@2.8.1)(typescript@5.8.3)
+ version: 12.1.2(patch_hash=926ba262ec682d27369f1a8648a0dfb657fb5f1b28539ca3628d292276c91c3d)(rollup@4.34.9)(tslib@2.8.1)(typescript@5.8.3)
'@rsbuild/core':
specifier: catalog:rsbuild
version: 1.3.8
@@ -549,6 +564,31 @@ importers:
specifier: ^8.18.1
version: 8.18.1
+ packages/testing-library/examples/basic:
+ dependencies:
+ '@lynx-js/react':
+ specifier: workspace:*
+ version: link:../../../react
+ devDependencies:
+ '@lynx-js/react-rsbuild-plugin':
+ specifier: workspace:*
+ version: link:../../../rspeedy/plugin-react
+ '@lynx-js/rspeedy':
+ specifier: workspace:*
+ version: link:../../../rspeedy/core
+ '@testing-library/jest-dom':
+ specifier: ^6.6.3
+ version: 6.6.3
+
+ packages/testing-library/test-environment:
+ devDependencies:
+ '@testing-library/jest-dom':
+ specifier: ^6.6.3
+ version: 6.6.3
+ '@types/jsdom':
+ specifier: ^21.1.7
+ version: 21.1.7
+
packages/third-party/tailwind-preset:
devDependencies:
tailwindcss:
@@ -1118,6 +1158,9 @@ packages:
'@actions/io@1.1.3':
resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==}
+ '@adobe/css-tools@4.4.2':
+ resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
+
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@@ -1279,8 +1322,8 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
- '@babel/plugin-syntax-jsx@7.24.7':
- resolution: {integrity: sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==}
+ '@babel/plugin-syntax-jsx@7.25.9':
+ resolution: {integrity: sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
@@ -2536,83 +2579,98 @@ packages:
rollup:
optional: true
- '@rollup/rollup-android-arm-eabi@4.21.1':
- resolution: {integrity: sha512-2thheikVEuU7ZxFXubPDOtspKn1x0yqaYQwvALVtEcvFhMifPADBrgRPyHV0TF3b+9BgvgjgagVyvA/UqPZHmg==}
+ '@rollup/rollup-android-arm-eabi@4.34.9':
+ resolution: {integrity: sha512-qZdlImWXur0CFakn2BJ2znJOdqYZKiedEPEVNTBrpfPjc/YuTGcaYZcdmNFTkUj3DU0ZM/AElcM8Ybww3xVLzA==}
cpu: [arm]
os: [android]
- '@rollup/rollup-android-arm64@4.21.1':
- resolution: {integrity: sha512-t1lLYn4V9WgnIFHXy1d2Di/7gyzBWS8G5pQSXdZqfrdCGTwi1VasRMSS81DTYb+avDs/Zz4A6dzERki5oRYz1g==}
+ '@rollup/rollup-android-arm64@4.34.9':
+ resolution: {integrity: sha512-4KW7P53h6HtJf5Y608T1ISKvNIYLWRKMvfnG0c44M6In4DQVU58HZFEVhWINDZKp7FZps98G3gxwC1sb0wXUUg==}
cpu: [arm64]
os: [android]
- '@rollup/rollup-darwin-arm64@4.21.1':
- resolution: {integrity: sha512-AH/wNWSEEHvs6t4iJ3RANxW5ZCK3fUnmf0gyMxWCesY1AlUj8jY7GC+rQE4wd3gwmZ9XDOpL0kcFnCjtN7FXlA==}
+ '@rollup/rollup-darwin-arm64@4.34.9':
+ resolution: {integrity: sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ==}
cpu: [arm64]
os: [darwin]
- '@rollup/rollup-darwin-x64@4.21.1':
- resolution: {integrity: sha512-dO0BIz/+5ZdkLZrVgQrDdW7m2RkrLwYTh2YMFG9IpBtlC1x1NPNSXkfczhZieOlOLEqgXOFH3wYHB7PmBtf+Bg==}
+ '@rollup/rollup-darwin-x64@4.34.9':
+ resolution: {integrity: sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q==}
cpu: [x64]
os: [darwin]
- '@rollup/rollup-linux-arm-gnueabihf@4.21.1':
- resolution: {integrity: sha512-sWWgdQ1fq+XKrlda8PsMCfut8caFwZBmhYeoehJ05FdI0YZXk6ZyUjWLrIgbR/VgiGycrFKMMgp7eJ69HOF2pQ==}
+ '@rollup/rollup-freebsd-arm64@4.34.9':
+ resolution: {integrity: sha512-2lzjQPJbN5UnHm7bHIUKFMulGTQwdvOkouJDpPysJS+QFBGDJqcfh+CxxtG23Ik/9tEvnebQiylYoazFMAgrYw==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.34.9':
+ resolution: {integrity: sha512-SLl0hi2Ah2H7xQYd6Qaiu01kFPzQ+hqvdYSoOtHYg/zCIFs6t8sV95kaoqjzjFwuYQLtOI0RZre/Ke0nPaQV+g==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.34.9':
+ resolution: {integrity: sha512-88I+D3TeKItrw+Y/2ud4Tw0+3CxQ2kLgu3QvrogZ0OfkmX/DEppehus7L3TS2Q4lpB+hYyxhkQiYPJ6Mf5/dPg==}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm-musleabihf@4.21.1':
- resolution: {integrity: sha512-9OIiSuj5EsYQlmwhmFRA0LRO0dRRjdCVZA3hnmZe1rEwRk11Jy3ECGGq3a7RrVEZ0/pCsYWx8jG3IvcrJ6RCew==}
+ '@rollup/rollup-linux-arm-musleabihf@4.34.9':
+ resolution: {integrity: sha512-3qyfWljSFHi9zH0KgtEPG4cBXHDFhwD8kwg6xLfHQ0IWuH9crp005GfoUUh/6w9/FWGBwEHg3lxK1iHRN1MFlA==}
cpu: [arm]
os: [linux]
- '@rollup/rollup-linux-arm64-gnu@4.21.1':
- resolution: {integrity: sha512-0kuAkRK4MeIUbzQYu63NrJmfoUVicajoRAL1bpwdYIYRcs57iyIV9NLcuyDyDXE2GiZCL4uhKSYAnyWpjZkWow==}
+ '@rollup/rollup-linux-arm64-gnu@4.34.9':
+ resolution: {integrity: sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw==}
cpu: [arm64]
os: [linux]
- '@rollup/rollup-linux-arm64-musl@4.21.1':
- resolution: {integrity: sha512-/6dYC9fZtfEY0vozpc5bx1RP4VrtEOhNQGb0HwvYNwXD1BBbwQ5cKIbUVVU7G2d5WRE90NfB922elN8ASXAJEA==}
+ '@rollup/rollup-linux-arm64-musl@4.34.9':
+ resolution: {integrity: sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A==}
cpu: [arm64]
os: [linux]
- '@rollup/rollup-linux-powerpc64le-gnu@4.21.1':
- resolution: {integrity: sha512-ltUWy+sHeAh3YZ91NUsV4Xg3uBXAlscQe8ZOXRCVAKLsivGuJsrkawYPUEyCV3DYa9urgJugMLn8Z3Z/6CeyRQ==}
+ '@rollup/rollup-linux-loongarch64-gnu@4.34.9':
+ resolution: {integrity: sha512-dRAgTfDsn0TE0HI6cmo13hemKpVHOEyeciGtvlBTkpx/F65kTvShtY/EVyZEIfxFkV5JJTuQ9tP5HGBS0hfxIg==}
+ cpu: [loong64]
+ os: [linux]
+
+ '@rollup/rollup-linux-powerpc64le-gnu@4.34.9':
+ resolution: {integrity: sha512-PHcNOAEhkoMSQtMf+rJofwisZqaU8iQ8EaSps58f5HYll9EAY5BSErCZ8qBDMVbq88h4UxaNPlbrKqfWP8RfJA==}
cpu: [ppc64]
os: [linux]
- '@rollup/rollup-linux-riscv64-gnu@4.21.1':
- resolution: {integrity: sha512-BggMndzI7Tlv4/abrgLwa/dxNEMn2gC61DCLrTzw8LkpSKel4o+O+gtjbnkevZ18SKkeN3ihRGPuBxjaetWzWg==}
+ '@rollup/rollup-linux-riscv64-gnu@4.34.9':
+ resolution: {integrity: sha512-Z2i0Uy5G96KBYKjeQFKbbsB54xFOL5/y1P5wNBsbXB8yE+At3oh0DVMjQVzCJRJSfReiB2tX8T6HUFZ2k8iaKg==}
cpu: [riscv64]
os: [linux]
- '@rollup/rollup-linux-s390x-gnu@4.21.1':
- resolution: {integrity: sha512-z/9rtlGd/OMv+gb1mNSjElasMf9yXusAxnRDrBaYB+eS1shFm6/4/xDH1SAISO5729fFKUkJ88TkGPRUh8WSAA==}
+ '@rollup/rollup-linux-s390x-gnu@4.34.9':
+ resolution: {integrity: sha512-U+5SwTMoeYXoDzJX5dhDTxRltSrIax8KWwfaaYcynuJw8mT33W7oOgz0a+AaXtGuvhzTr2tVKh5UO8GVANTxyQ==}
cpu: [s390x]
os: [linux]
- '@rollup/rollup-linux-x64-gnu@4.21.1':
- resolution: {integrity: sha512-kXQVcWqDcDKw0S2E0TmhlTLlUgAmMVqPrJZR+KpH/1ZaZhLSl23GZpQVmawBQGVhyP5WXIsIQ/zqbDBBYmxm5w==}
+ '@rollup/rollup-linux-x64-gnu@4.34.9':
+ resolution: {integrity: sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A==}
cpu: [x64]
os: [linux]
- '@rollup/rollup-linux-x64-musl@4.21.1':
- resolution: {integrity: sha512-CbFv/WMQsSdl+bpX6rVbzR4kAjSSBuDgCqb1l4J68UYsQNalz5wOqLGYj4ZI0thGpyX5kc+LLZ9CL+kpqDovZA==}
+ '@rollup/rollup-linux-x64-musl@4.34.9':
+ resolution: {integrity: sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA==}
cpu: [x64]
os: [linux]
- '@rollup/rollup-win32-arm64-msvc@4.21.1':
- resolution: {integrity: sha512-3Q3brDgA86gHXWHklrwdREKIrIbxC0ZgU8lwpj0eEKGBQH+31uPqr0P2v11pn0tSIxHvcdOWxa4j+YvLNx1i6g==}
+ '@rollup/rollup-win32-arm64-msvc@4.34.9':
+ resolution: {integrity: sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q==}
cpu: [arm64]
os: [win32]
- '@rollup/rollup-win32-ia32-msvc@4.21.1':
- resolution: {integrity: sha512-tNg+jJcKR3Uwe4L0/wY3Ro0H+u3nrb04+tcq1GSYzBEmKLeOQF2emk1whxlzNqb6MMrQ2JOcQEpuuiPLyRcSIw==}
+ '@rollup/rollup-win32-ia32-msvc@4.34.9':
+ resolution: {integrity: sha512-KB48mPtaoHy1AwDNkAJfHXvHp24H0ryZog28spEs0V48l3H1fr4i37tiyHsgKZJnCmvxsbATdZGBpbmxTE3a9w==}
cpu: [ia32]
os: [win32]
- '@rollup/rollup-win32-x64-msvc@4.21.1':
- resolution: {integrity: sha512-xGiIH95H1zU7naUyTKEyOA/I0aexNMUdO9qRv0bLKN3qu25bBdrxZHqA3PTJ24YNN/GdMzG4xkDcd/GvjuhfLg==}
+ '@rollup/rollup-win32-x64-msvc@4.34.9':
+ resolution: {integrity: sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw==}
cpu: [x64]
os: [win32]
@@ -3079,6 +3137,14 @@ packages:
'@swc/types@0.1.13':
resolution: {integrity: sha512-JL7eeCk6zWCbiYQg2xQSdLXQJl8Qoc9rXmG2cEKvHe3CKwMHwHGpfOb8frzNLmbycOo6I51qxnLnn9ESf4I20Q==}
+ '@testing-library/dom@10.4.0':
+ resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@6.6.3':
+ resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
'@tootallnate/quickjs-emscripten@0.23.0':
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
@@ -3098,6 +3164,9 @@ packages:
'@types/argparse@1.0.38':
resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==}
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -3185,6 +3254,9 @@ packages:
'@types/istanbul-reports@3.0.4':
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
+ '@types/jsdom@21.1.7':
+ resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -3265,6 +3337,9 @@ packages:
'@types/tapable@2.2.7':
resolution: {integrity: sha512-D6QzACV9vNX3r8HQQNTOnpG+Bv1rko+yEA82wKs3O9CQ5+XW7HI7TED17/UE7+5dIxyxZIWTxKbsBeF6uKFCwA==}
+ '@types/tough-cookie@4.0.5':
+ resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
+
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -3671,6 +3746,9 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
array-buffer-byte-length@1.0.1:
resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==}
engines: {node: '>= 0.4'}
@@ -3926,6 +4004,10 @@ packages:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
+ chalk@3.0.0:
+ resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==}
+ engines: {node: '>=8'}
+
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -4326,6 +4408,9 @@ packages:
resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==}
engines: {node: '>= 6'}
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -4553,6 +4638,12 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
dom-converter@0.2.0:
resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==}
@@ -6198,6 +6289,10 @@ packages:
lru_map@0.3.3:
resolution: {integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==}
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
@@ -7155,6 +7250,10 @@ packages:
pretty-error@4.0.0:
resolution: {integrity: sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==}
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
pretty-format@29.7.0:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -7252,6 +7351,9 @@ packages:
peerDependencies:
react: ^16.6.0 || ^17.0.0 || ^18.0.0
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
@@ -7334,6 +7436,10 @@ packages:
recma-stringify@1.0.0:
resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
+ redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+
reduce-configs@1.1.0:
resolution: {integrity: sha512-DQxy6liNadHfrLahZR7lMdc227NYVaQZhY5FMsxLEjX8X0SCuH+ESHSLCoz2yDZFq1/CLMDOAHdsEHwOEXKtvg==}
@@ -7484,8 +7590,8 @@ packages:
resolution: {integrity: sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ==}
engines: {node: '>=10.0.0'}
- rollup@4.21.1:
- resolution: {integrity: sha512-ZnYyKvscThhgd3M5+Qt3pmhO4jIRR5RGzaSovB6Q7rGNrK5cUncrtLmcTTJVSdcKXyZjW8X8MB0JMSuH9bcAJg==}
+ rollup@4.34.9:
+ resolution: {integrity: sha512-nF5XYqWWp9hx/LrpC8sZvvvmq0TeTjQgaZHYmAgwysT9nh8sWnZhBnM8ZyVbbJFIQBLwHDNoMqsBZBbUo4U8sQ==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
@@ -8051,6 +8157,10 @@ packages:
resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==}
engines: {node: '>=12'}
+ strip-indent@3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+
strip-indent@4.0.0:
resolution: {integrity: sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==}
engines: {node: '>=12'}
@@ -8957,6 +9067,8 @@ snapshots:
'@actions/io@1.1.3': {}
+ '@adobe/css-tools@4.4.2': {}
+
'@alloc/quick-lru@5.2.0': {}
'@ampproject/remapping@2.3.0':
@@ -9123,7 +9235,7 @@ snapshots:
'@babel/core': 7.26.10
'@babel/helper-plugin-utils': 7.25.9
- '@babel/plugin-syntax-jsx@7.24.7(@babel/core@7.26.10)':
+ '@babel/plugin-syntax-jsx@7.25.9(@babel/core@7.26.10)':
dependencies:
'@babel/core': 7.26.10
'@babel/helper-plugin-utils': 7.25.9
@@ -10420,69 +10532,78 @@ snapshots:
'@remix-run/router@1.23.0': {}
- '@rollup/plugin-typescript@12.1.2(patch_hash=926ba262ec682d27369f1a8648a0dfb657fb5f1b28539ca3628d292276c91c3d)(rollup@4.21.1)(tslib@2.8.1)(typescript@5.8.3)':
+ '@rollup/plugin-typescript@12.1.2(patch_hash=926ba262ec682d27369f1a8648a0dfb657fb5f1b28539ca3628d292276c91c3d)(rollup@4.34.9)(tslib@2.8.1)(typescript@5.8.3)':
dependencies:
- '@rollup/pluginutils': 5.1.2(rollup@4.21.1)
+ '@rollup/pluginutils': 5.1.2(rollup@4.34.9)
resolve: 1.22.8
typescript: 5.8.3
optionalDependencies:
- rollup: 4.21.1
+ rollup: 4.34.9
tslib: 2.8.1
- '@rollup/pluginutils@5.1.2(rollup@4.21.1)':
+ '@rollup/pluginutils@5.1.2(rollup@4.34.9)':
dependencies:
'@types/estree': 1.0.6
estree-walker: 2.0.2
picomatch: 2.3.1
optionalDependencies:
- rollup: 4.21.1
+ rollup: 4.34.9
+
+ '@rollup/rollup-android-arm-eabi@4.34.9':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.34.9':
+ optional: true
- '@rollup/rollup-android-arm-eabi@4.21.1':
+ '@rollup/rollup-darwin-arm64@4.34.9':
optional: true
- '@rollup/rollup-android-arm64@4.21.1':
+ '@rollup/rollup-darwin-x64@4.34.9':
optional: true
- '@rollup/rollup-darwin-arm64@4.21.1':
+ '@rollup/rollup-freebsd-arm64@4.34.9':
optional: true
- '@rollup/rollup-darwin-x64@4.21.1':
+ '@rollup/rollup-freebsd-x64@4.34.9':
optional: true
- '@rollup/rollup-linux-arm-gnueabihf@4.21.1':
+ '@rollup/rollup-linux-arm-gnueabihf@4.34.9':
optional: true
- '@rollup/rollup-linux-arm-musleabihf@4.21.1':
+ '@rollup/rollup-linux-arm-musleabihf@4.34.9':
optional: true
- '@rollup/rollup-linux-arm64-gnu@4.21.1':
+ '@rollup/rollup-linux-arm64-gnu@4.34.9':
optional: true
- '@rollup/rollup-linux-arm64-musl@4.21.1':
+ '@rollup/rollup-linux-arm64-musl@4.34.9':
optional: true
- '@rollup/rollup-linux-powerpc64le-gnu@4.21.1':
+ '@rollup/rollup-linux-loongarch64-gnu@4.34.9':
optional: true
- '@rollup/rollup-linux-riscv64-gnu@4.21.1':
+ '@rollup/rollup-linux-powerpc64le-gnu@4.34.9':
optional: true
- '@rollup/rollup-linux-s390x-gnu@4.21.1':
+ '@rollup/rollup-linux-riscv64-gnu@4.34.9':
optional: true
- '@rollup/rollup-linux-x64-gnu@4.21.1':
+ '@rollup/rollup-linux-s390x-gnu@4.34.9':
optional: true
- '@rollup/rollup-linux-x64-musl@4.21.1':
+ '@rollup/rollup-linux-x64-gnu@4.34.9':
optional: true
- '@rollup/rollup-win32-arm64-msvc@4.21.1':
+ '@rollup/rollup-linux-x64-musl@4.34.9':
optional: true
- '@rollup/rollup-win32-ia32-msvc@4.21.1':
+ '@rollup/rollup-win32-arm64-msvc@4.34.9':
optional: true
- '@rollup/rollup-win32-x64-msvc@4.21.1':
+ '@rollup/rollup-win32-ia32-msvc@4.34.9':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.34.9':
optional: true
'@rsbuild/core@1.3.5':
@@ -11215,6 +11336,27 @@ snapshots:
dependencies:
'@swc/counter': 0.1.3
+ '@testing-library/dom@10.4.0':
+ dependencies:
+ '@babel/code-frame': 7.26.2
+ '@babel/runtime': 7.25.4
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ chalk: 4.1.2
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ pretty-format: 27.5.1
+
+ '@testing-library/jest-dom@6.6.3':
+ dependencies:
+ '@adobe/css-tools': 4.4.2
+ aria-query: 5.3.0
+ chalk: 3.0.0
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ lodash: 4.17.21
+ redent: 3.0.0
+
'@tootallnate/quickjs-emscripten@0.23.0': {}
'@trysound/sax@0.2.0': {}
@@ -11230,6 +11372,8 @@ snapshots:
'@types/argparse@1.0.38': {}
+ '@types/aria-query@5.0.4': {}
+
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.27.0
@@ -11348,6 +11492,12 @@ snapshots:
dependencies:
'@types/istanbul-lib-report': 3.0.3
+ '@types/jsdom@21.1.7':
+ dependencies:
+ '@types/node': 22.14.1
+ '@types/tough-cookie': 4.0.5
+ parse5: 7.2.1
+
'@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {}
@@ -11428,6 +11578,8 @@ snapshots:
dependencies:
tapable: 2.2.1
+ '@types/tough-cookie@4.0.5': {}
+
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@@ -11873,6 +12025,10 @@ snapshots:
argparse@2.0.1: {}
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
array-buffer-byte-length@1.0.1:
dependencies:
call-bind: 1.0.7
@@ -12171,6 +12327,11 @@ snapshots:
escape-string-regexp: 1.0.5
supports-color: 5.5.0
+ chalk@3.0.0:
+ dependencies:
+ ansi-styles: 4.3.0
+ supports-color: 7.2.0
+
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -12622,6 +12783,8 @@ snapshots:
css-what@6.1.0: {}
+ css.escape@1.5.1: {}
+
cssesc@3.0.0: {}
cssnano-preset-default@6.1.2(postcss@8.5.3):
@@ -12827,6 +12990,10 @@ snapshots:
dependencies:
esutils: 2.0.3
+ dom-accessibility-api@0.5.16: {}
+
+ dom-accessibility-api@0.6.3: {}
+
dom-converter@0.2.0:
dependencies:
utila: 0.4.0
@@ -14525,7 +14692,7 @@ snapshots:
dependencies:
'@babel/core': 7.26.10
'@babel/generator': 7.27.0
- '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.26.10)
+ '@babel/plugin-syntax-jsx': 7.25.9(@babel/core@7.26.10)
'@babel/plugin-syntax-typescript': 7.25.4(@babel/core@7.26.10)
'@babel/types': 7.27.0
'@jest/expect-utils': 29.7.0
@@ -14848,6 +15015,8 @@ snapshots:
lru_map@0.3.3: {}
+ lz-string@1.5.0: {}
+
magic-string@0.30.17:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
@@ -16044,6 +16213,12 @@ snapshots:
lodash: 4.17.21
renderkid: 3.0.0
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
pretty-format@29.7.0:
dependencies:
'@jest/schemas': 29.6.3
@@ -16160,6 +16335,8 @@ snapshots:
react-fast-compare: 3.2.2
shallowequal: 1.1.0
+ react-is@17.0.2: {}
+
react-is@18.3.1: {}
react-lazy-with-preload@2.2.1: {}
@@ -16275,6 +16452,11 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
+ redent@3.0.0:
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+
reduce-configs@1.1.0: {}
refa@0.12.1:
@@ -16457,26 +16639,29 @@ snapshots:
robots-parser@3.0.1: {}
- rollup@4.21.1:
+ rollup@4.34.9:
dependencies:
- '@types/estree': 1.0.5
+ '@types/estree': 1.0.6
optionalDependencies:
- '@rollup/rollup-android-arm-eabi': 4.21.1
- '@rollup/rollup-android-arm64': 4.21.1
- '@rollup/rollup-darwin-arm64': 4.21.1
- '@rollup/rollup-darwin-x64': 4.21.1
- '@rollup/rollup-linux-arm-gnueabihf': 4.21.1
- '@rollup/rollup-linux-arm-musleabihf': 4.21.1
- '@rollup/rollup-linux-arm64-gnu': 4.21.1
- '@rollup/rollup-linux-arm64-musl': 4.21.1
- '@rollup/rollup-linux-powerpc64le-gnu': 4.21.1
- '@rollup/rollup-linux-riscv64-gnu': 4.21.1
- '@rollup/rollup-linux-s390x-gnu': 4.21.1
- '@rollup/rollup-linux-x64-gnu': 4.21.1
- '@rollup/rollup-linux-x64-musl': 4.21.1
- '@rollup/rollup-win32-arm64-msvc': 4.21.1
- '@rollup/rollup-win32-ia32-msvc': 4.21.1
- '@rollup/rollup-win32-x64-msvc': 4.21.1
+ '@rollup/rollup-android-arm-eabi': 4.34.9
+ '@rollup/rollup-android-arm64': 4.34.9
+ '@rollup/rollup-darwin-arm64': 4.34.9
+ '@rollup/rollup-darwin-x64': 4.34.9
+ '@rollup/rollup-freebsd-arm64': 4.34.9
+ '@rollup/rollup-freebsd-x64': 4.34.9
+ '@rollup/rollup-linux-arm-gnueabihf': 4.34.9
+ '@rollup/rollup-linux-arm-musleabihf': 4.34.9
+ '@rollup/rollup-linux-arm64-gnu': 4.34.9
+ '@rollup/rollup-linux-arm64-musl': 4.34.9
+ '@rollup/rollup-linux-loongarch64-gnu': 4.34.9
+ '@rollup/rollup-linux-powerpc64le-gnu': 4.34.9
+ '@rollup/rollup-linux-riscv64-gnu': 4.34.9
+ '@rollup/rollup-linux-s390x-gnu': 4.34.9
+ '@rollup/rollup-linux-x64-gnu': 4.34.9
+ '@rollup/rollup-linux-x64-musl': 4.34.9
+ '@rollup/rollup-win32-arm64-msvc': 4.34.9
+ '@rollup/rollup-win32-ia32-msvc': 4.34.9
+ '@rollup/rollup-win32-x64-msvc': 4.34.9
fsevents: 2.3.3
rrweb-cssom@0.8.0: {}
@@ -17081,6 +17266,10 @@ snapshots:
strip-final-newline@3.0.0: {}
+ strip-indent@3.0.0:
+ dependencies:
+ min-indent: 1.0.1
+
strip-indent@4.0.0:
dependencies:
min-indent: 1.0.1
@@ -17695,7 +17884,7 @@ snapshots:
dependencies:
esbuild: 0.21.5
postcss: 8.5.3
- rollup: 4.21.1
+ rollup: 4.34.9
optionalDependencies:
'@types/node': 22.14.1
fsevents: 2.3.3
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 9e779d6205..73ad8aa967 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -6,11 +6,15 @@ packages:
- packages/react/runtime
- packages/react/transform
- packages/react/worklet-runtime
+ - packages/react/testing-library
- packages/rspeedy/*
- packages/tools/*
- packages/web-platform/*
- packages/webpack/*
- packages/third-party/*
+ - packages/testing-library/*
+ - packages/testing-library/examples/*
+ - "!packages/testing-library/examples"
- website
# Default catalogs
diff --git a/vitest.config.ts b/vitest.config.ts
index 08ac275bcf..f0aacc4949 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -30,6 +30,8 @@ export default defineConfig({
'packages/tools/canary-release/**',
'packages/web-platform/**',
'packages/webpack/test-tools/**',
+ 'packages/testing-library/test-environment/**',
+ 'packages/react/testing-library/**',
],
},
diff --git a/vitest.workspace.json b/vitest.workspace.json
index fe4db867b0..9fd0bc750a 100644
--- a/vitest.workspace.json
+++ b/vitest.workspace.json
@@ -2,5 +2,7 @@
"packages/react/*/vitest.config.ts",
"packages/rspeedy/*/vitest.config.ts",
"packages/tools/*/vitest.config.ts",
- "packages/webpack/*/vitest.config.ts"
+ "packages/webpack/*/vitest.config.ts",
+ "packages/testing-library/*/vitest.config.mts",
+ "packages/testing-library/examples/*/vitest.config.ts"
]