diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5931ef7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# Cancel in-progress runs for the same ref when a new commit lands. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + name: Typecheck, lint, build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up pnpm + # Reads the pnpm version from package.json#packageManager. + uses: pnpm/action-setup@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Lint + run: pnpm lint + + - name: Build + run: pnpm build + + test: + name: Test (${{ matrix.name }}) + needs: check + runs-on: ubuntu-latest + timeout-minutes: 25 + strategy: + # Always run both backends so a regression in one is visible even if the + # other fails first. + fail-fast: false + matrix: + include: + # WebSocket suite hits wss://sync3.automerge.org. + - name: WebSocket backend + script: test:websocket + # Subduction suite hits wss://subduction.sync.inkandswitch.com via + # the --sub flag (plus pure-unit Subduction tests). + - name: Subduction backend + script: test:subduction + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + # Several integration tests shell out to `dist/cli.js`, so the build + # artifact must exist before tests run. + run: pnpm build + + - name: Test + run: pnpm run ${{ matrix.script }} + timeout-minutes: 20 diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..efdee55 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,41 @@ +// Minimal ESLint flat config for pushwork. +// +// Goal: catch genuine bugs (undeclared bindings, unreachable code, etc.) without +// nitpicking existing style — formatting belongs to Prettier (.prettierrc). +// +// Most stylistic rules from typescript-eslint's `recommended` set are toned down +// here so this can run green on the existing codebase. Tighten over time. +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: ["dist/**", "node_modules/**", "coverage/**", "**/*.d.ts"], + }, + ...tseslint.configs.recommended, + { + rules: { + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], + "no-empty": ["warn", { allowEmptyCatch: true }], + // `ignoreReadBeforeAssign` keeps the rule from flagging the legitimate + // pattern where a `let` is declared first so it can be captured by a + // closure defined immediately afterward, and is then assigned later + // (e.g. `let timer; const cleanup = () => clearTimeout(timer); timer = setTimeout(...)`). + // Rewriting those to `const` would force restructuring just to silence + // the lint without any real readability gain. + "prefer-const": ["warn", { ignoreReadBeforeAssign: true }], + }, + }, +); diff --git a/package.json b/package.json index 6a8a687..4efe581 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,12 @@ "dev": "tsc --watch", "test": "jest", "test:bail": "jest --bail", - "test:watch": "jest --watch", "test:coverage": "jest --coverage", - "lint": "eslint src --ext .ts", - "lint:fix": "eslint src --ext .ts --fix", + "test:subduction": "jest test/unit/repo-factory.test.ts test/unit/network-sync-sub.test.ts test/unit/subduction-config.test.ts test/integration/sub-flag.test.ts", + "test:watch": "jest --watch", + "test:websocket": "jest --testPathIgnorePatterns=/node_modules/ --testPathIgnorePatterns=\"(repo-factory|network-sync-sub|subduction-config|sub-flag)[.]test[.]ts$\"", + "lint": "eslint src", + "lint:fix": "eslint src --fix", "clean": "rm -rf dist", "prepack": "pnpm run build", "start": "node dist/cli.js", @@ -61,12 +63,14 @@ "@types/node": "^20.0.0", "@types/tmp": "^0.2.4", "babel-jest": "^30.2.0", + "eslint": "^9.18.0", "fast-check": "^4.3.0", "jest": "^29.7.0", "tmp": "^0.2.1", "ts-jest": "^29.1.0", "tsx": "^4.19.2", - "typescript": "^5.2.0" + "typescript": "^5.2.0", + "typescript-eslint": "^8.20.0" }, "jest": { "preset": "ts-jest", @@ -86,6 +90,7 @@ "setupFilesAfterEnv": [ "/test/jest.setup.ts" ], + "globalSetup": "/test/jest.globalSetup.ts", "maxWorkers": "75%", "maxConcurrency": 10, "transformIgnorePatterns": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86fdaa2..3eff60d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: babel-jest: specifier: ^30.2.0 version: 30.3.0(@babel/core@7.29.0) + eslint: + specifier: ^9.18.0 + version: 9.39.4 fast-check: specifier: ^4.3.0 version: 4.6.0 @@ -90,6 +93,9 @@ importers: typescript: specifier: ^5.2.0 version: 5.9.3 + typescript-eslint: + specifier: ^8.20.0 + version: 8.59.3(eslint@9.39.4)(typescript@5.9.3) packages: @@ -878,6 +884,64 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -1023,6 +1087,9 @@ packages: '@types/diff@5.2.3': resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/graceful-fs@4.1.9': resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} @@ -1038,6 +1105,9 @@ packages: '@types/jest@29.5.14': resolution: {integrity: sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/mime-types@2.1.4': resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} @@ -1056,9 +1126,81 @@ packages: '@types/yargs@17.0.35': resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@typescript-eslint/eslint-plugin@8.59.3': + resolution: {integrity: sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.3 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.3': + resolution: {integrity: sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.3': + resolution: {integrity: sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.3': + resolution: {integrity: sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.3': + resolution: {integrity: sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.3': + resolution: {integrity: sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.3': + resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.3': + resolution: {integrity: sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.3': + resolution: {integrity: sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.3': + resolution: {integrity: sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} engines: {node: '>=8'} @@ -1090,6 +1232,9 @@ packages: argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + babel-jest@29.7.0: resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1153,6 +1298,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + base-x@4.0.1: resolution: {integrity: sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==} @@ -1173,6 +1322,10 @@ packages: brace-expansion@2.0.3: resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1311,6 +1464,9 @@ packages: babel-plugin-macros: optional: true + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -1366,11 +1522,57 @@ packages: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} hasBin: true + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1394,15 +1596,34 @@ packages: resolution: {integrity: sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==} engines: {node: '>=12.17.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1411,6 +1632,17 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -1445,6 +1677,10 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -1454,6 +1690,10 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1484,6 +1724,14 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -1507,6 +1755,10 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -1515,6 +1767,10 @@ packages: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -1718,19 +1974,35 @@ packages: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} hasBin: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -1739,6 +2011,10 @@ packages: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -1746,12 +2022,19 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + log-symbols@5.1.0: resolution: {integrity: sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==} engines: {node: '>=12'} @@ -1791,6 +2074,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -1839,6 +2126,10 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + ora@7.0.1: resolution: {integrity: sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw==} engines: {node: '>=16'} @@ -1855,6 +2146,10 @@ packages: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -1862,6 +2157,10 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -1904,6 +2203,10 @@ packages: resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} engines: {node: '>=8'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.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} @@ -1912,6 +2215,10 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} @@ -1951,6 +2258,10 @@ packages: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -2082,6 +2393,10 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + tmp@0.2.5: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} @@ -2093,6 +2408,12 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-jest@29.4.9: resolution: {integrity: sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} @@ -2125,6 +2446,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-detect@4.0.8: resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} engines: {node: '>=4'} @@ -2137,6 +2462,13 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + typescript-eslint@8.59.3: + resolution: {integrity: sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2172,6 +2504,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2192,6 +2527,10 @@ packages: engines: {node: '>= 8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -3128,6 +3467,68 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': + dependencies: + eslint: 9.39.4 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -3406,6 +3807,8 @@ snapshots: '@types/diff@5.2.3': {} + '@types/estree@1.0.9': {} + '@types/graceful-fs@4.1.9': dependencies: '@types/node': 20.19.39 @@ -3425,6 +3828,8 @@ snapshots: expect: 29.7.0 pretty-format: 29.7.0 + '@types/json-schema@7.0.15': {} + '@types/mime-types@2.1.4': {} '@types/node@20.19.39': @@ -3441,8 +3846,112 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 + '@typescript-eslint/eslint-plugin@8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/type-utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.3 + eslint: 9.39.4 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.3 + debug: 4.4.3 + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.3(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) + '@typescript-eslint/types': 8.59.3 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.3': + dependencies: + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/visitor-keys': 8.59.3 + + '@typescript-eslint/tsconfig-utils@8.59.3(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.3(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.3': {} + + '@typescript-eslint/typescript-estree@8.59.3(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.3(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.3(typescript@5.9.3) + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/visitor-keys': 8.59.3 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.3(eslint@9.39.4)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@typescript-eslint/scope-manager': 8.59.3 + '@typescript-eslint/types': 8.59.3 + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.3': + dependencies: + '@typescript-eslint/types': 8.59.3 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.0': {} + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -3468,6 +3977,8 @@ snapshots: dependencies: sprintf-js: 1.0.3 + argparse@2.0.1: {} + babel-jest@29.7.0(@babel/core@7.29.0): dependencies: '@babel/core': 7.29.0 @@ -3582,6 +4093,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + base-x@4.0.1: {} base64-js@1.5.1: {} @@ -3603,6 +4116,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -3737,6 +4254,8 @@ snapshots: dedent@1.7.2: {} + deep-is@0.1.4: {} + deepmerge@4.3.1: {} detect-libc@2.1.2: @@ -3797,8 +4316,76 @@ snapshots: escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + esprima@4.0.1: {} + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + esutils@2.0.3: {} eventemitter3@5.0.4: {} @@ -3829,14 +4416,26 @@ snapshots: dependencies: pure-rand: 8.4.0 + fast-deep-equal@3.1.3: {} + fast-json-stable-stringify@2.1.0: {} + fast-levenshtein@2.0.6: {} + fast-sha256@1.3.0: {} fb-watchman@2.0.2: dependencies: bser: 2.1.1 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -3846,6 +4445,18 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -3870,6 +4481,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -3888,6 +4503,8 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + globals@14.0.0: {} + graceful-fs@4.2.11: {} handlebars@4.7.9: @@ -3913,6 +4530,13 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -3933,10 +4557,16 @@ snapshots: dependencies: hasown: 2.0.2 + is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} is-generator-fn@2.1.0: {} + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + is-interactive@2.0.0: {} is-number@7.0.0: {} @@ -4347,26 +4977,51 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + jsesc@3.1.0: {} + json-buffer@3.0.1: {} + json-parse-even-better-errors@2.3.1: {} + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + kleur@3.0.3: {} leven@3.1.0: {} + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lines-and-columns@1.2.4: {} locate-path@5.0.0: dependencies: p-locate: 4.1.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash.debounce@4.0.8: {} lodash.memoize@4.1.2: {} + lodash.merge@4.6.2: {} + log-symbols@5.1.0: dependencies: chalk: 5.6.2 @@ -4403,6 +5058,10 @@ snapshots: mimic-fn@2.1.0: {} + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.13 @@ -4444,6 +5103,15 @@ snapshots: dependencies: mimic-fn: 2.1.0 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + ora@7.0.1: dependencies: chalk: 5.6.2 @@ -4468,10 +5136,18 @@ snapshots: dependencies: p-limit: 2.3.0 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-try@2.2.0: {} package-json-from-dist@1.0.1: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.0 @@ -4504,6 +5180,8 @@ snapshots: dependencies: find-up: 4.1.0 + prelude-ls@1.2.1: {} + pretty-format@29.7.0: dependencies: '@jest/schemas': 29.6.3 @@ -4515,6 +5193,8 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + punycode@2.3.1: {} + pure-rand@6.1.0: {} pure-rand@8.4.0: {} @@ -4554,6 +5234,8 @@ snapshots: dependencies: resolve-from: 5.0.0 + resolve-from@4.0.0: {} + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -4669,6 +5351,11 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tmp@0.2.5: {} tmpl@1.0.5: {} @@ -4677,6 +5364,10 @@ snapshots: dependencies: is-number: 7.0.0 + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.39))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 @@ -4704,12 +5395,27 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-detect@4.0.8: {} type-fest@0.21.3: {} type-fest@4.41.0: {} + typescript-eslint@8.59.3(eslint@9.39.4)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.3(@typescript-eslint/parser@8.59.3(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.3(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.3(eslint@9.39.4)(typescript@5.9.3) + eslint: 9.39.4 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} uglify-js@3.19.3: @@ -4734,6 +5440,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + util-deprecate@1.0.2: {} uuid@9.0.1: {} @@ -4752,6 +5462,8 @@ snapshots: dependencies: isexe: 2.0.0 + word-wrap@1.2.5: {} + wordwrap@1.0.0: {} wrap-ansi@7.0.0: diff --git a/src/core/sync-engine.ts b/src/core/sync-engine.ts index 6a2dff5..480f447 100644 --- a/src/core/sync-engine.ts +++ b/src/core/sync-engine.ts @@ -231,7 +231,7 @@ export class SyncEngine { * Used by --force to re-sync every file. */ async resetSnapshot(): Promise { - let snapshot = await this.snapshotManager.load() + const snapshot = await this.snapshotManager.load() if (!snapshot) return this.snapshotManager.clear(snapshot) await this.snapshotManager.save(snapshot) @@ -243,7 +243,7 @@ export class SyncEngine { * documents. The root directory document itself is preserved. */ async nuclearReset(): Promise { - let snapshot = await this.snapshotManager.load() + const snapshot = await this.snapshotManager.load() if (!snapshot) return // Clear the root directory document's entries @@ -1062,7 +1062,19 @@ export class SyncEngine { // Create or update local file await writeFileContent(localPath, change.remoteContent) - // Update or create snapshot entry for this file + // Update or create snapshot entry for this file. + // + // IMPORTANT: For artifact paths we must record the contentHash here. + // detectLocalChanges treats a missing contentHash as "assume the file + // was modified locally" (see change-detection.ts), so an artifact file + // pulled from a peer without a hash would be falsely flagged as a + // local change on the next sync. That spurious change would trigger + // updateRemoteFile, which replaces the artifact document entirely + // with a fresh one (new URL). Two peers each independently doing this + // to the same file forks the parent directory document into concurrent + // branches whose merge contains BOTH the original and replacement + // entries — the source of the artifact-deletion "resurrection" bug. + const isArtifact = this.isArtifactPath(change.path) const snapshotEntry = snapshot.files.get(change.path) if (snapshotEntry) { // Update existing entry @@ -1072,6 +1084,9 @@ export class SyncEngine { const fileHandle = await this.repo.find(change.remoteUrl) snapshotEntry.url = this.getEntryUrl(fileHandle, change.path) } + if (isArtifact) { + snapshotEntry.contentHash = contentHash(change.remoteContent) + } } else { // Create new snapshot entry for newly discovered remote file // We need to find the remote file's URL from the directory hierarchy @@ -1092,6 +1107,9 @@ export class SyncEngine { head: change.remoteHead, extension: getFileExtension(change.path), mimeType: getEnhancedMimeType(change.path), + ...(isArtifact + ? {contentHash: contentHash(change.remoteContent)} + : {}), }) } } catch (error) { diff --git a/test/integration/in-memory-sync.test.ts b/test/integration/in-memory-sync.test.ts deleted file mode 100644 index 66cd371..0000000 --- a/test/integration/in-memory-sync.test.ts +++ /dev/null @@ -1,830 +0,0 @@ -/** - * Sync Reliability Tests - * - * These tests verify sync reliability using the CLI subprocess pattern - * (same as fuzzer.test.ts) but with convergence-based assertions. - * - * Key difference from fuzzer tests: instead of fixed delays, we use - * convergence detection to know when sync is complete. - */ - -import * as fs from "fs/promises"; -import * as path from "path"; -import * as tmp from "tmp"; -import { execFile } from "child_process"; -import { promisify } from "util"; -import * as crypto from "crypto"; - -const execFilePromise = promisify(execFile); - -// Path to the pushwork CLI -const PUSHWORK_CLI = path.join(__dirname, "../../dist/cli.js"); - -/** - * Execute pushwork CLI command - */ -async function pushwork( - args: string[], - cwd: string -): Promise<{ stdout: string; stderr: string }> { - try { - const result = await execFilePromise("node", [PUSHWORK_CLI, ...args], { - cwd, - env: { ...process.env, FORCE_COLOR: "0" }, - }); - return result; - } catch (error: any) { - throw new Error( - `pushwork ${args.join(" ")} failed: ${error.message}\nstdout: ${error.stdout}\nstderr: ${error.stderr}` - ); - } -} - -/** - * Compute hash of all files in a directory (excluding .pushwork) - */ -async function hashDirectory(dirPath: string): Promise { - const files = await getAllFiles(dirPath); - const hash = crypto.createHash("sha256"); - - files.sort(); - - for (const file of files) { - if (file.includes(".pushwork")) continue; - - const fullPath = path.join(dirPath, file); - const content = await fs.readFile(fullPath); - - hash.update(file); - hash.update(content); - } - - return hash.digest("hex"); -} - -/** - * Recursively get all files in a directory - */ -async function getAllFiles( - dirPath: string, - basePath: string = dirPath -): Promise { - const entries = await fs.readdir(dirPath, { withFileTypes: true }); - const files: string[] = []; - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - const relativePath = path.relative(basePath, fullPath); - - if (entry.isDirectory()) { - if (entry.name === ".pushwork") continue; - const subFiles = await getAllFiles(fullPath, basePath); - files.push(...subFiles); - } else if (entry.isFile()) { - files.push(relativePath); - } - } - - return files; -} - -/** - * Check if a path exists - */ -async function pathExists(filePath: string): Promise { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } -} - -/** - * Sync until repos converge or max rounds reached. - * Returns the number of rounds it took to converge, or throws if it didn't. - * - * This is the key helper - instead of fixed delays, we sync until convergence. - */ -async function syncUntilConverged( - repoA: string, - repoB: string, - options: { - maxRounds?: number; - timeoutMs?: number; - } = {} -): Promise<{ rounds: number; hashA: string; hashB: string }> { - const { maxRounds = 5, timeoutMs = 30000 } = options; - const startTime = Date.now(); - - for (let round = 1; round <= maxRounds; round++) { - if (Date.now() - startTime > timeoutMs) { - const hashA = await hashDirectory(repoA); - const hashB = await hashDirectory(repoB); - throw new Error( - `Sync timeout after ${round - 1} rounds and ${Date.now() - startTime}ms. ` + - `hashA=${hashA.slice(0, 8)}, hashB=${hashB.slice(0, 8)}` - ); - } - - // Sync both repos (use --gentle for incremental sync) - await pushwork(["sync", "--gentle"], repoA); - await pushwork(["sync", "--gentle"], repoB); - - // Check if converged - const hashA = await hashDirectory(repoA); - const hashB = await hashDirectory(repoB); - - if (hashA === hashB) { - return { rounds: round, hashA, hashB }; - } - } - - const hashA = await hashDirectory(repoA); - const hashB = await hashDirectory(repoB); - throw new Error( - `Failed to converge after ${maxRounds} sync rounds. ` + - `hashA=${hashA.slice(0, 8)}, hashB=${hashB.slice(0, 8)}` - ); -} - -describe("Sync Reliability Tests", () => { - let tmpDir: string; - let cleanup: () => void; - - beforeEach(() => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - tmpDir = tmpObj.name; - cleanup = tmpObj.removeCallback; - }); - - afterEach(() => { - cleanup(); - }); - - describe("Basic Two-Repo Sync", () => { - /** - * STRICT TEST: Check state immediately after clone, no extra syncs. - * This should expose the same issues as the fuzzer. - */ - it("should have matching state immediately after clone (strict)", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Create file and init A - await fs.writeFile(path.join(repoA, "test.txt"), "Hello from A"); - await pushwork(["init", "."], repoA); - - // Clone to B (no extra syncs!) - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // STRICT: Check immediately, no syncUntilConverged - const hashA = await hashDirectory(repoA); - const hashB = await hashDirectory(repoB); - - // Debug output if they don't match - if (hashA !== hashB) { - const filesA = await getAllFiles(repoA); - const filesB = await getAllFiles(repoB); - console.log("MISMATCH DETECTED:"); - console.log(" repoA files:", filesA.filter(f => !f.includes(".pushwork"))); - console.log(" repoB files:", filesB.filter(f => !f.includes(".pushwork"))); - console.log(" hashA:", hashA.slice(0, 16)); - console.log(" hashB:", hashB.slice(0, 16)); - } - - expect(hashA).toBe(hashB); - - // Verify file exists in both - expect(await pathExists(path.join(repoA, "test.txt"))).toBe(true); - expect(await pathExists(path.join(repoB, "test.txt"))).toBe(true); - - // Verify content matches - const contentA = await fs.readFile(path.join(repoA, "test.txt"), "utf-8"); - const contentB = await fs.readFile(path.join(repoB, "test.txt"), "utf-8"); - expect(contentA).toBe("Hello from A"); - expect(contentB).toBe("Hello from A"); - }, 30000); - - it("should sync a file from A to B (with convergence)", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Create file and init A - await fs.writeFile(path.join(repoA, "test.txt"), "Hello from A"); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Verify convergence (allows retries) - const { rounds, hashA, hashB } = await syncUntilConverged(repoA, repoB); - - expect(hashA).toBe(hashB); - expect(rounds).toBeLessThanOrEqual(2); // Should converge quickly - - // Verify content - const contentA = await fs.readFile(path.join(repoA, "test.txt"), "utf-8"); - const contentB = await fs.readFile(path.join(repoB, "test.txt"), "utf-8"); - expect(contentA).toBe(contentB); - expect(contentA).toBe("Hello from A"); - }, 30000); - - it("should sync a new file added to B back to A", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Init A with initial file - await fs.writeFile(path.join(repoA, "initial.txt"), "initial"); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Initial convergence - await syncUntilConverged(repoA, repoB); - - // B creates new file - await fs.writeFile(path.join(repoB, "from-b.txt"), "Created by B"); - - // Sync until converged - const { rounds } = await syncUntilConverged(repoA, repoB); - - expect(rounds).toBeLessThanOrEqual(3); - - // Verify A got B's file - expect(await pathExists(path.join(repoA, "from-b.txt"))).toBe(true); - const content = await fs.readFile(path.join(repoA, "from-b.txt"), "utf-8"); - expect(content).toBe("Created by B"); - }, 30000); - - it("should sync subdirectories correctly", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Create nested structure in A - await fs.mkdir(path.join(repoA, "subdir"), { recursive: true }); - await fs.writeFile(path.join(repoA, "subdir", "nested.txt"), "Nested content"); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Verify convergence - const { rounds } = await syncUntilConverged(repoA, repoB); - - expect(rounds).toBeLessThanOrEqual(2); - - // Verify B got the nested file - expect(await pathExists(path.join(repoB, "subdir", "nested.txt"))).toBe(true); - const content = await fs.readFile(path.join(repoB, "subdir", "nested.txt"), "utf-8"); - expect(content).toBe("Nested content"); - }, 30000); - }); - - describe("Concurrent Operations", () => { - it("should handle concurrent file creation on both sides", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Init A - await fs.writeFile(path.join(repoA, "initial.txt"), "initial"); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Initial convergence - await syncUntilConverged(repoA, repoB); - - // Both create files concurrently (before syncing) - await fs.writeFile(path.join(repoA, "file-a.txt"), "From A"); - await fs.writeFile(path.join(repoB, "file-b.txt"), "From B"); - - // Sync until converged - const { rounds } = await syncUntilConverged(repoA, repoB); - - expect(rounds).toBeLessThanOrEqual(3); - - // Both should have both files - expect(await pathExists(path.join(repoA, "file-a.txt"))).toBe(true); - expect(await pathExists(path.join(repoA, "file-b.txt"))).toBe(true); - expect(await pathExists(path.join(repoB, "file-a.txt"))).toBe(true); - expect(await pathExists(path.join(repoB, "file-b.txt"))).toBe(true); - }, 30000); - - it("should handle file modification sync", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Init A with file - await fs.writeFile(path.join(repoA, "shared.txt"), "Original"); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Initial convergence - await syncUntilConverged(repoA, repoB); - - // A modifies the file - await fs.writeFile(path.join(repoA, "shared.txt"), "Modified by A"); - - // Sync until converged - const { rounds } = await syncUntilConverged(repoA, repoB); - - expect(rounds).toBeLessThanOrEqual(3); - - // B should have the modification - const contentB = await fs.readFile(path.join(repoB, "shared.txt"), "utf-8"); - expect(contentB).toBe("Modified by A"); - }, 30000); - - it("should handle file deletion sync", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Init A with file - await fs.writeFile(path.join(repoA, "to-delete.txt"), "Will be deleted"); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Initial convergence - await syncUntilConverged(repoA, repoB); - - // Verify B has the file - expect(await pathExists(path.join(repoB, "to-delete.txt"))).toBe(true); - - // A deletes the file - await fs.unlink(path.join(repoA, "to-delete.txt")); - - // Sync until converged - const { rounds } = await syncUntilConverged(repoA, repoB); - - expect(rounds).toBeLessThanOrEqual(3); - - // File should be deleted in B - expect(await pathExists(path.join(repoB, "to-delete.txt"))).toBe(false); - }, 30000); - }); - - describe("Subdirectory File Deletion - Resurrection Bug", () => { - it("deleted file in artifact directory should not resurrect", async () => { - // Files in artifact directories (dist/ by default) resurrect after sync. - // Phase 1 (push) correctly removes the file entry from the directory doc, - // but the Automerge merge with the server's version re-introduces it. - // Phase 2 (pull) then sees it as a "new remote document" and re-creates it. - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true }); - await fs.writeFile(path.join(repoA, "dist", "assets", "app.js"), "// build 1"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - // Delete the file - await fs.unlink(path.join(repoA, "dist", "assets", "app.js")); - - // Sync - push deletion then pull - await pushwork(["sync"], repoA); - - // File should stay deleted - expect(await pathExists(path.join(repoA, "dist", "assets", "app.js"))).toBe(false); - - // Sync again - should NOT come back from server - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "dist", "assets", "app.js"))).toBe(false); - }, 60000); - - it("deleted file in depth-1 subdirectory should not resurrect (control)", async () => { - // Control: depth-1 subdirectories work correctly - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "subdir"), { recursive: true }); - await fs.writeFile(path.join(repoA, "subdir", "file.txt"), "content"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - await fs.unlink(path.join(repoA, "subdir", "file.txt")); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "subdir", "file.txt"))).toBe(false); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "subdir", "file.txt"))).toBe(false); - }, 60000); - - it("deleted build artifacts should not resurrect after rebuild cycle", async () => { - // Real-world scenario: build step creates new hashed files and deletes - // old ones in dist/assets/. The deleted files come back from the server. - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true }); - await fs.writeFile(path.join(repoA, "dist", "assets", "app-ABC123.js"), "// build 1"); - await fs.writeFile(path.join(repoA, "dist", "assets", "vendor-DEF456.js"), "// vendor 1"); - await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index 1"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - // Simulate rebuild: new hashed files replace old ones - await fs.unlink(path.join(repoA, "dist", "assets", "app-ABC123.js")); - await fs.unlink(path.join(repoA, "dist", "assets", "vendor-DEF456.js")); - await fs.writeFile(path.join(repoA, "dist", "assets", "app-XYZ789.js"), "// build 2"); - await fs.writeFile(path.join(repoA, "dist", "assets", "vendor-UVW012.js"), "// vendor 2"); - await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index 2"); - - await pushwork(["sync"], repoA); - - // Old files should be gone, new files should exist - expect(await pathExists(path.join(repoA, "dist", "assets", "app-ABC123.js"))).toBe(false); - expect(await pathExists(path.join(repoA, "dist", "assets", "vendor-DEF456.js"))).toBe(false); - expect(await pathExists(path.join(repoA, "dist", "assets", "app-XYZ789.js"))).toBe(true); - expect(await pathExists(path.join(repoA, "dist", "assets", "vendor-UVW012.js"))).toBe(true); - - // Sync again - old files should NOT come back from server - await pushwork(["sync"], repoA); - - expect(await pathExists(path.join(repoA, "dist", "assets", "app-ABC123.js"))).toBe(false); - expect(await pathExists(path.join(repoA, "dist", "assets", "vendor-DEF456.js"))).toBe(false); - }, 60000); - - it("deleted artifact files should not resurrect on clone", async () => { - // Two repos: A deletes files in an artifact directory, B should not - // see the deleted files after syncing. - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true }); - await fs.writeFile(path.join(repoA, "dist", "assets", "app-ABC123.js"), "// build 1"); - await fs.writeFile(path.join(repoA, "dist", "assets", "vendor-DEF456.js"), "// vendor 1"); - await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index 1"); - await pushwork(["init", "."], repoA); - - // Clone to B and converge - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await syncUntilConverged(repoA, repoB); - - expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC123.js"))).toBe(true); - - // A rebuilds - await fs.unlink(path.join(repoA, "dist", "assets", "app-ABC123.js")); - await fs.unlink(path.join(repoA, "dist", "assets", "vendor-DEF456.js")); - await fs.writeFile(path.join(repoA, "dist", "assets", "app-XYZ789.js"), "// build 2"); - await fs.writeFile(path.join(repoA, "dist", "assets", "vendor-UVW012.js"), "// vendor 2"); - await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index 2"); - - // Sync A then B - await pushwork(["sync"], repoA); - - // A should not have resurrected files - expect(await pathExists(path.join(repoA, "dist", "assets", "app-ABC123.js"))).toBe(false); - expect(await pathExists(path.join(repoA, "dist", "assets", "vendor-DEF456.js"))).toBe(false); - - await pushwork(["sync"], repoB); - - // B should have new files, NOT old files - expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC123.js"))).toBe(false); - expect(await pathExists(path.join(repoB, "dist", "assets", "vendor-DEF456.js"))).toBe(false); - expect(await pathExists(path.join(repoB, "dist", "assets", "app-XYZ789.js"))).toBe(true); - }, 90000); - - it("deleted file in depth-3 subdirectory should not resurrect", async () => { - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "a", "b", "c"), { recursive: true }); - await fs.writeFile(path.join(repoA, "a", "b", "c", "deep.txt"), "deep"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - await fs.unlink(path.join(repoA, "a", "b", "c", "deep.txt")); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "a", "b", "c", "deep.txt"))).toBe(false); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "a", "b", "c", "deep.txt"))).toBe(false); - }, 60000); - - it("create+delete in same subdirectory should not resurrect deleted files", async () => { - // Regression guard: simultaneous create+delete in the same non-artifact - // subdirectory should work. This passes today but we don't want it to regress. - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "subdir"), { recursive: true }); - await fs.writeFile(path.join(repoA, "subdir", "old.txt"), "old content"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - // Simultaneously create new file and delete old file in same dir - await fs.unlink(path.join(repoA, "subdir", "old.txt")); - await fs.writeFile(path.join(repoA, "subdir", "new.txt"), "new content"); - - await pushwork(["sync"], repoA); - - expect(await pathExists(path.join(repoA, "subdir", "old.txt"))).toBe(false); - expect(await pathExists(path.join(repoA, "subdir", "new.txt"))).toBe(true); - - // Sync again - old file should NOT come back - await pushwork(["sync"], repoA); - - expect(await pathExists(path.join(repoA, "subdir", "old.txt"))).toBe(false); - expect(await pathExists(path.join(repoA, "subdir", "new.txt"))).toBe(true); - }, 60000); - - it("deleted file in depth-2 with sibling dirs should not resurrect", async () => { - // The depth-3 test has intermediate dirs (a/b/c) with only one child each. - // The dist/assets test has dist/ containing both assets/ (subdir) and - // index.js (file). Test if having a file sibling alongside the subdir matters. - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "parent", "child"), { recursive: true }); - await fs.writeFile(path.join(repoA, "parent", "sibling.txt"), "sibling at parent level"); - await fs.writeFile(path.join(repoA, "parent", "child", "target.txt"), "will be deleted"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - await fs.unlink(path.join(repoA, "parent", "child", "target.txt")); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "parent", "child", "target.txt"))).toBe(false); - expect(await pathExists(path.join(repoA, "parent", "sibling.txt"))).toBe(true); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "parent", "child", "target.txt"))).toBe(false); - }, 60000); - - it("deleted file in root directory should not resurrect", async () => { - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.writeFile(path.join(repoA, "root-file.txt"), "root content"); - await fs.writeFile(path.join(repoA, "keep.txt"), "keep this"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - // Delete file in root - await fs.unlink(path.join(repoA, "root-file.txt")); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "root-file.txt"))).toBe(false); - expect(await pathExists(path.join(repoA, "keep.txt"))).toBe(true); - - // Sync again - should NOT come back - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "root-file.txt"))).toBe(false); - }, 60000); - - it("deleted file in non-artifact subdirectory (src/) should not resurrect", async () => { - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "src"), { recursive: true }); - await fs.writeFile(path.join(repoA, "src", "index.ts"), "export default 1"); - await fs.writeFile(path.join(repoA, "src", "helper.ts"), "export function help() {}"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - // Delete one file in src/ - await fs.unlink(path.join(repoA, "src", "helper.ts")); - - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "src", "helper.ts"))).toBe(false); - expect(await pathExists(path.join(repoA, "src", "index.ts"))).toBe(true); - - // Sync again - should NOT come back - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "src", "helper.ts"))).toBe(false); - }, 60000); - - it("deleted files should not resurrect after multiple sync cycles", async () => { - // Simulate real-world usage: multiple syncs over time with deletions - const repoA = path.join(tmpDir, "repo-a"); - await fs.mkdir(repoA); - - await fs.mkdir(path.join(repoA, "src"), { recursive: true }); - await fs.writeFile(path.join(repoA, "readme.txt"), "readme"); - await fs.writeFile(path.join(repoA, "src", "app.ts"), "app"); - await fs.writeFile(path.join(repoA, "src", "old.ts"), "old"); - await pushwork(["init", "."], repoA); - await pushwork(["sync"], repoA); - - // Cycle 1: delete root file - await fs.unlink(path.join(repoA, "readme.txt")); - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "readme.txt"))).toBe(false); - - // Cycle 2: delete src file - await fs.unlink(path.join(repoA, "src", "old.ts")); - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "src", "old.ts"))).toBe(false); - - // Cycle 3: just sync - nothing should come back - await pushwork(["sync"], repoA); - expect(await pathExists(path.join(repoA, "readme.txt"))).toBe(false); - expect(await pathExists(path.join(repoA, "src", "old.ts"))).toBe(false); - expect(await pathExists(path.join(repoA, "src", "app.ts"))).toBe(true); - }, 90000); - - it("peer B should not see files deleted by peer A (root)", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - await fs.writeFile(path.join(repoA, "keep.txt"), "keep"); - await fs.writeFile(path.join(repoA, "delete-me.txt"), "gone"); - await pushwork(["init", "."], repoA); - - // Clone to B and converge - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await syncUntilConverged(repoA, repoB); - - expect(await pathExists(path.join(repoB, "delete-me.txt"))).toBe(true); - - // A deletes a root file - await fs.unlink(path.join(repoA, "delete-me.txt")); - await pushwork(["sync"], repoA); - - // B syncs - should see the deletion - await pushwork(["sync"], repoB); - expect(await pathExists(path.join(repoB, "delete-me.txt"))).toBe(false); - expect(await pathExists(path.join(repoB, "keep.txt"))).toBe(true); - - // B syncs again - should stay deleted - await pushwork(["sync"], repoB); - expect(await pathExists(path.join(repoB, "delete-me.txt"))).toBe(false); - }, 90000); - - it("peer B should not see files deleted by peer A (src/)", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - await fs.mkdir(path.join(repoA, "src"), { recursive: true }); - await fs.writeFile(path.join(repoA, "src", "index.ts"), "export default 1"); - await fs.writeFile(path.join(repoA, "src", "old.ts"), "old code"); - await pushwork(["init", "."], repoA); - - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await syncUntilConverged(repoA, repoB); - - expect(await pathExists(path.join(repoB, "src", "old.ts"))).toBe(true); - - // A deletes a file in src/ - await fs.unlink(path.join(repoA, "src", "old.ts")); - await pushwork(["sync"], repoA); - - // B syncs - should see the deletion - await pushwork(["sync"], repoB); - expect(await pathExists(path.join(repoB, "src", "old.ts"))).toBe(false); - expect(await pathExists(path.join(repoB, "src", "index.ts"))).toBe(true); - - // B syncs again - should stay deleted - await pushwork(["sync"], repoB); - expect(await pathExists(path.join(repoB, "src", "old.ts"))).toBe(false); - }, 90000); - - it("peer B should not see files deleted by peer A (dist/)", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true }); - await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index"); - await fs.writeFile(path.join(repoA, "dist", "assets", "app-ABC.js"), "// build 1"); - await pushwork(["init", "."], repoA); - - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await syncUntilConverged(repoA, repoB); - - expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC.js"))).toBe(true); - - // A rebuilds: delete old artifact, create new one - await fs.unlink(path.join(repoA, "dist", "assets", "app-ABC.js")); - await fs.writeFile(path.join(repoA, "dist", "assets", "app-XYZ.js"), "// build 2"); - await pushwork(["sync"], repoA); - - // A should not have resurrected - expect(await pathExists(path.join(repoA, "dist", "assets", "app-ABC.js"))).toBe(false); - - // B syncs - should see new file, NOT old file - await pushwork(["sync"], repoB); - expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC.js"))).toBe(false); - expect(await pathExists(path.join(repoB, "dist", "assets", "app-XYZ.js"))).toBe(true); - - // B syncs again - old file should stay gone - await pushwork(["sync"], repoB); - expect(await pathExists(path.join(repoB, "dist", "assets", "app-ABC.js"))).toBe(false); - }, 90000); - - it("peer B should see artifact file content update after URL replacement", async () => { - // When peer A modifies an artifact file, the document is replaced entirely - // (new Automerge doc with a new URL). Peer B's snapshot still points to the - // old (now orphaned) URL. detectRemoteChanges sees no head change on the old - // doc, and detectNewRemoteDocuments skips paths already in the snapshot. - // Without URL replacement detection, B never sees the update. - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - await fs.mkdir(path.join(repoA, "dist"), { recursive: true }); - await fs.writeFile(path.join(repoA, "dist", "app.js"), "// version 1"); - await pushwork(["init", "."], repoA); - - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - await syncUntilConverged(repoA, repoB); - - const bContentV1 = await fs.readFile(path.join(repoB, "dist", "app.js"), "utf-8"); - expect(bContentV1).toBe("// version 1"); - - // A modifies the artifact file — this triggers nuclear replacement (new URL) - await fs.writeFile(path.join(repoA, "dist", "app.js"), "// version 2"); - await pushwork(["sync"], repoA); - - // B syncs — should pick up the new content despite the URL change - await pushwork(["sync"], repoB); - const bContentV2 = await fs.readFile(path.join(repoB, "dist", "app.js"), "utf-8"); - expect(bContentV2).toBe("// version 2"); - }, 90000); - }); - - describe("Move/Rename Detection", () => { - it("should handle file rename", async () => { - const repoA = path.join(tmpDir, "repo-a"); - const repoB = path.join(tmpDir, "repo-b"); - await fs.mkdir(repoA); - await fs.mkdir(repoB); - - // Init A with file - const content = "This content will be used for similarity detection during move"; - await fs.writeFile(path.join(repoA, "original.txt"), content); - await pushwork(["init", "."], repoA); - - // Clone to B - const { stdout: rootUrl } = await pushwork(["url"], repoA); - await pushwork(["clone", rootUrl.trim(), repoB], tmpDir); - - // Initial convergence - await syncUntilConverged(repoA, repoB); - - // A renames the file - await fs.rename( - path.join(repoA, "original.txt"), - path.join(repoA, "renamed.txt") - ); - - // Sync until converged - const { rounds } = await syncUntilConverged(repoA, repoB); - - expect(rounds).toBeLessThanOrEqual(3); - - // Verify both repos have renamed.txt and not original.txt - expect(await pathExists(path.join(repoA, "original.txt"))).toBe(false); - expect(await pathExists(path.join(repoA, "renamed.txt"))).toBe(true); - expect(await pathExists(path.join(repoB, "original.txt"))).toBe(false); - expect(await pathExists(path.join(repoB, "renamed.txt"))).toBe(true); - - // Verify content preserved - const contentB = await fs.readFile(path.join(repoB, "renamed.txt"), "utf-8"); - expect(contentB).toBe(content); - }, 30000); - }); -}); diff --git a/test/integration/init-sync.test.ts b/test/integration/init-sync.test.ts deleted file mode 100644 index 676d21c..0000000 --- a/test/integration/init-sync.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import * as tmp from "tmp"; -import { execSync } from "child_process"; -import { SnapshotManager } from "../../src/core"; - -describe("Init Command Integration", () => { - let tmpDir: string; - let cleanup: () => void; - const pushworkCmd = `node "${path.join(__dirname, "../../dist/cli.js")}"`; - - beforeAll(() => { - // Build the project before running tests - execSync("pnpm build", { cwd: path.join(__dirname, "../.."), stdio: "pipe" }); - }); - - beforeEach(() => { - const tmpObj = tmp.dirSync({ unsafeCleanup: true }); - tmpDir = tmpObj.name; - cleanup = tmpObj.removeCallback; - }); - - afterEach(async () => { - cleanup(); - }); - - describe("Initial Sync", () => { - it("should sync existing files during init", async () => { - // Create some files before initializing - await fs.writeFile(path.join(tmpDir, "file1.txt"), "Hello, World!"); - await fs.writeFile(path.join(tmpDir, "file2.txt"), "Another file"); - await fs.mkdir(path.join(tmpDir, "subdir")); - await fs.writeFile( - path.join(tmpDir, "subdir", "nested.txt"), - "Nested content" - ); - - // Run pushwork init - execSync(`${pushworkCmd} init "${tmpDir}"`, { stdio: "pipe" }); - - // Verify snapshot was created with file entries - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.size).toBeGreaterThanOrEqual(3); - expect(snapshot!.files.has("file1.txt")).toBe(true); - expect(snapshot!.files.has("file2.txt")).toBe(true); - expect(snapshot!.files.has("subdir/nested.txt")).toBe(true); - }); - - it("should handle empty directory during init", async () => { - // Run pushwork init on empty directory - execSync(`${pushworkCmd} init "${tmpDir}"`, { stdio: "pipe" }); - - // Verify snapshot was created (even if empty) - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.size).toBe(0); - }); - - it("should respect exclude patterns during initial sync", async () => { - // Create files, including some that should be excluded by default - await fs.writeFile(path.join(tmpDir, "included.txt"), "Include me"); - await fs.mkdir(path.join(tmpDir, "node_modules")); - await fs.writeFile( - path.join(tmpDir, "node_modules", "package.json"), - "{}" - ); - await fs.mkdir(path.join(tmpDir, ".git")); - await fs.writeFile( - path.join(tmpDir, ".git", "config"), - "[core]" - ); - - // Run pushwork init - execSync(`${pushworkCmd} init "${tmpDir}"`, { stdio: "pipe" }); - - // Verify snapshot only contains included file - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.has("included.txt")).toBe(true); - // node_modules and .git should be excluded by default - expect(snapshot!.files.has("node_modules/package.json")).toBe(false); - expect(snapshot!.files.has(".git/config")).toBe(false); - }); - }); -}); diff --git a/test/integration/sub-flag.test.ts b/test/integration/sub-flag.test.ts index f36cccd..daeb5ee 100644 --- a/test/integration/sub-flag.test.ts +++ b/test/integration/sub-flag.test.ts @@ -1,20 +1,31 @@ import * as fs from "fs/promises"; import * as path from "path"; import * as tmp from "tmp"; -import { execSync, execFile as execFileCb } from "child_process"; +import { execFile as execFileCb } from "child_process"; import { promisify } from "util"; +import * as crypto from "crypto"; +import * as fc from "fast-check"; import { SnapshotManager } from "../../src/core"; const execFile = promisify(execFileCb); +const CLI_PATH = path.join(__dirname, "../../dist/cli.js"); + +// The CLI bundle (`dist/cli.js`) is built once by `test/jest.globalSetup.ts` +// before any worker spawns; no per-suite build is needed here. +// +// All integration tests in this file exercise the CLI against the live +// Subduction sync server (`wss://subduction.sync.inkandswitch.com`) via +// the `--sub` flag on `init` and `clone`. `sync` reads the backend choice +// from `.pushwork/config.json` so it has no flag of its own. +// +// We standardize on Subduction here because the previous WebSocket-backed +// fuzzer suite was prone to upstream 502s from `wss://sync3.automerge.org`. +// Property-based fuzz tests in particular need a sync layer that doesn't +// flake on every CI run. describe("--sub flag integration", () => { let tmpDir: string; let cleanup: () => void; - const cliPath = path.join(__dirname, "../../dist/cli.js"); - - beforeAll(() => { - execSync("pnpm build", { cwd: path.join(__dirname, "../.."), stdio: "pipe" }); - }); beforeEach(() => { const tmpObj = tmp.dirSync({ unsafeCleanup: true }); @@ -26,162 +37,1625 @@ describe("--sub flag integration", () => { cleanup(); }); + // -------------------- helpers -------------------- + + async function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + /** - * Run pushwork CLI command and return stdout. - * Throws on non-zero exit code. + * Run pushwork CLI command and return `{ stdout, stderr }`. + * + * Throws on non-zero exit code. Retries up to `maxRetries` times on + * transient sync-server errors (502, 503, ECONNREFUSED, ETIMEDOUT, + * "unavailable"), with exponential backoff. + * + * The per-invocation timeout is generous (50s) because `init --sub` and + * `sync` against the live Subduction server can be slow under CI load. + * Each `it()` below also sets its own Jest-level timeout that is larger + * than this so a stalled command surfaces the underlying CLI output + * instead of Jest's generic "exceeded timeout" message. */ - async function pushwork(args: string[], timeoutMs = 30000): Promise { - const { stdout } = await execFile("node", [cliPath, ...args], { - timeout: timeoutMs, - env: { ...process.env, NO_COLOR: "1" }, - }); - return stdout; + async function pushwork( + args: string[], + cwd?: string, + maxRetries = 3, + timeoutMs = 50000, + ): Promise<{ stdout: string; stderr: string }> { + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await execFile("node", [CLI_PATH, ...args], { + cwd, + timeout: timeoutMs, + env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, + }); + } catch (error: any) { + lastError = error; + const errorMessage = (error.message || "") + (error.stderr || ""); + + const isTransient = + errorMessage.includes("502") || + errorMessage.includes("503") || + errorMessage.includes("ECONNREFUSED") || + errorMessage.includes("ETIMEDOUT") || + errorMessage.includes("unavailable"); + + if (isTransient && attempt < maxRetries) { + // Exponential backoff: 1s, 2s, 4s, ... + await wait(Math.pow(2, attempt - 1) * 1000); + continue; + } + + throw new Error( + `pushwork ${args.join(" ")} failed: ${error.message}\n` + + `stdout: ${error.stdout}\nstderr: ${error.stderr}`, + ); + } + } + + throw lastError; } + /** + * Recursively list all files under a directory, returning relative paths. + * Skips `.pushwork/` (sync metadata) and any dotfile directory. + */ + async function getAllFiles( + dirPath: string, + basePath: string = dirPath, + ): Promise { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + if (entry.name.startsWith(".")) continue; + const fullPath = path.join(dirPath, entry.name); + const relativePath = path.relative(basePath, fullPath); + + if (entry.isDirectory()) { + files.push(...(await getAllFiles(fullPath, basePath))); + } else if (entry.isFile()) { + files.push(relativePath); + } + } + + return files; + } + + /** + * Compute SHA-256 over a directory's sorted file paths + contents + * (excluding `.pushwork`). Two directories are considered equivalent + * iff their hashes match. + */ + async function hashDirectory(dirPath: string): Promise { + const files = await getAllFiles(dirPath); + files.sort(); + const hash = crypto.createHash("sha256"); + for (const file of files) { + const fullPath = path.join(dirPath, file); + hash.update(file); + hash.update(await fs.readFile(fullPath)); + } + return hash.digest("hex"); + } + + async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } + } + + // -------------------- single-peer smoke tests -------------------- + describe("init --sub", () => { - it("should initialize a directory with --sub flag", async () => { - await fs.writeFile(path.join(tmpDir, "hello.txt"), "Hello from sub!"); - - await pushwork(["init", "--sub", tmpDir]); - - // Verify .pushwork was created - const pushworkDir = path.join(tmpDir, ".pushwork"); - const stat = await fs.stat(pushworkDir); - expect(stat.isDirectory()).toBe(true); - - // Verify snapshot exists and tracks the file - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.rootDirectoryUrl).toBeDefined(); - expect(snapshot!.rootDirectoryUrl).toMatch(/^automerge:/); - expect(snapshot!.files.has("hello.txt")).toBe(true); - }, 60000); - - it("should track files in subdirectories", async () => { - await fs.mkdir(path.join(tmpDir, "src"), { recursive: true }); - await fs.writeFile(path.join(tmpDir, "src", "index.ts"), "export default {}"); - await fs.writeFile(path.join(tmpDir, "package.json"), '{"name": "test"}'); - - await pushwork(["init", "--sub", tmpDir]); - - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.has("src/index.ts")).toBe(true); - expect(snapshot!.files.has("package.json")).toBe(true); - }, 60000); - - it("should respect default exclude patterns with --sub", async () => { - await fs.writeFile(path.join(tmpDir, "included.txt"), "keep me"); - await fs.mkdir(path.join(tmpDir, "node_modules")); - await fs.writeFile(path.join(tmpDir, "node_modules", "dep.js"), "module"); - await fs.mkdir(path.join(tmpDir, ".git")); - await fs.writeFile(path.join(tmpDir, ".git", "HEAD"), "ref: refs/heads/main"); - - await pushwork(["init", "--sub", tmpDir]); - - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.has("included.txt")).toBe(true); - expect(snapshot!.files.has("node_modules/dep.js")).toBe(false); - expect(snapshot!.files.has(".git/HEAD")).toBe(false); - }, 60000); + it( + "should initialize a directory with --sub flag", + async () => { + await fs.writeFile(path.join(tmpDir, "hello.txt"), "Hello from sub!"); + + await pushwork(["init", "--sub", tmpDir]); + + const pushworkDir = path.join(tmpDir, ".pushwork"); + const stat = await fs.stat(pushworkDir); + expect(stat.isDirectory()).toBe(true); + + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot = await snapshotManager.load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.rootDirectoryUrl).toBeDefined(); + expect(snapshot!.rootDirectoryUrl).toMatch(/^automerge:/); + expect(snapshot!.files.has("hello.txt")).toBe(true); + }, + 60000, + ); + + it( + "should track files in subdirectories", + async () => { + await fs.mkdir(path.join(tmpDir, "src"), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, "src", "index.ts"), + "export default {}", + ); + await fs.writeFile( + path.join(tmpDir, "package.json"), + '{"name": "test"}', + ); + + await pushwork(["init", "--sub", tmpDir]); + + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot = await snapshotManager.load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.files.has("src/index.ts")).toBe(true); + expect(snapshot!.files.has("package.json")).toBe(true); + }, + 60000, + ); + + it( + "should respect default exclude patterns with --sub", + async () => { + await fs.writeFile(path.join(tmpDir, "included.txt"), "keep me"); + await fs.mkdir(path.join(tmpDir, "node_modules")); + await fs.writeFile( + path.join(tmpDir, "node_modules", "dep.js"), + "module", + ); + await fs.mkdir(path.join(tmpDir, ".git")); + await fs.writeFile( + path.join(tmpDir, ".git", "HEAD"), + "ref: refs/heads/main", + ); + + await pushwork(["init", "--sub", tmpDir]); + + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot = await snapshotManager.load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.files.has("included.txt")).toBe(true); + expect(snapshot!.files.has("node_modules/dep.js")).toBe(false); + expect(snapshot!.files.has(".git/HEAD")).toBe(false); + }, + 60000, + ); + + it( + "should initialize an empty directory", + async () => { + await pushwork(["init", "--sub", tmpDir]); + + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot = await snapshotManager.load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.rootDirectoryUrl).toBeDefined(); + expect(snapshot!.rootDirectoryUrl).toMatch(/^automerge:/); + expect(snapshot!.files.size).toBe(0); + }, + 60000, + ); }); describe("sync --sub", () => { - it("should sync after init --sub", async () => { - await fs.writeFile(path.join(tmpDir, "file1.txt"), "initial content"); + it( + "should sync after init --sub", + async () => { + await fs.writeFile(path.join(tmpDir, "file1.txt"), "initial content"); - // Init with --sub - await pushwork(["init", "--sub", tmpDir]); + await pushwork(["init", "--sub", tmpDir]); - // Add a new file - await fs.writeFile(path.join(tmpDir, "file2.txt"), "new file"); + await fs.writeFile(path.join(tmpDir, "file2.txt"), "new file"); - // Sync with --sub - await pushwork(["sync", "--sub", tmpDir]); + // Sync reads the backend choice from .pushwork/config.json (persisted + // by `init --sub` above); the sync command itself has no --sub flag. + await pushwork(["sync", tmpDir]); - // Verify the new file is now tracked - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.has("file1.txt")).toBe(true); - expect(snapshot!.files.has("file2.txt")).toBe(true); - }, 60000); + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot = await snapshotManager.load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.files.has("file1.txt")).toBe(true); + expect(snapshot!.files.has("file2.txt")).toBe(true); + }, + 60000, + ); - it("should detect file modifications on sync --sub", async () => { - await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 1"); + it( + "should detect file modifications on sync --sub", + async () => { + await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 1"); - await pushwork(["init", "--sub", tmpDir]); + await pushwork(["init", "--sub", tmpDir]); - // Record initial heads - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot1 = await snapshotManager.load(); - const initialHead = snapshot1!.files.get("mutable.txt")!.head; + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot1 = await snapshotManager.load(); + const initialHead = snapshot1!.files.get("mutable.txt")!.head; - // Modify the file - await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 2"); + await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 2"); - // Sync - await pushwork(["sync", "--sub", tmpDir]); + await pushwork(["sync", tmpDir]); - // Heads should have changed - const snapshot2 = await snapshotManager.load(); - const updatedHead = snapshot2!.files.get("mutable.txt")!.head; - expect(updatedHead).not.toEqual(initialHead); - }, 60000); + const snapshot2 = await snapshotManager.load(); + const updatedHead = snapshot2!.files.get("mutable.txt")!.head; + expect(updatedHead).not.toEqual(initialHead); + }, + 60000, + ); - it("should handle file deletions on sync --sub", async () => { - await fs.writeFile(path.join(tmpDir, "ephemeral.txt"), "delete me"); - await fs.writeFile(path.join(tmpDir, "keeper.txt"), "keep me"); + it( + "should handle file deletions on sync --sub", + async () => { + await fs.writeFile(path.join(tmpDir, "ephemeral.txt"), "delete me"); + await fs.writeFile(path.join(tmpDir, "keeper.txt"), "keep me"); - await pushwork(["init", "--sub", tmpDir]); + await pushwork(["init", "--sub", tmpDir]); - // Delete a file - await fs.unlink(path.join(tmpDir, "ephemeral.txt")); + await fs.unlink(path.join(tmpDir, "ephemeral.txt")); - // Sync - await pushwork(["sync", "--sub", tmpDir]); + await pushwork(["sync", tmpDir]); - // Deleted file should be gone from snapshot - const snapshotManager = new SnapshotManager(tmpDir); - const snapshot = await snapshotManager.load(); - expect(snapshot).not.toBeNull(); - expect(snapshot!.files.has("ephemeral.txt")).toBe(false); - expect(snapshot!.files.has("keeper.txt")).toBe(true); - }, 60000); + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot = await snapshotManager.load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.files.has("ephemeral.txt")).toBe(false); + expect(snapshot!.files.has("keeper.txt")).toBe(true); + }, + 60000, + ); }); describe("url after init --sub", () => { - it("should print a valid automerge URL", async () => { - await pushwork(["init", "--sub", tmpDir]); - - const stdout = await pushwork(["url", tmpDir]); - expect(stdout.trim()).toMatch(/^automerge:/); - }, 60000); + it( + "should print a valid automerge URL", + async () => { + await pushwork(["init", "--sub", tmpDir]); + const { stdout } = await pushwork(["url", tmpDir]); + expect(stdout.trim()).toMatch(/^automerge:/); + }, + 60000, + ); }); describe("status after init --sub", () => { - it("should report status without errors", async () => { - await fs.writeFile(path.join(tmpDir, "test.txt"), "status check"); - await pushwork(["init", "--sub", tmpDir]); - - // status should not throw - const stdout = await pushwork(["status", tmpDir]); - expect(stdout).toBeDefined(); - }, 60000); + it( + "should report status without errors", + async () => { + await fs.writeFile(path.join(tmpDir, "test.txt"), "status check"); + await pushwork(["init", "--sub", tmpDir]); + const { stdout } = await pushwork(["status", tmpDir]); + expect(stdout).toBeDefined(); + }, + 60000, + ); }); describe("diff after init --sub", () => { - it("should show no changes immediately after init", async () => { - await fs.writeFile(path.join(tmpDir, "stable.txt"), "no changes"); - await pushwork(["init", "--sub", tmpDir]); - - const stdout = await pushwork(["diff", tmpDir]); - // After a fresh init+sync, there should be no pending changes - expect(stdout).not.toContain("modified"); - }, 60000); + it( + "should show no changes immediately after init", + async () => { + await fs.writeFile(path.join(tmpDir, "stable.txt"), "no changes"); + await pushwork(["init", "--sub", tmpDir]); + const { stdout } = await pushwork(["diff", tmpDir]); + expect(stdout).not.toContain("modified"); + }, + 60000, + ); + }); + + // -------------------- two-peer / fuzzer tests -------------------- + + describe("Basic Setup and Clone", () => { + it( + "should initialize a repo with a single file and clone it successfully", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.writeFile(path.join(repoA, "test.txt"), "Hello, Pushwork!"); + await pushwork(["init", "--sub", "."], repoA); + + await wait(1000); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + const cleanRootUrl = rootUrl.trim(); + expect(cleanRootUrl).toMatch(/^automerge:/); + + await pushwork(["clone", "--sub", cleanRootUrl, repoB], tmpDir); + await wait(1000); + + expect(await hashDirectory(repoA)).toBe(await hashDirectory(repoB)); + expect(await pathExists(path.join(repoA, "test.txt"))).toBe(true); + expect(await pathExists(path.join(repoB, "test.txt"))).toBe(true); + + const contentA = await fs.readFile( + path.join(repoA, "test.txt"), + "utf-8", + ); + const contentB = await fs.readFile( + path.join(repoB, "test.txt"), + "utf-8", + ); + expect(contentA).toBe("Hello, Pushwork!"); + expect(contentB).toBe("Hello, Pushwork!"); + }, + 60000, + ); + }); + + describe("Manual two-peer scenarios", () => { + it( + "should handle a simple edit on one side", + async () => { + const repoA = path.join(tmpDir, "manual-a"); + const repoB = path.join(tmpDir, "manual-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.writeFile(path.join(repoA, "test.txt"), "initial content"); + await pushwork(["init", "--sub", "."], repoA); + await wait(500); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await wait(500); + + await fs.writeFile(path.join(repoA, "test.txt"), "modified content"); + + await pushwork(["sync", "--gentle"], repoA); + await wait(1000); + await pushwork(["sync", "--gentle"], repoB); + await wait(1000); + + const contentA = await fs.readFile( + path.join(repoA, "test.txt"), + "utf-8", + ); + const contentB = await fs.readFile( + path.join(repoB, "test.txt"), + "utf-8", + ); + expect(contentA).toBe("modified content"); + expect(contentB).toBe("modified content"); + }, + 60000, + ); + + it( + "should handle edit + rename on one side", + async () => { + const repoA = path.join(tmpDir, "rename-a"); + const repoB = path.join(tmpDir, "rename-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.writeFile( + path.join(repoA, "original.txt"), + "original content", + ); + await pushwork(["init", "--sub", "."], repoA); + await wait(500); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await wait(500); + + // Edit AND rename (historically a problem-prone combination). + await fs.writeFile(path.join(repoA, "original.txt"), "edited content"); + await fs.rename( + path.join(repoA, "original.txt"), + path.join(repoA, "renamed.txt"), + ); + + await pushwork(["sync", "--gentle"], repoA); + await wait(1000); + await pushwork(["sync", "--gentle"], repoB); + await wait(1000); + await pushwork(["sync", "--gentle"], repoA); + await wait(1000); + await pushwork(["sync", "--gentle"], repoB); + await wait(1000); + + expect(await pathExists(path.join(repoA, "original.txt"))).toBe(false); + expect(await pathExists(path.join(repoB, "original.txt"))).toBe(false); + expect(await pathExists(path.join(repoA, "renamed.txt"))).toBe(true); + expect(await pathExists(path.join(repoB, "renamed.txt"))).toBe(true); + + const contentA = await fs.readFile( + path.join(repoA, "renamed.txt"), + "utf-8", + ); + const contentB = await fs.readFile( + path.join(repoB, "renamed.txt"), + "utf-8", + ); + expect(contentA).toBe("edited content"); + expect(contentB).toBe("edited content"); + }, + 120000, + ); + + it( + "should converge clone-then-add scenario", + async () => { + const repoA = path.join(tmpDir, "simple-a"); + const repoB = path.join(tmpDir, "simple-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.writeFile(path.join(repoA, "initial.txt"), "initial"); + await pushwork(["init", "--sub", "."], repoA); + await wait(1000); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await wait(1000); + + // B creates a new file. + await fs.writeFile(path.join(repoB, "aaa.txt"), ""); + + await pushwork(["sync", "--gentle"], repoB); + await wait(1000); + await pushwork(["sync", "--gentle"], repoA); + await wait(1000); + + const filesA = (await fs.readdir(repoA)).filter( + (f) => !f.startsWith("."), + ); + const filesB = (await fs.readdir(repoB)).filter( + (f) => !f.startsWith("."), + ); + expect(filesA).toEqual(filesB); + expect(await pathExists(path.join(repoA, "aaa.txt"))).toBe(true); + expect(await pathExists(path.join(repoB, "aaa.txt"))).toBe(true); + }, + 60000, + ); + + it( + "should converge files in subdirectories and moves between directories", + async () => { + const repoA = path.join(tmpDir, "subdir-a"); + const repoB = path.join(tmpDir, "subdir-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.mkdir(path.join(repoA, "dir1"), { recursive: true }); + await fs.writeFile(path.join(repoA, "dir1", "file1.txt"), "in dir1"); + + await pushwork(["init", "--sub", "."], repoA); + await wait(500); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await wait(500); + + expect(await pathExists(path.join(repoB, "dir1", "file1.txt"))).toBe( + true, + ); + expect( + await fs.readFile(path.join(repoB, "dir1", "file1.txt"), "utf-8"), + ).toBe("in dir1"); + + await fs.mkdir(path.join(repoA, "dir2"), { recursive: true }); + await fs.writeFile(path.join(repoA, "dir2", "file2.txt"), "in dir2"); + + await pushwork(["sync", "--gentle"], repoA); + await wait(1000); + await pushwork(["sync", "--gentle"], repoB); + await wait(1000); + + expect(await pathExists(path.join(repoB, "dir2", "file2.txt"))).toBe( + true, + ); + expect( + await fs.readFile(path.join(repoB, "dir2", "file2.txt"), "utf-8"), + ).toBe("in dir2"); + }, + 60000, + ); + }); + + describe("Property-based fuzzing with fast-check", () => { + type FileOperation = + | { type: "add"; path: string; content: string } + | { type: "edit"; path: string; content: string } + | { type: "delete"; path: string } + | { type: "rename"; fromPath: string; toPath: string } + | { + type: "editAndRename"; + fromPath: string; + toPath: string; + content: string; + }; + + const dirNameArbitrary = fc.stringMatching(/^[a-z]{2,6}$/); + const baseNameArbitrary = fc + .tuple( + fc.stringMatching(/^[a-z]{3,8}$/), + fc.constantFrom("txt", "md", "json", "ts"), + ) + .map(([name, ext]) => `${name}.${ext}`); + const filePathArbitrary = fc.oneof( + baseNameArbitrary, + fc + .tuple(dirNameArbitrary, baseNameArbitrary) + .map(([dir, file]) => `${dir}/${file}`), + fc + .tuple(dirNameArbitrary, dirNameArbitrary, baseNameArbitrary) + .map(([d1, d2, file]) => `${d1}/${d2}/${file}`), + ); + const fileContentArbitrary = fc.string({ minLength: 0, maxLength: 100 }); + + const fileOperationArbitrary: fc.Arbitrary = fc.oneof( + fc.record({ + type: fc.constant("add" as const), + path: filePathArbitrary, + content: fileContentArbitrary, + }), + fc.record({ + type: fc.constant("edit" as const), + path: filePathArbitrary, + content: fileContentArbitrary, + }), + fc.record({ + type: fc.constant("delete" as const), + path: filePathArbitrary, + }), + fc.record({ + type: fc.constant("rename" as const), + fromPath: filePathArbitrary, + toPath: filePathArbitrary, + }), + fc.record({ + type: fc.constant("editAndRename" as const), + fromPath: filePathArbitrary, + toPath: filePathArbitrary, + content: fileContentArbitrary, + }), + ); + + async function ensureParentDir(filePath: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + } + + async function applyOperation( + repoPath: string, + op: FileOperation, + ): Promise { + try { + switch (op.type) { + case "add": + case "edit": { + const filePath = path.join(repoPath, op.path); + await ensureParentDir(filePath); + await fs.writeFile(filePath, op.content); + break; + } + case "delete": { + const filePath = path.join(repoPath, op.path); + if (await pathExists(filePath)) { + await fs.unlink(filePath); + } + break; + } + case "rename": { + const fromPath = path.join(repoPath, op.fromPath); + const toPath = path.join(repoPath, op.toPath); + if ((await pathExists(fromPath)) && !(await pathExists(toPath))) { + await ensureParentDir(toPath); + await fs.rename(fromPath, toPath); + } + break; + } + case "editAndRename": { + const fromPath = path.join(repoPath, op.fromPath); + const toPath = path.join(repoPath, op.toPath); + if ((await pathExists(fromPath)) && !(await pathExists(toPath))) { + await fs.writeFile(fromPath, op.content); + await ensureParentDir(toPath); + await fs.rename(fromPath, toPath); + } + break; + } + } + } catch { + // Operations that can't be applied (e.g. delete of non-existent + // file) are expected during fuzzing — they're effectively no-ops. + } + } + + async function applyOperations( + repoPath: string, + operations: FileOperation[], + ): Promise { + for (const op of operations) { + await applyOperation(repoPath, op); + } + } + + it( + "should converge after random operations on both sides", + async () => { + await fc.assert( + fc.asyncProperty( + fc.array(fileOperationArbitrary, { minLength: 1, maxLength: 10 }), + fc.array(fileOperationArbitrary, { minLength: 1, maxLength: 10 }), + async (opsA, opsB) => { + const testRoot = path.join( + tmpDir, + `prop-${Date.now()}-${Math.floor(Math.random() * 1e9)}`, + ); + await fs.mkdir(testRoot, { recursive: true }); + const repoA = path.join(testRoot, "repo-a"); + const repoB = path.join(testRoot, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + try { + await fs.writeFile( + path.join(repoA, "initial.txt"), + "initial", + ); + await pushwork(["init", "--sub", "."], repoA); + await wait(2000); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork( + ["clone", "--sub", rootUrl.trim(), repoB], + testRoot, + 5, + ); + await wait(1000); + + // Sanity-check that the initial clone converged before + // running random operations, so a failure later is + // clearly attributable to the ops rather than to clone. + const hashBefore = await hashDirectory(repoA); + const hashBcheck = await hashDirectory(repoB); + if (hashBefore !== hashBcheck) { + throw new Error( + `Initial clone hash mismatch:\n` + + ` repoA hash: ${hashBefore}\n` + + ` repoB hash: ${hashBcheck}\n` + + ` repoA files: ${JSON.stringify(await getAllFiles(repoA))}\n` + + ` repoB files: ${JSON.stringify(await getAllFiles(repoB))}`, + ); + } + + await applyOperations(repoA, opsA); + await applyOperations(repoB, opsB); + + // Multiple sync rounds to let both sides observe each + // other's changes. The pattern is A push, B push+pull, + // A pull, B confirm, A final, B final. + for (const repo of [repoA, repoB, repoA, repoB, repoA, repoB]) { + await pushwork(["sync", "--gentle"], repo); + await wait(1000); + } + await wait(2000); + + expect(await hashDirectory(repoA)).toBe( + await hashDirectory(repoB), + ); + + const { stdout: diffOutput } = await pushwork( + ["diff", "--name-only"], + repoA, + ); + const diffLines = diffOutput + .split("\n") + .filter( + (line) => + line.trim() && + !line.includes("✓") && + !line.includes("Local-only") && + !line.includes("Root URL"), + ); + expect(diffLines.length).toBe(0); + } finally { + await fs + .rm(testRoot, { recursive: true, force: true }) + .catch(() => undefined); + } + }, + ), + { + // Each run takes ~30-60s against the live sync server; 3 + // gives reasonable coverage without ballooning CI time. + numRuns: 3, + timeout: 120000, + endOnFailure: true, + }, + ); + }, + 600000, + ); + }); + + // -------------------- sync reliability (convergence-based) -------------------- + + /** + * Sync both repos in alternation until their filesystem hashes match, + * or until `maxRounds` is reached. Returns the round count on success + * so tests can assert quick convergence. + * + * This is the convergence-based alternative to fixed `wait()` delays + * used in the manual two-peer scenarios above. Each round costs two + * `sync --gentle` calls, so keep `maxRounds` modest when the per-test + * timeout is 30 s. + */ + async function syncUntilConverged( + repoA: string, + repoB: string, + options: { maxRounds?: number; timeoutMs?: number } = {}, + ): Promise<{ rounds: number; hashA: string; hashB: string }> { + const { maxRounds = 5, timeoutMs = 30000 } = options; + const startTime = Date.now(); + + for (let round = 1; round <= maxRounds; round++) { + if (Date.now() - startTime > timeoutMs) { + const hashA = await hashDirectory(repoA); + const hashB = await hashDirectory(repoB); + throw new Error( + `Sync timeout after ${round - 1} rounds and ${Date.now() - startTime}ms. ` + + `hashA=${hashA.slice(0, 8)}, hashB=${hashB.slice(0, 8)}`, + ); + } + + await pushwork(["sync", "--gentle"], repoA); + await pushwork(["sync", "--gentle"], repoB); + + const hashA = await hashDirectory(repoA); + const hashB = await hashDirectory(repoB); + if (hashA === hashB) return { rounds: round, hashA, hashB }; + } + + const hashA = await hashDirectory(repoA); + const hashB = await hashDirectory(repoB); + throw new Error( + `Failed to converge after ${maxRounds} sync rounds. ` + + `hashA=${hashA.slice(0, 8)}, hashB=${hashB.slice(0, 8)}`, + ); + } + + describe("Basic Two-Repo Sync", () => { + it( + "should have matching state immediately after clone (strict)", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.writeFile(path.join(repoA, "test.txt"), "Hello from A"); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + + // STRICT: check immediately, no extra sync rounds. + expect(await hashDirectory(repoA)).toBe(await hashDirectory(repoB)); + expect(await pathExists(path.join(repoA, "test.txt"))).toBe(true); + expect(await pathExists(path.join(repoB, "test.txt"))).toBe(true); + expect( + await fs.readFile(path.join(repoA, "test.txt"), "utf-8"), + ).toBe("Hello from A"); + expect( + await fs.readFile(path.join(repoB, "test.txt"), "utf-8"), + ).toBe("Hello from A"); + }, + 30000, + ); + + it( + "should sync a file from A to B (with convergence)", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.writeFile(path.join(repoA, "test.txt"), "Hello from A"); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + + const { rounds, hashA, hashB } = await syncUntilConverged(repoA, repoB); + expect(hashA).toBe(hashB); + expect(rounds).toBeLessThanOrEqual(2); + expect( + await fs.readFile(path.join(repoA, "test.txt"), "utf-8"), + ).toBe("Hello from A"); + expect( + await fs.readFile(path.join(repoB, "test.txt"), "utf-8"), + ).toBe("Hello from A"); + }, + 30000, + ); + + it( + "should sync a new file added to B back to A", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.writeFile(path.join(repoA, "initial.txt"), "initial"); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await syncUntilConverged(repoA, repoB); + + await fs.writeFile(path.join(repoB, "from-b.txt"), "Created by B"); + + const { rounds } = await syncUntilConverged(repoA, repoB); + expect(rounds).toBeLessThanOrEqual(3); + expect(await pathExists(path.join(repoA, "from-b.txt"))).toBe(true); + expect( + await fs.readFile(path.join(repoA, "from-b.txt"), "utf-8"), + ).toBe("Created by B"); + }, + 30000, + ); + + it( + "should sync subdirectories correctly", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.mkdir(path.join(repoA, "subdir"), { recursive: true }); + await fs.writeFile( + path.join(repoA, "subdir", "nested.txt"), + "Nested content", + ); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + const { rounds } = await syncUntilConverged(repoA, repoB); + + expect(rounds).toBeLessThanOrEqual(2); + expect( + await pathExists(path.join(repoB, "subdir", "nested.txt")), + ).toBe(true); + expect( + await fs.readFile(path.join(repoB, "subdir", "nested.txt"), "utf-8"), + ).toBe("Nested content"); + }, + 30000, + ); + }); + + describe("Concurrent Operations", () => { + it( + "should handle concurrent file creation on both sides", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.writeFile(path.join(repoA, "initial.txt"), "initial"); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await syncUntilConverged(repoA, repoB); + + await fs.writeFile(path.join(repoA, "file-a.txt"), "From A"); + await fs.writeFile(path.join(repoB, "file-b.txt"), "From B"); + + const { rounds } = await syncUntilConverged(repoA, repoB); + expect(rounds).toBeLessThanOrEqual(3); + + expect(await pathExists(path.join(repoA, "file-a.txt"))).toBe(true); + expect(await pathExists(path.join(repoA, "file-b.txt"))).toBe(true); + expect(await pathExists(path.join(repoB, "file-a.txt"))).toBe(true); + expect(await pathExists(path.join(repoB, "file-b.txt"))).toBe(true); + }, + 30000, + ); + + it( + "should handle file modification sync", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.writeFile(path.join(repoA, "shared.txt"), "Original"); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await syncUntilConverged(repoA, repoB); + + await fs.writeFile(path.join(repoA, "shared.txt"), "Modified by A"); + + const { rounds } = await syncUntilConverged(repoA, repoB); + expect(rounds).toBeLessThanOrEqual(3); + expect( + await fs.readFile(path.join(repoB, "shared.txt"), "utf-8"), + ).toBe("Modified by A"); + }, + 30000, + ); + + it( + "should handle file deletion sync", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.writeFile( + path.join(repoA, "to-delete.txt"), + "Will be deleted", + ); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await syncUntilConverged(repoA, repoB); + + expect(await pathExists(path.join(repoB, "to-delete.txt"))).toBe(true); + + await fs.unlink(path.join(repoA, "to-delete.txt")); + + const { rounds } = await syncUntilConverged(repoA, repoB); + expect(rounds).toBeLessThanOrEqual(3); + expect(await pathExists(path.join(repoB, "to-delete.txt"))).toBe(false); + }, + 30000, + ); + }); + + describe("Subdirectory File Deletion - Resurrection Bug", () => { + // Single-peer regression tests for the artifact-deletion resurrection + // bug. The fix lives in `applyRemoteChangeToLocal` (sync-engine.ts): + // artifact files pulled from a peer must record a `contentHash` so + // the next sync doesn't misinterpret them as locally modified. + + it( + "deleted file in artifact directory should not resurrect", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + await fs.mkdir(repoA); + + await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true }); + await fs.writeFile( + path.join(repoA, "dist", "assets", "app.js"), + "// build 1", + ); + await pushwork(["init", "--sub", "."], repoA); + await pushwork(["sync"], repoA); + + await fs.unlink(path.join(repoA, "dist", "assets", "app.js")); + + await pushwork(["sync"], repoA); + expect( + await pathExists(path.join(repoA, "dist", "assets", "app.js")), + ).toBe(false); + + await pushwork(["sync"], repoA); + expect( + await pathExists(path.join(repoA, "dist", "assets", "app.js")), + ).toBe(false); + }, + 60000, + ); + + it( + "deleted file in depth-1 subdirectory should not resurrect (control)", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + await fs.mkdir(repoA); + + await fs.mkdir(path.join(repoA, "subdir"), { recursive: true }); + await fs.writeFile( + path.join(repoA, "subdir", "file.txt"), + "content", + ); + await pushwork(["init", "--sub", "."], repoA); + await pushwork(["sync"], repoA); + + await fs.unlink(path.join(repoA, "subdir", "file.txt")); + + await pushwork(["sync"], repoA); + expect( + await pathExists(path.join(repoA, "subdir", "file.txt")), + ).toBe(false); + + await pushwork(["sync"], repoA); + expect( + await pathExists(path.join(repoA, "subdir", "file.txt")), + ).toBe(false); + }, + 60000, + ); + + it( + "deleted build artifacts should not resurrect after rebuild cycle", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + await fs.mkdir(repoA); + + await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true }); + await fs.writeFile( + path.join(repoA, "dist", "assets", "app-ABC123.js"), + "// build 1", + ); + await fs.writeFile( + path.join(repoA, "dist", "assets", "vendor-DEF456.js"), + "// vendor 1", + ); + await fs.writeFile( + path.join(repoA, "dist", "index.js"), + "// index 1", + ); + await pushwork(["init", "--sub", "."], repoA); + await pushwork(["sync"], repoA); + + await fs.unlink(path.join(repoA, "dist", "assets", "app-ABC123.js")); + await fs.unlink( + path.join(repoA, "dist", "assets", "vendor-DEF456.js"), + ); + await fs.writeFile( + path.join(repoA, "dist", "assets", "app-XYZ789.js"), + "// build 2", + ); + await fs.writeFile( + path.join(repoA, "dist", "assets", "vendor-UVW012.js"), + "// vendor 2", + ); + await fs.writeFile( + path.join(repoA, "dist", "index.js"), + "// index 2", + ); + + await pushwork(["sync"], repoA); + + expect( + await pathExists(path.join(repoA, "dist", "assets", "app-ABC123.js")), + ).toBe(false); + expect( + await pathExists( + path.join(repoA, "dist", "assets", "vendor-DEF456.js"), + ), + ).toBe(false); + expect( + await pathExists(path.join(repoA, "dist", "assets", "app-XYZ789.js")), + ).toBe(true); + expect( + await pathExists( + path.join(repoA, "dist", "assets", "vendor-UVW012.js"), + ), + ).toBe(true); + + await pushwork(["sync"], repoA); + + expect( + await pathExists(path.join(repoA, "dist", "assets", "app-ABC123.js")), + ).toBe(false); + expect( + await pathExists( + path.join(repoA, "dist", "assets", "vendor-DEF456.js"), + ), + ).toBe(false); + }, + 60000, + ); + + it( + "deleted artifact files should not resurrect on clone", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true }); + await fs.writeFile( + path.join(repoA, "dist", "assets", "app-ABC123.js"), + "// build 1", + ); + await fs.writeFile( + path.join(repoA, "dist", "assets", "vendor-DEF456.js"), + "// vendor 1", + ); + await fs.writeFile( + path.join(repoA, "dist", "index.js"), + "// index 1", + ); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await syncUntilConverged(repoA, repoB); + + expect( + await pathExists(path.join(repoB, "dist", "assets", "app-ABC123.js")), + ).toBe(true); + + // A rebuilds. + await fs.unlink(path.join(repoA, "dist", "assets", "app-ABC123.js")); + await fs.unlink( + path.join(repoA, "dist", "assets", "vendor-DEF456.js"), + ); + await fs.writeFile( + path.join(repoA, "dist", "assets", "app-XYZ789.js"), + "// build 2", + ); + await fs.writeFile( + path.join(repoA, "dist", "assets", "vendor-UVW012.js"), + "// vendor 2", + ); + await fs.writeFile( + path.join(repoA, "dist", "index.js"), + "// index 2", + ); + + await pushwork(["sync"], repoA); + + expect( + await pathExists(path.join(repoA, "dist", "assets", "app-ABC123.js")), + ).toBe(false); + expect( + await pathExists( + path.join(repoA, "dist", "assets", "vendor-DEF456.js"), + ), + ).toBe(false); + + await pushwork(["sync"], repoB); + + expect( + await pathExists(path.join(repoB, "dist", "assets", "app-ABC123.js")), + ).toBe(false); + expect( + await pathExists( + path.join(repoB, "dist", "assets", "vendor-DEF456.js"), + ), + ).toBe(false); + expect( + await pathExists(path.join(repoB, "dist", "assets", "app-XYZ789.js")), + ).toBe(true); + }, + 90000, + ); + + it( + "deleted file in depth-3 subdirectory should not resurrect", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + await fs.mkdir(repoA); + + await fs.mkdir(path.join(repoA, "a", "b", "c"), { recursive: true }); + await fs.writeFile( + path.join(repoA, "a", "b", "c", "deep.txt"), + "deep", + ); + await pushwork(["init", "--sub", "."], repoA); + await pushwork(["sync"], repoA); + + await fs.unlink(path.join(repoA, "a", "b", "c", "deep.txt")); + + await pushwork(["sync"], repoA); + expect( + await pathExists(path.join(repoA, "a", "b", "c", "deep.txt")), + ).toBe(false); + + await pushwork(["sync"], repoA); + expect( + await pathExists(path.join(repoA, "a", "b", "c", "deep.txt")), + ).toBe(false); + }, + 60000, + ); + + it( + "create+delete in same subdirectory should not resurrect deleted files", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + await fs.mkdir(repoA); + + await fs.mkdir(path.join(repoA, "subdir"), { recursive: true }); + await fs.writeFile( + path.join(repoA, "subdir", "old.txt"), + "old content", + ); + await pushwork(["init", "--sub", "."], repoA); + await pushwork(["sync"], repoA); + + await fs.unlink(path.join(repoA, "subdir", "old.txt")); + await fs.writeFile( + path.join(repoA, "subdir", "new.txt"), + "new content", + ); + + await pushwork(["sync"], repoA); + + expect( + await pathExists(path.join(repoA, "subdir", "old.txt")), + ).toBe(false); + expect( + await pathExists(path.join(repoA, "subdir", "new.txt")), + ).toBe(true); + + await pushwork(["sync"], repoA); + + expect( + await pathExists(path.join(repoA, "subdir", "old.txt")), + ).toBe(false); + expect( + await pathExists(path.join(repoA, "subdir", "new.txt")), + ).toBe(true); + }, + 60000, + ); + + it( + "deleted file in depth-2 with sibling dirs should not resurrect", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + await fs.mkdir(repoA); + + await fs.mkdir(path.join(repoA, "parent", "child"), { + recursive: true, + }); + await fs.writeFile( + path.join(repoA, "parent", "sibling.txt"), + "sibling at parent level", + ); + await fs.writeFile( + path.join(repoA, "parent", "child", "target.txt"), + "will be deleted", + ); + await pushwork(["init", "--sub", "."], repoA); + await pushwork(["sync"], repoA); + + await fs.unlink(path.join(repoA, "parent", "child", "target.txt")); + + await pushwork(["sync"], repoA); + expect( + await pathExists(path.join(repoA, "parent", "child", "target.txt")), + ).toBe(false); + expect( + await pathExists(path.join(repoA, "parent", "sibling.txt")), + ).toBe(true); + + await pushwork(["sync"], repoA); + expect( + await pathExists(path.join(repoA, "parent", "child", "target.txt")), + ).toBe(false); + }, + 60000, + ); + + it( + "deleted file in root directory should not resurrect", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + await fs.mkdir(repoA); + + await fs.writeFile(path.join(repoA, "root-file.txt"), "root content"); + await fs.writeFile(path.join(repoA, "keep.txt"), "keep this"); + await pushwork(["init", "--sub", "."], repoA); + await pushwork(["sync"], repoA); + + await fs.unlink(path.join(repoA, "root-file.txt")); + + await pushwork(["sync"], repoA); + expect(await pathExists(path.join(repoA, "root-file.txt"))).toBe( + false, + ); + expect(await pathExists(path.join(repoA, "keep.txt"))).toBe(true); + + await pushwork(["sync"], repoA); + expect(await pathExists(path.join(repoA, "root-file.txt"))).toBe( + false, + ); + }, + 60000, + ); + + it( + "deleted file in non-artifact subdirectory (src/) should not resurrect", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + await fs.mkdir(repoA); + + await fs.mkdir(path.join(repoA, "src"), { recursive: true }); + await fs.writeFile( + path.join(repoA, "src", "index.ts"), + "export default 1", + ); + await fs.writeFile( + path.join(repoA, "src", "helper.ts"), + "export function help() {}", + ); + await pushwork(["init", "--sub", "."], repoA); + await pushwork(["sync"], repoA); + + await fs.unlink(path.join(repoA, "src", "helper.ts")); + + await pushwork(["sync"], repoA); + expect(await pathExists(path.join(repoA, "src", "helper.ts"))).toBe( + false, + ); + expect(await pathExists(path.join(repoA, "src", "index.ts"))).toBe( + true, + ); + + await pushwork(["sync"], repoA); + expect(await pathExists(path.join(repoA, "src", "helper.ts"))).toBe( + false, + ); + }, + 60000, + ); + + it( + "deleted files should not resurrect after multiple sync cycles", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + await fs.mkdir(repoA); + + await fs.mkdir(path.join(repoA, "src"), { recursive: true }); + await fs.writeFile(path.join(repoA, "readme.txt"), "readme"); + await fs.writeFile(path.join(repoA, "src", "app.ts"), "app"); + await fs.writeFile(path.join(repoA, "src", "old.ts"), "old"); + await pushwork(["init", "--sub", "."], repoA); + await pushwork(["sync"], repoA); + + // Cycle 1: delete root file. + await fs.unlink(path.join(repoA, "readme.txt")); + await pushwork(["sync"], repoA); + expect(await pathExists(path.join(repoA, "readme.txt"))).toBe(false); + + // Cycle 2: delete src file. + await fs.unlink(path.join(repoA, "src", "old.ts")); + await pushwork(["sync"], repoA); + expect(await pathExists(path.join(repoA, "src", "old.ts"))).toBe( + false, + ); + + // Cycle 3: just sync — nothing should come back. + await pushwork(["sync"], repoA); + expect(await pathExists(path.join(repoA, "readme.txt"))).toBe(false); + expect(await pathExists(path.join(repoA, "src", "old.ts"))).toBe( + false, + ); + expect(await pathExists(path.join(repoA, "src", "app.ts"))).toBe(true); + }, + 90000, + ); + + it( + "peer B should not see files deleted by peer A (root)", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.writeFile(path.join(repoA, "keep.txt"), "keep"); + await fs.writeFile(path.join(repoA, "delete-me.txt"), "gone"); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await syncUntilConverged(repoA, repoB); + + expect(await pathExists(path.join(repoB, "delete-me.txt"))).toBe(true); + + await fs.unlink(path.join(repoA, "delete-me.txt")); + await pushwork(["sync"], repoA); + + await pushwork(["sync"], repoB); + expect(await pathExists(path.join(repoB, "delete-me.txt"))).toBe( + false, + ); + expect(await pathExists(path.join(repoB, "keep.txt"))).toBe(true); + + await pushwork(["sync"], repoB); + expect(await pathExists(path.join(repoB, "delete-me.txt"))).toBe( + false, + ); + }, + 90000, + ); + + it( + "peer B should not see files deleted by peer A (src/)", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.mkdir(path.join(repoA, "src"), { recursive: true }); + await fs.writeFile( + path.join(repoA, "src", "index.ts"), + "export default 1", + ); + await fs.writeFile( + path.join(repoA, "src", "old.ts"), + "old code", + ); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await syncUntilConverged(repoA, repoB); + + expect(await pathExists(path.join(repoB, "src", "old.ts"))).toBe(true); + + await fs.unlink(path.join(repoA, "src", "old.ts")); + await pushwork(["sync"], repoA); + + await pushwork(["sync"], repoB); + expect(await pathExists(path.join(repoB, "src", "old.ts"))).toBe( + false, + ); + expect(await pathExists(path.join(repoB, "src", "index.ts"))).toBe( + true, + ); + + await pushwork(["sync"], repoB); + expect(await pathExists(path.join(repoB, "src", "old.ts"))).toBe( + false, + ); + }, + 90000, + ); + + it( + "peer B should not see files deleted by peer A (dist/)", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.mkdir(path.join(repoA, "dist", "assets"), { recursive: true }); + await fs.writeFile(path.join(repoA, "dist", "index.js"), "// index"); + await fs.writeFile( + path.join(repoA, "dist", "assets", "app-ABC.js"), + "// build 1", + ); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await syncUntilConverged(repoA, repoB); + + expect( + await pathExists(path.join(repoB, "dist", "assets", "app-ABC.js")), + ).toBe(true); + + // A rebuilds: delete old artifact, create new one. + await fs.unlink(path.join(repoA, "dist", "assets", "app-ABC.js")); + await fs.writeFile( + path.join(repoA, "dist", "assets", "app-XYZ.js"), + "// build 2", + ); + await pushwork(["sync"], repoA); + + expect( + await pathExists(path.join(repoA, "dist", "assets", "app-ABC.js")), + ).toBe(false); + + await pushwork(["sync"], repoB); + expect( + await pathExists(path.join(repoB, "dist", "assets", "app-ABC.js")), + ).toBe(false); + expect( + await pathExists(path.join(repoB, "dist", "assets", "app-XYZ.js")), + ).toBe(true); + + await pushwork(["sync"], repoB); + expect( + await pathExists(path.join(repoB, "dist", "assets", "app-ABC.js")), + ).toBe(false); + }, + 90000, + ); + + it( + "peer B should see artifact file content update after URL replacement", + async () => { + // When peer A modifies an artifact file, the document is replaced + // entirely (new Automerge doc URL). B's snapshot still points at + // the old (now orphaned) URL. detectRemoteChanges sees no head + // change on the old doc; detectNewRemoteDocuments skips paths + // already in the snapshot. Without the URL-replacement detection + // in `detectNewRemoteDocuments` B would never see the update. + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + await fs.mkdir(path.join(repoA, "dist"), { recursive: true }); + await fs.writeFile( + path.join(repoA, "dist", "app.js"), + "// version 1", + ); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await syncUntilConverged(repoA, repoB); + + expect( + await fs.readFile(path.join(repoB, "dist", "app.js"), "utf-8"), + ).toBe("// version 1"); + + // A modifies the artifact file — this triggers nuclear replacement. + await fs.writeFile( + path.join(repoA, "dist", "app.js"), + "// version 2", + ); + await pushwork(["sync"], repoA); + + await pushwork(["sync"], repoB); + expect( + await fs.readFile(path.join(repoB, "dist", "app.js"), "utf-8"), + ).toBe("// version 2"); + }, + 90000, + ); + }); + + describe("Move/Rename Detection", () => { + it( + "should handle file rename", + async () => { + const repoA = path.join(tmpDir, "repo-a"); + const repoB = path.join(tmpDir, "repo-b"); + await fs.mkdir(repoA); + await fs.mkdir(repoB); + + const content = + "This content will be used for similarity detection during move"; + await fs.writeFile(path.join(repoA, "original.txt"), content); + await pushwork(["init", "--sub", "."], repoA); + + const { stdout: rootUrl } = await pushwork(["url"], repoA); + await pushwork(["clone", "--sub", rootUrl.trim(), repoB], tmpDir); + await syncUntilConverged(repoA, repoB); + + await fs.rename( + path.join(repoA, "original.txt"), + path.join(repoA, "renamed.txt"), + ); + + const { rounds } = await syncUntilConverged(repoA, repoB); + expect(rounds).toBeLessThanOrEqual(3); + + expect(await pathExists(path.join(repoA, "original.txt"))).toBe(false); + expect(await pathExists(path.join(repoA, "renamed.txt"))).toBe(true); + expect(await pathExists(path.join(repoB, "original.txt"))).toBe(false); + expect(await pathExists(path.join(repoB, "renamed.txt"))).toBe(true); + + expect( + await fs.readFile(path.join(repoB, "renamed.txt"), "utf-8"), + ).toBe(content); + }, + 30000, + ); }); }); diff --git a/test/integration/sync-deletion.test.ts b/test/integration/sync-deletion.test.ts index d5239e3..43b2de5 100644 --- a/test/integration/sync-deletion.test.ts +++ b/test/integration/sync-deletion.test.ts @@ -135,31 +135,6 @@ describe("Sync Engine Deletion Integration", () => { snapshotManager.removeFileEntry(snapshot, "rapid-changes.ts"); } }); - - it("should handle deletion during content modification attempts", async () => { - const filePath = path.join(testDir, "modify-delete-race.ts"); - const initialContent = "interface Race { test: boolean; }"; - - // Create initial file - await writeFileContent(filePath, initialContent); - - // Start modification and deletion concurrently - const modifyPromise = writeFileContent( - filePath, - initialContent + "\n// Modified" - ); - const deletePromise = (async () => { - // Small delay to let modification start - await new Promise((resolve) => setTimeout(resolve, 1)); - await removePath(filePath); - })(); - - // Wait for both operations to complete - await Promise.allSettled([modifyPromise, deletePromise]); - - // File should be deleted regardless of modification timing - expect(await pathExists(filePath)).toBe(false); - }); }); describe("Directory Structure Impact", () => { diff --git a/test/jest.globalSetup.ts b/test/jest.globalSetup.ts new file mode 100644 index 0000000..aca69fb --- /dev/null +++ b/test/jest.globalSetup.ts @@ -0,0 +1,37 @@ +/** + * Jest globalSetup — runs once before any test workers spawn. + * + * Builds the CLI bundle (dist/cli.js) that several integration tests shell + * out to. Centralizing the build here avoids two distinct hazards: + * + * 1. Multiple test files used to call `execSync("pnpm build")` from their + * own `beforeAll` hooks. When Jest ran those files in parallel workers, + * concurrent `tsc` invocations would race on writes to `dist/`, which + * manifested as a "Converting circular structure to JSON" failure + * coming from jest-worker's IPC serialization layer (the underlying + * build error contained non-serializable handles). + * + * 2. Other integration tests (in-memory-sync, fuzzer, etc.) shell out to + * `dist/cli.js` without doing their own build, so they implicitly + * assumed something else had already built. Now they can rely on it. + * + * In CI the workflow runs `pnpm build` explicitly before `pnpm test:*`, but + * keeping the build here makes `pnpm test` work locally without a separate + * build step. + */ +import { execSync } from "child_process"; +import { existsSync } from "fs"; +import * as path from "path"; + +export default function globalSetup(): void { + const repoRoot = path.join(__dirname, ".."); + const cliBundle = path.join(repoRoot, "dist", "cli.js"); + + // Allow opting out — useful when iterating on a test that doesn't touch + // the CLI and you've already built. `JEST_SKIP_BUILD=1 pnpm test ...`. + if (process.env.JEST_SKIP_BUILD === "1" && existsSync(cliBundle)) { + return; + } + + execSync("pnpm build", { cwd: repoRoot, stdio: "pipe" }); +} diff --git a/test/unit/repo-factory.test.ts b/test/unit/repo-factory.test.ts index 2eb78f2..c104854 100644 --- a/test/unit/repo-factory.test.ts +++ b/test/unit/repo-factory.test.ts @@ -14,15 +14,13 @@ import * as fs from "fs/promises"; import * as tmp from "tmp"; import { execSync } from "child_process"; +// The CLI bundle (`dist/cli.js`) is built once by `test/jest.globalSetup.ts` +// before any worker spawns; no per-suite build is needed here. describe("createRepo with --sub", () => { let tmpDir: string; let cleanup: () => void; const cliPath = path.join(__dirname, "../../dist/cli.js"); - beforeAll(() => { - execSync("pnpm build", { cwd: path.join(__dirname, "../.."), stdio: "pipe" }); - }); - beforeEach(async () => { const tmpObj = tmp.dirSync({ unsafeCleanup: true }); tmpDir = tmpObj.name; @@ -33,79 +31,104 @@ describe("createRepo with --sub", () => { cleanup(); }); - it("should create a working repo with --sub flag", async () => { - await fs.writeFile(path.join(tmpDir, "test.txt"), "hello"); - - execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { - stdio: "pipe", - timeout: 30000, - }); - - const snapshotPath = path.join(tmpDir, ".pushwork", "snapshot.json"); - const stat = await fs.stat(snapshotPath); - expect(stat.isFile()).toBe(true); - }); - - it("should produce a valid automerge URL", async () => { - await fs.writeFile(path.join(tmpDir, "test.txt"), "hello"); - - execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { - stdio: "pipe", - timeout: 30000, - }); - - const url = execSync(`node "${cliPath}" url "${tmpDir}"`, { - encoding: "utf8", - timeout: 10000, - }).trim(); - - expect(url).toMatch(/^automerge:/); - }); - - it("should track files in the snapshot", async () => { - await fs.writeFile(path.join(tmpDir, "a.txt"), "aaa"); - await fs.mkdir(path.join(tmpDir, "sub"), { recursive: true }); - await fs.writeFile(path.join(tmpDir, "sub", "b.txt"), "bbb"); - - execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { - stdio: "pipe", - timeout: 30000, - }); - - const ls = execSync(`node "${cliPath}" ls "${tmpDir}"`, { - encoding: "utf8", - timeout: 10000, - }); - - expect(ls).toContain("a.txt"); - expect(ls).toContain("b.txt"); - }); - - it("should be able to sync after init", async () => { - await fs.writeFile(path.join(tmpDir, "initial.txt"), "first"); - - execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { - stdio: "pipe", - timeout: 30000, - }); - - // Add a new file - await fs.writeFile(path.join(tmpDir, "added.txt"), "second"); - - // Sync should not throw. The `sync` command has no --sub flag — it - // reads the backend choice from .pushwork/config.json (persisted by - // the init --sub above). - execSync(`node "${cliPath}" sync "${tmpDir}"`, { - stdio: "pipe", - timeout: 30000, - }); - - const ls = execSync(`node "${cliPath}" ls "${tmpDir}"`, { - encoding: "utf8", - timeout: 10000, - }); - - expect(ls).toContain("initial.txt"); - expect(ls).toContain("added.txt"); - }); + // `init --sub` and `sync` against the live Subduction server can take + // longer than Jest's default 5s test timeout. The execSync timeout is + // kept slightly below the Jest per-test timeout so that a network stall + // surfaces as the underlying CLI error rather than Jest's generic + // "exceeded timeout" message. + const INIT_TIMEOUT_MS = 50000; + const FAST_TIMEOUT_MS = 15000; + const JEST_TIMEOUT_MS = 60000; + + it( + "should create a working repo with --sub flag", + async () => { + await fs.writeFile(path.join(tmpDir, "test.txt"), "hello"); + + execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { + stdio: "pipe", + timeout: INIT_TIMEOUT_MS, + }); + + const snapshotPath = path.join(tmpDir, ".pushwork", "snapshot.json"); + const stat = await fs.stat(snapshotPath); + expect(stat.isFile()).toBe(true); + }, + JEST_TIMEOUT_MS + ); + + it( + "should produce a valid automerge URL", + async () => { + await fs.writeFile(path.join(tmpDir, "test.txt"), "hello"); + + execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { + stdio: "pipe", + timeout: INIT_TIMEOUT_MS, + }); + + const url = execSync(`node "${cliPath}" url "${tmpDir}"`, { + encoding: "utf8", + timeout: FAST_TIMEOUT_MS, + }).trim(); + + expect(url).toMatch(/^automerge:/); + }, + JEST_TIMEOUT_MS + ); + + it( + "should track files in the snapshot", + async () => { + await fs.writeFile(path.join(tmpDir, "a.txt"), "aaa"); + await fs.mkdir(path.join(tmpDir, "sub"), { recursive: true }); + await fs.writeFile(path.join(tmpDir, "sub", "b.txt"), "bbb"); + + execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { + stdio: "pipe", + timeout: INIT_TIMEOUT_MS, + }); + + const ls = execSync(`node "${cliPath}" ls "${tmpDir}"`, { + encoding: "utf8", + timeout: FAST_TIMEOUT_MS, + }); + + expect(ls).toContain("a.txt"); + expect(ls).toContain("b.txt"); + }, + JEST_TIMEOUT_MS + ); + + it( + "should be able to sync after init", + async () => { + await fs.writeFile(path.join(tmpDir, "initial.txt"), "first"); + + execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { + stdio: "pipe", + timeout: INIT_TIMEOUT_MS, + }); + + // Add a new file + await fs.writeFile(path.join(tmpDir, "added.txt"), "second"); + + // Sync should not throw. The `sync` command has no --sub flag — it + // reads the backend choice from .pushwork/config.json (persisted by + // the init --sub above). + execSync(`node "${cliPath}" sync "${tmpDir}"`, { + stdio: "pipe", + timeout: INIT_TIMEOUT_MS, + }); + + const ls = execSync(`node "${cliPath}" ls "${tmpDir}"`, { + encoding: "utf8", + timeout: FAST_TIMEOUT_MS, + }); + + expect(ls).toContain("initial.txt"); + expect(ls).toContain("added.txt"); + }, + JEST_TIMEOUT_MS + ); });