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(` + + + + + + + + + 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(` + + + + + + + + + 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" ]