diff --git a/.gitignore b/.gitignore index 15e93a0b5..2396c7a7b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,12 @@ .vscode/*.log *.local dist +*.tsbuildinfo node_modules npm-debug.log* plugin.zip .env +.cursor/plans/ # Yarn v2 with Zero Installs (https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored) .yarn/* @@ -22,4 +24,4 @@ plugin.zip # Code Link CLI — ignore folders (Untitled, etc.) for testing packages/code-link-cli/*/ !packages/code-link-cli/src/ -!packages/code-link-cli/skills/ \ No newline at end of file +!packages/code-link-cli/skills/ diff --git a/.yarn/cache/@emnapi-core-npm-1.10.0-e5e48f37ba-d32f386084.zip b/.yarn/cache/@emnapi-core-npm-1.10.0-e5e48f37ba-d32f386084.zip new file mode 100644 index 000000000..1e9dc131c Binary files /dev/null and b/.yarn/cache/@emnapi-core-npm-1.10.0-e5e48f37ba-d32f386084.zip differ diff --git a/.yarn/cache/@emnapi-runtime-npm-1.10.0-4648518988-d21083d07f.zip b/.yarn/cache/@emnapi-runtime-npm-1.10.0-4648518988-d21083d07f.zip new file mode 100644 index 000000000..5a9a47fda Binary files /dev/null and b/.yarn/cache/@emnapi-runtime-npm-1.10.0-4648518988-d21083d07f.zip differ diff --git a/.yarn/cache/@emnapi-wasi-threads-npm-1.2.1-8626cfd5d3-57cd4292be.zip b/.yarn/cache/@emnapi-wasi-threads-npm-1.2.1-8626cfd5d3-57cd4292be.zip new file mode 100644 index 000000000..92d5d0f81 Binary files /dev/null and b/.yarn/cache/@emnapi-wasi-threads-npm-1.2.1-8626cfd5d3-57cd4292be.zip differ diff --git a/.yarn/cache/@napi-rs-wasm-runtime-npm-1.1.4-53a1e4ec11-1db3dc7eeb.zip b/.yarn/cache/@napi-rs-wasm-runtime-npm-1.1.4-53a1e4ec11-1db3dc7eeb.zip new file mode 100644 index 000000000..a60cb2a00 Binary files /dev/null and b/.yarn/cache/@napi-rs-wasm-runtime-npm-1.1.4-53a1e4ec11-1db3dc7eeb.zip differ diff --git a/.yarn/cache/@oxc-project-types-npm-0.127.0-db76f58945-f154f47203.zip b/.yarn/cache/@oxc-project-types-npm-0.127.0-db76f58945-f154f47203.zip new file mode 100644 index 000000000..79a8859f5 Binary files /dev/null and b/.yarn/cache/@oxc-project-types-npm-0.127.0-db76f58945-f154f47203.zip differ diff --git a/.yarn/cache/@polka-url-npm-1.0.0-next.29-b32b372106-69ca11ab15.zip b/.yarn/cache/@polka-url-npm-1.0.0-next.29-b32b372106-69ca11ab15.zip deleted file mode 100644 index 4b3a7c558..000000000 Binary files a/.yarn/cache/@polka-url-npm-1.0.0-next.29-b32b372106-69ca11ab15.zip and /dev/null differ diff --git a/.yarn/cache/@rolldown-binding-darwin-arm64-npm-1.0.0-rc.17-3decf90594-10.zip b/.yarn/cache/@rolldown-binding-darwin-arm64-npm-1.0.0-rc.17-3decf90594-10.zip new file mode 100644 index 000000000..6326be13e Binary files /dev/null and b/.yarn/cache/@rolldown-binding-darwin-arm64-npm-1.0.0-rc.17-3decf90594-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-darwin-x64-npm-1.0.0-rc.17-c475560d1e-10.zip b/.yarn/cache/@rolldown-binding-darwin-x64-npm-1.0.0-rc.17-c475560d1e-10.zip new file mode 100644 index 000000000..c31398560 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-darwin-x64-npm-1.0.0-rc.17-c475560d1e-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-linux-arm64-gnu-npm-1.0.0-rc.17-0e2f495c07-10.zip b/.yarn/cache/@rolldown-binding-linux-arm64-gnu-npm-1.0.0-rc.17-0e2f495c07-10.zip new file mode 100644 index 000000000..27112db41 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-linux-arm64-gnu-npm-1.0.0-rc.17-0e2f495c07-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-linux-arm64-musl-npm-1.0.0-rc.17-c6be1e999b-10.zip b/.yarn/cache/@rolldown-binding-linux-arm64-musl-npm-1.0.0-rc.17-c6be1e999b-10.zip new file mode 100644 index 000000000..ff273c102 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-linux-arm64-musl-npm-1.0.0-rc.17-c6be1e999b-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-linux-x64-gnu-npm-1.0.0-rc.17-9f83fbbfc2-10.zip b/.yarn/cache/@rolldown-binding-linux-x64-gnu-npm-1.0.0-rc.17-9f83fbbfc2-10.zip new file mode 100644 index 000000000..544c57bab Binary files /dev/null and b/.yarn/cache/@rolldown-binding-linux-x64-gnu-npm-1.0.0-rc.17-9f83fbbfc2-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-linux-x64-musl-npm-1.0.0-rc.17-f1385429b1-10.zip b/.yarn/cache/@rolldown-binding-linux-x64-musl-npm-1.0.0-rc.17-f1385429b1-10.zip new file mode 100644 index 000000000..216d9deb6 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-linux-x64-musl-npm-1.0.0-rc.17-f1385429b1-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-win32-arm64-msvc-npm-1.0.0-rc.17-185dfbbaf5-10.zip b/.yarn/cache/@rolldown-binding-win32-arm64-msvc-npm-1.0.0-rc.17-185dfbbaf5-10.zip new file mode 100644 index 000000000..9854e55e0 Binary files /dev/null and b/.yarn/cache/@rolldown-binding-win32-arm64-msvc-npm-1.0.0-rc.17-185dfbbaf5-10.zip differ diff --git a/.yarn/cache/@rolldown-binding-win32-x64-msvc-npm-1.0.0-rc.17-e5f890220c-10.zip b/.yarn/cache/@rolldown-binding-win32-x64-msvc-npm-1.0.0-rc.17-e5f890220c-10.zip new file mode 100644 index 000000000..ada4f804a Binary files /dev/null and b/.yarn/cache/@rolldown-binding-win32-x64-msvc-npm-1.0.0-rc.17-e5f890220c-10.zip differ diff --git a/.yarn/cache/@rolldown-pluginutils-npm-1.0.0-rc.17-c8be250a71-d659ea756e.zip b/.yarn/cache/@rolldown-pluginutils-npm-1.0.0-rc.17-c8be250a71-d659ea756e.zip new file mode 100644 index 000000000..1e0f0f6e0 Binary files /dev/null and b/.yarn/cache/@rolldown-pluginutils-npm-1.0.0-rc.17-c8be250a71-d659ea756e.zip differ diff --git a/.yarn/cache/@rollup-rollup-darwin-arm64-npm-4.59.0-db3495ba42-10.zip b/.yarn/cache/@rollup-rollup-darwin-arm64-npm-4.59.0-db3495ba42-10.zip deleted file mode 100644 index 1c87f6f86..000000000 Binary files a/.yarn/cache/@rollup-rollup-darwin-arm64-npm-4.59.0-db3495ba42-10.zip and /dev/null differ diff --git a/.yarn/cache/@rollup-rollup-darwin-x64-npm-4.59.0-cfe999cbb8-10.zip b/.yarn/cache/@rollup-rollup-darwin-x64-npm-4.59.0-cfe999cbb8-10.zip deleted file mode 100644 index 8150b0b2c..000000000 Binary files a/.yarn/cache/@rollup-rollup-darwin-x64-npm-4.59.0-cfe999cbb8-10.zip and /dev/null differ diff --git a/.yarn/cache/@rollup-rollup-linux-arm64-gnu-npm-4.59.0-8929991df7-10.zip b/.yarn/cache/@rollup-rollup-linux-arm64-gnu-npm-4.59.0-8929991df7-10.zip deleted file mode 100644 index fdb44be8a..000000000 Binary files a/.yarn/cache/@rollup-rollup-linux-arm64-gnu-npm-4.59.0-8929991df7-10.zip and /dev/null differ diff --git a/.yarn/cache/@rollup-rollup-linux-arm64-musl-npm-4.59.0-fcbe29740d-10.zip b/.yarn/cache/@rollup-rollup-linux-arm64-musl-npm-4.59.0-fcbe29740d-10.zip deleted file mode 100644 index 9056b6e03..000000000 Binary files a/.yarn/cache/@rollup-rollup-linux-arm64-musl-npm-4.59.0-fcbe29740d-10.zip and /dev/null differ diff --git a/.yarn/cache/@rollup-rollup-linux-x64-gnu-npm-4.59.0-da6c703f69-10.zip b/.yarn/cache/@rollup-rollup-linux-x64-gnu-npm-4.59.0-da6c703f69-10.zip deleted file mode 100644 index 50120fe1f..000000000 Binary files a/.yarn/cache/@rollup-rollup-linux-x64-gnu-npm-4.59.0-da6c703f69-10.zip and /dev/null differ diff --git a/.yarn/cache/@rollup-rollup-linux-x64-musl-npm-4.59.0-50f79fbe61-10.zip b/.yarn/cache/@rollup-rollup-linux-x64-musl-npm-4.59.0-50f79fbe61-10.zip deleted file mode 100644 index 4cfdb1ef6..000000000 Binary files a/.yarn/cache/@rollup-rollup-linux-x64-musl-npm-4.59.0-50f79fbe61-10.zip and /dev/null differ diff --git a/.yarn/cache/@rollup-rollup-win32-arm64-msvc-npm-4.59.0-d1e11bba9f-10.zip b/.yarn/cache/@rollup-rollup-win32-arm64-msvc-npm-4.59.0-d1e11bba9f-10.zip deleted file mode 100644 index a56fa3cb8..000000000 Binary files a/.yarn/cache/@rollup-rollup-win32-arm64-msvc-npm-4.59.0-d1e11bba9f-10.zip and /dev/null differ diff --git a/.yarn/cache/@rollup-rollup-win32-x64-gnu-npm-4.59.0-462c90c9b5-10.zip b/.yarn/cache/@rollup-rollup-win32-x64-gnu-npm-4.59.0-462c90c9b5-10.zip deleted file mode 100644 index ae559f412..000000000 Binary files a/.yarn/cache/@rollup-rollup-win32-x64-gnu-npm-4.59.0-462c90c9b5-10.zip and /dev/null differ diff --git a/.yarn/cache/@rollup-rollup-win32-x64-msvc-npm-4.59.0-1850b314ab-10.zip b/.yarn/cache/@rollup-rollup-win32-x64-msvc-npm-4.59.0-1850b314ab-10.zip deleted file mode 100644 index 0ec425503..000000000 Binary files a/.yarn/cache/@rollup-rollup-win32-x64-msvc-npm-4.59.0-1850b314ab-10.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-expect-npm-3.2.4-97c526d5cc-dc69ce886c.zip b/.yarn/cache/@vitest-expect-npm-3.2.4-97c526d5cc-dc69ce886c.zip deleted file mode 100644 index 2edbb1bfa..000000000 Binary files a/.yarn/cache/@vitest-expect-npm-3.2.4-97c526d5cc-dc69ce886c.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-expect-npm-4.0.18-03919ccd0b-2115bff1bb.zip b/.yarn/cache/@vitest-expect-npm-4.0.18-03919ccd0b-2115bff1bb.zip deleted file mode 100644 index 3db069f11..000000000 Binary files a/.yarn/cache/@vitest-expect-npm-4.0.18-03919ccd0b-2115bff1bb.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-expect-npm-4.1.0-3eeb9c0bc4-6090a1fb0d.zip b/.yarn/cache/@vitest-expect-npm-4.1.0-3eeb9c0bc4-6090a1fb0d.zip deleted file mode 100644 index 0f9838eef..000000000 Binary files a/.yarn/cache/@vitest-expect-npm-4.1.0-3eeb9c0bc4-6090a1fb0d.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-expect-npm-4.1.5-43769200b1-3e94d2d0cf.zip b/.yarn/cache/@vitest-expect-npm-4.1.5-43769200b1-3e94d2d0cf.zip new file mode 100644 index 000000000..49280015d Binary files /dev/null and b/.yarn/cache/@vitest-expect-npm-4.1.5-43769200b1-3e94d2d0cf.zip differ diff --git a/.yarn/cache/@vitest-mocker-npm-3.2.4-48badb1f19-5e92431b6e.zip b/.yarn/cache/@vitest-mocker-npm-3.2.4-48badb1f19-5e92431b6e.zip deleted file mode 100644 index 26535835b..000000000 Binary files a/.yarn/cache/@vitest-mocker-npm-3.2.4-48badb1f19-5e92431b6e.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-mocker-npm-4.0.18-118c87f90e-46f584a4c1.zip b/.yarn/cache/@vitest-mocker-npm-4.0.18-118c87f90e-46f584a4c1.zip deleted file mode 100644 index 9705f5fa8..000000000 Binary files a/.yarn/cache/@vitest-mocker-npm-4.0.18-118c87f90e-46f584a4c1.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-mocker-npm-4.1.0-25e66789ee-357156976f.zip b/.yarn/cache/@vitest-mocker-npm-4.1.5-30dc29aba3-949784ba08.zip similarity index 60% rename from .yarn/cache/@vitest-mocker-npm-4.1.0-25e66789ee-357156976f.zip rename to .yarn/cache/@vitest-mocker-npm-4.1.5-30dc29aba3-949784ba08.zip index e0064f14f..dacca2548 100644 Binary files a/.yarn/cache/@vitest-mocker-npm-4.1.0-25e66789ee-357156976f.zip and b/.yarn/cache/@vitest-mocker-npm-4.1.5-30dc29aba3-949784ba08.zip differ diff --git a/.yarn/cache/@vitest-pretty-format-npm-3.2.4-d7da0d3faf-8dd30cbf95.zip b/.yarn/cache/@vitest-pretty-format-npm-3.2.4-d7da0d3faf-8dd30cbf95.zip deleted file mode 100644 index 6e967bdb2..000000000 Binary files a/.yarn/cache/@vitest-pretty-format-npm-3.2.4-d7da0d3faf-8dd30cbf95.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-pretty-format-npm-4.0.18-a73855e4a4-4cafc7c985.zip b/.yarn/cache/@vitest-pretty-format-npm-4.0.18-a73855e4a4-4cafc7c985.zip deleted file mode 100644 index 933564838..000000000 Binary files a/.yarn/cache/@vitest-pretty-format-npm-4.0.18-a73855e4a4-4cafc7c985.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-pretty-format-npm-4.1.0-3cec4716a0-5ffc63d96f.zip b/.yarn/cache/@vitest-pretty-format-npm-4.1.0-3cec4716a0-5ffc63d96f.zip deleted file mode 100644 index 72a9148a8..000000000 Binary files a/.yarn/cache/@vitest-pretty-format-npm-4.1.0-3cec4716a0-5ffc63d96f.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-pretty-format-npm-4.1.5-a77ea7d61a-783f8c4a0e.zip b/.yarn/cache/@vitest-pretty-format-npm-4.1.5-a77ea7d61a-783f8c4a0e.zip new file mode 100644 index 000000000..b877968ed Binary files /dev/null and b/.yarn/cache/@vitest-pretty-format-npm-4.1.5-a77ea7d61a-783f8c4a0e.zip differ diff --git a/.yarn/cache/@vitest-runner-npm-3.2.4-b2e96befcb-197bd55def.zip b/.yarn/cache/@vitest-runner-npm-3.2.4-b2e96befcb-197bd55def.zip deleted file mode 100644 index 3e4371a3b..000000000 Binary files a/.yarn/cache/@vitest-runner-npm-3.2.4-b2e96befcb-197bd55def.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-runner-npm-4.0.18-3dbdf3fb30-d7deebf086.zip b/.yarn/cache/@vitest-runner-npm-4.0.18-3dbdf3fb30-d7deebf086.zip deleted file mode 100644 index fd61fc011..000000000 Binary files a/.yarn/cache/@vitest-runner-npm-4.0.18-3dbdf3fb30-d7deebf086.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-runner-npm-4.1.0-b0c6ee6d10-c000ed75cc.zip b/.yarn/cache/@vitest-runner-npm-4.1.0-b0c6ee6d10-c000ed75cc.zip deleted file mode 100644 index fc8dd97a7..000000000 Binary files a/.yarn/cache/@vitest-runner-npm-4.1.0-b0c6ee6d10-c000ed75cc.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-runner-npm-4.1.5-c0d9cf3987-ba19d84a9f.zip b/.yarn/cache/@vitest-runner-npm-4.1.5-c0d9cf3987-ba19d84a9f.zip new file mode 100644 index 000000000..3e28d92a0 Binary files /dev/null and b/.yarn/cache/@vitest-runner-npm-4.1.5-c0d9cf3987-ba19d84a9f.zip differ diff --git a/.yarn/cache/@vitest-snapshot-npm-3.2.4-c43292ea8b-acfb682491.zip b/.yarn/cache/@vitest-snapshot-npm-3.2.4-c43292ea8b-acfb682491.zip deleted file mode 100644 index 2d2dd104c..000000000 Binary files a/.yarn/cache/@vitest-snapshot-npm-3.2.4-c43292ea8b-acfb682491.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-snapshot-npm-4.0.18-35134202ed-50aa5fb7fc.zip b/.yarn/cache/@vitest-snapshot-npm-4.0.18-35134202ed-50aa5fb7fc.zip deleted file mode 100644 index 3dfd6c15d..000000000 Binary files a/.yarn/cache/@vitest-snapshot-npm-4.0.18-35134202ed-50aa5fb7fc.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-snapshot-npm-4.1.0-6db619cd7e-04cd6fdd88.zip b/.yarn/cache/@vitest-snapshot-npm-4.1.0-6db619cd7e-04cd6fdd88.zip deleted file mode 100644 index 2b0590f16..000000000 Binary files a/.yarn/cache/@vitest-snapshot-npm-4.1.0-6db619cd7e-04cd6fdd88.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-snapshot-npm-4.1.5-ef18f3adb3-cf70530d8a.zip b/.yarn/cache/@vitest-snapshot-npm-4.1.5-ef18f3adb3-cf70530d8a.zip new file mode 100644 index 000000000..a36531a24 Binary files /dev/null and b/.yarn/cache/@vitest-snapshot-npm-4.1.5-ef18f3adb3-cf70530d8a.zip differ diff --git a/.yarn/cache/@vitest-spy-npm-3.2.4-ed1c68e965-7d38c299f4.zip b/.yarn/cache/@vitest-spy-npm-3.2.4-ed1c68e965-7d38c299f4.zip deleted file mode 100644 index f7e565fe3..000000000 Binary files a/.yarn/cache/@vitest-spy-npm-3.2.4-ed1c68e965-7d38c299f4.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-spy-npm-4.0.18-296c59dce4-f7b1618ae1.zip b/.yarn/cache/@vitest-spy-npm-4.0.18-296c59dce4-f7b1618ae1.zip deleted file mode 100644 index cabab6646..000000000 Binary files a/.yarn/cache/@vitest-spy-npm-4.0.18-296c59dce4-f7b1618ae1.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-spy-npm-4.1.0-342f1119a3-17c2f90626.zip b/.yarn/cache/@vitest-spy-npm-4.1.0-342f1119a3-17c2f90626.zip deleted file mode 100644 index 34b91170d..000000000 Binary files a/.yarn/cache/@vitest-spy-npm-4.1.0-342f1119a3-17c2f90626.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-spy-npm-4.1.5-4f8c9ce4ca-4db4bb3aea.zip b/.yarn/cache/@vitest-spy-npm-4.1.5-4f8c9ce4ca-4db4bb3aea.zip new file mode 100644 index 000000000..00cd0cd13 Binary files /dev/null and b/.yarn/cache/@vitest-spy-npm-4.1.5-4f8c9ce4ca-4db4bb3aea.zip differ diff --git a/.yarn/cache/@vitest-ui-npm-3.2.4-7a4861d969-727ca0a142.zip b/.yarn/cache/@vitest-ui-npm-3.2.4-7a4861d969-727ca0a142.zip deleted file mode 100644 index 6a2599fae..000000000 Binary files a/.yarn/cache/@vitest-ui-npm-3.2.4-7a4861d969-727ca0a142.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-ui-npm-4.1.0-63cd44ac5c-800142f704.zip b/.yarn/cache/@vitest-ui-npm-4.1.0-63cd44ac5c-800142f704.zip deleted file mode 100644 index b1eeb8b3f..000000000 Binary files a/.yarn/cache/@vitest-ui-npm-4.1.0-63cd44ac5c-800142f704.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-utils-npm-3.2.4-2d32b4da53-7f12ef63bd.zip b/.yarn/cache/@vitest-utils-npm-3.2.4-2d32b4da53-7f12ef63bd.zip deleted file mode 100644 index 325fc1f56..000000000 Binary files a/.yarn/cache/@vitest-utils-npm-3.2.4-2d32b4da53-7f12ef63bd.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-utils-npm-4.0.18-b1c99a49e0-e8b2ad7bc3.zip b/.yarn/cache/@vitest-utils-npm-4.0.18-b1c99a49e0-e8b2ad7bc3.zip deleted file mode 100644 index c61d50e0f..000000000 Binary files a/.yarn/cache/@vitest-utils-npm-4.0.18-b1c99a49e0-e8b2ad7bc3.zip and /dev/null differ diff --git a/.yarn/cache/@vitest-utils-npm-4.1.0-08725c0b06-1ca5b588d7.zip b/.yarn/cache/@vitest-utils-npm-4.1.5-52149c3f2c-4f75a2df6f.zip similarity index 77% rename from .yarn/cache/@vitest-utils-npm-4.1.0-08725c0b06-1ca5b588d7.zip rename to .yarn/cache/@vitest-utils-npm-4.1.5-52149c3f2c-4f75a2df6f.zip index dcc811cb7..823fee60c 100644 Binary files a/.yarn/cache/@vitest-utils-npm-4.1.0-08725c0b06-1ca5b588d7.zip and b/.yarn/cache/@vitest-utils-npm-4.1.5-52149c3f2c-4f75a2df6f.zip differ diff --git a/.yarn/cache/assertion-error-npm-2.0.1-8169d136f2-a0789dd882.zip b/.yarn/cache/assertion-error-npm-2.0.1-8169d136f2-a0789dd882.zip deleted file mode 100644 index 0f58a55c0..000000000 Binary files a/.yarn/cache/assertion-error-npm-2.0.1-8169d136f2-a0789dd882.zip and /dev/null differ diff --git a/.yarn/cache/chai-npm-5.2.0-373e52d821-2ce03671c1.zip b/.yarn/cache/chai-npm-5.2.0-373e52d821-2ce03671c1.zip deleted file mode 100644 index 6802bae46..000000000 Binary files a/.yarn/cache/chai-npm-5.2.0-373e52d821-2ce03671c1.zip and /dev/null differ diff --git a/.yarn/cache/check-error-npm-2.1.1-34e4ef357e-d785ed17b1.zip b/.yarn/cache/check-error-npm-2.1.1-34e4ef357e-d785ed17b1.zip deleted file mode 100644 index bd980cd28..000000000 Binary files a/.yarn/cache/check-error-npm-2.1.1-34e4ef357e-d785ed17b1.zip and /dev/null differ diff --git a/.yarn/cache/deep-eql-npm-5.0.2-3bce58289f-a529b81e2e.zip b/.yarn/cache/deep-eql-npm-5.0.2-3bce58289f-a529b81e2e.zip deleted file mode 100644 index 2e0c97eb0..000000000 Binary files a/.yarn/cache/deep-eql-npm-5.0.2-3bce58289f-a529b81e2e.zip and /dev/null differ diff --git a/.yarn/cache/es-module-lexer-npm-1.7.0-456b47a08a-b6f3e576a3.zip b/.yarn/cache/es-module-lexer-npm-1.7.0-456b47a08a-b6f3e576a3.zip deleted file mode 100644 index 2896c7066..000000000 Binary files a/.yarn/cache/es-module-lexer-npm-1.7.0-456b47a08a-b6f3e576a3.zip and /dev/null differ diff --git a/.yarn/cache/expect-type-npm-1.2.1-5a68fc99bd-d121d90f4f.zip b/.yarn/cache/expect-type-npm-1.2.1-5a68fc99bd-d121d90f4f.zip deleted file mode 100644 index 7af67e5ba..000000000 Binary files a/.yarn/cache/expect-type-npm-1.2.1-5a68fc99bd-d121d90f4f.zip and /dev/null differ diff --git a/.yarn/cache/fflate-npm-0.8.2-5129f303f0-2bd26ba6d2.zip b/.yarn/cache/fflate-npm-0.8.2-5129f303f0-2bd26ba6d2.zip deleted file mode 100644 index 346d149c0..000000000 Binary files a/.yarn/cache/fflate-npm-0.8.2-5129f303f0-2bd26ba6d2.zip and /dev/null differ diff --git a/.yarn/cache/flatted-npm-3.4.0-fe87ab6426-6007896f62.zip b/.yarn/cache/flatted-npm-3.4.0-fe87ab6426-6007896f62.zip deleted file mode 100644 index 9d81c42f4..000000000 Binary files a/.yarn/cache/flatted-npm-3.4.0-fe87ab6426-6007896f62.zip and /dev/null differ diff --git a/.yarn/cache/js-tokens-npm-9.0.1-3ed793c0c1-3288ba73bb.zip b/.yarn/cache/js-tokens-npm-9.0.1-3ed793c0c1-3288ba73bb.zip deleted file mode 100644 index 3e0fc9ac0..000000000 Binary files a/.yarn/cache/js-tokens-npm-9.0.1-3ed793c0c1-3288ba73bb.zip and /dev/null differ diff --git a/.yarn/cache/loupe-npm-3.1.4-c86e2a1e5f-06ab189373.zip b/.yarn/cache/loupe-npm-3.1.4-c86e2a1e5f-06ab189373.zip deleted file mode 100644 index d24d91f92..000000000 Binary files a/.yarn/cache/loupe-npm-3.1.4-c86e2a1e5f-06ab189373.zip and /dev/null differ diff --git a/.yarn/cache/magic-string-npm-0.30.19-a09e4f9538-5045467fad.zip b/.yarn/cache/magic-string-npm-0.30.19-a09e4f9538-5045467fad.zip deleted file mode 100644 index 1c85c42cb..000000000 Binary files a/.yarn/cache/magic-string-npm-0.30.19-a09e4f9538-5045467fad.zip and /dev/null differ diff --git a/.yarn/cache/mrmime-npm-2.0.1-c00bdddb2f-1f966e2c05.zip b/.yarn/cache/mrmime-npm-2.0.1-c00bdddb2f-1f966e2c05.zip deleted file mode 100644 index d721babb8..000000000 Binary files a/.yarn/cache/mrmime-npm-2.0.1-c00bdddb2f-1f966e2c05.zip and /dev/null differ diff --git a/.yarn/cache/pathval-npm-2.0.1-7fb9ae82ba-f5e8b82f6b.zip b/.yarn/cache/pathval-npm-2.0.1-7fb9ae82ba-f5e8b82f6b.zip deleted file mode 100644 index 6ccc47dd4..000000000 Binary files a/.yarn/cache/pathval-npm-2.0.1-7fb9ae82ba-f5e8b82f6b.zip and /dev/null differ diff --git a/.yarn/cache/picomatch-npm-4.0.4-e82d450244-f6ef80a359.zip b/.yarn/cache/picomatch-npm-4.0.4-e82d450244-f6ef80a359.zip new file mode 100644 index 000000000..ab281517c Binary files /dev/null and b/.yarn/cache/picomatch-npm-4.0.4-e82d450244-f6ef80a359.zip differ diff --git a/.yarn/cache/postcss-npm-8.5.10-e528db09cb-7eac6169e5.zip b/.yarn/cache/postcss-npm-8.5.10-e528db09cb-7eac6169e5.zip new file mode 100644 index 000000000..32b2e74cd Binary files /dev/null and b/.yarn/cache/postcss-npm-8.5.10-e528db09cb-7eac6169e5.zip differ diff --git a/.yarn/cache/postcss-npm-8.5.6-e7f126c6f3-9e4fbe9757.zip b/.yarn/cache/postcss-npm-8.5.6-e7f126c6f3-9e4fbe9757.zip deleted file mode 100644 index 0d949a301..000000000 Binary files a/.yarn/cache/postcss-npm-8.5.6-e7f126c6f3-9e4fbe9757.zip and /dev/null differ diff --git a/.yarn/cache/rolldown-npm-1.0.0-rc.17-2b04ad53ae-5e7415a7cb.zip b/.yarn/cache/rolldown-npm-1.0.0-rc.17-2b04ad53ae-5e7415a7cb.zip new file mode 100644 index 000000000..afc77742c Binary files /dev/null and b/.yarn/cache/rolldown-npm-1.0.0-rc.17-2b04ad53ae-5e7415a7cb.zip differ diff --git a/.yarn/cache/rollup-npm-4.59.0-3b10f603ec-728237932a.zip b/.yarn/cache/rollup-npm-4.59.0-3b10f603ec-728237932a.zip deleted file mode 100644 index 62d8db942..000000000 Binary files a/.yarn/cache/rollup-npm-4.59.0-3b10f603ec-728237932a.zip and /dev/null differ diff --git a/.yarn/cache/sirv-npm-3.0.1-6bef01ff05-b110ebe28e.zip b/.yarn/cache/sirv-npm-3.0.1-6bef01ff05-b110ebe28e.zip deleted file mode 100644 index 95574c5aa..000000000 Binary files a/.yarn/cache/sirv-npm-3.0.1-6bef01ff05-b110ebe28e.zip and /dev/null differ diff --git a/.yarn/cache/sirv-npm-3.0.2-6cf658c733-259617f4ab.zip b/.yarn/cache/sirv-npm-3.0.2-6cf658c733-259617f4ab.zip deleted file mode 100644 index 1207ca3d8..000000000 Binary files a/.yarn/cache/sirv-npm-3.0.2-6cf658c733-259617f4ab.zip and /dev/null differ diff --git a/.yarn/cache/std-env-npm-3.10.0-30d3e2646f-19c9cda4f3.zip b/.yarn/cache/std-env-npm-3.10.0-30d3e2646f-19c9cda4f3.zip deleted file mode 100644 index 8803cc08f..000000000 Binary files a/.yarn/cache/std-env-npm-3.10.0-30d3e2646f-19c9cda4f3.zip and /dev/null differ diff --git a/.yarn/cache/std-env-npm-3.9.0-67cc0f541d-3044b2c54a.zip b/.yarn/cache/std-env-npm-3.9.0-67cc0f541d-3044b2c54a.zip deleted file mode 100644 index 57aa9e2bf..000000000 Binary files a/.yarn/cache/std-env-npm-3.9.0-67cc0f541d-3044b2c54a.zip and /dev/null differ diff --git a/.yarn/cache/strip-literal-npm-3.0.0-911fbf7e2b-da1616f654.zip b/.yarn/cache/strip-literal-npm-3.0.0-911fbf7e2b-da1616f654.zip deleted file mode 100644 index 5a988aba2..000000000 Binary files a/.yarn/cache/strip-literal-npm-3.0.0-911fbf7e2b-da1616f654.zip and /dev/null differ diff --git a/.yarn/cache/tinyexec-npm-0.3.2-381b1e349c-b9d5fed316.zip b/.yarn/cache/tinyexec-npm-0.3.2-381b1e349c-b9d5fed316.zip deleted file mode 100644 index d47cdc010..000000000 Binary files a/.yarn/cache/tinyexec-npm-0.3.2-381b1e349c-b9d5fed316.zip and /dev/null differ diff --git a/.yarn/cache/tinyglobby-npm-0.2.16-102914a73b-5c2c41b572.zip b/.yarn/cache/tinyglobby-npm-0.2.16-102914a73b-5c2c41b572.zip new file mode 100644 index 000000000..1bbd1845b Binary files /dev/null and b/.yarn/cache/tinyglobby-npm-0.2.16-102914a73b-5c2c41b572.zip differ diff --git a/.yarn/cache/tinypool-npm-1.1.1-6772421283-0d54139e9d.zip b/.yarn/cache/tinypool-npm-1.1.1-6772421283-0d54139e9d.zip deleted file mode 100644 index 1523132f5..000000000 Binary files a/.yarn/cache/tinypool-npm-1.1.1-6772421283-0d54139e9d.zip and /dev/null differ diff --git a/.yarn/cache/tinyrainbow-npm-2.0.0-b4ba575b93-94d4e16246.zip b/.yarn/cache/tinyrainbow-npm-2.0.0-b4ba575b93-94d4e16246.zip deleted file mode 100644 index faa3fd08b..000000000 Binary files a/.yarn/cache/tinyrainbow-npm-2.0.0-b4ba575b93-94d4e16246.zip and /dev/null differ diff --git a/.yarn/cache/tinyrainbow-npm-3.0.3-06ed35d14d-169cc63c15.zip b/.yarn/cache/tinyrainbow-npm-3.0.3-06ed35d14d-169cc63c15.zip deleted file mode 100644 index 55660b3fd..000000000 Binary files a/.yarn/cache/tinyrainbow-npm-3.0.3-06ed35d14d-169cc63c15.zip and /dev/null differ diff --git a/.yarn/cache/tinyrainbow-npm-3.1.0-35ba47f8ae-4c2c01dde1.zip b/.yarn/cache/tinyrainbow-npm-3.1.0-35ba47f8ae-4c2c01dde1.zip new file mode 100644 index 000000000..1334359a1 Binary files /dev/null and b/.yarn/cache/tinyrainbow-npm-3.1.0-35ba47f8ae-4c2c01dde1.zip differ diff --git a/.yarn/cache/tinyspy-npm-4.0.3-c7c4f5d39d-b6a3ed40dd.zip b/.yarn/cache/tinyspy-npm-4.0.3-c7c4f5d39d-b6a3ed40dd.zip deleted file mode 100644 index ff368357e..000000000 Binary files a/.yarn/cache/tinyspy-npm-4.0.3-c7c4f5d39d-b6a3ed40dd.zip and /dev/null differ diff --git a/.yarn/cache/totalist-npm-3.0.1-91e71f3baa-5132d562cf.zip b/.yarn/cache/totalist-npm-3.0.1-91e71f3baa-5132d562cf.zip deleted file mode 100644 index 8683d1c4a..000000000 Binary files a/.yarn/cache/totalist-npm-3.0.1-91e71f3baa-5132d562cf.zip and /dev/null differ diff --git a/.yarn/cache/vite-node-npm-3.2.4-cb1d79df3b-343244ecab.zip b/.yarn/cache/vite-node-npm-3.2.4-cb1d79df3b-343244ecab.zip deleted file mode 100644 index 836b9a435..000000000 Binary files a/.yarn/cache/vite-node-npm-3.2.4-cb1d79df3b-343244ecab.zip and /dev/null differ diff --git a/.yarn/cache/vite-npm-7.1.5-cf091ecd54-59edeef7e9.zip b/.yarn/cache/vite-npm-7.1.5-cf091ecd54-59edeef7e9.zip deleted file mode 100644 index c846024b4..000000000 Binary files a/.yarn/cache/vite-npm-7.1.5-cf091ecd54-59edeef7e9.zip and /dev/null differ diff --git a/.yarn/cache/vite-npm-7.3.1-330baf2f0d-62e48ffa42.zip b/.yarn/cache/vite-npm-7.3.1-330baf2f0d-62e48ffa42.zip deleted file mode 100644 index 26ec0ade3..000000000 Binary files a/.yarn/cache/vite-npm-7.3.1-330baf2f0d-62e48ffa42.zip and /dev/null differ diff --git a/.yarn/cache/vite-npm-8.0.10-fb1e87d03c-64c6fa4efa.zip b/.yarn/cache/vite-npm-8.0.10-fb1e87d03c-64c6fa4efa.zip new file mode 100644 index 000000000..b1dc6a2d4 Binary files /dev/null and b/.yarn/cache/vite-npm-8.0.10-fb1e87d03c-64c6fa4efa.zip differ diff --git a/.yarn/cache/vitest-npm-3.2.4-7a07f931b1-f10bbce093.zip b/.yarn/cache/vitest-npm-3.2.4-7a07f931b1-f10bbce093.zip deleted file mode 100644 index f4ea6ae15..000000000 Binary files a/.yarn/cache/vitest-npm-3.2.4-7a07f931b1-f10bbce093.zip and /dev/null differ diff --git a/.yarn/cache/vitest-npm-4.0.18-52f42bdace-6c6464ebcf.zip b/.yarn/cache/vitest-npm-4.0.18-52f42bdace-6c6464ebcf.zip deleted file mode 100644 index df66e7cbf..000000000 Binary files a/.yarn/cache/vitest-npm-4.0.18-52f42bdace-6c6464ebcf.zip and /dev/null differ diff --git a/.yarn/cache/vitest-npm-4.1.0-1ffb78741a-3b47e169d3.zip b/.yarn/cache/vitest-npm-4.1.0-1ffb78741a-3b47e169d3.zip deleted file mode 100644 index 874f3d884..000000000 Binary files a/.yarn/cache/vitest-npm-4.1.0-1ffb78741a-3b47e169d3.zip and /dev/null differ diff --git a/.yarn/cache/vitest-npm-4.1.5-36c3ce03d5-8b76851499.zip b/.yarn/cache/vitest-npm-4.1.5-36c3ce03d5-8b76851499.zip new file mode 100644 index 000000000..99c0ee087 Binary files /dev/null and b/.yarn/cache/vitest-npm-4.1.5-36c3ce03d5-8b76851499.zip differ diff --git a/package.json b/package.json index 98f5beb1e..085ca9043 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,6 @@ "typescript": "^5.9.2", "valibot": "^1.2.0", "vite": "^8.0.1", - "vitest": "^4.1.0" + "vitest": "^4.1.5" } } diff --git a/packages/code-link-cli/package.json b/packages/code-link-cli/package.json index 62866c5a5..37904d204 100644 --- a/packages/code-link-cli/package.json +++ b/packages/code-link-cli/package.json @@ -36,6 +36,6 @@ "@types/ws": "^8.18.1", "tsdown": "^0.20.1", "tsx": "^4.21.0", - "vitest": "^4.0.15" + "vitest": "^4.1.5" } } diff --git a/packages/code-link-cli/src/controller.apply.test.ts b/packages/code-link-cli/src/controller.apply.test.ts new file mode 100644 index 000000000..f02081984 --- /dev/null +++ b/packages/code-link-cli/src/controller.apply.test.ts @@ -0,0 +1,243 @@ +import type { PromptSession } from "@code-link/shared" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { describe, expect, it } from "vitest" +import type { WebSocket } from "ws" +import { type ApplyCtx, applyEffect } from "./controller.ts" +import { SyncRuntime } from "./runtime.ts" +import type { SyncState } from "./sync-events.ts" +import type { Config } from "./types.ts" + +function config(overrides: Partial = {}): Config { + return { + port: 0, + projectHash: "project", + projectDir: null, + filesDir: null, + dangerouslyAutoDelete: false, + allowUnsupportedNpm: false, + ...overrides, + } +} + +function socket({ open = true }: { open?: boolean } = {}) { + const sent: unknown[] = [] + return { + sent, + ws: { + readyState: open ? 1 : 3, + send(payload: string, cb?: (err?: Error | null) => void) { + sent.push(JSON.parse(payload) as unknown) + cb?.(null) + }, + } as WebSocket, + } +} + +function applyCtx(runtime: SyncRuntime, ws: WebSocket | null, overrides: Partial = {}): ApplyCtx { + const syncState: SyncState = + ws === null ? { internalPhase: "disconnected", socket: null } : { internalPhase: "watching", socket: ws } + return { + config: config(overrides), + runtime, + syncState, + shutdown: async () => {}, + } +} + +describe("applyEffect transaction boundaries", () => { + it("does not record a local send when the socket send fails", async () => { + const runtime = new SyncRuntime() + const closed = socket({ open: false }) + + await applyEffect({ type: "SEND_LOCAL_CHANGE", fileName: "A.tsx", content: "x" }, applyCtx(runtime, closed.ws)) + + expect(runtime.memory.matchesContentEcho("A.tsx", "x")).toBe(false) + }) + + it("does not register a pending rename when the rename send fails", async () => { + const runtime = new SyncRuntime() + const closed = socket({ open: false }) + + await applyEffect( + { + type: "SEND_FILE_RENAME", + oldFileName: "Old.tsx", + newFileName: "New.tsx", + content: "x", + }, + applyCtx(runtime, closed.ws) + ) + + expect(runtime.getPendingRename("New.tsx")).toBeUndefined() + }) + + it("rolls back write echo and skips metadata when a remote disk write fails", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-write-fail-")) + try { + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + await fs.writeFile(path.join(filesDir, "Blocked.tsx"), "not a directory", "utf-8") + + const runtime = new SyncRuntime() + runtime.configureWorkspace(tmpDir, false) + + await applyEffect( + { + type: "WRITE_FILES", + files: [{ name: "Blocked.tsx/Nested.tsx", content: "x", modifiedAt: 1 }], + echoPolicy: "authoritative", + }, + applyCtx(runtime, null) + ) + + expect(runtime.metadata.get("Blocked.tsx/Nested.tsx")).toBeUndefined() + expect(runtime.memory.matchesContentEcho("Blocked.tsx/Nested.tsx", "x")).toBe(false) + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }) + } + }) + + it("rolls back expected delete echoes and keeps metadata when a local delete fails", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-delete-fail-")) + try { + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(path.join(filesDir, "Folder.tsx"), { recursive: true }) + + const runtime = new SyncRuntime() + runtime.configureWorkspace(tmpDir, false) + runtime.memory.recordSyncedContent("Folder.tsx", "x", 1) + + await applyEffect({ type: "DELETE_LOCAL_FILES", names: ["Folder.tsx"] }, applyCtx(runtime, null)) + + expect(runtime.metadata.get("Folder.tsx")).toBeDefined() + expect(runtime.memory.matchesExpectedDeleteEcho("Folder.tsx")).toBe(false) + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }) + } + }) + + it("starts delete prompts without awaiting a user decision", async () => { + const runtime = new SyncRuntime() + runtime.mintConnectionId() + const open = socket() + + await applyEffect({ type: "LOCAL_INITIATED_FILE_DELETE", fileNames: ["A.tsx"] }, applyCtx(runtime, open.ws)) + + expect(open.sent).toEqual([ + expect.objectContaining({ + type: "file-delete", + fileNames: ["A.tsx"], + requireConfirmation: true, + session: expect.objectContaining({ connectionId: 1 }), + }), + ]) + }) + + it("auto-delete sends exactly one remote delete and records only after send success", async () => { + const runtime = new SyncRuntime() + runtime.memory.recordSyncedContent("A.tsx", "old", 1) + const open = socket() + + await applyEffect( + { type: "LOCAL_INITIATED_FILE_DELETE", fileNames: ["A.tsx"] }, + applyCtx(runtime, open.ws, { dangerouslyAutoDelete: true }) + ) + + expect(open.sent).toEqual([{ type: "file-delete", fileNames: ["A.tsx"] }]) + expect(runtime.metadata.get("A.tsx")).toBeUndefined() + }) + + it("refreshes an active conflict prompt when local conflict content changes", async () => { + const runtime = new SyncRuntime() + runtime.mintConnectionId() + const open = socket() + const prompt = runtime.startOrUpdateConflictPrompt([ + { fileName: "A.tsx", localContent: "old", remoteContent: "remote" }, + ]) + if (!prompt) throw new Error("Expected conflict prompt") + + await applyEffect( + { type: "UPDATE_ACTIVE_CONFLICT_LOCAL", fileName: "A.tsx", content: "new" }, + applyCtx(runtime, open.ws) + ) + + expect(open.sent).toEqual([ + { + type: "conflicts-detected", + session: prompt.session, + conflicts: [{ fileName: "A.tsx", localContent: "new", remoteContent: "remote" }], + }, + ]) + }) + + it("clears an active conflict prompt and records metadata when the final conflict converges", async () => { + const runtime = new SyncRuntime() + runtime.mintConnectionId() + const open = socket() + const prompt = runtime.startOrUpdateConflictPrompt([ + { fileName: "A.tsx", localContent: "local", remoteContent: "remote" }, + ]) + if (!prompt) throw new Error("Expected conflict prompt") + + await applyEffect( + { type: "UPDATE_ACTIVE_CONFLICT_REMOTE", fileName: "A.tsx", content: "local", modifiedAt: 123 }, + applyCtx(runtime, open.ws) + ) + + expect(open.sent).toEqual([{ type: "conflicts-cleared", session: prompt.session }]) + expect(runtime.getActiveConflictPrompt()).toBeNull() + expect(runtime.metadata.get("A.tsx")?.lastRemoteTimestamp).toBe(123) + }) + + it("sends delete prompt path invalidations without clearing unrelated pending deletes", async () => { + const runtime = new SyncRuntime() + runtime.mintConnectionId() + const open = socket() + const prompt = runtime.startDeletePrompt(["A.tsx", "B.tsx"]) + if (!prompt) throw new Error("Expected delete prompt") + + await applyEffect({ type: "INVALIDATE_DELETE_PROMPT_PATH", fileName: "A.tsx" }, applyCtx(runtime, open.ws)) + + expect(open.sent).toEqual([{ type: "delete-prompt-cleared", session: prompt.session, fileNames: ["A.tsx"] }]) + expect(runtime.getDeletePromptFileNames(prompt.session, ["A.tsx", "B.tsx"])).toEqual(["B.tsx"]) + }) +}) + +describe("prompt response apply boundaries", () => { + it("ignores stale delete responses without sending deletes", async () => { + const runtime = new SyncRuntime() + const stale: PromptSession = { connectionId: 99, promptId: "stale" } + const open = socket() + + await applyEffect( + { type: "RESOLVE_DELETE_PROMPT", session: stale, confirmedFileNames: ["A.tsx"], cancelledFiles: [] }, + applyCtx(runtime, open.ws) + ) + + expect(open.sent).toEqual([]) + }) + + it("ignores delete prompt responses for paths that were invalidated", async () => { + const runtime = new SyncRuntime() + runtime.mintConnectionId() + const prompt = runtime.startDeletePrompt(["A.tsx", "B.tsx"]) + if (!prompt) throw new Error("Expected prompt") + runtime.invalidateDeletePromptPath("A.tsx") + const open = socket() + + await applyEffect( + { + type: "RESOLVE_DELETE_PROMPT", + session: prompt.session, + confirmedFileNames: ["A.tsx"], + cancelledFiles: [], + }, + applyCtx(runtime, open.ws) + ) + + expect(open.sent).toEqual([]) + expect(runtime.getDeletePromptFileNames(prompt.session, ["A.tsx", "B.tsx"])).toEqual(["B.tsx"]) + }) +}) diff --git a/packages/code-link-cli/src/controller.integration.test.ts b/packages/code-link-cli/src/controller.integration.test.ts new file mode 100644 index 000000000..a93d7babd --- /dev/null +++ b/packages/code-link-cli/src/controller.integration.test.ts @@ -0,0 +1,447 @@ +/** + * Cross-component integration tests for start(): connection + handshake + watcher + state machine. + * Uses real tempdir FS, fake WebSocket surface, mocked TLS/certs and mocked initConnection server. + */ + +import { CLOSE_CODE_REPLACED, type PluginToCliMessage, shortProjectHash } from "@code-link/shared" +import fs from "fs/promises" +import os from "os" +import path from "path" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import type { Config, WatcherEvent } from "./types.ts" + +const { harness, initWatcherMock, emitWatcherChange } = vi.hoisted(() => { + const READY_STATE = { CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 } as const + + type FakeWs = { + readyState: number + sent: string[] + lastCloseCode?: number + receive: (msg: PluginToCliMessage) => void + send: (data: string | Buffer, cb?: (err?: Error | null) => void) => void + close: (code?: number) => void + } + + const handlers: Partial<{ + handshake: (client: FakeWs, message: { projectId: string; projectName: string }) => void + message: (message: PluginToCliMessage) => void + disconnect: (client: FakeWs) => void + }> = {} + + let activeClient: FakeWs | null = null + let connectionId = 0 + + function createFakeWs(): FakeWs { + const ws: FakeWs = { + readyState: READY_STATE.OPEN, + sent: [], + receive(msg: PluginToCliMessage) { + try { + if (msg.type === "handshake") { + connectionId += 1 + const connId = connectionId + const previousActiveClient = activeClient + activeClient = ws + if (previousActiveClient && previousActiveClient !== activeClient) { + if ( + previousActiveClient.readyState === READY_STATE.OPEN || + previousActiveClient.readyState === READY_STATE.CONNECTING + ) { + previousActiveClient.close(CLOSE_CODE_REPLACED) + } + } + handlers.handshake?.(ws, msg) + } else if (activeClient === ws) { + handlers.message?.(msg) + } else { + // stale client — matches connection.ts ignoring non-handshake from stale + } + } catch { + /* ignore parse errors in test */ + } + }, + send(data, cb) { + const s = typeof data === "string" ? data : data.toString() + this.sent.push(s) + cb?.(null) + }, + close(code = 1000) { + this.lastCloseCode = code + if (this.readyState === READY_STATE.CLOSED) return + this.readyState = READY_STATE.CLOSED + if (activeClient === ws) { + activeClient = null + handlers.disconnect?.(ws) + } + }, + } + return ws + } + + const changeHandlers: Array<(e: WatcherEvent) => void> = [] + const initWatcherMock = vi.fn((_filesDir: string) => ({ + on(_event: "change", handler: (e: WatcherEvent) => void) { + changeHandlers.push(handler) + }, + close: vi.fn(() => Promise.resolve()), + })) + + function emitWatcherChange(event: WatcherEvent) { + for (const h of changeHandlers) { + h(event) + } + } + + async function initConnection(_port: number, _certs: { key: string; cert: string }) { + return { + on(event: "handshake" | "message" | "disconnect", handler: (typeof handlers)[typeof event]) { + if (event === "handshake") handlers.handshake = handler as typeof handlers.handshake + if (event === "message") handlers.message = handler as typeof handlers.message + if (event === "disconnect") handlers.disconnect = handler as typeof handlers.disconnect + }, + close: vi.fn(), + } + } + + function sendMessage(socket: FakeWs, message: import("@code-link/shared").CliToPluginMessage): Promise { + return new Promise(resolve => { + if (socket.readyState !== READY_STATE.OPEN) { + resolve(false) + return + } + socket.send(JSON.stringify(message), () => resolve(true)) + }) + } + + return { + harness: { + createFakeWs, + handlers, + get activeClient() { + return activeClient + }, + initConnection, + sendMessage, + reset() { + handlers.handshake = undefined + handlers.message = undefined + handlers.disconnect = undefined + activeClient = null + changeHandlers.length = 0 + initWatcherMock.mockClear() + }, + }, + initWatcherMock, + emitWatcherChange, + } +}) + +vi.mock("./helpers/certs.ts", () => ({ + getOrCreateCerts: vi.fn(() => Promise.resolve({ key: "test-key", cert: "test-cert" })), + CERT_DIR: "/tmp", +})) + +vi.mock("./helpers/connection.ts", () => ({ + initConnection: harness.initConnection, + sendMessage: harness.sendMessage, +})) + +vi.mock("./helpers/watcher.ts", () => ({ + initWatcher: initWatcherMock, +})) + +vi.mock("./helpers/installer.ts", () => ({ + Installer: class { + initialize = vi.fn(() => Promise.resolve()) + process = vi.fn() + constructor(_opts: unknown) {} + }, +})) + +vi.mock("./helpers/git.ts", () => ({ + tryGitInit: vi.fn(), +})) + +async function loadStart() { + const { start } = await import("./controller.ts") + return start +} + +describe("start() integration", () => { + let tmpDir: string + + beforeEach(() => { + harness.reset() + }) + + afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) + } + }) + + function baseConfig(projectHash: string): Config { + return { + port: 42_000, + projectHash, + projectDir: null, + filesDir: null, + dangerouslyAutoDelete: true, + allowUnsupportedNpm: false, + once: false, + explicitDirectory: tmpDir, + explicitName: "TestProject", + } + } + + it("replaces active socket with CLOSE_CODE_REPLACED when a second client handshakes", async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-int-")) + const projectHash = "integration-test-project-hash-12345" + const start = await loadStart() + await start(baseConfig(projectHash)) + + const id = shortProjectHash(projectHash) + const ws1 = harness.createFakeWs() + ws1.receive({ type: "handshake", projectId: id, projectName: "P1" }) + await vi.waitFor(() => expect(harness.activeClient).toBe(ws1)) + + const ws2 = harness.createFakeWs() + ws2.receive({ type: "handshake", projectId: id, projectName: "P2" }) + await vi.waitFor(() => expect(harness.activeClient).toBe(ws2)) + + expect(ws1.readyState).toBe(3) + expect(ws1.lastCloseCode).toBe(CLOSE_CODE_REPLACED) + }) + + it("emits sync-phase initial_sync then ready only after SYNC_COMPLETE effects", async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-int-")) + const projectHash = "integration-sync-phase-789" + const start = await loadStart() + await start(baseConfig(projectHash)) + + const id = shortProjectHash(projectHash) + const ws = harness.createFakeWs() + ws.receive({ type: "handshake", projectId: id, projectName: "P" }) + await vi.waitFor(() => + expect( + ws.sent.some(payload => { + const message = JSON.parse(payload) as { type?: string; phase?: string } + return message.type === "sync-phase" && message.phase === "initial_sync" + }) + ).toBe(true) + ) + await vi.waitFor(() => expect(initWatcherMock).toHaveBeenCalled(), { timeout: 5000 }) + + ws.receive({ type: "request-files" }) + await vi.waitFor(() => ws.sent.some(s => JSON.parse(s).type === "file-list")) + + ws.receive({ type: "file-list", files: [] }) + await vi.waitFor(() => { + const phases = ws.sent + .map(payload => JSON.parse(payload) as { type?: string; phase?: string }) + .filter(message => message.type === "sync-phase") + .map(message => message.phase) + + const idxInitial = phases.indexOf("initial_sync") + const idxReady = phases.indexOf("ready") + expect(idxInitial).toBeGreaterThanOrEqual(0) + expect(idxReady).toBeGreaterThan(idxInitial) + }) + }) + + it("ignores watcher change events while snapshot_processing (during detectConflicts)", async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-int-")) + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + await fs.writeFile(path.join(filesDir, "A.tsx"), "export const A = 1", "utf-8") + + const filesMod = await import("./helpers/files.ts") + const orig = filesMod.detectConflicts + const spy = vi.spyOn(filesMod, "detectConflicts").mockImplementation(async (remoteFiles, filesDirArg, opts) => { + emitWatcherChange({ kind: "change", relativePath: "A.tsx", content: "export const A = 2" }) + return orig(remoteFiles, filesDirArg, opts) + }) + + const projectHash = "integration-watcher-snap-123" + const start = await loadStart() + await start(baseConfig(projectHash)) + + const id = shortProjectHash(projectHash) + const ws = harness.createFakeWs() + ws.receive({ type: "handshake", projectId: id, projectName: "P" }) + await vi.waitFor(() => expect(initWatcherMock).toHaveBeenCalled(), { timeout: 5000 }) + + ws.receive({ type: "request-files" }) + await vi.waitFor(() => ws.sent.some(s => JSON.parse(s).type === "file-list")) + + ws.receive({ + type: "file-list", + files: [{ name: "A.tsx", content: "export const A = 1", modifiedAt: Date.now() }], + }) + + await vi.waitFor(() => { + const hasFileChange = ws.sent.some(s => { + try { + const m = JSON.parse(s) as { type?: string } + return m.type === "file-change" + } catch { + return false + } + }) + expect(hasFileChange).toBe(false) + }) + + spy.mockRestore() + }) + + it("processes queued remote file changes after snapshot follow-ups finish", async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-int-")) + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + await fs.writeFile(path.join(filesDir, "A.tsx"), "export const A = 1", "utf-8") + + let ws: ReturnType | null = null + const filesMod = await import("./helpers/files.ts") + const orig = filesMod.detectConflicts + const spy = vi.spyOn(filesMod, "detectConflicts").mockImplementation(async (remoteFiles, filesDirArg, opts) => { + if (!ws) throw new Error("Expected websocket before detectConflicts") + ws.receive({ + type: "file-change", + fileName: "A.tsx", + content: "export const A = 2", + }) + return orig(remoteFiles, filesDirArg, opts) + }) + + const projectHash = "integration-remote-snap-123" + const start = await loadStart() + await start(baseConfig(projectHash)) + + const id = shortProjectHash(projectHash) + ws = harness.createFakeWs() + ws.receive({ type: "handshake", projectId: id, projectName: "P" }) + await vi.waitFor(() => expect(initWatcherMock).toHaveBeenCalled(), { timeout: 5000 }) + + ws.receive({ + type: "file-list", + files: [{ name: "A.tsx", content: "export const A = 1", modifiedAt: Date.now() }], + }) + + await vi.waitFor(async () => { + expect(await fs.readFile(path.join(filesDir, "A.tsx"), "utf-8")).toBe("export const A = 2") + }) + + spy.mockRestore() + }) + + it("survives disconnect during conflict resolution and accepts a new handshake", async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-int-")) + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + await fs.writeFile(path.join(filesDir, "C.tsx"), "local", "utf-8") + + const projectHash = "integration-conflict-reconnect-456" + const start = await loadStart() + await start(baseConfig(projectHash)) + + const id = shortProjectHash(projectHash) + const ws = harness.createFakeWs() + ws.receive({ type: "handshake", projectId: id, projectName: "P" }) + await vi.waitFor(() => expect(initWatcherMock).toHaveBeenCalled(), { timeout: 5000 }) + + ws.receive({ type: "request-files" }) + await vi.waitFor(() => ws.sent.some(s => JSON.parse(s).type === "file-list")) + + ws.receive({ + type: "file-list", + files: [{ name: "C.tsx", content: "remote", modifiedAt: Date.now() }], + }) + + await vi.waitFor(() => ws.sent.some(s => JSON.parse(s).type === "conflict-version-request")) + + ws.close(1000) + + await vi.waitFor(() => expect(harness.activeClient).toBeNull()) + + const ws2 = harness.createFakeWs() + ws2.receive({ type: "handshake", projectId: id, projectName: "P" }) + await vi.waitFor(() => expect(harness.activeClient).toBe(ws2)) + }) + + it("ignores stale delete confirmations after reconnect", async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-int-")) + const projectHash = "integration-stale-delete-789" + const start = await loadStart() + await start(baseConfig(projectHash)) + + const id = shortProjectHash(projectHash) + const ws1 = harness.createFakeWs() + ws1.receive({ type: "handshake", projectId: id, projectName: "P" }) + await vi.waitFor(() => expect(initWatcherMock).toHaveBeenCalled(), { timeout: 5000 }) + + const filePath = path.join(tmpDir, "files", "Ghost.tsx") + const content = "export const Ghost = 1" + await fs.writeFile(filePath, content, "utf-8") + + ws1.close(1000) + await vi.waitFor(() => expect(harness.activeClient).toBeNull()) + + const ws2 = harness.createFakeWs() + ws2.receive({ type: "handshake", projectId: id, projectName: "P" }) + await vi.waitFor(() => expect(harness.activeClient).toBe(ws2)) + + ws2.receive({ + type: "delete-confirmed", + fileNames: ["Ghost.tsx"], + session: { connectionId: 0, promptId: "stale" }, + }) + + await vi.waitFor(async () => { + expect(await fs.readFile(filePath, "utf-8")).toBe(content) + }) + }) + + it("clears pending delete prompts when replacing the active socket", async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-int-")) + const projectHash = "integration-replace-delete-prompt-123" + const start = await loadStart() + await start({ ...baseConfig(projectHash), dangerouslyAutoDelete: false }) + + const id = shortProjectHash(projectHash) + const ws1 = harness.createFakeWs() + ws1.receive({ type: "handshake", projectId: id, projectName: "P" }) + await vi.waitFor(() => expect(initWatcherMock).toHaveBeenCalled(), { timeout: 5000 }) + ws1.receive({ type: "file-list", files: [] }) + await vi.waitFor(() => + ws1.sent.some(s => JSON.parse(s).type === "sync-phase" && JSON.parse(s).phase === "ready") + ) + + emitWatcherChange({ kind: "delete", relativePath: "A.tsx" }) + await vi.waitFor(() => + expect(ws1.sent.some(s => JSON.parse(s).type === "file-delete" && JSON.parse(s).requireConfirmation)).toBe( + true + ) + ) + + const ws2 = harness.createFakeWs() + ws2.receive({ type: "handshake", projectId: id, projectName: "P" }) + await vi.waitFor(() => + expect( + ws2.sent.some(s => JSON.parse(s).type === "sync-phase" && JSON.parse(s).phase === "initial_sync") + ).toBe(true) + ) + ws2.receive({ type: "file-list", files: [] }) + await vi.waitFor(() => + ws2.sent.some(s => JSON.parse(s).type === "sync-phase" && JSON.parse(s).phase === "ready") + ) + + ws2.sent.length = 0 + emitWatcherChange({ kind: "delete", relativePath: "A.tsx" }) + + await vi.waitFor(() => + expect(ws2.sent.some(s => JSON.parse(s).type === "file-delete" && JSON.parse(s).requireConfirmation)).toBe( + true + ) + ) + }) +}) diff --git a/packages/code-link-cli/src/controller.once.test.ts b/packages/code-link-cli/src/controller.once.test.ts index a78846620..fdaf00a0e 100644 --- a/packages/code-link-cli/src/controller.once.test.ts +++ b/packages/code-link-cli/src/controller.once.test.ts @@ -1,42 +1,23 @@ -import { beforeEach, describe, expect, it, vi } from "vitest" +import { describe, expect, it } from "vitest" import type { WebSocket } from "ws" -import { executeEffect } from "./controller.ts" +import { type ApplyCtx, applyEffect } from "./controller.ts" +import { SyncRuntime } from "./runtime.ts" +import type { SyncState } from "./sync-events.ts" import type { Config } from "./types.ts" -const { sendMessage, status, success, tryGitInit, wasRecentlyDisconnected, didShowDisconnect, resetDisconnectState } = - vi.hoisted(() => ({ - sendMessage: vi.fn(), - status: vi.fn(), - success: vi.fn(), - tryGitInit: vi.fn(), - wasRecentlyDisconnected: vi.fn(), - didShowDisconnect: vi.fn(), - resetDisconnectState: vi.fn(), - })) - -vi.mock("./helpers/connection.ts", () => ({ - initConnection: vi.fn(), - sendMessage, -})) - -vi.mock("./helpers/git.ts", () => ({ - tryGitInit, -})) - -vi.mock("./utils/logging.ts", async importOriginal => { - const actual = await importOriginal() - +function socket() { + const sent: unknown[] = [] return { - ...actual, - status, - success, - wasRecentlyDisconnected, - didShowDisconnect, - resetDisconnectState, + sent, + ws: { + readyState: 1, + send(payload: string, cb?: (err?: Error | null) => void) { + sent.push(JSON.parse(payload) as unknown) + cb?.(null) + }, + } as WebSocket, } -}) - -const mockSocket = {} as WebSocket +} function createConfig(overrides: Partial = {}): Config { return { @@ -50,78 +31,122 @@ function createConfig(overrides: Partial = {}): Config { } } -describe("SYNC_COMPLETE once mode", () => { - beforeEach(() => { - sendMessage.mockReset() - status.mockReset() - success.mockReset() - tryGitInit.mockReset() - wasRecentlyDisconnected.mockReset() - didShowDisconnect.mockReset() - resetDisconnectState.mockReset() - sendMessage.mockResolvedValue(true) - wasRecentlyDisconnected.mockReturnValue(false) - didShowDisconnect.mockReturnValue(false) +function ctx( + runtime: SyncRuntime, + ws: WebSocket, + config: Config, + onShutdown?: () => void +): ApplyCtx & { didShutdown: () => boolean } { + let didShutdown = false + if (config.projectDir && !runtime.workspace.projectDir) { + runtime.configureWorkspace(config.projectDir, config.projectDirCreated ?? false) + } + const syncState: SyncState = { internalPhase: "watching", socket: ws } + return { + config, + runtime, + syncState, + shutdown: async () => { + onShutdown?.() + didShutdown = true + }, + didShutdown: () => didShutdown, + } +} + +describe("SYNC_COMPLETE apply", () => { + it("shuts down in once mode and records the ready phase", async () => { + const runtime = new SyncRuntime() + const open = socket() + const applyCtx = ctx(runtime, open.ws, createConfig({ once: true })) + + await applyEffect({ type: "SYNC_COMPLETE", totalCount: 2, updatedCount: 1, unchangedCount: 1 }, applyCtx) + + expect(applyCtx.didShutdown()).toBe(true) + expect(open.sent).toContainEqual({ type: "sync-phase", phase: "ready" }) + expect(runtime.lastEmittedSyncPhase).toBe("ready") }) - it("shuts down after the initial sync when once mode is enabled", async () => { - const shutdown = vi.fn().mockResolvedValue(undefined) + it("keeps watching when once is disabled", async () => { + const runtime = new SyncRuntime() + const open = socket() + const applyCtx = ctx(runtime, open.ws, createConfig({ once: false })) + + await applyEffect({ type: "SYNC_COMPLETE", totalCount: 2, updatedCount: 1, unchangedCount: 1 }, applyCtx) - await executeEffect( + expect(applyCtx.didShutdown()).toBe(false) + expect(open.sent).toContainEqual({ type: "sync-phase", phase: "ready" }) + expect(runtime.lastEmittedSyncPhase).toBe("ready") + }) + + it("does not shut down on a reconnect where no notice was shown", async () => { + const runtime = new SyncRuntime() + runtime.disconnectUi.scheduleNotice(() => {}) + runtime.disconnectUi.cancelNotice() + const open = socket() + const applyCtx = ctx(runtime, open.ws, createConfig({ once: true })) + + await applyEffect({ type: "SYNC_COMPLETE", totalCount: 0, updatedCount: 0, unchangedCount: 0 }, applyCtx) + + expect(applyCtx.didShutdown()).toBe(false) + expect(runtime.disconnectUi.wasRecentlyDisconnected()).toBe(false) + expect(runtime.lastEmittedSyncPhase).toBe("ready") + }) + + it("defers once-mode shutdown until pending delete prompts resolve", async () => { + const runtime = new SyncRuntime() + runtime.mintConnectionId() + const prompt = runtime.startDeletePrompt(["A.tsx"]) + if (!prompt) throw new Error("Expected delete prompt") + const open = socket() + const applyCtx = ctx(runtime, open.ws, createConfig({ once: true })) + + await applyEffect({ type: "SYNC_COMPLETE", totalCount: 1, updatedCount: 1, unchangedCount: 0 }, applyCtx) + + expect(applyCtx.didShutdown()).toBe(false) + expect(open.sent).not.toContainEqual({ type: "sync-phase", phase: "ready" }) + + await applyEffect( { - type: "SYNC_COMPLETE", - totalCount: 2, - updatedCount: 1, - unchangedCount: 1, + type: "RESOLVE_DELETE_PROMPT", + session: prompt.session, + confirmedFileNames: ["A.tsx"], + cancelledFiles: [], }, - { - config: createConfig({ once: true }), - hashTracker: {} as never, - installer: null, - fileMetadataCache: {} as never, - pendingRenameConfirmations: new Map(), - shutdown, - userActions: {} as never, - syncState: { - mode: "watching", - socket: mockSocket, - pendingRemoteChanges: [], - }, - } + applyCtx ) - expect(sendMessage).toHaveBeenCalledWith(mockSocket, { type: "sync-complete" }) - expect(status).toHaveBeenCalledWith("Initial sync complete, exiting...") - expect(shutdown).toHaveBeenCalledTimes(1) + expect(open.sent).toContainEqual({ type: "file-delete", fileNames: ["A.tsx"] }) + expect(open.sent).toContainEqual({ type: "sync-phase", phase: "ready" }) + expect(applyCtx.didShutdown()).toBe(true) }) - it("keeps watching when once mode is disabled", async () => { - const shutdown = vi.fn().mockResolvedValue(undefined) + it("clears resolved conflict prompts before once-mode shutdown", async () => { + const runtime = new SyncRuntime() + runtime.mintConnectionId() + const open = socket() + const prompt = runtime.startOrUpdateConflictPrompt([ + { fileName: "A.tsx", localContent: "local", remoteContent: "remote" }, + ]) + if (!prompt) throw new Error("Expected conflict prompt") + + let activePromptAtShutdown = true + const applyCtx = ctx(runtime, open.ws, createConfig({ once: true }), () => { + activePromptAtShutdown = runtime.getActiveConflictPrompt() !== null + }) - await executeEffect( + await applyEffect( { - type: "SYNC_COMPLETE", - totalCount: 2, - updatedCount: 1, - unchangedCount: 1, + type: "RESOLVE_CONFLICT_PROMPT", + session: prompt.session, + fileNames: ["A.tsx"], + resolution: "local", }, - { - config: createConfig({ once: false }), - hashTracker: {} as never, - installer: null, - fileMetadataCache: {} as never, - pendingRenameConfirmations: new Map(), - shutdown, - userActions: {} as never, - syncState: { - mode: "watching", - socket: mockSocket, - pendingRemoteChanges: [], - }, - } + applyCtx ) - expect(status).toHaveBeenCalledWith("Watching for changes...") - expect(shutdown).not.toHaveBeenCalled() + expect(applyCtx.didShutdown()).toBe(true) + expect(activePromptAtShutdown).toBe(false) + expect(runtime.getActiveConflictPrompt()).toBeNull() }) }) diff --git a/packages/code-link-cli/src/controller.rename.test.ts b/packages/code-link-cli/src/controller.rename.test.ts index 3b4043c49..2831779b8 100644 --- a/packages/code-link-cli/src/controller.rename.test.ts +++ b/packages/code-link-cli/src/controller.rename.test.ts @@ -1,418 +1,205 @@ import fs from "fs/promises" import os from "os" import path from "path" -import { beforeEach, describe, expect, it, vi } from "vitest" +import { describe, expect, it } from "vitest" import type { WebSocket } from "ws" -import { executeEffect } from "./controller.ts" +import { type ApplyCtx, applyEffect } from "./controller.ts" +import { SyncRuntime } from "./runtime.ts" +import type { SyncState } from "./sync-events.ts" import type { Config } from "./types.ts" -import { createHashTracker } from "./utils/hash-tracker.ts" -import { hashFileContent } from "./utils/state-persistence.ts" -const { sendMessage } = vi.hoisted(() => ({ - sendMessage: vi.fn(), -})) - -vi.mock("./helpers/connection.ts", () => ({ - initConnection: vi.fn(), - sendMessage, -})) +function baseConfig(overrides: Partial = {}): Config { + return { + port: 0, + projectHash: "project", + projectDir: null, + filesDir: null, + dangerouslyAutoDelete: false, + allowUnsupportedNpm: false, + ...overrides, + } +} + +function socket() { + const sent: unknown[] = [] + return { + sent, + ws: { + readyState: 1, + send(payload: string, cb?: (err?: Error | null) => void) { + sent.push(JSON.parse(payload) as unknown) + cb?.(null) + }, + } as WebSocket, + } +} + +function ctx(runtime: SyncRuntime, ws: WebSocket | null, config: Config = baseConfig()): ApplyCtx { + const syncState: SyncState = + ws === null ? { internalPhase: "disconnected", socket: null } : { internalPhase: "watching", socket: ws } + if (config.projectDir && !runtime.workspace.projectDir) runtime.configureWorkspace(config.projectDir, false) + return { config, runtime, syncState, shutdown: async () => {} } +} + +describe("SEND_FILE_RENAME", () => { + it("clears echo guards without sending when the rename is an echoed write+delete", async () => { + const runtime = new SyncRuntime() + const open = socket() + const content = "export const New = () => null" + runtime.memory.armContentEcho("New.tsx", content) + runtime.memory.armExpectedDeleteEcho("Old.tsx") -const mockSocket = {} as WebSocket -const shutdown = (): Promise => Promise.resolve() + await applyEffect( + { type: "SEND_FILE_RENAME", oldFileName: "Old.tsx", newFileName: "New.tsx", content }, + ctx(runtime, open.ws) + ) -describe("rename confirmation bookkeeping", () => { - beforeEach(() => { - sendMessage.mockReset() + expect(open.sent).toEqual([]) + expect(runtime.memory.matchesContentEcho("New.tsx", content)).toBe(false) + expect(runtime.memory.matchesExpectedDeleteEcho("Old.tsx")).toBe(false) }) - it("skips echoed remote renames when write and delete collapse into one watcher rename", async () => { + it("sends a file rename and registers the pending rename only after send success", async () => { + const runtime = new SyncRuntime() + const open = socket() const content = "export const New = () => null" - const hashTracker = createHashTracker() - hashTracker.remember("New.tsx", content) - hashTracker.markDelete("Old.tsx") - - const pendingRenameConfirmations = new Map() - await executeEffect( - { - type: "SEND_FILE_RENAME", - oldFileName: "Old.tsx", - newFileName: "New.tsx", - content, - }, - { - config: { - port: 0, - projectHash: "project", - projectDir: null, - filesDir: null, - dangerouslyAutoDelete: false, - allowUnsupportedNpm: false, - } satisfies Config, - hashTracker, - installer: null, - fileMetadataCache: { - recordDelete: vi.fn(), - } as never, - pendingRenameConfirmations, - shutdown, - userActions: {} as never, - syncState: { - mode: "watching", - socket: mockSocket, - pendingRemoteChanges: [], - }, - } + await applyEffect( + { type: "SEND_FILE_RENAME", oldFileName: "Old.tsx", newFileName: "New.tsx", content }, + ctx(runtime, open.ws) ) - expect(sendMessage).not.toHaveBeenCalled() - expect(hashTracker.shouldSkipDelete("Old.tsx")).toBe(false) - expect(hashTracker.shouldSkip("New.tsx", content)).toBe(false) - expect(pendingRenameConfirmations.size).toBe(0) + expect(open.sent).toEqual([{ type: "file-rename", oldFileName: "Old.tsx", newFileName: "New.tsx", content }]) + expect(runtime.getPendingRename("New.tsx")).toEqual({ oldFileName: "Old.tsx", content }) }) - it("waits for file-synced before deleting old tracking", async () => { - sendMessage.mockResolvedValue(true) - - const hashTracker = { - remember: vi.fn(), - shouldSkip: vi.fn(), - forget: vi.fn(), - clear: vi.fn(), - markDelete: vi.fn(), - shouldSkipDelete: vi.fn(), - clearDelete: vi.fn(), - } - const fileMetadataCache = { - recordDelete: vi.fn(), - } - const pendingRenameConfirmations = new Map() + it("normalizes an extensionless rename target", async () => { + const runtime = new SyncRuntime() + const open = socket() + const content = "export const New = () => null" - await executeEffect( - { - type: "SEND_FILE_RENAME", - oldFileName: "Old.tsx", - newFileName: "New.tsx", - content: "export const New = () => null", - }, - { - config: { - port: 0, - projectHash: "project", - projectDir: null, - filesDir: null, - dangerouslyAutoDelete: false, - allowUnsupportedNpm: false, - } satisfies Config, - hashTracker: hashTracker as never, - installer: null, - fileMetadataCache: fileMetadataCache as never, - pendingRenameConfirmations, - shutdown, - userActions: {} as never, - syncState: { - mode: "watching", - socket: {} as never, - pendingRemoteChanges: [], - }, - } + await applyEffect( + { type: "SEND_FILE_RENAME", oldFileName: "Old.tsx", newFileName: "New", content }, + ctx(runtime, open.ws) ) - expect(sendMessage).toHaveBeenCalledWith(expect.anything(), { - type: "file-rename", - oldFileName: "Old.tsx", - newFileName: "New.tsx", - content: "export const New = () => null", - }) - expect(hashTracker.forget).not.toHaveBeenCalled() - expect(hashTracker.remember).not.toHaveBeenCalled() - expect(fileMetadataCache.recordDelete).not.toHaveBeenCalled() - expect(pendingRenameConfirmations.get("New.tsx")).toEqual({ - oldFileName: "Old.tsx", - content: "export const New = () => null", - }) + expect(open.sent).toEqual([{ type: "file-rename", oldFileName: "Old.tsx", newFileName: "New.tsx", content }]) + expect(runtime.getPendingRename("New.tsx")).toEqual({ oldFileName: "Old.tsx", content }) }) - it("normalizes extensionless rename targets for later confirmation lookup", async () => { - sendMessage.mockResolvedValue(true) - - const hashTracker = { - remember: vi.fn(), - shouldSkip: vi.fn(), - forget: vi.fn(), - clear: vi.fn(), - markDelete: vi.fn(), - shouldSkipDelete: vi.fn(), - clearDelete: vi.fn(), - } - const pendingRenameConfirmations = new Map() + it("does not register a pending rename when no socket is active", async () => { + const runtime = new SyncRuntime() - await executeEffect( - { - type: "SEND_FILE_RENAME", - oldFileName: "Old.tsx", - newFileName: "New", - content: "export const New = () => null", - }, - { - config: { - port: 0, - projectHash: "project", - projectDir: null, - filesDir: null, - dangerouslyAutoDelete: false, - allowUnsupportedNpm: false, - } satisfies Config, - hashTracker: hashTracker as never, - installer: null, - fileMetadataCache: { - recordDelete: vi.fn(), - } as never, - pendingRenameConfirmations, - shutdown, - userActions: {} as never, - syncState: { - mode: "watching", - socket: {} as never, - pendingRemoteChanges: [], - }, - } + await applyEffect( + { type: "SEND_FILE_RENAME", oldFileName: "Old.tsx", newFileName: "New.tsx", content: "x" }, + ctx(runtime, null) ) - expect(sendMessage).toHaveBeenCalledWith(expect.anything(), { - type: "file-rename", - oldFileName: "Old.tsx", - newFileName: "New.tsx", - content: "export const New = () => null", - }) - expect(pendingRenameConfirmations.get("New.tsx")).toEqual({ - oldFileName: "Old.tsx", - content: "export const New = () => null", - }) - expect(pendingRenameConfirmations.has("New")).toBe(false) + expect(runtime.getPendingRename("New.tsx")).toBeUndefined() }) +}) - it("applies old-file cleanup after file-synced arrives", async () => { +describe("UPDATE_FILE_METADATA", () => { + it("settles a pending rename using current disk content", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-")) - const filesDir = path.join(tmpDir, "files") - await fs.mkdir(filesDir, { recursive: true }) - await fs.writeFile(path.join(filesDir, "New.tsx"), "export const New = () => null", "utf-8") - - const hashTracker = { - remember: vi.fn(), - shouldSkip: vi.fn(), - forget: vi.fn(), - clear: vi.fn(), - markDelete: vi.fn(), - shouldSkipDelete: vi.fn(), - clearDelete: vi.fn(), + try { + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + const content = "export const New = () => null" + await fs.writeFile(path.join(filesDir, "New.tsx"), content, "utf-8") + const runtime = new SyncRuntime() + runtime.registerPendingRename("New.tsx", { oldFileName: "Old.tsx", content }) + + await applyEffect( + { type: "UPDATE_FILE_METADATA", fileName: "New.tsx", remoteModifiedAt: 1234 }, + ctx(runtime, null, baseConfig({ projectDir: tmpDir, filesDir })) + ) + + expect(runtime.metadata.get("New.tsx")?.lastRemoteTimestamp).toBe(1234) + expect(runtime.metadata.get("Old.tsx")).toBeUndefined() + expect(runtime.memory.matchesContentEcho("New.tsx", content)).toBe(true) + expect(runtime.getPendingRename("New.tsx")).toBeUndefined() + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }) } - const fileMetadataCache = { - recordSyncedSnapshot: vi.fn(), - recordDelete: vi.fn(), - } - const pendingRenameConfirmations = new Map([ - ["New.tsx", { oldFileName: "Old.tsx", content: "export const New = () => null" }], - ]) - - await executeEffect( - { - type: "UPDATE_FILE_METADATA", - fileName: "New.tsx", - remoteModifiedAt: 1234, - }, - { - config: { - port: 0, - projectHash: "project", - projectDir: tmpDir, - filesDir, - dangerouslyAutoDelete: false, - allowUnsupportedNpm: false, - } satisfies Config, - hashTracker: hashTracker as never, - installer: null, - fileMetadataCache: fileMetadataCache as never, - pendingRenameConfirmations, - shutdown, - userActions: {} as never, - syncState: { - mode: "watching", - socket: mockSocket, - pendingRemoteChanges: [], - }, - } - ) - - expect(fileMetadataCache.recordSyncedSnapshot).toHaveBeenCalledWith( - "New.tsx", - hashFileContent("export const New = () => null"), - 1234 - ) - expect(hashTracker.forget).toHaveBeenCalledWith("Old.tsx") - expect(fileMetadataCache.recordDelete).toHaveBeenCalledWith("Old.tsx") - expect(hashTracker.remember).toHaveBeenCalledWith("New.tsx", "export const New = () => null") - expect(pendingRenameConfirmations.has("New.tsx")).toBe(false) - - await fs.rm(tmpDir, { recursive: true, force: true }) }) - it("applies old-file cleanup when file-synced uses a normalized rename target", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-normalized-")) - const filesDir = path.join(tmpDir, "files") - await fs.mkdir(filesDir, { recursive: true }) - - const hashTracker = { - remember: vi.fn(), - shouldSkip: vi.fn(), - forget: vi.fn(), - clear: vi.fn(), - markDelete: vi.fn(), - shouldSkipDelete: vi.fn(), - clearDelete: vi.fn(), + it("uses newer disk content when a later local edit landed before the ack", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-late-")) + try { + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + await fs.writeFile(path.join(filesDir, "New.tsx"), "export const New = 2", "utf-8") + const runtime = new SyncRuntime() + runtime.registerPendingRename("New.tsx", { + oldFileName: "Old.tsx", + content: "export const New = 1", + }) + + await applyEffect( + { type: "UPDATE_FILE_METADATA", fileName: "New.tsx", remoteModifiedAt: 1234 }, + ctx(runtime, null, baseConfig({ projectDir: tmpDir, filesDir })) + ) + + expect(runtime.metadata.get("New.tsx")?.lastSyncedHash).toBeDefined() + expect(runtime.memory.matchesContentEcho("New.tsx", "export const New = 2")).toBe(true) + expect(runtime.getPendingRename("New.tsx")).toBeUndefined() + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }) } - const fileMetadataCache = { - recordSyncedSnapshot: vi.fn(), - recordDelete: vi.fn(), + }) + + it("falls back to pending rename content when the file is gone from disk", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-missing-")) + try { + const filesDir = path.join(tmpDir, "files") + await fs.mkdir(filesDir, { recursive: true }) + const runtime = new SyncRuntime() + const pending = "export const New = () => null" + runtime.registerPendingRename("New.tsx", { oldFileName: "Old.tsx", content: pending }) + + await applyEffect( + { type: "UPDATE_FILE_METADATA", fileName: "New.tsx", remoteModifiedAt: 5678 }, + ctx(runtime, null, baseConfig({ projectDir: tmpDir, filesDir })) + ) + + expect(runtime.metadata.get("New.tsx")?.lastRemoteTimestamp).toBe(5678) + expect(runtime.memory.matchesContentEcho("New.tsx", pending)).toBe(false) + expect(runtime.getPendingRename("New.tsx")).toBeUndefined() + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }) } - const pendingRenameConfirmations = new Map() + }) +}) - sendMessage.mockResolvedValue(true) +describe("SEND_LOCAL_CHANGE", () => { + it("pushes local content and arms the echo after send success", async () => { + const runtime = new SyncRuntime() + const open = socket() - await executeEffect( - { - type: "SEND_FILE_RENAME", - oldFileName: "Old.tsx", - newFileName: "New", - content: "export const New = () => null", - }, - { - config: { - port: 0, - projectHash: "project", - projectDir: tmpDir, - filesDir, - dangerouslyAutoDelete: false, - allowUnsupportedNpm: false, - } satisfies Config, - hashTracker: hashTracker as never, - installer: null, - fileMetadataCache: fileMetadataCache as never, - pendingRenameConfirmations, - shutdown, - userActions: {} as never, - syncState: { - mode: "watching", - socket: {} as never, - pendingRemoteChanges: [], - }, - } - ) + await applyEffect({ type: "SEND_LOCAL_CHANGE", fileName: "A.tsx", content: "x" }, ctx(runtime, open.ws)) - await executeEffect( - { - type: "UPDATE_FILE_METADATA", - fileName: "New.tsx", - remoteModifiedAt: 1234, - }, - { - config: { - port: 0, - projectHash: "project", - projectDir: tmpDir, - filesDir, - dangerouslyAutoDelete: false, - allowUnsupportedNpm: false, - } satisfies Config, - hashTracker: hashTracker as never, - installer: null, - fileMetadataCache: fileMetadataCache as never, - pendingRenameConfirmations, - shutdown, - userActions: {} as never, - syncState: { - mode: "watching", - socket: mockSocket, - pendingRemoteChanges: [], - }, - } - ) + expect(open.sent).toEqual([{ type: "file-change", fileName: "A.tsx", content: "x" }]) + expect(runtime.memory.matchesContentEcho("A.tsx", "x")).toBe(true) + }) - expect(fileMetadataCache.recordSyncedSnapshot).toHaveBeenCalledWith( - "New.tsx", - hashFileContent("export const New = () => null"), - 1234 - ) - expect(hashTracker.forget).toHaveBeenCalledWith("Old.tsx") - expect(fileMetadataCache.recordDelete).toHaveBeenCalledWith("Old.tsx") - expect(hashTracker.remember).not.toHaveBeenCalled() - expect(pendingRenameConfirmations.has("New.tsx")).toBe(false) + it("skips the push when content matches the last synced hash", async () => { + const runtime = new SyncRuntime() + runtime.metadata.recordRemoteWrite("A.tsx", "x", 100) + const open = socket() - await fs.rm(tmpDir, { recursive: true, force: true }) + await applyEffect({ type: "SEND_LOCAL_CHANGE", fileName: "A.tsx", content: "x" }, ctx(runtime, open.ws)) + + expect(open.sent).toEqual([]) }) - it("uses current file content when cleanup runs after a newer local change", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "code-link-rename-late-")) - const filesDir = path.join(tmpDir, "files") - await fs.mkdir(filesDir, { recursive: true }) - await fs.writeFile(path.join(filesDir, "New.tsx"), "export const New = 2", "utf-8") - - const hashTracker = { - remember: vi.fn(), - shouldSkip: vi.fn(), - forget: vi.fn(), - clear: vi.fn(), - markDelete: vi.fn(), - shouldSkipDelete: vi.fn(), - clearDelete: vi.fn(), - } - const fileMetadataCache = { - recordSyncedSnapshot: vi.fn(), - recordDelete: vi.fn(), - } - const pendingRenameConfirmations = new Map([ - ["New.tsx", { oldFileName: "Old.tsx", content: "export const New = 1" }], - ]) - - await executeEffect( - { - type: "UPDATE_FILE_METADATA", - fileName: "New.tsx", - remoteModifiedAt: 1234, - }, - { - config: { - port: 0, - projectHash: "project", - projectDir: tmpDir, - filesDir, - dangerouslyAutoDelete: false, - allowUnsupportedNpm: false, - } satisfies Config, - hashTracker: hashTracker as never, - installer: null, - fileMetadataCache: fileMetadataCache as never, - pendingRenameConfirmations, - shutdown, - userActions: {} as never, - syncState: { - mode: "watching", - socket: mockSocket, - pendingRemoteChanges: [], - }, - } - ) + it("skips the push when the change is an inbound echo", async () => { + const runtime = new SyncRuntime() + runtime.memory.armContentEcho("A.tsx", "x") + const open = socket() - expect(fileMetadataCache.recordSyncedSnapshot).toHaveBeenCalledWith( - "New.tsx", - hashFileContent("export const New = 2"), - 1234 - ) - expect(hashTracker.forget).toHaveBeenCalledWith("Old.tsx") - expect(fileMetadataCache.recordDelete).toHaveBeenCalledWith("Old.tsx") - expect(hashTracker.remember).toHaveBeenCalledWith("New.tsx", "export const New = 2") - expect(pendingRenameConfirmations.has("New.tsx")).toBe(false) + await applyEffect({ type: "SEND_LOCAL_CHANGE", fileName: "A.tsx", content: "x" }, ctx(runtime, open.ws)) - await fs.rm(tmpDir, { recursive: true, force: true }) + expect(open.sent).toEqual([]) }) }) diff --git a/packages/code-link-cli/src/controller.test.ts b/packages/code-link-cli/src/controller.test.ts index 081accb6d..8817b3b53 100644 --- a/packages/code-link-cli/src/controller.test.ts +++ b/packages/code-link-cli/src/controller.test.ts @@ -2,41 +2,38 @@ import { describe, expect, it } from "vitest" import type { WebSocket } from "ws" import { transition } from "./controller.ts" import { DEFAULT_REMOTE_DRIFT_MS, filterEchoedFiles } from "./helpers/files.ts" -import { createHashTracker } from "./utils/hash-tracker.ts" +import { SyncMemory } from "./sync-memory.ts" // Readable coverage of core controller functionality const mockSocket = {} as WebSocket +const testSession = { connectionId: 1, promptId: "prompt" } function disconnectedState() { return { - mode: "disconnected" as const, + internalPhase: "disconnected" as const, socket: null, - pendingRemoteChanges: [], } } function watchingState() { return { - mode: "watching" as const, + internalPhase: "watching" as const, socket: mockSocket, - pendingRemoteChanges: [], } } function handshakingState() { return { - mode: "handshaking" as const, + internalPhase: "handshaking" as const, socket: mockSocket, - pendingRemoteChanges: [], } } function snapshotProcessingState() { return { - mode: "snapshot_processing" as const, + internalPhase: "snapshot_processing" as const, socket: mockSocket, - pendingRemoteChanges: [], } } @@ -52,10 +49,9 @@ function conflictResolutionState( }[] ) { return { - mode: "conflict_resolution" as const, + internalPhase: "conflict_resolution" as const, socket: mockSocket, pendingConflicts, - pendingRemoteChanges: [], } } @@ -73,9 +69,10 @@ describe("Code Link", () => { { name: "Button.tsx", content: "export const Button = () =>