From 6151d12971ff9a24c66159ea14fea3a31ad57560 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:41:00 -0400 Subject: [PATCH 01/27] chore(repo): add the verify-clerk-expo skill's package and repo wiring The skill is outside the pnpm workspace and installs its pinned e2e with npm from its own lockfile. .cursor/skills/verify-clerk-expo is a symlink to the skill, and .prettierignore keeps the pre-commit hook off the files that are copied from other repositories. Co-Authored-By: Claude Opus 5.5 --- .changeset/expo-verify-skill.md | 2 ++ .claude/skills/verify-clerk-expo/.gitignore | 3 +++ .claude/skills/verify-clerk-expo/package.json | 18 ++++++++++++++++++ .claude/skills/verify-clerk-expo/tsconfig.json | 16 ++++++++++++++++ .cursor/skills/verify-clerk-expo | 1 + .prettierignore | 10 ++++++++++ 6 files changed, 50 insertions(+) create mode 100644 .changeset/expo-verify-skill.md create mode 100644 .claude/skills/verify-clerk-expo/.gitignore create mode 100644 .claude/skills/verify-clerk-expo/package.json create mode 100644 .claude/skills/verify-clerk-expo/tsconfig.json create mode 120000 .cursor/skills/verify-clerk-expo diff --git a/.changeset/expo-verify-skill.md b/.changeset/expo-verify-skill.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/expo-verify-skill.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/verify-clerk-expo/.gitignore b/.claude/skills/verify-clerk-expo/.gitignore new file mode 100644 index 00000000000..603ba938f6c --- /dev/null +++ b/.claude/skills/verify-clerk-expo/.gitignore @@ -0,0 +1,3 @@ +.e2e/ +.verify/ +specs/explored/ diff --git a/.claude/skills/verify-clerk-expo/package.json b/.claude/skills/verify-clerk-expo/package.json new file mode 100644 index 00000000000..d2e716f2005 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/package.json @@ -0,0 +1,18 @@ +{ + "name": "verify-clerk-expo", + "private": true, + "type": "module", + "scripts": { + "test": "node --test test/*.test.ts", + "typecheck": "tsc -p ." + }, + "devDependencies": { + "@e2e-dev/mobile": "0.9.0", + "@types/node": "24.19.1", + "e2e": "0.15.2", + "typescript": "7.0.2" + }, + "engines": { + "node": "24.x" + } +} diff --git a/.claude/skills/verify-clerk-expo/tsconfig.json b/.claude/skills/verify-clerk-expo/tsconfig.json new file mode 100644 index 00000000000..a4cd3716872 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2024", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "specs/**/*.ts", "test/**/*.ts", "testing/**/*.ts", "e2e.config.ts"] +} diff --git a/.cursor/skills/verify-clerk-expo b/.cursor/skills/verify-clerk-expo new file mode 120000 index 00000000000..bb470267b68 --- /dev/null +++ b/.cursor/skills/verify-clerk-expo @@ -0,0 +1 @@ +../../.claude/skills/verify-clerk-expo \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index da490cc898c..d6bee45bddf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -26,3 +26,13 @@ renovate.json5 # Frozen snapshots of TypeDoc-generated MDX; must match raw `extract-methods.mjs` output. .typedoc/__tests__/__snapshots__/ CLAUDE.md +# Copied byte for byte from another repo; do not reformat. +.claude/skills/verify-clerk-expo/src/core/ +.claude/skills/verify-clerk-expo/src/platform/ +.claude/skills/verify-clerk-expo/specs/fixtures.ts +.claude/skills/verify-clerk-expo/testing/ +.claude/skills/verify-clerk-expo/e2e.config.ts +.claude/skills/verify-clerk-expo/test/*.ts +!.claude/skills/verify-clerk-expo/test/freshness.test.ts +!.claude/skills/verify-clerk-expo/test/host.test.ts +.claude/skills/verify-clerk-expo/.verify/ From 00e4dafa6ec37af6bc3d7969445d0b181999f565 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:41:00 -0400 Subject: [PATCH 02/27] chore(repo): add the verify-clerk-expo skill's lockfile Co-Authored-By: Claude Opus 5.5 --- .../verify-clerk-expo/package-lock.json | 2528 +++++++++++++++++ 1 file changed, 2528 insertions(+) create mode 100644 .claude/skills/verify-clerk-expo/package-lock.json diff --git a/.claude/skills/verify-clerk-expo/package-lock.json b/.claude/skills/verify-clerk-expo/package-lock.json new file mode 100644 index 00000000000..1d806819b2b --- /dev/null +++ b/.claude/skills/verify-clerk-expo/package-lock.json @@ -0,0 +1,2528 @@ +{ + "name": "verify-clerk-expo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "verify-clerk-expo", + "devDependencies": { + "@e2e-dev/mobile": "0.9.0", + "@types/node": "24.19.1", + "e2e": "0.15.2", + "typescript": "7.0.2" + }, + "engines": { + "node": "24.x" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.18.tgz", + "integrity": "sha512-+GZJIgz1jk86pwEbb3f1BD2bdoSKyWE4Jg4YUc7NMnMozbWemSKYZcw2F4nMaO5qwsL5A8RmioAKW81YktRpIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@clack/core": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.5.1.tgz", + "integrity": "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.8.1.tgz", + "integrity": "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.5.1", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@e2e-dev/mobile": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@e2e-dev/mobile/-/mobile-0.9.0.tgz", + "integrity": "sha512-knd+oMEUcERzgUktyXyg04HrkvZTf9d6rVrZ/EiT0Y5RD8g+jyT94C0azA6WP57Blb3Jpn9KU88HySzwO0FKDw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "agent-device": "0.21.18", + "pngjs": "7.0.0", + "zod": "4.6.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "ai": "^7.0.0", + "e2e": ">=0.15.0 <1" + }, + "peerDependenciesMeta": { + "ai": { + "optional": true + } + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz", + "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.1.tgz", + "integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/expect": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-5.0.2.tgz", + "integrity": "sha512-pd6YDkhOHptwC65NYNpGpNxWlJH9M9Gxwtej73osTddb1NpiE0Jz5CIjTTEGXRgK0YtiSN7qBokmBXZ5uPJL1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "5.0.2", + "@vitest/utils": "5.0.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-5.0.2.tgz", + "integrity": "sha512-YHM+mQQ7N1ROHMugJ7P/M+fC/q1G4zs/iAMZZCvhHcY8WkwPQcXBHD+z18oz+808Cfa12K7jOr3XPQMcLIovjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.2.tgz", + "integrity": "sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-5.0.2.tgz", + "integrity": "sha512-mgpUtxFhKeOKVaArBVHNJGdYHAeRh135o59l13uVmPUCS1OUtis5pBqXEgE25iqRgFqEBwXvJlkhknRsYnMeqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "5.0.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-device": { + "version": "0.21.18", + "resolved": "https://registry.npmjs.org/agent-device/-/agent-device-0.21.18.tgz", + "integrity": "sha512-ptNJ7a4jkAXFLSmZqKJDwxL+YpCEUuZv3oCBY76PwE3b2W+yQqQuG0ej0/bOspIHJRmMGNOkWhtxm3gmRW9hRQ==", + "dev": true, + "license": "MIT", + "bin": { + "agent-device": "bin/agent-device.mjs" + }, + "engines": { + "node": ">=22.12" + }, + "peerDependencies": { + "ai": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "ai": { + "optional": true + } + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "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/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/e2e": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/e2e/-/e2e-0.15.2.tgz", + "integrity": "sha512-QBbrovhKlr++kc4J2Pt+2oJ4tEl36MgIDbaZmuMaE+mjTokfIxnSWJHAgFY6ox8rfuOkvCGYXBSFIKXyiqv7Cw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.18", + "@clack/prompts": "1.8.1", + "@modelcontextprotocol/sdk": "1.30.1", + "@vitest/expect": "5.0.2", + "commander": "15.0.0", + "picocolors": "1.1.1", + "pngjs": "7.0.0", + "tsx": "4.23.14", + "zod": "4.6.1" + }, + "bin": { + "e2e": "dist/cli/bin.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "@ai-sdk/openai": "^4.0.0", + "@ai-sdk/openai-compatible": "^3.0.0", + "@ai-sdk/xai": "^5.0.0", + "ai": "^7.0.0" + }, + "peerDependenciesMeta": { + "@ai-sdk/openai": { + "optional": true + }, + "@ai-sdk/openai-compatible": { + "optional": true + }, + "@ai-sdk/xai": { + "optional": true + }, + "ai": { + "optional": true + } + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "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/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "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/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz", + "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "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/sponsors/panva" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "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/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "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/sponsors/ljharb" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.14", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.14.tgz", + "integrity": "sha512-yFwMnbAsUFz/T3kR8P2ENc/DDUaYd9tKg4oVYo0lhkX0LlK4UuqYAO6mpQ2y6lmcyip1uPJ34C2kPACopDwG4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.1.tgz", + "integrity": "sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "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/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} From 80f0652ee39c6f5b4663b78382b04310f9207a5f Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:41:01 -0400 Subject: [PATCH 03/27] test(expo): add the verify code shared with clerk-ios and clerk-android src/core, src/platform/ios, specs/fixtures.ts, testing, e2e.config.ts, and these tests are byte copies of clerk/clerk-ios. src/platform/android and test/android.test.ts are byte copies of clerk/clerk-android. src/core/MANIFEST pins the core. Co-Authored-By: Claude Opus 5.5 --- .../verify-clerk-expo/bin/control-clerk-expo | 5 + .../skills/verify-clerk-expo/e2e.config.ts | 3 + .../verify-clerk-expo/specs/fixtures.ts | 175 ++ .../verify-clerk-expo/src/core/MANIFEST | 27 + .../src/core/agent-device.ts | 6 + .../verify-clerk-expo/src/core/broker.ts | 143 ++ .../verify-clerk-expo/src/core/claims.ts | 63 + .../verify-clerk-expo/src/core/clerk.ts | 149 ++ .../skills/verify-clerk-expo/src/core/cli.ts | 351 ++++ .../src/core/device-command.ts | 14 + .../verify-clerk-expo/src/core/devices.ts | 176 ++ .../verify-clerk-expo/src/core/e2e-config.ts | 69 + .../skills/verify-clerk-expo/src/core/e2e.ts | 322 ++++ .../verify-clerk-expo/src/core/evidence.ts | 69 + .../skills/verify-clerk-expo/src/core/exec.ts | 78 + .../src/core/instances/base.json | 454 +++++ .../src/core/instances/definitions.ts | 46 + .../src/core/instances/instances.ts | 283 +++ .../src/core/instances/platform.ts | 376 ++++ .../src/core/instances/settings.ts | 409 +++++ .../src/core/instances/throwaway.ts | 547 ++++++ .../skills/verify-clerk-expo/src/core/keys.ts | 13 + .../verify-clerk-expo/src/core/ledgers.ts | 81 + .../verify-clerk-expo/src/core/manifest.ts | 40 + .../verify-clerk-expo/src/core/publish.ts | 73 + .../verify-clerk-expo/src/core/secret.ts | 37 + .../skills/verify-clerk-expo/src/core/slot.ts | 73 + .../verify-clerk-expo/src/core/state.ts | 203 +++ .../verify-clerk-expo/src/core/types.ts | 500 ++++++ .../verify-clerk-expo/src/core/verbs.ts | 758 ++++++++ .../verify-clerk-expo/src/core/workspace.ts | 225 +++ .../src/platform/android/emulator.ts | 29 + .../src/platform/android/local.ts | 427 +++++ .../src/platform/android/sdk.ts | 167 ++ .../src/platform/ios/local.ts | 285 +++ .../src/platform/ios/simulator.ts | 15 + .../verify-clerk-expo/test/android.test.ts | 450 +++++ .../verify-clerk-expo/test/app-start.test.ts | 64 + .../verify-clerk-expo/test/broker.test.ts | 39 + .../verify-clerk-expo/test/claims.test.ts | 47 + .../verify-clerk-expo/test/clerk.test.ts | 34 + .../skills/verify-clerk-expo/test/cli.test.ts | 213 +++ .../test/device-command.test.ts | 23 + .../verify-clerk-expo/test/down.test.ts | 160 ++ .../skills/verify-clerk-expo/test/e2e.test.ts | 144 ++ .../verify-clerk-expo/test/end-run.test.ts | 32 + .../verify-clerk-expo/test/evidence.test.ts | 192 ++ .../verify-clerk-expo/test/fixtures.test.ts | 19 + .../verify-clerk-expo/test/instances.test.ts | 1589 +++++++++++++++++ .../verify-clerk-expo/test/lease-flow.test.ts | 198 ++ .../verify-clerk-expo/test/ledgers.test.ts | 114 ++ .../verify-clerk-expo/test/lock.test.ts | 37 + .../verify-clerk-expo/test/processes.test.ts | 77 + .../verify-clerk-expo/test/run-groups.test.ts | 360 ++++ .../verify-clerk-expo/test/settings.test.ts | 325 ++++ .../test/something-ran.test.ts | 51 + .../verify-clerk-expo/test/state.test.ts | 60 + .../verify-clerk-expo/testing/claim-taker.ts | 5 + .../verify-clerk-expo/testing/fake-clerk.ts | 233 +++ .../testing/fake-instances.ts | 25 + .../verify-clerk-expo/testing/lock-holder.ts | 12 + 61 files changed, 11194 insertions(+) create mode 100755 .claude/skills/verify-clerk-expo/bin/control-clerk-expo create mode 100644 .claude/skills/verify-clerk-expo/e2e.config.ts create mode 100644 .claude/skills/verify-clerk-expo/specs/fixtures.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/MANIFEST create mode 100644 .claude/skills/verify-clerk-expo/src/core/agent-device.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/broker.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/claims.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/clerk.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/cli.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/device-command.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/devices.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/e2e-config.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/e2e.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/evidence.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/exec.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/instances/base.json create mode 100644 .claude/skills/verify-clerk-expo/src/core/instances/definitions.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/instances/instances.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/instances/platform.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/instances/settings.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/instances/throwaway.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/keys.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/ledgers.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/manifest.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/publish.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/secret.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/slot.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/state.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/types.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/verbs.ts create mode 100644 .claude/skills/verify-clerk-expo/src/core/workspace.ts create mode 100644 .claude/skills/verify-clerk-expo/src/platform/android/emulator.ts create mode 100644 .claude/skills/verify-clerk-expo/src/platform/android/local.ts create mode 100644 .claude/skills/verify-clerk-expo/src/platform/android/sdk.ts create mode 100644 .claude/skills/verify-clerk-expo/src/platform/ios/local.ts create mode 100644 .claude/skills/verify-clerk-expo/src/platform/ios/simulator.ts create mode 100644 .claude/skills/verify-clerk-expo/test/android.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/app-start.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/broker.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/claims.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/clerk.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/cli.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/device-command.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/down.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/e2e.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/end-run.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/evidence.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/fixtures.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/instances.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/lease-flow.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/ledgers.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/lock.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/processes.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/run-groups.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/settings.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/something-ran.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/state.test.ts create mode 100644 .claude/skills/verify-clerk-expo/testing/claim-taker.ts create mode 100644 .claude/skills/verify-clerk-expo/testing/fake-clerk.ts create mode 100644 .claude/skills/verify-clerk-expo/testing/fake-instances.ts create mode 100644 .claude/skills/verify-clerk-expo/testing/lock-holder.ts diff --git a/.claude/skills/verify-clerk-expo/bin/control-clerk-expo b/.claude/skills/verify-clerk-expo/bin/control-clerk-expo new file mode 100755 index 00000000000..c9b246cb7a1 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/bin/control-clerk-expo @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import { main } from '../src/core/cli.ts'; +import { host } from '../src/host.ts'; + +process.exitCode = await main(process.argv.slice(2), host); diff --git a/.claude/skills/verify-clerk-expo/e2e.config.ts b/.claude/skills/verify-clerk-expo/e2e.config.ts new file mode 100644 index 00000000000..0ee149d2d4a --- /dev/null +++ b/.claude/skills/verify-clerk-expo/e2e.config.ts @@ -0,0 +1,3 @@ +import { composeE2EConfig, loadRunContext, withJudge } from './src/core/e2e-config.ts'; + +export default await withJudge(composeE2EConfig(loadRunContext())); diff --git a/.claude/skills/verify-clerk-expo/specs/fixtures.ts b/.claude/skills/verify-clerk-expo/specs/fixtures.ts new file mode 100644 index 00000000000..6de28fc577b --- /dev/null +++ b/.claude/skills/verify-clerk-expo/specs/fixtures.ts @@ -0,0 +1,175 @@ +import { execFile } from 'node:child_process'; +import { appendFileSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { test as base } from '@e2e-dev/mobile'; +import { expect } from 'e2e'; +import { agentDeviceFor } from '../src/core/agent-device.ts'; +import { deviceCommand } from '../src/core/device-command.ts'; +import { ASSERTION_TIMEOUT_MS, loadRunContext } from '../src/core/e2e-config.ts'; +import { appStart, describeState, parseVerifyState, performAppStart } from '../src/core/state.ts'; +import { agentDeviceStateDir } from '../src/core/workspace.ts'; +import type { host as hostAdapter } from '../src/host.ts'; +import { + CLERK_TEST_CODE, + STATE_ELEMENT_ID, + VerifyFailure, + type BrokerLaunchRequest, + type BrokerLaunchResponse, + type ErrorCode, + type HostFixture, + type InstanceSettings, + type Lease, + type RunContext, + type RunTarget, + type SeededUser, + type StorageScope, + type TestEmail, + type VerifyState, +} from '../src/core/types.ts'; + +type HostScreen = (typeof hostAdapter)['screens'][number]; + +export { expect, CLERK_TEST_CODE }; +export type { InstanceSettings }; + +const LAUNCH_TIMEOUT_MS = 60_000; +const POLL_MS = 400; +const ONLY_E2E_WORKER_SLOT = 0; + +const e2eWorkerSession = (context: RunContext): string => `${context.agentDeviceSession}-${ONLY_E2E_WORKER_SLOT}`; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function adb(target: RunTarget, args: readonly string[]): Promise { + const result = await deviceCommand(JSON.parse(readFileSync(target.leaseFile, 'utf8')) as Lease, args); + if (result.code !== 0) throw new Error(`adb ${args[0]} failed: ${result.stderr.trim() || result.stdout.trim() || `exit ${result.code}`}`); +} + +function typeIntoFocused(context: RunContext, target: RunTarget, text: string): Promise { + const device = agentDeviceFor(JSON.parse(readFileSync(target.leaseFile, 'utf8')) as Lease); + return new Promise((resolve, reject) => { + const bin = join(dirname(context.workspace), 'node_modules', '.bin', 'agent-device'); + const env = { ...process.env, AGENT_DEVICE_STATE_DIR: agentDeviceStateDir(context.workspace) }; + execFile(bin, ['type', text, '--session', e2eWorkerSession(context), ...device.selector], { env }, (error, _stdout, stderr) => { + if (error === null) resolve(); + else reject(new Error(`agent-device type failed: ${stderr.trim() || error.message}`)); + }); + }); +} + +export const test = base.extend<{ host: HostFixture }>({ + host: async ({ app, device, screen, platform }, use) => { + const context = loadRunContext(); + const target = context.targets.find((t) => t.platform === platform); + if (target === undefined) throw new VerifyFailure('NOT_READY', `the run context has no ${platform} target`, '{cli} up'); + const statesFile = context.run === null ? null : join(context.workspace, 'runs', context.run, 'states.jsonl'); + let lastText: string | null = null; + let lastScope: StorageScope | null = null; + + async function call(path: string, body: unknown): Promise { + if (context.broker === null) { + throw new VerifyFailure('NOT_READY', 'host needs the broker that `{cli} run` starts', 'run this spec with `{cli} run `'); + } + const token = readFileSync(context.broker.tokenFile, 'utf8'); + const response = await fetch(`${context.broker.url}${path}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const json = (await response.json()) as T & { code?: ErrorCode; message?: string; fix?: string }; + if (!response.ok) { + throw new VerifyFailure(json.code ?? 'NOT_READY', `${json.code}: ${json.message} (fix: ${json.fix})`, json.fix ?? 'see e2e.log'); + } + return json; + } + + async function read(): Promise { + let text: string | null; + try { + text = await screen.getByTestId(STATE_ELEMENT_ID).textContent(); + } catch { + return null; + } + if (text === null) return null; + const state = parseVerifyState(text); + if (statesFile !== null && text !== lastText) appendFileSync(statesFile, `${JSON.stringify(state)}\n`); + lastText = text; + return state; + } + + async function tapCenter(target: Parameters['tap']>[0]): Promise { + await target.waitFor({ state: 'visible', timeout: ASSERTION_TIMEOUT_MS }); + const box = await target.boundingBox(); + if (box === null) throw new Error('tap target has no box on screen'); + await target.tap({ position: { x: box.width / 2, y: box.height / 2 } }); + } + + async function dismissSavePasswordPrompt(): Promise { + if ((await screen.getByText('Save Password?').count()) === 0) return; + await tapCenter(screen.getByRole('button', { name: 'Not Now' })); + } + + async function poll(predicate: (state: VerifyState) => boolean, timeoutMs: number, waitingFor: string): Promise { + const deadline = Date.now() + timeoutMs; + let last: VerifyState | null = null; + for (;;) { + const seen = await read(); + if (seen === null) await dismissSavePasswordPrompt().catch(() => undefined); + last = seen ?? last; + if (last !== null && predicate(last)) return last; + if (Date.now() >= deadline) { + throw new Error(`verify.state did not reach ${waitingFor} within ${timeoutMs}ms; last state: ${last === null ? 'none' : describeState(last)}`); + } + await sleep(POLL_MS); + } + } + + const host: HostFixture = { + async newEmail() { + return (await call<{ email: TestEmail }>('/reserveEmail', {})).email; + }, + async seedUser(options = {}) { + return call('/seedUser', { phone: options.phone === true }); + }, + async launch(options) { + const user = options.signedInAs ?? null; + const request: BrokerLaunchRequest = { + platform: target.platform, + user, + screen: options.screen ?? null, + authMode: options.authMode ?? null, + debugLogs: options.debugLogs === true, + storageScope: options.keepStorage === true ? lastScope : null, + }; + const launch = await call('/launch', request); + lastScope = launch.storageScope; + const start = appStart(target.platform, target.appId, target.entry, launch.launchArguments); + await performAppStart(start, target.appId, { + openApp: (appId, options) => (options === undefined ? device.openApp(appId) : device.openApp(appId, { relaunch: true, launchArguments: [...options.launchArguments] })), + adb: (args) => adb(target, args), + }); + const ready = (s: VerifyState) => + s.launchId === launch.launchId && + (s.lastError !== null || (s.environmentLoaded && (user === null || s.ticket === 'succeeded' || s.ticket === 'failed'))); + return poll(ready, LAUNCH_TIMEOUT_MS, `launch ${launch.launchId} ready`); + }, + async state() { + const state = await read(); + if (state === null) throw new Error(`no ${STATE_ELEMENT_ID} element on screen`); + return state; + }, + waitForState(predicate, timeoutMs = ASSERTION_TIMEOUT_MS) { + return poll(predicate, timeoutMs, 'the expected state'); + }, + async screenshot(label) { + await app.screenshot(label); + }, + tap: tapCenter, + async fill(field, text) { + await host.tap(field); + await typeIntoFocused(context, target, text); + }, + }; + await use(host); + }, +}); diff --git a/.claude/skills/verify-clerk-expo/src/core/MANIFEST b/.claude/skills/verify-clerk-expo/src/core/MANIFEST new file mode 100644 index 00000000000..bccde49081f --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/MANIFEST @@ -0,0 +1,27 @@ +a3143414b4a2c3409297178ee5c621d0feab1b62082cbe8f4f4ac5993480e696 agent-device.ts +da006eeb8811579c827341d9a153a8823da8a76a0bd7d2017c215b6ee7f3215a broker.ts +7c741449c755524bba80f1ce314554293003a34f7f7c30696015ba1c0c3dc1ab claims.ts +09822ccb46010bdfe322d8c343801619a14c70117f6f091a446a6b1ba7385614 clerk.ts +6e3fae37a1da3e2a9c4d84986abe5b271e9ed1244796edee090437008a0c54cf cli.ts +01c9b0ed75f505c8362eb34f4bc0131b5280688f4f3b221e6fe5176c050a87cb device-command.ts +ca141cced6e7b55d723a67e4a18ac7ad83d3ad57f1378f2803621ef15e7f0589 devices.ts +6803809800dac105e456c130ddcf870e0e7ecc793e5b55465f840f9a65a984bb e2e-config.ts +094960c5cc53e25cfe446ff88a439bf699b062e7cad2a7f9a7181b1fa24873aa e2e.ts +2fb4cb08795614760eae23a9ad2ce91e7a94a9dcceb764e1caa5e979d6ca4fc6 evidence.ts +5a8d69e1203041fd68d9a2e967de3af7c162ced2d151c2d92b3f8e5512825945 exec.ts +a783d02979eed9282dd460579dd513a67b5979033a3dbaabc4f537fd7586c3c5 instances/base.json +07001c548ca0d9aa0b87d265ebc925222eb56be8181c59bac1a8f638e312c293 instances/definitions.ts +5ea58dbab63668ea6d7dd761d7c5a0517528a2d57e4a9a81ab172e475daad62c instances/instances.ts +3ae5ea12db1da74307901c824fac45fbac83f65f332df30685421645303bd49a instances/platform.ts +7124897d7bf95d1d85d077e26779915c15bb80738055ca07b84254f3b6e4df66 instances/settings.ts +04aeff02b759bc66cc76e510d849ea2c0c3eae8b523c94f350d0377ed82d80e1 instances/throwaway.ts +ab9bc7afb6481c6cd1bd48fdf3951bfbc411d6a29489d31608c185660544c4c5 keys.ts +2591c6f115a19e962c056d51a8234417fe5700c8987c9765a12d74ffe522c0a0 ledgers.ts +5f704d353c572992318cd11b1efda300d663b0bec6f5f6dd00e49bef2bfc9992 manifest.ts +675feed489cfa088bb1b4034c86c22d616410485eaa3099677f35f28dad9111b publish.ts +af6dac468fecd6b2320d68691b8db8ca944266c12e0447b058a4d3d43b12bb77 secret.ts +685eabf010c7f63d076cfd3da1a681b5cc8024720538c1bf3a99792b563d8b60 slot.ts +ec38ba1b0dacf4f94c7c64dcc80a89b0e7a1de3197fb5e6677a60df02946e753 state.ts +0c7e3fd5ff7d3771fc4bf8a0d019e31a357dc545478585c29d5f2a5029a14bca types.ts +cd3451b16187abc0b8a0e764d4ffa8a573aa9c3c8e4f351874a3ba1eba6de1e9 verbs.ts +c986ee2c9731745df0878e1184dd4fb939c46c2a08ae278e8e745e5a0da4cad6 workspace.ts diff --git a/.claude/skills/verify-clerk-expo/src/core/agent-device.ts b/.claude/skills/verify-clerk-expo/src/core/agent-device.ts new file mode 100644 index 00000000000..56f2046cb16 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/agent-device.ts @@ -0,0 +1,6 @@ +import type { Lease } from './types.ts'; + +export function agentDeviceFor(lease: Lease): { readonly selector: readonly string[] } { + const device = ['--platform', lease.platform, lease.platform === 'ios' ? '--udid' : '--serial', lease.deviceId]; + return { selector: device }; +} diff --git a/.claude/skills/verify-clerk-expo/src/core/broker.ts b/.claude/skills/verify-clerk-expo/src/core/broker.ts new file mode 100644 index 00000000000..c52ed992602 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/broker.ts @@ -0,0 +1,143 @@ +import { randomBytes, timingSafeEqual } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { join } from 'node:path'; +import { isConflict, newTestEmail, parseTestPhone, type ClerkBackend } from './clerk.ts'; +import { encodeLaunchArguments } from './state.ts'; +import { newEntryId, type Workspace } from './workspace.ts'; +import { + AUTH_MODES, + VerifyFailure, + type BrokerLaunchRequest, + type BrokerLaunchResponse, + type LaunchId, + type Platform, + type PublishableKey, + type RunId, + type ScratchPath, + type SeededUser, + type StorageScope, + type TestEmail, + type TestPhone, +} from './types.ts'; + +const TICKET_SECONDS = 120; + +export interface BrokerDeps { + readonly clerk: () => ClerkBackend; + readonly publishableKey: () => PublishableKey; + readonly screens: readonly string[]; + readonly platforms: readonly Platform[]; +} + +export interface Broker { + readonly url: string; + readonly tokenFile: string; + stop(): Promise; +} + +const randomId = (bytes: number) => randomBytes(bytes).toString('hex'); + +export async function startBroker(run: RunId, workspace: Workspace, scratch: ScratchPath, deps: BrokerDeps): Promise { + const token = randomId(32); + const tokenFile = join(scratch, 'broker-token'); + writeFileSync(tokenFile, token, { mode: 0o600 }); + const users = new Map(); + let emails = workspace.entries().filter((e) => e.kind === 'identity' && e.run === run).length; + + function reserve(): TestEmail { + emails += 1; + const email = newTestEmail(run, emails); + workspace.append({ id: newEntryId(), kind: 'identity', run, email }); + return email; + } + + async function seedUser(wantsPhone: boolean): Promise { + const clerk = deps.clerk(); + const email = reserve(); + const first = Math.floor(Math.random() * 100); + for (let i = 0; i < (wantsPhone ? 100 : 1); i += 1) { + const phone: TestPhone | null = wantsPhone ? parseTestPhone(`+1201555${String(100 + ((first + i) % 100)).padStart(4, '0')}`) : null; + try { + const user = await clerk.createUser(email, phone); + workspace.append({ id: newEntryId(), kind: 'user', run, userId: user.id, email }); + users.set(user.id, user); + return user; + } catch (error) { + if (!(wantsPhone && isConflict(error))) throw error; + } + } + throw new VerifyFailure('INSTANCE_MISCONFIGURED', 'every 555-0100..0199 test phone is taken on this instance', "{cli} down deletes this worktree's test instance with its users; then rerun, or seed without a phone"); + } + + async function launch(request: BrokerLaunchRequest): Promise { + if (!deps.platforms.includes(request.platform)) { + throw new VerifyFailure('USAGE', `this run drives ${deps.platforms.join(', ')}, not ${String(request.platform)}`, 'launch from a spec that this run selected'); + } + if (request.screen !== null && !deps.screens.includes(request.screen)) { + throw new VerifyFailure('USAGE', `unknown screen ${request.screen}`, `use one of ${deps.screens.join(', ')}`); + } + if (request.authMode !== null && !AUTH_MODES.includes(request.authMode)) { + throw new VerifyFailure('USAGE', `unknown auth mode ${request.authMode}`, `use one of ${AUTH_MODES.join(', ')}`); + } + let ticket; + if (request.user !== null) { + const user = users.get(request.user.id); + if (user === undefined) throw new VerifyFailure('NOT_TEST_IDENTITY', `user ${request.user.id} was not seeded by this run`, 'sign in only users from host.seedUser'); + ticket = await deps.clerk().mintTicket(user, TICKET_SECONDS); + } + const scope = request.storageScope ?? (randomId(8) as StorageScope); + const launchId = randomId(8) as LaunchId; + const launchArguments = encodeLaunchArguments(request.platform, { + verifyPublishableKey: deps.publishableKey(), + verifyRunId: run, + verifyStorageScope: scope, + verifyLaunchId: launchId, + ...(request.screen === null ? {} : { verifyScreen: request.screen }), + ...(request.authMode === null ? {} : { verifyAuthMode: request.authMode }), + ...(request.debugLogs ? { verifyLogLevel: 'debug' as const } : {}), + ...(ticket === undefined ? {} : { verifySignInTicket: ticket }), + }); + return { launchId, storageScope: scope, launchArguments }; + } + + const routes: Readonly) => Promise>> = { + '/seedUser': (body) => seedUser(body.phone === true), + '/reserveEmail': async () => ({ email: reserve() }), + '/launch': (body) => launch(body as unknown as BrokerLaunchRequest), + }; + + async function handle(request: IncomingMessage, response: ServerResponse): Promise { + const auth = Buffer.from(request.headers.authorization ?? ''); + const expected = Buffer.from(`Bearer ${token}`); + if (auth.length !== expected.length || !timingSafeEqual(auth, expected)) { + response.writeHead(401).end(); + return; + } + const route = request.method === 'POST' ? routes[request.url ?? ''] : undefined; + if (route === undefined) { + response.writeHead(404).end(); + return; + } + let text = ''; + for await (const chunk of request) text += chunk; + try { + const body = text.length > 0 ? (JSON.parse(text) as Record) : {}; + const result = await route(body); + response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(result)); + } catch (error) { + const failure = error instanceof VerifyFailure ? error : new VerifyFailure('NOT_READY', (error as Error).message, 'see e2e.log'); + response.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ code: failure.code, message: failure.message, fix: failure.fix })); + } + } + + const server = createServer((request, response) => void handle(request, response)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('broker did not bind a TCP port'); + return { + url: `http://127.0.0.1:${address.port}`, + tokenFile, + stop: () => new Promise((resolve) => server.close(() => resolve())), + }; +} diff --git a/.claude/skills/verify-clerk-expo/src/core/claims.ts b/.claude/skills/verify-clerk-expo/src/core/claims.ts new file mode 100644 index 00000000000..390bbb9b7b7 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/claims.ts @@ -0,0 +1,63 @@ +import { randomUUID } from 'node:crypto'; +import { existsSync, readdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { currentProcess, isRunning, type ProcessRef } from './exec.ts'; +import { compareAndSwapSlot, readSlot } from './slot.ts'; +import type { DeviceName, Platform } from './types.ts'; + +export interface Claim { + readonly platform: Platform; + readonly slot: number; + readonly gen: number; + readonly nonce: string; + readonly deviceName: DeviceName; + readonly worktree: string; + readonly owner: ProcessRef; + readonly reaping: boolean; + readonly createdAt: string; +} + +export const defaultClaimsDir = (): string => join(homedir(), '.verify', 'claims'); + +const slotDir = (dir: string, platform: Platform, slot: number) => join(dir, `${platform}-${slot}`); + +export function readClaim(dir: string, platform: Platform, slot: number): { readonly gen: number; readonly claim: Claim | null } { + const state = readSlot(slotDir(dir, platform, slot)); + return { gen: state.gen, claim: state.value === null ? null : { ...(JSON.parse(state.value) as Omit), gen: state.gen } }; +} + +export function readClaims(dir: string, platform: Platform): readonly Claim[] { + if (!existsSync(dir)) return []; + const slots = readdirSync(dir).flatMap((name) => { + const match = new RegExp(`^${platform}-(\\d+)$`).exec(name); + return match === null ? [] : [Number(match[1])]; + }); + return slots.flatMap((slot) => { + const { claim } = readClaim(dir, platform, slot); + return claim === null ? [] : [claim]; + }); +} + +export function takeSlot(dir: string, platform: Platform, slot: number, from: number, worktree: string, reaping = false): Claim | null { + const claim: Omit = { + platform, + slot, + nonce: randomUUID(), + deviceName: `verify-${platform}-${slot}`, + worktree, + owner: currentProcess(), + reaping, + createdAt: new Date().toISOString(), + }; + return compareAndSwapSlot(slotDir(dir, platform, slot), from, JSON.stringify(claim)) ? { ...claim, gen: from + 1 } : null; +} + +export function freeSlot(dir: string, claim: Claim): boolean { + return compareAndSwapSlot(slotDir(dir, claim.platform, claim.slot), claim.gen, null); +} + +export function isOrphaned(claim: Claim): boolean { + if (claim.reaping) return !isRunning(claim.owner); + return !existsSync(claim.worktree) && !isRunning(claim.owner); +} diff --git a/.claude/skills/verify-clerk-expo/src/core/clerk.ts b/.claude/skills/verify-clerk-expo/src/core/clerk.ts new file mode 100644 index 00000000000..b6f585b4744 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/clerk.ts @@ -0,0 +1,149 @@ +import { Secret } from './secret.ts'; +import type { InstanceKeys } from './keys.ts'; +import { + VerifyFailure, + type PublishableKey, + type RunId, + type SeededUser, + type TestEmail, + type TestPhone, +} from './types.ts'; + +export function frontendApiHost(pk: PublishableKey): string { + const decoded = Buffer.from(pk.replace(/^pk_(test|live)_/, ''), 'base64url').toString('utf8'); + return decoded.replace(/\$$/, ''); +} + +function notTestIdentity(value: string, kind: string): VerifyFailure { + return new VerifyFailure('NOT_TEST_IDENTITY', `${value} is not a ${kind}`, 'use an address with +clerk_test or a 555-0100..0199 phone that this run created'); +} + +export function newTestEmail(run: RunId, n: number): TestEmail { + const runPart = run.toLowerCase().replace(/[^a-z0-9]/g, '_'); + return parseTestEmail(`verify_${runPart}_${n}+clerk_test@example.com`); +} + +const TEST_EMAIL = /^[a-z0-9._-]+\+clerk_test@[a-z0-9.-]+\.[a-z]+$/; +export function parseTestEmail(value: string): TestEmail { + if (!TEST_EMAIL.test(value)) throw notTestIdentity(value, '+clerk_test email'); + return value as TestEmail; +} + +export function parseTestPhone(value: string): TestPhone { + const digits = value.replace(/[^0-9]/g, ''); + const national = digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits; + if (!/^[2-9]\d{2}55501\d{2}$/.test(national)) throw notTestIdentity(value, '555-0100..0199 test phone'); + return `+1${national}` as TestPhone; +} + +export interface ClerkBackend { + createUser(email: TestEmail, phone: TestPhone | null): Promise; + mintTicket(user: SeededUser, expiresInSeconds: number): Promise>; + findUserId(email: TestEmail): Promise; + userCount(): Promise; + apiHost(): Promise; +} + +export const DEVELOPMENT_USER_LIMIT = 100; +export const REPLACE_AT_USERS = 60; + +export const BACKEND_API_HOSTS = ['api.clerk.com', 'api.clerk.dev'] as const; +export type BackendApiHost = (typeof BACKEND_API_HOSTS)[number]; + +export const BACKEND_API_FIX = + 'in a cloud environment, set Path prefixes on the API credential for api.clerk.com to /v1/platform/ so it is attached to Platform API calls only, and add api.clerk.dev to the allowed domains'; + +class ClerkHttpError extends Error { + readonly status: number; + readonly codes: readonly string[]; + constructor(status: number, codes: readonly string[], path: string) { + super(`Clerk ${path} answered ${status}${codes.length ? ` (${codes.join(', ')})` : ''}`); + this.status = status; + this.codes = codes; + } +} + +export function createClerkBackends(fetchImpl: typeof fetch = fetch, onFallback: (line: string) => void = () => undefined): (keys: () => InstanceKeys) => ClerkBackend { + let host: BackendApiHost = BACKEND_API_HOSTS[0]; + + async function request(keys: InstanceKeys, method: string, path: string, body?: unknown): Promise { + const { sk } = keys; + const send = async (to: BackendApiHost): Promise<{ readonly status: number; readonly text: string }> => { + const response = await sk.use('bapi-authorization', (plain) => + fetchImpl(`https://${to}/v1${path}`, { + method, + headers: { Authorization: `Bearer ${plain}`, 'Content-Type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + ); + return { status: response.status, text: await response.text() }; + }; + let answer = await send(host); + if (answer.status === 401 && host === BACKEND_API_HOSTS[0]) { + const other = await send(BACKEND_API_HOSTS[1]).catch(() => null); + if (other !== null && other.status >= 200 && other.status < 300) { + host = BACKEND_API_HOSTS[1]; + onFallback(`clerk ${BACKEND_API_HOSTS[0]} answered 401 to the instance's own secret key and ${host} accepted it, so something replaces the Authorization header on ${BACKEND_API_HOSTS[0]}; using ${host} for the Backend API`); + answer = other; + } + } + let json: unknown = null; + try { + json = answer.text.length > 0 ? JSON.parse(answer.text) : null; + } catch { + json = null; + } + if (answer.status < 200 || answer.status >= 300) { + const errors = (json as { errors?: { code?: string }[] } | null)?.errors ?? []; + throw new ClerkHttpError(answer.status, errors.map((e) => e.code ?? 'unknown'), `${method} ${path.split('?')[0]}`); + } + return json; + } + + return (keys) => { + const bapi = (method: string, path: string, body?: unknown): Promise => request(keys(), method, path, body); + + async function usersByEmail(email: TestEmail): Promise { + const users = await bapi('GET', `/users?email_address=${encodeURIComponent(email)}`); + return Array.isArray(users) ? users.filter((u): u is { id: string } => typeof u?.id === 'string') : []; + } + + return { + async createUser(email, phone) { + const created = (await bapi('POST', '/users', { + email_address: [email], + ...(phone === null ? {} : { phone_number: [phone] }), + skip_password_requirement: true, + }).catch((error: unknown) => { + if (!(error instanceof ClerkHttpError && error.status === 403 && error.codes.includes('user_quota_exceeded'))) throw error; + throw new VerifyFailure('INSTANCE_MISCONFIGURED', `the instance holds the ${DEVELOPMENT_USER_LIMIT} users a development instance allows, and Clerk refused one more`, `the next \`{cli} run\` replaces the instance once it holds ${REPLACE_AT_USERS} users; rerun`); + })) as { id?: unknown }; + if (typeof created.id !== 'string') throw new Error('Clerk created a user without an id'); + return { id: created.id, email, phone }; + }, + async mintTicket(user, expiresInSeconds) { + const token = (await bapi('POST', '/sign_in_tokens', { user_id: user.id, expires_in_seconds: expiresInSeconds })) as { token?: unknown }; + if (typeof token.token !== 'string') throw new Error('Clerk returned a sign-in token without a token'); + return new Secret('ticket', token.token); + }, + async findUserId(email) { + return (await usersByEmail(email))[0]?.id ?? null; + }, + async userCount() { + const counted = (await bapi('GET', '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/users/count')) as { total_count?: unknown } | null; + if (typeof counted?.total_count !== 'number') throw new Error('Clerk counted the users of the instance without a total'); + return counted.total_count; + }, + async apiHost() { + await bapi('GET', '/users?limit=1'); + return host; + }, + }; + }; +} + +export const isUnauthorized = (error: unknown): boolean => error instanceof ClerkHttpError && error.status === 401; + +export function isConflict(error: unknown): boolean { + return error instanceof ClerkHttpError && error.status === 422; +} diff --git a/.claude/skills/verify-clerk-expo/src/core/cli.ts b/.claude/skills/verify-clerk-expo/src/core/cli.ts new file mode 100644 index 00000000000..0c8fec7f56a --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/cli.ts @@ -0,0 +1,351 @@ +import { execFileSync } from 'node:child_process'; +import { basename, dirname, isAbsolute, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { run as defaultRunner } from './exec.ts'; +import { PLATFORM_CREDENTIAL_VARIABLES } from './keys.ts'; +import { redact } from './secret.ts'; +import { leaseLine } from './devices.ts'; +import { count, describeState } from './state.ts'; +import { createInstances } from './instances/instances.ts'; +import { verbs, type Deps } from './verbs.ts'; +import { openWorkspace, parseRunId } from './workspace.ts'; +import { + FORM_ENTRY_TAG, + KNOWN_BUG_TAG, + CLI_PLACEHOLDER, + RETRYABLE, + VerifyFailure, + type Command, + type DoctorCheck, + type HostAdapter, + type Invocation, + type OptInTag, + type Platform, + type RunResult, + type Verb, + type VerbResult, +} from './types.ts'; + +const VERBS: readonly Verb[] = ['doctor', 'up', 'run', 'screen', 'attach', 'down']; + +const USAGE_FIX = [ + '{cli} doctor [--platform p] [--live]', + '{cli} up [--platform p] [--wait ]', + '{cli} run ... | --all [--platform p] [--skip form-entry] [--include known-bug] [--grep re] [--no-video] [--wait ]', + '{cli} screen [--platform p] [--png]', + '{cli} attach --pr [--screenshot label]...', + '{cli} down [--platform p] [--stale] [--dry-run]', + 'every verb takes --json', +].join('; '); + +const usage = (message: string) => new VerifyFailure('USAGE', message, USAGE_FIX); + +type FlagSpec = Readonly>; + +const FLAGS: Readonly> = { + doctor: { platform: 'value', live: 'bool' }, + up: { platform: 'value', wait: 'value' }, + run: { platform: 'value', all: 'bool', skip: 'list', include: 'list', grep: 'value', 'no-video': 'bool', wait: 'value' }, + screen: { platform: 'value', png: 'bool' }, + attach: { pr: 'value', screenshot: 'list' }, + down: { platform: 'value', stale: 'bool', 'dry-run': 'bool' }, +}; + +function platformFlag(value: string | undefined): Platform | undefined { + if (value === undefined) return undefined; + if (value === 'ios' || value === 'android') return value; + throw usage(`--platform must be ios or android, not ${value}`); +} + +function positiveInt(flag: string, value: string | undefined, fallback: number | undefined): number { + if (value === undefined) { + if (fallback === undefined) throw usage(`--${flag} is required`); + return fallback; + } + if (!/^\d+$/.test(value)) throw usage(`--${flag} must be a whole number, not ${value}`); + return Number(value); +} + +export function parseArgv(argv: readonly string[]): Invocation { + const [verbArg, ...rest] = argv; + const verb = VERBS.find((v) => v === verbArg); + if (verb === undefined) throw usage(verbArg === undefined ? 'no verb given' : `unknown verb ${verbArg}`); + const spec = FLAGS[verb]; + const values = new Map(); + const lists = new Map(); + const bools = new Set(); + const positionals: string[] = []; + let json = false; + for (let i = 0; i < rest.length; i += 1) { + const arg = rest[i]!; + if (!arg.startsWith('--')) { + positionals.push(arg); + continue; + } + const [name, inline] = arg.slice(2).split(/=(.*)/s, 2) as [string, string | undefined]; + if (name === 'json' && inline === undefined) { + json = true; + continue; + } + const kind = spec[name]; + if (kind === undefined) throw usage(`{cli} ${verb} does not take --${name}`); + if (kind === 'bool') { + if (inline !== undefined) throw usage(`--${name} takes no value`); + bools.add(name); + continue; + } + const value = inline ?? rest[(i += 1)]; + if (value === undefined || value.startsWith('--')) throw usage(`--${name} needs a value`); + if (kind === 'list') lists.set(name, [...(lists.get(name) ?? []), value]); + else { + if (values.has(name)) throw usage(`--${name} given twice`); + values.set(name, value); + } + } + const noPositionals = () => { + if (positionals.length > 0) throw usage(`{cli} ${verb} takes no arguments, got ${positionals.join(' ')}`); + }; + const platform = platformFlag(values.get('platform')); + const base = { ...(platform === undefined ? {} : { platform }) }; + + let command: Command; + switch (verb) { + case 'doctor': + noPositionals(); + command = { verb, ...base, live: bools.has('live') }; + break; + case 'up': + noPositionals(); + command = { verb, ...base, waitSeconds: positiveInt('wait', values.get('wait'), 0) }; + break; + case 'run': { + const all = bools.has('all'); + if (all && positionals.length > 0) throw usage('pass selectors or --all, not both'); + if (!all && positionals.length === 0) throw usage('{cli} run needs a feature, feature/spec, path.e2e.ts, or --all'); + const tags = (flag: string, allowed: OptInTag) => + (lists.get(flag) ?? []).map((tag): OptInTag => { + if (tag !== allowed) throw usage(`--${flag} takes ${allowed}, not ${tag}`); + return tag; + }); + const skip = tags('skip', FORM_ENTRY_TAG); + const include = tags('include', KNOWN_BUG_TAG); + const grep = values.get('grep'); + command = { + verb, + ...base, + selection: all ? { all: true } : { selectors: positionals }, + skip, + include, + ...(grep === undefined ? {} : { grep }), + video: !bools.has('no-video'), + waitSeconds: positiveInt('wait', values.get('wait'), 0), + }; + break; + } + case 'screen': + noPositionals(); + command = { verb, ...(platform === undefined ? {} : { platform }), png: bools.has('png') }; + break; + case 'attach': { + if (positionals.length !== 1) throw usage('{cli} attach takes exactly one run id'); + const shots = lists.get('screenshot'); + command = { verb, run: parseRunId(positionals[0]!), pr: positiveInt('pr', values.get('pr'), undefined), screenshots: shots ?? 'all' }; + break; + } + case 'down': + noPositionals(); + command = { verb, ...(platform === undefined ? {} : { platform }), stale: bools.has('stale'), dryRun: bools.has('dry-run') }; + break; + default: { + const exhaustive: never = verb; + throw usage(`unknown verb ${String(exhaustive)}`); + } + } + return { command, json }; +} + +export interface Output { + result(value: VerbResult): void; + failure(error: VerifyFailure): void; + progress(line: string): void; +} + +interface Sink { + write(text: string): unknown; +} + +const pad = (text: string, width: number) => text.padEnd(width); + +function rel(skillDir: string, path: string): string { + return relative(skillDir, path) || path; +} + +function renderRun(result: RunResult, skillDir: string): string[] { + const r = result.record; + const lines: string[] = []; + const width = Math.max(0, ...r.results.map((x) => x.spec.path.replace(/^specs\/(golden\/)?/, '').length)); + for (const x of r.results) { + const label = { passed: 'pass', failed: 'FAIL', skipped: 'skip', flaky: 'flaky', interrupted: 'INTR' }[x.status]; + const name = x.spec.path.replace(/^specs\/(golden\/)?/, ''); + const tail = x.status === 'skipped' ? (x.skipReason ?? '') : `${x.seconds}s`; + lines.push(` ${pad(label, 5)} ${pad(name, width)} ${x.title} ${tail}`); + if (x.error !== null) lines.push(` ${x.error}`); + if (x.status === 'passed' && x.tags.includes(KNOWN_BUG_TAG)) lines.push(' passed with --include known-bug: the bug may be fixed; drop the tag'); + if (x.failurePage !== null) lines.push(` failure page ${rel(skillDir, x.failurePage)}`); + if (x.failureScreenshot !== null) lines.push(` screenshot ${rel(skillDir, x.failureScreenshot)}`); + } + lines.push(`evidence ${rel(process.cwd(), result.dir)}`); + if (r.videos.length > 0) lines.push(` video ${r.videos.map((v) => basename(v)).join(', ')}`); + if (r.screenshots.length > 0) lines.push(` screenshots ${r.screenshots.map((s) => basename(s.path)).join(', ')}`); + if (r.lastState !== null) lines.push(` last state ${describeState(r.lastState)} (the last test only; every state is in states.jsonl)`); + if (r.appLog !== null) lines.push(` app log ${basename(r.appLog)}`); + if (r.tainted.length > 0) lines.push(` TAINTED ${r.tainted.map((t) => rel(result.dir, t)).join(', ')} (attach is blocked)`); + lines.push(`next ${isAbsolute(result.next) ? rel(process.cwd(), result.next) : result.next}`); + return lines; +} + +function render(value: VerbResult, skillDir: string): string[] { + switch (value.verb) { + case 'doctor': { + const width = Math.max(...value.checks.map((c) => c.id.length)); + const label = (c: DoctorCheck) => (!c.ok ? 'FAIL' : c.state === 'warning' ? 'warn' : c.state === 'not-run' ? 'skip' : 'ok'); + return value.checks.flatMap((c) => [`${pad(label(c), 5)} ${pad(c.id, width)} ${c.detail}`, ...(c.fix === undefined ? [] : [` fix: ${c.fix}`])]); + } + case 'up': + return value.leases.map(leaseLine); + case 'run': + return renderRun(value, skillDir); + case 'screen': { + const lines = [`screen ${value.platform} ${value.device}`]; + for (const node of value.nodes) { + const label = node.name ?? node.text; + if (label === null && node.testId === null) continue; + lines.push( + `${' '.repeat(Math.min(node.depth, 8))}${pad(node.role, 10)} ${label === null ? '' : JSON.stringify(label)}${node.testId === null ? '' : ` id=${node.testId}`}${node.locator === null ? '' : ` ${node.locator}`}`, + ); + } + lines.push(value.state === null ? 'state no verify.state on screen' : `state ${describeState(value.state)}`); + if (value.png !== null) lines.push(`png ${rel(process.cwd(), value.png)}`); + return lines; + } + case 'attach': + return [`${value.alreadyPosted ? 'already posted' : 'posted'} ${value.posted.map((p) => basename(p)).join(', ')} ${value.commentUrl}`]; + case 'down': + return [ + ...(value.dryRun + ? [ + 'dry run: nothing was changed', + `would release ${value.wouldRelease.map((l) => l.device).join(', ') || 'nothing'}`, + `would delete ${count(value.wouldDelete.length, 'application')}`, + ...value.wouldDelete.map((target) => ` application ${target.name} (with every test user in it)`), + stoppedLine('would stop ', value.wouldStop), + ] + : [ + `released ${value.released.map((l) => l.device).join(', ') || 'nothing'}`, + `deleted ${count(value.deletedApplications.length, 'application')}${value.deletedApplications.length === 0 ? '' : ` (${value.deletedApplications.map((a) => a.name).join(', ')}, with every test user in ${value.deletedApplications.length === 1 ? 'it' : 'them'})`}`, + stoppedLine('stopped ', value.stoppedProcesses), + ]), + `kept ${count(value.keptRuns.length, 'run')} in .verify/runs/`, + ]; + default: { + const exhaustive: never = value; + return [JSON.stringify(exhaustive)]; + } + } +} + +export function createOutput(json: boolean, skillDir: string, cli: string, stdout: Sink = process.stdout, stderr: Sink = process.stderr): Output { + const text = (value: string) => redact(value).replaceAll(CLI_PLACEHOLDER, cli); + return { + result(value) { + if (json) stdout.write(`${text(JSON.stringify({ ok: true, ...value }))}\n`); + else stdout.write(`${text(render(value, skillDir).join('\n'))}\n`); + }, + failure(error) { + const body = { code: error.code, message: error.message, fix: error.fix, retryable: RETRYABLE.has(error.code) }; + if (json) stdout.write(`${text(JSON.stringify({ ok: false, error: body }))}\n`); + else stderr.write(`${text(`error ${error.code} ${error.message}\n fix: ${error.fix}`)}\n`); + }, + progress(line) { + if (!json) stderr.write(`${text(line)}\n`); + }, + }; +} + +function stoppedLine(label: string, processes: readonly string[]): string { + const daemon = processes.some((p) => p.startsWith('agent-device ')) ? '' : '; no agent-device daemon running'; + return `${label}${processes.join(', ') || 'nothing'}${daemon}`; +} + +export function exitCodeFor(value: VerbResult): number { + if (value.verb === 'doctor') return value.ok ? 0 : 3; + if (value.verb === 'run') return value.record.results.some((r) => r.status === 'failed' || r.status === 'interrupted') ? 1 : 0; + return 0; +} + +const SKILL_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +export function takePlatformKey(env: NodeJS.ProcessEnv): Readonly> { + const withKeys = { ...env }; + for (const name of PLATFORM_CREDENTIAL_VARIABLES) delete env[name]; + return withKeys; +} + +export async function main(argv: readonly string[], host: HostAdapter): Promise { + let invocation: Invocation; + try { + invocation = parseArgv(argv); + } catch (error) { + const failure = error instanceof VerifyFailure ? error : usage(String(error)); + createOutput(argv.includes('--json'), SKILL_DIR, host.cli).failure(failure); + return 2; + } + const out = createOutput(invocation.json, SKILL_DIR, host.cli); + try { + const withPlatformKey = takePlatformKey(process.env); + const worktree = execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd: SKILL_DIR, encoding: 'utf8' }).trim(); + const workspace = openWorkspace({ skillDir: SKILL_DIR, worktree }); + const progress = (line: string) => out.progress(line); + const deps: Deps = { + host, + workspace, + runner: defaultRunner, + env: process.env, + progress, + instances: createInstances({ workspace, env: withPlatformKey, runner: defaultRunner, progress }), + }; + const command = invocation.command; + let result: VerbResult; + switch (command.verb) { + case 'doctor': + result = await verbs.doctor(deps, command); + break; + case 'up': + result = await verbs.up(deps, command); + break; + case 'run': + result = await verbs.run(deps, command); + break; + case 'screen': + result = await verbs.screen(deps, command); + break; + case 'attach': + result = await verbs.attach(deps, command); + break; + case 'down': + result = await verbs.down(deps, command); + break; + default: { + const exhaustive: never = command; + throw usage(`unknown verb ${JSON.stringify(exhaustive)}`); + } + } + out.result(result); + return exitCodeFor(result); + } catch (error) { + const failure = + error instanceof VerifyFailure ? error : new VerifyFailure('NOT_READY', (error as Error).message ?? String(error), 'run `{cli} doctor`, then retry'); + out.failure(failure); + return failure.code === 'USAGE' ? 2 : 3; + } +} diff --git a/.claude/skills/verify-clerk-expo/src/core/device-command.ts b/.claude/skills/verify-clerk-expo/src/core/device-command.ts new file mode 100644 index 00000000000..06fc91044dd --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/device-command.ts @@ -0,0 +1,14 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { run, type ExecResult, type Runner } from './exec.ts'; +import type { Lease } from './types.ts'; + +function localAdb(env: Readonly>): string { + const root = env.ANDROID_HOME ?? env.ANDROID_SDK_ROOT; + const inSdk = root === undefined ? '' : join(root, 'platform-tools', 'adb'); + return inSdk !== '' && existsSync(inSdk) ? inSdk : 'adb'; +} + +export async function deviceCommand(lease: Lease, args: readonly string[], options: { readonly runner?: Runner; readonly env?: Readonly> } = {}): Promise { + return (options.runner ?? run)(localAdb(options.env ?? process.env), ['-s', lease.deviceId, ...args]); +} diff --git a/.claude/skills/verify-clerk-expo/src/core/devices.ts b/.claude/skills/verify-clerk-expo/src/core/devices.ts new file mode 100644 index 00000000000..c0bd11d9381 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/devices.ts @@ -0,0 +1,176 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import type { Instances } from './instances/instances.ts'; +import { run } from './exec.ts'; +import { finishOrphanLedgers } from './ledgers.ts'; +import { newEntryId, type Workspace } from './workspace.ts'; +import { + VerifyFailure, + type AcquireLock, + type BackendKind, + type BuildKey, + type BuildView, + type BuiltApp, + type DeviceBackend, + type HostAdapter, + type Lease, + type LeaseView, + type ProcessRef, + type Platform, + type ScratchPath, +} from './types.ts'; + +export interface LeaseOutcome { + readonly lease: Lease; + readonly backend: DeviceBackend; + readonly app: BuiltApp; + readonly view: LeaseView; + readonly build: BuildView; +} + +export function backendFor(host: HostAdapter, platform: Platform, kind: BackendKind): DeviceBackend { + const backend = host.backends.find((b) => b.platform === platform && b.kind === kind); + if (backend === undefined) throw new VerifyFailure('UNSUPPORTED', `${host.repo} has no ${kind} backend for ${platform}`, `${host.repo} has no ${platform} backend`); + return backend; +} + +export function selectBackend(host: HostAdapter, platform: Platform): DeviceBackend { + const candidates = host.backends.filter((b) => b.platform === platform).map((backend) => ({ backend, availability: backend.availability() })); + const chosen = candidates.find((c) => c.availability.usable); + if (chosen === undefined) { + const out = candidates.map((c) => `${c.backend.kind}: ${c.availability.why}`).join('; '); + throw new VerifyFailure('UNSUPPORTED', `no ${platform} backend runs on this machine${out === '' ? '' : ` (${out})`}`, candidates.length === 0 ? `${host.repo} has no ${platform} backend` : `run on ${candidates.map((c) => c.backend.requirement).join(' or ')}`); + } + return chosen.backend; +} + +export function leaseView(backend: DeviceBackend, lease: Lease, renewed: boolean): LeaseView { + return { + platform: lease.platform, + backend: lease.backend, + device: backend.describe(lease), + installedBuild: lease.installedBuild, + renewed, + }; +} + +export function leaseLine(view: LeaseView): string { + return `device ${view.device} ${view.backend} ${view.renewed ? 'renewed' : 'leased by this worktree'} installed ${view.installedBuild ?? 'nothing'}`; +} + +const BUILD_DENYLIST = [/\.md$/i, /(^|\/)\.claude\//, /(^|\/)docs\//, /(^|\/)\.verify\//]; + +export async function computeBuildKey(host: HostAdapter, platform: Platform, worktree: string): Promise { + const listed = await run('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', ...host.buildInputs(platform)], { cwd: worktree }); + if (listed.code !== 0) throw new VerifyFailure('NOT_READY', `git ls-files failed: ${listed.stderr.trim()}`, 'run {cli} from inside a git worktree'); + const files = [...new Set(listed.stdout.split('\0').filter((f) => f.length > 0 && !BUILD_DENYLIST.some((re) => re.test(f))))].sort(); + const hash = createHash('sha256'); + for (const file of files) { + const path = join(worktree, file); + hash.update(file).update('\0'); + hash.update(existsSync(path) ? readFileSync(path) : 'deleted').update('\0'); + } + return `${platform}-${hash.digest('hex').slice(0, 12)}` as BuildKey; +} + +function buildDir(workspace: Workspace, key: BuildKey): ScratchPath { + return join(workspace.buildsDir(), key) as ScratchPath; +} + +export function readBuiltApp(workspace: Workspace, key: BuildKey): BuiltApp | null { + const file = join(buildDir(workspace, key), 'build.json'); + if (!existsSync(file)) return null; + const app = JSON.parse(readFileSync(file, 'utf8')) as BuiltApp; + return existsSync(app.path) ? app : null; +} + +async function ensureBuild(host: HostAdapter, platform: Platform, workspace: Workspace, progress: (line: string) => void): Promise<{ app: BuiltApp; view: BuildView }> { + const key = await computeBuildKey(host, platform, workspace.worktree); + const existing = readBuiltApp(workspace, key); + if (existing !== null) return { app: existing, view: { platform, key, source: existing.source, reused: true, seconds: 0 } }; + const started = Date.now(); + progress(`build ${key} local building...`); + const into = buildDir(workspace, key); + mkdirSync(into, { recursive: true }); + const app = await host.build(platform, key, into, progress); + writeFileSync(join(into, 'build.json'), `${JSON.stringify(app, null, 2)}\n`); + return { app, view: { platform, key, source: 'local', reused: false, seconds: Math.round((Date.now() - started) / 1000) } }; +} + +function closePending(workspace: Workspace, platform: Platform): void { + for (const entry of workspace.unclosedEntries()) { + if ((entry.kind === 'lease-intent' || entry.kind === 'lease-held') && entry.platform === platform) { + workspace.append({ id: newEntryId(), kind: 'done', ref: entry.id }); + } + } +} + +export async function releaseLease(workspace: Workspace, backend: DeviceBackend, lease: Lease): Promise { + await backend.release(lease); + workspace.clearLease(lease.platform); + closePending(workspace, lease.platform); +} + +export async function ensureLease( + lock: AcquireLock, + workspace: Workspace, + host: HostAdapter, + options: { readonly waitSeconds: number; readonly progress: (line: string) => void; readonly instances: Instances; readonly retryWith: string }, +): Promise { + const { platform } = lock; + const held = workspace.readLease(platform); + const backend = selectBackend(host, platform); + for (const stale of await backend.reapable()) { + options.progress(`reap ${backend.describe(stale)} (owner process and worktree are gone)`); + await backend.release(stale); + } + await finishOrphanLedgers(workspace.home, resolve(workspace.worktree), options.instances, options.progress); + + const { app, view: build } = await ensureBuild(host, platform, workspace, options.progress); + const builtBy = build.reused ? 'reused' : `built in ${build.seconds}s`; + options.progress(`build ${build.key} ${build.source} ${builtBy}`); + + let lease: Lease | null = held; + let renewed = false; + const state = lease === null ? 'held' : await backend.check(lease); + if (lease !== null && state !== 'held') { + options.progress(`lost ${backend.describe(lease)} renewing`); + await releaseLease(workspace, backend, lease); + lease = null; + renewed = true; + } + if (lease === null) { + for (const orphan of await backend.reapable(workspace.worktree)) { + options.progress(`reap ${backend.describe(orphan)} (claimed by this worktree with no lease file)`); + await backend.release(orphan); + } + const intent = { id: newEntryId(), kind: 'lease-intent' as const, platform, backend: backend.kind, worktree: workspace.worktree }; + workspace.append(intent); + const acquired = await backend.acquire({ platform, worktree: workspace.worktree, waitSeconds: options.waitSeconds, retryWith: options.retryWith, progress: options.progress }); + workspace.writeLease(acquired); + workspace.append({ + id: newEntryId(), + kind: 'lease-held', + platform, + backend: backend.kind, + deviceId: acquired.deviceId, + }); + workspace.append({ id: newEntryId(), kind: 'done', ref: intent.id }); + lease = acquired; + } + + if (lease.installedBuild !== app.key) { + const target = lease; + options.progress(`install ${app.key} on ${backend.describe(target)}`); + const wait = { + seconds: options.waitSeconds, + busyFix: `let the run in this worktree finish, or rerun with a wait: ${options.retryWith}`, + onWait: (owner: ProcessRef) => + options.progress(`wait another {cli} run in this worktree (pid ${owner.pid}) is driving the device; waiting up to ${options.waitSeconds}s to install`), + }; + lease = { ...(await workspace.withDevice(platform, wait, () => backend.install(target, app))), installedBuild: app.key }; + workspace.writeLease(lease); + } + return { lease, backend, app, build, view: leaseView(backend, lease, renewed) }; +} diff --git a/.claude/skills/verify-clerk-expo/src/core/e2e-config.ts b/.claude/skills/verify-clerk-expo/src/core/e2e-config.ts new file mode 100644 index 00000000000..e4402431966 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/e2e-config.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { E2EConfig } from 'e2e'; +import { mobile } from '@e2e-dev/mobile'; +import { VerifyFailure, type Lease, type RunContext } from './types.ts'; +import { agentDeviceStateDir } from './workspace.ts'; + +export const ASSERTION_TIMEOUT_MS = 10_000; + +const SKILL_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +const LEASE_CONTEXT = join(SKILL_DIR, '.verify', 'context.json'); + +function parseContext(text: string, source: string): RunContext { + const raw = JSON.parse(text) as Partial; + if (raw.v !== 1 || typeof raw.workspace !== 'string' || !Array.isArray(raw.targets) || typeof raw.agentDeviceSession !== 'string') { + throw new VerifyFailure('NOT_READY', `${source} is not a verify run context`, 'run specs through `{cli} run`'); + } + return raw as RunContext; +} + +export function loadRunContext(): RunContext { + const named = process.env.VERIFY_CONTEXT; + if (named !== undefined && named !== '') return parseContext(readFileSync(named, 'utf8'), named); + if (!existsSync(LEASE_CONTEXT)) { + throw new VerifyFailure('NOT_READY', 'no device is leased for this worktree', '{cli} up'); + } + const context = parseContext(readFileSync(LEASE_CONTEXT, 'utf8'), LEASE_CONTEXT); + if (!context.targets.every((t) => existsSync(t.leaseFile))) throw new VerifyFailure('NOT_READY', 'the lease this context names was released', '{cli} up'); + return context; +} + +export function composeE2EConfig(context: RunContext): E2EConfig { + process.env.AGENT_DEVICE_STATE_DIR ??= agentDeviceStateDir(context.workspace); + const targets = context.targets.map((target) => { + const lease = JSON.parse(readFileSync(target.leaseFile, 'utf8')) as Lease; + return { + name: target.platform, + engine: mobile({ platform: target.platform, device: [lease.deviceId], session: context.agentDeviceSession, videoTouches: false }), + app: { bundleId: target.appId, appPath: target.appPath }, + }; + }); + return { + tests: ['specs/**/*.e2e.ts'], + targets, + workers: 1, + retries: 0, + assertionTimeout: ASSERTION_TIMEOUT_MS, + trace: 'off', + }; +} + +export async function withJudge(config: E2EConfig): Promise { + const spec = process.env.VERIFY_JUDGE_MODEL; + if (spec === undefined || spec === '') return config; + const match = /^chatgpt:(.+)$/.exec(spec); + if (match === null) throw new VerifyFailure('USAGE', `VERIFY_JUDGE_MODEL=${spec} is not chatgpt:`, 'export VERIFY_JUDGE_MODEL=chatgpt:, or unset it'); + try { + const { chatgpt } = await import('e2e/oauth/chatgpt'); + return { ...config, agents: { default: { model: chatgpt(match[1]!) } } }; + } catch (error) { + throw new VerifyFailure( + 'NOT_READY', + `the AI judge could not load: ${(error as Error).message}`, + `cd ${SKILL_DIR} && npm i -D ai @ai-sdk/openai && npx e2e login openai`, + ); + } +} diff --git a/.claude/skills/verify-clerk-expo/src/core/e2e.ts b/.claude/skills/verify-clerk-expo/src/core/e2e.ts new file mode 100644 index 00000000000..e5076b8fdf6 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/e2e.ts @@ -0,0 +1,322 @@ +import { spawn } from 'node:child_process'; +import { appendFileSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { withoutClerkKeys } from './keys.ts'; +import { redact } from './secret.ts'; +import { agentDeviceStateDir } from './workspace.ts'; +import { + FORM_ENTRY_TAG, + KNOWN_BUG_TAG, + type OptInTag, + VerifyFailure, + type ActiveRunContext, + type E2EInvocation, + type EvidencePath, + type FeatureName, + type Platform, + type RunCommand, + type RunContext, + type SpecRef, + type SpecResult, + type SpecSelection, + type SpecStatus, +} from './types.ts'; + +const SPEC_SUFFIX = '.e2e.ts'; + +function walk(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + return entry.isDirectory() ? walk(path) : entry.name.endsWith(SPEC_SUFFIX) ? [path] : []; + }); +} + +const toPosix = (path: string) => path.split(sep).join('/'); + +function specRef(skillDir: string, absolute: string): SpecRef | null { + const path = toPosix(relative(skillDir, absolute)); + const golden = /^specs\/golden\/([^/]+)\/.+\.e2e\.ts$/.exec(path); + if (golden !== null) return { kind: 'golden', path, feature: golden[1] as FeatureName }; + if (/^specs\/explored\/.+\.e2e\.ts$/.test(path)) return { kind: 'explored', path, feature: null }; + return null; +} + +function levenshtein(a: string, b: string): number { + const row = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i += 1) { + let previous = row[0]!; + row[0] = i; + for (let j = 1; j <= b.length; j += 1) { + const current = row[j]!; + row[j] = Math.min(row[j]! + 1, row[j - 1]! + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1)); + previous = current; + } + } + return row[b.length]!; +} + +export function resolveSpecs(skillDir: string, selection: SpecSelection, cwd: string = process.cwd()): readonly SpecRef[] { + const goldenDir = join(skillDir, 'specs', 'golden'); + const features = existsSync(goldenDir) ? readdirSync(goldenDir).filter((name) => statSync(join(goldenDir, name)).isDirectory()) : []; + const toRefs = (paths: readonly string[]) => paths.flatMap((p) => specRef(skillDir, p) ?? []).sort((a, b) => a.path.localeCompare(b.path)); + + if ('all' in selection) { + const refs = toRefs(walk(goldenDir)); + if (refs.length === 0) throw new VerifyFailure('NO_SPECS', 'there are no golden specs under specs/golden/', 'write one under specs/golden//, or run an explored spec by path'); + return refs; + } + + const found = new Map(); + for (const selector of selection.selectors) { + let refs: readonly SpecRef[] = []; + if (selector.endsWith(SPEC_SUFFIX)) { + const candidates = isAbsolute(selector) ? [selector] : [resolve(cwd, selector), resolve(skillDir, selector)]; + const file = candidates.find((c) => existsSync(c)); + if (file !== undefined) { + const ref = specRef(skillDir, file); + if (ref === null) throw new VerifyFailure('NO_SPECS', `${selector} is not under specs/golden// or specs/explored/`, 'move the spec under specs/explored/ and run it by that path'); + refs = [ref]; + } + } else if (selector.includes('/')) { + const [feature, spec] = selector.split('/', 2); + const file = join(goldenDir, feature ?? '', `${(spec ?? '').replace(/\.e2e\.ts$/, '')}${SPEC_SUFFIX}`); + if (existsSync(file)) refs = toRefs([file]); + } else if (features.includes(selector)) { + refs = toRefs(walk(join(goldenDir, selector))); + } + if (refs.length === 0) { + const nearest = [...features].sort((a, b) => levenshtein(selector, a) - levenshtein(selector, b)).slice(0, 3); + throw new VerifyFailure( + 'NO_SPECS', + `no specs match ${selector}`, + nearest.length > 0 ? `try one of: ${nearest.join(', ')}` : 'pass a path to a .e2e.ts file under specs/explored/', + ); + } + for (const ref of refs) found.set(ref.path, ref); + } + return [...found.values()]; +} + +export function contextFile(context: ActiveRunContext): string { + return join(context.workspace, 'scratch', context.run, 'context.json'); +} + +export function writeRunContext(file: string, context: RunContext): void { + writeFileSync(file, `${JSON.stringify(context, null, 2)}\n`, { mode: 0o600 }); +} + +export function e2eOutputDir(runDir: EvidencePath, index: number): EvidencePath { + return join(runDir, index === 0 ? 'e2e' : `e2e-${index + 1}`) as EvidencePath; +} + +export function excludedTagNames(command: Pick): readonly OptInTag[] { + return [...new Set([...command.skip, KNOWN_BUG_TAG])].filter((tag) => !command.include.includes(tag)); +} + +export function excludedTags(command: Pick): readonly string[] { + const tags = excludedTagNames(command); + return tags.length === 0 ? [] : ['--exclude-tag', tags.join(',')]; +} + +export function planE2E( + context: ActiveRunContext, + specs: readonly SpecRef[], + command: RunCommand, + platform: Platform, + skillDir: string, + outputDir: EvidencePath, +): E2EInvocation { + const output = toPosix(relative(skillDir, outputDir)); + const args = [ + 'run', + ...specs.map((s) => s.path), + '--config', + 'e2e.config.ts', + '--target', + platform, + '--output', + output, + '--reporter', + 'list,markdown', + ...excludedTags(command), + ...(command.grep === undefined ? [] : ['--grep', command.grep]), + '--pass-with-no-tests', + ]; + return { args, env: { VERIFY_CONTEXT: contextFile(context), AGENT_DEVICE_STATE_DIR: agentDeviceStateDir(context.workspace), E2E_TELEMETRY_DISABLED: '1' } }; +} + +export async function invokeE2E(invocation: E2EInvocation, log: EvidencePath, skillDir: string, onLine: (line: string) => void): Promise<{ readonly exitCode: number }> { + const bin = join(skillDir, 'node_modules', '.bin', 'e2e'); + if (!existsSync(bin)) throw new VerifyFailure('NOT_READY', 'the pinned e2e is not installed', `cd ${skillDir} && npm ci`); + return new Promise((resolvePromise) => { + const child = spawn(bin, [...invocation.args], { + cwd: skillDir, + env: { ...withoutClerkKeys(process.env), ...invocation.env, NO_COLOR: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const pipe = (stream: NodeJS.ReadableStream) => { + let buffered = ''; + const flush = (line: string) => { + const safe = redact(line); + appendFileSync(log, `${safe}\n`); + onLine(safe); + }; + stream.on('data', (chunk: Buffer) => { + buffered += chunk.toString(); + const lines = buffered.split('\n'); + buffered = lines.pop() ?? ''; + lines.forEach(flush); + }); + stream.on('end', () => { + if (buffered.length > 0) flush(buffered); + }); + }; + pipe(child.stdout); + pipe(child.stderr); + child.on('error', () => resolvePromise({ exitCode: 127 })); + child.on('close', (code) => resolvePromise({ exitCode: code ?? 1 })); + }); +} + +interface WireError { + readonly message?: string; +} +interface WireArtifact { + readonly id?: string; + readonly kind?: string; + readonly path?: string; + readonly producer?: { readonly kind?: string; readonly stepId?: string }; +} +interface WireStep { + readonly id?: string; + readonly api?: string; + readonly label?: string; +} +interface WireAttempt { + readonly status?: string; + readonly durationMs?: number; + readonly error?: WireError; + readonly failure?: { readonly screen?: string; readonly screenshot?: string }; + readonly artifacts?: readonly WireArtifact[]; + readonly steps?: readonly WireStep[]; +} +interface WireResult { + readonly id?: string; + readonly kind?: string; + readonly titlePath?: readonly string[]; + readonly file?: string; + readonly platform?: string; + readonly tags?: readonly string[]; + readonly status?: string; + readonly skip?: { readonly cause?: string; readonly reason?: string }; + readonly attempts?: readonly WireAttempt[]; +} + +function wireResults(reportJson: unknown): readonly WireResult[] { + const report = reportJson as { schemaVersion?: unknown; run?: { results?: unknown } } | null; + if (report?.schemaVersion !== 'report-1' || !Array.isArray(report.run?.results)) { + throw new VerifyFailure('E2E_CRASHED', 'e2e wrote a report this skill cannot read (expected schemaVersion report-1)', 'check e2e-pins with `{cli} doctor`'); + } + return report.run.results as WireResult[]; +} + +const STATUS: Readonly> = { + passed: 'passed', + failed: 'failed', + 'timed-out': 'failed', + flaky: 'flaky', + interrupted: 'interrupted', + skipped: 'skipped', +}; + +function platformSkip(reason: string | undefined): string { + const declared = /platforms \[([^\]]*)\]/.exec(reason ?? '')?.[1]; + return declared === undefined ? `skipped: ${reason ?? 'other platform'}` : `skipped: ${declared.split(/,\s*/).join(' and ')} only`; +} + +export function parseE2EReport(reportJson: unknown, specs: readonly SpecRef[], outputDir: EvidencePath, excluded: readonly OptInTag[] = [KNOWN_BUG_TAG]): readonly SpecResult[] { + const failuresDir = join(outputDir, 'failures'); + const pages = existsSync(failuresDir) ? readdirSync(failuresDir) : []; + const selected = new Set(specs.map((s) => s.path)); + return wireResults(reportJson) + .filter((r) => (r.kind === 'test' || r.kind === 'setup') && (selected.size === 0 || selected.has(r.file ?? ''))) + .map((r): SpecResult => { + const file = r.file ?? ''; + const spec = specs.find((s) => s.path === file) ?? { kind: 'explored', path: file, feature: null }; + const attempts = r.attempts ?? []; + const last = attempts.at(-1); + const notRun = r.status === 'skipped' && r.skip?.cause !== 'filtered' && r.skip?.cause !== 'platform-unavailable'; + const status = notRun ? 'failed' : (STATUS[r.status ?? ''] ?? 'failed'); + const page = r.id === undefined ? undefined : pages.find((p) => p.endsWith(`-${r.id!.slice(0, 8)}.md`)); + const artifactPath = (id: string | undefined): EvidencePath | null => { + const path = id === undefined ? undefined : last?.artifacts?.find((a) => a.id === id)?.path; + return path === undefined ? null : (join(outputDir, 'artifacts', path) as EvidencePath); + }; + const screenPath = artifactPath(last?.failure?.screen); + const excludedBy = (tag: OptInTag) => excluded.includes(tag) && (r.tags ?? []).includes(tag); + let skipReason: string | null = null; + let skippedBy: SpecResult['skippedBy'] = null; + if (status === 'skipped') { + if (r.skip?.cause === 'platform-unavailable') skippedBy = 'platform'; + else if (r.skip?.cause === 'filtered' && (excludedBy(KNOWN_BUG_TAG) || excludedBy(FORM_ENTRY_TAG))) skippedBy = 'tag'; + skipReason = + r.skip?.cause === 'platform-unavailable' + ? platformSkip(r.skip.reason) + : r.skip?.cause === 'filtered' && excludedBy(KNOWN_BUG_TAG) + ? `skipped: ${KNOWN_BUG_TAG}` + : r.skip?.cause === 'filtered' && excludedBy(FORM_ENTRY_TAG) + ? `skipped by --skip ${FORM_ENTRY_TAG}` + : `${r.skip?.cause ?? 'skipped'}: ${r.skip?.reason ?? ''}`.trim(); + } + const message = notRun ? `not run: ${r.skip?.cause ?? 'skipped'} ${r.skip?.reason ?? ''}`.trim() : last?.error?.message; + return { + spec, + title: (r.titlePath ?? []).join(' > '), + platform: r.platform === 'android' ? 'android' : 'ios', + status, + seconds: Math.round(attempts.reduce((sum, a) => sum + (a.durationMs ?? 0), 0) / 100) / 10, + error: message === undefined ? null : redact(message.split('\n').filter((line) => line.trim().length > 0).join('; ')), + skipReason, + skippedBy, + tags: r.tags ?? [], + failurePage: page === undefined ? null : (join(failuresDir, page) as EvidencePath), + failureScreen: screenPath !== null && existsSync(screenPath) ? (screenPath as EvidencePath) : null, + failureScreenshot: artifactPath(last?.failure?.screenshot), + }; + }); +} + +export function collectScreenshots(reportJson: unknown, runDir: EvidencePath, outputDir: EvidencePath): readonly { readonly label: string; readonly path: EvidencePath }[] { + const out = new Map(); + const dir = join(runDir, 'screenshots'); + for (const result of wireResults(reportJson)) { + for (const attempt of result.attempts ?? []) { + const steps = new Map((attempt.steps ?? []).map((s) => [s.id, s])); + for (const artifact of attempt.artifacts ?? []) { + if (artifact.kind !== 'screenshot' || artifact.path === undefined) continue; + const step = artifact.producer?.stepId === undefined ? undefined : steps.get(artifact.producer.stepId); + if (step?.api !== 'app.screenshot' || !step.label) continue; + const source = join(outputDir, 'artifacts', artifact.path); + if (!existsSync(source)) continue; + const label = step.label.replace(/[^A-Za-z0-9._-]/g, '-'); + mkdirSync(dir, { recursive: true }); + const target = join(dir, `${label}.png`) as EvidencePath; + copyFileSync(source, target); + out.set(label, target); + } + } + } + return [...out].map(([label, path]) => ({ label, path })); +} + +export function assertSomethingRan(results: readonly SpecResult[], selection: string): 'ran' | 'all-left-out' { + if (results.some((r) => r.status !== 'skipped')) return 'ran'; + if (results.some((r) => r.skippedBy !== null)) return 'all-left-out'; + const reasons = [...new Set(results.map((r) => r.skipReason).filter((x): x is string => x !== null))]; + throw new VerifyFailure( + 'NO_SPECS', + `no test ran for ${selection}${reasons.length === 0 ? ': the selection registered no tests' : `: ${reasons.join('; ')}`}`, + 'check the --grep pattern and the spec files; {cli} run runs every test in it', + ); +} diff --git a/.claude/skills/verify-clerk-expo/src/core/evidence.ts b/.claude/skills/verify-clerk-expo/src/core/evidence.ts new file mode 100644 index 00000000000..2bc93548971 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/evidence.ts @@ -0,0 +1,69 @@ +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseTestEmail } from './clerk.ts'; +import { usedSecretValues } from './secret.ts'; +import { count, parseVerifyState } from './state.ts'; +import { VerifyFailure, type Brand, type EvidencePath, type EvidenceRecord, type VerifyState } from './types.ts'; + +function files(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + return entry.isDirectory() ? files(path) : entry.isFile() ? [path] : []; + }); +} + +export function sealEvidence( + dir: EvidencePath, + partial: Omit, + secretValues: readonly string[] = usedSecretValues(), +): EvidenceRecord { + const needles = secretValues.filter((v) => v.length >= 8).map((v) => Buffer.from(v)); + const body = JSON.stringify(partial); + if (needles.some((n) => body.includes(n.toString()))) { + throw new VerifyFailure('EVIDENCE_UNSAFE', 'the run record itself holds a secret value', 'report this as a verify bug; do not attach the run'); + } + const tainted = files(dir) + .filter((file) => { + const bytes = readFileSync(file); + return needles.some((needle) => bytes.includes(needle)); + }) + .sort() as EvidencePath[]; + const sealed: EvidenceRecord = { ...partial, tainted, sealed: true }; + writeFileSync(join(dir, 'run.json'), `${JSON.stringify(sealed, null, 2)}\n`); + return sealed; +} + +export function readRecord(dir: EvidencePath): EvidenceRecord { + const file = join(dir, 'run.json'); + if (!existsSync(file)) throw new VerifyFailure('EVIDENCE_UNSAFE', `${dir} has no run.json, so it was never sealed`, 'run the specs again with `{cli} run`'); + return JSON.parse(readFileSync(file, 'utf8')) as EvidenceRecord; +} + +export function readStates(dir: EvidencePath): readonly VerifyState[] { + const file = join(dir, 'states.jsonl'); + if (!existsSync(file)) return []; + return readFileSync(file, 'utf8') + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => parseVerifyState(line)); +} + +export type Publishable = Brand; + +export function assertPublishable(record: EvidenceRecord, states: readonly VerifyState[]): Publishable { + const refuse = (message: string, fix: string): never => { + throw new VerifyFailure('EVIDENCE_UNSAFE', message, fix); + }; + if (record.sealed !== true) refuse(`run ${record.run} is not sealed`, 'run the specs again with `{cli} run`'); + if (record.tainted.length > 0) refuse(`run ${record.run} has secret values in ${record.tainted.join(', ')}`, 'do not attach this run; rerun and attach the new run'); + const failed = record.results.filter((r) => r.status === 'failed' || r.status === 'interrupted'); + if (failed.length > 0) refuse(`run ${record.run} has ${failed.length} failing spec(s)`, 'fix the failures and attach a passing run'); + const incomplete = record.settings.filter((group) => group.held === false || group.e2eReport === null); + if (incomplete.length > 0) refuse(`run ${record.run} has ${count(incomplete.length, 'group')} that did not run in full on its settings: ${incomplete.map((group) => group.label).join('; ')}`, 'attach a run in which every group ran on the settings it declares'); + if (!record.results.some((r) => r.status === 'passed' || r.status === 'flaky')) refuse(`run ${record.run} passed no specs`, 'attach a run whose specs ran and passed'); + for (const identity of record.identities) parseTestEmail(identity.email); + const own = new Set(record.identities.flatMap((i) => (i.userId === null ? [] : [i.userId]))); + const foreign = [...new Set(states.flatMap((s) => (s.userId !== null && !own.has(s.userId) ? [s.userId] : [])))]; + if (foreign.length > 0) refuse(`run ${record.run} shows user(s) it did not create: ${foreign.join(', ')}`, 'sign in only users from host.seedUser or host.newEmail'); + return record as Publishable; +} diff --git a/.claude/skills/verify-clerk-expo/src/core/exec.ts b/.claude/skills/verify-clerk-expo/src/core/exec.ts new file mode 100644 index 00000000000..f0a84270a57 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/exec.ts @@ -0,0 +1,78 @@ +import { execFileSync, spawn } from 'node:child_process'; +import type { ProcessRef } from './types.ts'; + +export interface ExecResult { + readonly code: number; + readonly stdout: string; + readonly stderr: string; +} + +export interface ExecOptions { + readonly cwd?: string; + readonly env?: Readonly>; + readonly input?: string; + readonly timeoutMs?: number; +} + +export interface CommandLine { + readonly command: string; + readonly args: readonly string[]; +} + +export type Runner = (command: string, args: readonly string[], options?: ExecOptions) => Promise; + +export const run: Runner = (command, args, options = {}) => + new Promise((resolve) => { + const child = spawn(command, [...args], { + cwd: options.cwd, + env: options.env === undefined ? process.env : { ...options.env }, + stdio: [options.input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr?.on('data', (chunk: Buffer) => (stderr += chunk.toString())); + let timedOut = false; + const timer = options.timeoutMs === undefined ? undefined : setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + }, options.timeoutMs); + child.on('error', (error) => { + clearTimeout(timer); + resolve({ code: 127, stdout, stderr: stderr + error.message }); + }); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ code: timedOut ? 124 : (code ?? 1), stdout, stderr }); + }); + if (options.input !== undefined) child.stdin?.end(options.input); + }); + +export function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +export const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export type { ProcessRef }; + +export function currentProcess(): ProcessRef { + return { pid: process.pid, startedAt: Date.now() - process.uptime() * 1000 }; +} + +const PS_WHOLE_SECONDS_SLACK_MS = 3000; + +export function isRunning(ref: ProcessRef): boolean { + if (!isAlive(ref.pid)) return false; + try { + const started = Date.parse(execFileSync('ps', ['-o', 'lstart=', '-p', String(ref.pid)], { encoding: 'utf8' }).trim()); + return Number.isNaN(started) || Math.abs(started - ref.startedAt) < PS_WHOLE_SECONDS_SLACK_MS; + } catch { + return false; + } +} diff --git a/.claude/skills/verify-clerk-expo/src/core/instances/base.json b/.claude/skills/verify-clerk-expo/src/core/instances/base.json new file mode 100644 index 00000000000..5e35b3f6256 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/instances/base.json @@ -0,0 +1,454 @@ +{ + "config": { + "auth_access_control": { + "allowlist_blocklist_enforced_on_sign_in": true, + "allowlist_enabled": false, + "block_disposable_email_domains": false, + "block_email_subaddresses": false, + "blocklist_enabled": false, + "sign_up_mode": "public" + }, + "auth_attack_protection": { + "bot_protection": { + "captcha_enabled": false, + "captcha_widget_type": "smart" + }, + "email_link_require_same_client": false, + "enumeration_protection": "bulk", + "pii_protection_enabled": true, + "user_lockout": { + "duration_in_minutes": 60, + "enabled": true, + "max_attempts": 100 + } + }, + "auth_biometric": { + "enrollment_prompt_after_sign_in": false, + "enrollment_prompt_after_sign_up": false, + "used_for_sign_in": false + }, + "auth_email": { + "immutable": false, + "required_for_sign_up": true, + "sign_in_strategies": [ + "email_code", + "email_link" + ], + "used_for_sign_in": true, + "used_for_sign_up": true, + "verification_strategies": [ + "email_code" + ], + "verify_at_sign_up": true + }, + "auth_multi_factor": { + "authenticator_app": { + "enabled": true + }, + "backup_code": { + "enabled": true + }, + "required_for_sign_in": false, + "required_for_sign_up": false + }, + "auth_passkey": { + "allow_autofill": true, + "satisfies_second_factor": true, + "show_sign_in_button": true, + "used_for_sign_in": false + }, + "auth_password": { + "device_trust": { + "enabled": true + }, + "disable_hibp": false, + "disable_password_reverification": false, + "enabled": true, + "enforce_hibp_on_sign_in": false, + "max_length": 0, + "min_length": 8, + "min_zxcvbn_strength": 0, + "require_lowercase": false, + "require_numbers": false, + "require_special_char": true, + "require_uppercase": false, + "required": true, + "show_zxcvbn": false + }, + "auth_phone": { + "immutable": false, + "required_for_sign_up": false, + "second_factor_strategies": [ + "phone_code" + ], + "sign_in_strategies": [ + "phone_code" + ], + "used_for_second_factor": true, + "used_for_sign_in": true, + "used_for_sign_up": true, + "verification_strategies": [ + "phone_code" + ], + "verify_at_sign_up": true + }, + "auth_username": { + "allow_extended_special_characters": false, + "allow_numeric_usernames": false, + "immutable": false, + "max_length": 64, + "min_length": 4, + "required_for_sign_up": false, + "used_for_sign_in": true, + "used_for_sign_up": true + }, + "auth_web3": { + "required_for_sign_up": false, + "sign_in_strategies": [ + "web3_metamask_signature" + ], + "used_for_sign_in": true, + "used_for_sign_up": true, + "verification_strategies": [ + "web3_metamask_signature" + ], + "verify_at_sign_up": true + }, + "compliance": { + "legal_consent": { + "enabled": false, + "privacy_policy_url": null, + "terms_of_service_url": null + } + }, + "connection_oauth_google": { + "block_email_subaddresses": false + }, + "organization_settings": { + "admin_delete_enabled": true, + "creator_role": "org:admin", + "domains_default_role": "org:member", + "domains_enabled": true, + "domains_enrollment_modes": [ + "manual_invitation", + "automatic_invitation", + "automatic_suggestion" + ], + "enabled": true, + "force_organization_selection": false, + "initial_role_set_key": "role_set:default", + "max_allowed_domains": 10, + "max_allowed_memberships": 3, + "max_allowed_roles": 10, + "max_role_sets_allowed": 30, + "organization_creation_defaults": { + "automatic_organization_creation": { + "enabled": false + }, + "detect_from_email_domain": { + "enabled": false + }, + "enabled": false, + "fallback": { + "name": "" + }, + "organization_name_template": { + "enabled": false, + "template": "" + } + }, + "slug_disabled": false + }, + "session_settings": { + "inactivity_timeout": { + "duration_seconds": 0, + "enabled": false + }, + "maximum_lifetime": { + "duration_seconds": 604800, + "enabled": true + }, + "multi_session_enabled": true + }, + "user_model": { + "first_name": { + "enabled": true, + "required": false + }, + "last_name": { + "enabled": true, + "required": false + } + } + }, + "environment": { + "auth_config.first_factors": [ + "email_code", + "email_link", + "oauth_google", + "password", + "phone_code", + "reset_password_email_code", + "reset_password_phone_code", + "ticket", + "web3_metamask_signature" + ], + "auth_config.first_name": "on", + "auth_config.identification_requirements[0]": [ + "email_address", + "oauth_google", + "phone_number", + "web3_wallet" + ], + "auth_config.identification_requirements[1]": [ + "username" + ], + "auth_config.identification_strategies": [ + "email_address", + "oauth_google", + "phone_number", + "username", + "web3_wallet" + ], + "auth_config.last_name": "on", + "auth_config.native_settings.api_enabled": true, + "auth_config.phone_number": "on", + "auth_config.second_factors": [ + "backup_code", + "phone_code", + "totp" + ], + "auth_config.single_session_mode": false, + "auth_config.test_mode": true, + "auth_config.username": "on", + "display_config.captcha_provider": null, + "display_config.captcha_widget_type": null, + "organization_settings.domains.default_role": "org:member", + "organization_settings.domains.enabled": true, + "organization_settings.domains.enrollment_modes": [ + "automatic_invitation", + "automatic_suggestion", + "manual_invitation" + ], + "organization_settings.enabled": true, + "organization_settings.force_organization_selection": false, + "organization_settings.max_allowed_memberships": 3, + "organization_settings.organization_creation_defaults.detect_from_email_domain.enabled": false, + "organization_settings.organization_creation_defaults.enabled": false, + "organization_settings.organization_creation_defaults.fallback.name": "", + "organization_settings.organization_creation_defaults.organization_name_template.enabled": false, + "organization_settings.organization_creation_defaults.organization_name_template.template": "", + "organization_settings.slug.disabled": false, + "user_settings.actions.create_organization": true, + "user_settings.attack_protection.email_link.require_same_client": false, + "user_settings.attack_protection.user_lockout.max_attempts": 100, + "user_settings.attributes.authenticator_app.enabled": true, + "user_settings.attributes.authenticator_app.second_factors": [ + "totp" + ], + "user_settings.attributes.authenticator_app.used_for_second_factor": true, + "user_settings.attributes.authenticator_app.verifications": [ + "totp" + ], + "user_settings.attributes.backup_code.enabled": true, + "user_settings.attributes.backup_code.second_factors": [ + "backup_code" + ], + "user_settings.attributes.backup_code.used_for_second_factor": true, + "user_settings.attributes.email_address.first_factors": [ + "email_code", + "email_link" + ], + "user_settings.attributes.first_name.enabled": true, + "user_settings.attributes.last_name.enabled": true, + "user_settings.attributes.phone_number.enabled": true, + "user_settings.attributes.phone_number.first_factors": [ + "phone_code" + ], + "user_settings.attributes.phone_number.second_factors": [ + "phone_code" + ], + "user_settings.attributes.phone_number.used_for_first_factor": true, + "user_settings.attributes.phone_number.used_for_second_factor": true, + "user_settings.attributes.phone_number.verifications": [ + "phone_code" + ], + "user_settings.attributes.phone_number.verify_at_sign_up": true, + "user_settings.attributes.ticket.enabled": true, + "user_settings.attributes.username.enabled": true, + "user_settings.attributes.username.used_for_first_factor": true, + "user_settings.attributes.web3_wallet.enabled": true, + "user_settings.attributes.web3_wallet.first_factors": [ + "web3_metamask_signature" + ], + "user_settings.attributes.web3_wallet.used_for_first_factor": true, + "user_settings.attributes.web3_wallet.verifications": [ + "web3_metamask_signature" + ], + "user_settings.attributes.web3_wallet.verify_at_sign_up": true, + "user_settings.password_settings.enforce_hibp_on_sign_in": false, + "user_settings.password_settings.min_length": 8, + "user_settings.password_settings.require_special_char": true, + "user_settings.restrictions.allowlist_blocklist_disabled_on_sign_in.enabled": false, + "user_settings.sign_up.captcha_enabled": false, + "user_settings.sign_up.mfa.required": false, + "user_settings.social.oauth_google.block_email_subaddresses": false + }, + "defaults": { + "api_keys_settings.enabled": false, + "api_keys_settings.orgs_api_keys_enabled": false, + "api_keys_settings.user_api_keys_enabled": false, + "auth_config.claimed_at": null, + "auth_config.cookieless_dev": true, + "auth_config.email_address": "on", + "auth_config.email_address_verification_strategies": [ + "email_code" + ], + "auth_config.enhanced_email_deliverability": false, + "auth_config.native_settings.trusted_device_enrollment_prompt_after_sign_in_enabled": false, + "auth_config.native_settings.trusted_device_enrollment_prompt_after_sign_up_enabled": false, + "auth_config.native_settings.trusted_device_sign_in_enabled": false, + "auth_config.password": "required", + "auth_config.reverification": true, + "auth_config.session_minter": true, + "auth_config.url_based_session_syncing": true, + "client_debug_mode": false, + "commerce_settings.billing.free_trial_requires_payment_method": true, + "commerce_settings.billing.organization.enabled": false, + "commerce_settings.billing.organization.has_paid_plans": false, + "commerce_settings.billing.stripe_publishable_key": null, + "commerce_settings.billing.user.enabled": false, + "commerce_settings.billing.user.has_paid_plans": false, + "display_config.captcha_oauth_bypass": [], + "display_config.experimental_force_oauth_first": false, + "display_config.google_one_tap_client_id": null, + "display_config.instance_environment_type": "development", + "display_config.preferred_sign_in_strategy": "password", + "display_config.show_devmode_warning": true, + "fraud_settings.native.device_attestation_mode": "disabled", + "maintenance_mode": false, + "organization_settings.actions.admin_delete": true, + "organization_settings.creator_role": "org:admin", + "organization_settings.organization_creation_defaults.automatic_organization_creation.enabled": false, + "partitioned_cookies": false, + "user_settings.actions.create_organizations_limit": null, + "user_settings.actions.delete_self": true, + "user_settings.attack_protection.enumeration_protection.enabled": false, + "user_settings.attack_protection.pii.enabled": true, + "user_settings.attack_protection.user_lockout.duration_in_minutes": 60, + "user_settings.attack_protection.user_lockout.enabled": true, + "user_settings.attributes.authenticator_app.first_factors": [], + "user_settings.attributes.authenticator_app.immutable": false, + "user_settings.attributes.authenticator_app.required": false, + "user_settings.attributes.authenticator_app.used_for_first_factor": false, + "user_settings.attributes.authenticator_app.verify_at_sign_up": false, + "user_settings.attributes.backup_code.first_factors": [], + "user_settings.attributes.backup_code.immutable": false, + "user_settings.attributes.backup_code.required": false, + "user_settings.attributes.backup_code.used_for_first_factor": false, + "user_settings.attributes.backup_code.verifications": [], + "user_settings.attributes.backup_code.verify_at_sign_up": false, + "user_settings.attributes.email_address.enabled": true, + "user_settings.attributes.email_address.immutable": false, + "user_settings.attributes.email_address.required": true, + "user_settings.attributes.email_address.second_factors": [], + "user_settings.attributes.email_address.used_for_first_factor": true, + "user_settings.attributes.email_address.used_for_second_factor": false, + "user_settings.attributes.email_address.verifications": [ + "email_code" + ], + "user_settings.attributes.email_address.verify_at_sign_up": true, + "user_settings.attributes.first_name.first_factors": [], + "user_settings.attributes.first_name.immutable": false, + "user_settings.attributes.first_name.required": false, + "user_settings.attributes.first_name.second_factors": [], + "user_settings.attributes.first_name.used_for_first_factor": false, + "user_settings.attributes.first_name.used_for_second_factor": false, + "user_settings.attributes.first_name.verifications": [], + "user_settings.attributes.first_name.verify_at_sign_up": false, + "user_settings.attributes.last_name.first_factors": [], + "user_settings.attributes.last_name.immutable": false, + "user_settings.attributes.last_name.required": false, + "user_settings.attributes.last_name.second_factors": [], + "user_settings.attributes.last_name.used_for_first_factor": false, + "user_settings.attributes.last_name.used_for_second_factor": false, + "user_settings.attributes.last_name.verifications": [], + "user_settings.attributes.last_name.verify_at_sign_up": false, + "user_settings.attributes.passkey.enabled": false, + "user_settings.attributes.passkey.first_factors": [], + "user_settings.attributes.passkey.immutable": false, + "user_settings.attributes.passkey.required": false, + "user_settings.attributes.passkey.second_factors": [], + "user_settings.attributes.passkey.used_for_first_factor": false, + "user_settings.attributes.passkey.used_for_second_factor": false, + "user_settings.attributes.passkey.verifications": [], + "user_settings.attributes.passkey.verify_at_sign_up": false, + "user_settings.attributes.password.enabled": true, + "user_settings.attributes.password.first_factors": [], + "user_settings.attributes.password.immutable": false, + "user_settings.attributes.password.required": true, + "user_settings.attributes.password.second_factors": [], + "user_settings.attributes.password.used_for_first_factor": false, + "user_settings.attributes.password.used_for_second_factor": false, + "user_settings.attributes.password.verifications": [], + "user_settings.attributes.password.verify_at_sign_up": false, + "user_settings.attributes.phone_number.immutable": false, + "user_settings.attributes.phone_number.required": false, + "user_settings.attributes.ticket.first_factors": [], + "user_settings.attributes.ticket.immutable": false, + "user_settings.attributes.ticket.required": false, + "user_settings.attributes.ticket.second_factors": [], + "user_settings.attributes.ticket.used_for_first_factor": false, + "user_settings.attributes.ticket.used_for_second_factor": false, + "user_settings.attributes.ticket.verifications": [], + "user_settings.attributes.ticket.verify_at_sign_up": false, + "user_settings.attributes.username.first_factors": [], + "user_settings.attributes.username.immutable": false, + "user_settings.attributes.username.required": false, + "user_settings.attributes.username.second_factors": [], + "user_settings.attributes.username.used_for_second_factor": false, + "user_settings.attributes.username.verifications": [], + "user_settings.attributes.username.verify_at_sign_up": false, + "user_settings.attributes.web3_wallet.immutable": false, + "user_settings.attributes.web3_wallet.required": false, + "user_settings.attributes.web3_wallet.second_factors": [], + "user_settings.attributes.web3_wallet.used_for_second_factor": false, + "user_settings.enterprise_sso.enabled": false, + "user_settings.enterprise_sso.self_serve_directory_sync": true, + "user_settings.enterprise_sso.self_serve_sso": true, + "user_settings.passkey_settings.allow_autofill": true, + "user_settings.passkey_settings.satisfies_second_factor": true, + "user_settings.passkey_settings.show_sign_in_button": true, + "user_settings.password_settings.allowed_special_characters": "!\"#$%&'()*+,-./:;<=>?@[]^_`{|}~", + "user_settings.password_settings.disable_hibp": false, + "user_settings.password_settings.disable_password_reverification": false, + "user_settings.password_settings.max_length": 0, + "user_settings.password_settings.min_zxcvbn_strength": 0, + "user_settings.password_settings.require_lowercase": false, + "user_settings.password_settings.require_numbers": false, + "user_settings.password_settings.require_uppercase": false, + "user_settings.password_settings.show_zxcvbn": false, + "user_settings.restrictions.allowlist.enabled": false, + "user_settings.restrictions.block_disposable_email_domains.enabled": false, + "user_settings.restrictions.block_email_subaddresses.enabled": false, + "user_settings.restrictions.blocklist.enabled": false, + "user_settings.saml.enabled": false, + "user_settings.sign_in.second_factor.required": false, + "user_settings.sign_up.captcha_widget_type": "smart", + "user_settings.sign_up.custom_action_required": false, + "user_settings.sign_up.legal_consent_enabled": false, + "user_settings.sign_up.mode": "public", + "user_settings.sign_up.progressive": true, + "user_settings.social.oauth_google.authenticatable": true, + "user_settings.social.oauth_google.deprecated": false, + "user_settings.social.oauth_google.enabled": true, + "user_settings.social.oauth_google.name": "Google", + "user_settings.social.oauth_google.not_selectable": false, + "user_settings.social.oauth_google.required": false, + "user_settings.social.oauth_google.strategy": "oauth_google", + "user_settings.username_settings.allow_extended_special_characters": false, + "user_settings.username_settings.allow_numeric_usernames": false, + "user_settings.username_settings.max_length": 64, + "user_settings.username_settings.min_length": 4 + } +} diff --git a/.claude/skills/verify-clerk-expo/src/core/instances/definitions.ts b/.claude/skills/verify-clerk-expo/src/core/instances/definitions.ts new file mode 100644 index 00000000000..c14ccc6cb17 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/instances/definitions.ts @@ -0,0 +1,46 @@ +import type { Json } from '../types.ts'; + +export type Leaves = Readonly>; + +const isObject = (value: Json | undefined): value is { readonly [key: string]: Json } => typeof value === 'object' && value !== null && !Array.isArray(value); + +const NOT_COMPARED = [/(^|\.)id$/, /(^|\.)object$/, /_url$/, /^display_config\.(application_name|theme\.|clerk_js_version|support_email|branded|logo_image|favicon_image|captcha_public_key)/]; + +export function flattenEnvironment(value: Json, path = '', out: Record = {}): Leaves { + if (Array.isArray(value)) { + if (value.every((item) => typeof item !== 'object' || item === null)) out[path] = value.map(String).sort(); + else value.forEach((item, index) => flattenEnvironment(item, `${path}[${index}]`, out)); + } else if (isObject(value)) { + for (const [key, child] of Object.entries(value)) flattenEnvironment(child, path === '' ? key : `${path}.${key}`, out); + } else { + out[path] = value; + } + return path === '' ? Object.fromEntries(Object.entries(out).filter(([leaf]) => !NOT_COMPARED.some((pattern) => pattern.test(leaf)))) : out; +} + +export interface EnvironmentDifference { + readonly path: string; + readonly expected: Json; + readonly found: Json | undefined; +} + +export interface EnvironmentComparison { + readonly compared: number; + readonly differing: readonly EnvironmentDifference[]; + readonly drifted: readonly EnvironmentDifference[]; + readonly unknown: readonly string[]; +} + +export function compareEnvironment(definition: { readonly environment: Leaves; readonly defaults: Leaves }, live: Json): EnvironmentComparison { + const found = flattenEnvironment(live); + const differences = (expected: Leaves): EnvironmentDifference[] => + Object.entries(expected).flatMap(([path, want]) => (JSON.stringify(found[path]) === JSON.stringify(want) ? [] : [{ path, expected: want, found: found[path] }])); + return { + compared: Object.keys(definition.environment).length, + differing: differences(definition.environment), + drifted: differences(definition.defaults), + unknown: Object.keys(found).filter((path) => !(path in definition.environment) && !(path in definition.defaults)).sort(), + }; +} + +export const describeDifference = (d: EnvironmentDifference, expectedBy = 'the file says'): string => `${d.path} is ${d.found === undefined ? 'absent' : JSON.stringify(d.found)}, and ${expectedBy} ${JSON.stringify(d.expected)}`; diff --git a/.claude/skills/verify-clerk-expo/src/core/instances/instances.ts b/.claude/skills/verify-clerk-expo/src/core/instances/instances.ts new file mode 100644 index 00000000000..93cb0e53d24 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/instances/instances.ts @@ -0,0 +1,283 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { BACKEND_API_FIX, BACKEND_API_HOSTS, createClerkBackends, isUnauthorized, type ClerkBackend } from '../clerk.ts'; +import { currentProcess, isRunning, sleep as defaultSleep, type Runner } from '../exec.ts'; +import type { InstanceKeys } from '../keys.ts'; +import { count } from '../state.ts'; +import { newEntryId, takeSlotLock, type Workspace } from '../workspace.ts'; +import { VerifyFailure, type ApplicationView, type DoctorCheck, type InstanceView, type ProcessRef } from '../types.ts'; +import { describeDifference } from './definitions.ts'; +import { createPlatform, describeCredential, type Platform } from './platform.ts'; +import { STANDARD_FILE, declaredIn, settingsOf, type SettingsGroup } from './settings.ts'; +import { createThrowaway, openApplications, type HeldApplication, type Throwaway } from './throwaway.ts'; + +export interface AppliedInstance { + readonly keys: InstanceKeys; + readonly instance: { readonly id: string; readonly name: string }; + readonly changed: { readonly answeredMs: number; readonly visibleMs: number } | null; + stillApplied(): Promise; + release(): Promise; +} + +export interface Instances { + access(): Promise; + recordedKey(): string | null; + ensure(options: { readonly willChange: boolean }, progress: (line: string) => void): Promise; + apply(group: SettingsGroup, progress: (line: string) => void): Promise; + keys(): InstanceKeys; + clerk(): ClerkBackend; + finish(ledger: Workspace, options: { readonly keepApplications: boolean }, progress: (line: string) => void): Promise; + doctorChecks(options: { readonly live: boolean }, progress: (line: string) => void): Promise; +} + +export interface InstancesDeps { + readonly workspace: Workspace; + readonly env: Readonly>; + readonly runner: Runner; + readonly progress: (line: string) => void; + readonly fetch?: typeof fetch; + readonly sleep?: (ms: number) => Promise; + readonly now?: () => number; + readonly drivers?: { readonly self: ProcessRef; readonly isRunning: (driver: ProcessRef) => boolean }; +} + +function check(id: DoctorCheck['id'], ok: boolean, detail: string, fix: string): DoctorCheck { + return ok ? { id, ok, detail } : { id, ok, detail, fix }; +} + +function specFiles(dir: string): string[] { + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + return entry.isDirectory() ? specFiles(path) : entry.name.endsWith('.e2e.ts') ? [path] : []; + }); +} + +const FRESH_KEY_SECONDS = 10; + +export function createInstances(deps: InstancesDeps): Instances { + const { workspace, env } = deps; + const sleep = deps.sleep ?? defaultSleep; + const backends = createClerkBackends(deps.fetch, deps.progress); + const platform: Platform = createPlatform({ env, runner: deps.runner, progress: deps.progress, ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }), sleep, ...(deps.now === undefined ? {} : { now: deps.now }) }); + const throwaway: Throwaway = createThrowaway({ + workspace, + platform, + clerk: backends, + env, + self: deps.drivers?.self ?? currentProcess(), + isRunning: deps.drivers?.isRunning ?? isRunning, + ...(deps.fetch === undefined ? {} : { fetch: deps.fetch }), + sleep, + ...(deps.now === undefined ? {} : { now: deps.now }), + }); + let reaching: Promise | undefined; + let applied: AppliedInstance | undefined; + + const current = (): AppliedInstance => { + if (applied === undefined) throw new VerifyFailure('NOT_READY', 'no instance is applied: a spec asked for a user or a launch outside the part of a run that drives the device', 'report this as a verify bug'); + return applied; + }; + const appliedClerk = backends(() => current().keys); + + async function reach(): Promise { + if (openApplications(workspace).length > 0) return 'this worktree already holds throwaway instances'; + const open = await platform.open(); + return `${describeCredential(open.credential)} reaches the verification workspace ${open.workspace}`; + } + + const access = (): Promise => (reaching ??= reach()); + + async function locked(fn: () => T | Promise): Promise { + mkdirSync(join(workspace.root, 'locks'), { recursive: true }); + const release = await takeSlotLock( + join(workspace.root, 'locks', 'instances'), + Number.POSITIVE_INFINITY, + () => new VerifyFailure('NOT_READY', 'unreachable', ''), + (owner) => deps.progress(`wait another {cli} in this worktree (pid ${owner.pid}) is creating, changing, or deleting instances; waiting for it, with no time limit`), + ); + try { + return await fn(); + } finally { + release(); + } + } + + async function apiHost(keys: InstanceKeys): Promise { + const clerk = backends(() => keys); + for (let second = 0; ; second += 1) { + try { + return await clerk.apiHost(); + } catch (error) { + if (!isUnauthorized(error) || second >= FRESH_KEY_SECONDS) throw error; + await sleep(1000); + } + } + } + + async function ownKeyAccepted(id: string, keys: InstanceKeys, progress: (line: string) => void): Promise { + const used = await apiHost(keys).catch((error: unknown) => { + throw new VerifyFailure('NOT_READY', `neither ${BACKEND_API_HOSTS.join(' nor ')} accepts the own secret key of ${id}: ${(error as Error).message}`, `${BACKEND_API_FIX}; \`{cli} down\` deletes the instance`); + }); + progress(`clerk Backend API on ${used}`); + } + + function declarations(): { readonly declaring: number } | { readonly refused: VerifyFailure } { + let declaring = 0; + for (const file of specFiles(join(workspace.skillDir, 'specs')).sort()) { + const path = relative(workspace.skillDir, file).split(sep).join('/'); + const source = readFileSync(file, 'utf8'); + try { + if (settingsOf(declaredIn(source, path), path).declared !== null) declaring += 1; + } catch (error) { + if (!(error instanceof VerifyFailure)) throw error; + return { refused: error }; + } + } + return { declaring }; + } + + async function settingsCheck(): Promise { + const details: string[] = []; + const fixes: string[] = []; + try { + const inspected = await throwaway.inspect(); + if (inspected.length === 0) details.push(`none created yet; up creates one application from ${STANDARD_FILE}`); + for (const { application, found } of inspected) { + const { id, settings } = application; + if (found === 'gone') { + details.push(`Clerk no longer serves ${id}`); + fixes.push('{cli} up creates a new application'); + } else if (settings === null) { + details.push(`${id} has no settings recorded`); + fixes.push('{cli} up returns it to the standard settings'); + } else if (found.differing.length > 0) { + details.push(`${id} is recorded as on ${settings.label} and shows ${found.differing.slice(0, 5).map((d) => describeDifference(d, 'those settings expect')).join('; ')}`); + fixes.push('{cli} up returns it to the standard settings'); + } else { + details.push( + [ + `${id} is on ${settings.label}${settings.askedBy === null ? '' : `, which ${settings.askedBy} asked for`}`, + `${found.compared} settings match ${STANDARD_FILE}${settings.declared === null ? '' : ' with that declaration'}`, + ...(found.drifted.length === 0 ? [] : [`${count(found.drifted.length, 'setting')} no spec depends on differ from the file (${found.drifted.slice(0, 4).map((d) => describeDifference(d)).join('; ')})`]), + ...(found.unknown.length === 0 ? [] : [`Clerk reports ${count(found.unknown.length, 'setting')} the file does not list (${found.unknown.slice(0, 4).join(', ')})`]), + ].join('; '), + ); + } + } + } catch (error) { + details.push(`could not read an environment: ${(error as Error).message}`); + fixes.push('check network access to *.clerk.accounts.dev'); + } + const scanned = declarations(); + if ('refused' in scanned) { + details.push(scanned.refused.message); + fixes.push(scanned.refused.fix); + } else { + details.push(scanned.declaring === 1 ? '1 spec file declares settings' : `${scanned.declaring} spec files declare settings`); + } + return check('settings', fixes.length === 0, details.join('; '), fixes.join('; ')); + } + + async function apiCheck(application: HeldApplication | null): Promise { + const [first, second] = BACKEND_API_HOSTS; + if (application === null) return check('clerk-api', true, `not observed yet: the first call with an instance's own key decides between ${first} and ${second}`, ''); + try { + const used = await apiHost(application.keys); + return check('clerk-api', true, used === first ? `${first} accepts the own key of ${application.id}` : `${second} is in use: ${first} answered 401 to the own key of ${application.id}, so something replaces the Authorization header there`, ''); + } catch (error) { + return check('clerk-api', false, `neither ${first} nor ${second} accepts the own key of ${application.id}: ${(error as Error).message}`, BACKEND_API_FIX); + } + } + + async function ensure(options: { readonly willChange: boolean }, progress: (line: string) => void): Promise { + progress(`instances ${await access()}`); + const up = await throwaway.ensure(options, progress); + if (up.created !== null) await ownKeyAccepted(up.created.id, up.created.keys, progress); + return up.views; + } + + async function finish(ledger: Workspace, options: { readonly keepApplications: boolean }, progress: (line: string) => void): Promise { + try { + return options.keepApplications ? [] : await throwaway.finish(ledger, progress); + } finally { + for (const entry of ledger.unclosedEntries()) { + if (entry.kind === 'identity' || entry.kind === 'user') ledger.append({ id: newEntryId(), kind: 'done', ref: entry.id }); + } + } + } + + function liveCheck(progress: (line: string) => void): Promise { + return locked(async () => { + const held = throwaway.held(); + if (held.length > 0) { + return [await apiCheck(held[0]!), await settingsCheck(), { id: 'live-instance', ok: true, state: 'not-run', detail: `not run: this worktree already holds ${held.map((h) => h.id).join(', ')}, which the checks above read` }]; + } + const started = Date.now(); + try { + const [made] = await ensure({ willChange: false }, progress); + const api = await apiCheck(throwaway.held()[0] ?? null); + const inspected = await settingsCheck(); + const finished = await finish(workspace, { keepApplications: false }, progress); + const what = made === undefined ? 'an application' : `${made.id} (${made.name})`; + return [api, inspected, check('live-instance', api.ok, `created ${what}, configured it, compared its environment, and deleted it (${count(finished.length, 'application')} gone from the list) in ${Math.round((Date.now() - started) / 1000)}s`, BACKEND_API_FIX)]; + } catch (error) { + const failure = error instanceof VerifyFailure ? error : new VerifyFailure('NOT_READY', (error as Error).message, '{cli} down deletes anything it left'); + await finish(workspace, { keepApplications: false }, progress).catch(() => undefined); + return [check('live-instance', false, failure.message, failure.fix)]; + } + }); + } + + return { + access, + recordedKey: () => throwaway.held().find((application) => application.settings !== null)?.settings?.key ?? null, + keys: () => current().keys, + clerk: () => { + current(); + return appliedClerk; + }, + ensure: (options, progress) => locked(() => ensure(options, progress)), + + async apply(group, progress) { + if (applied !== undefined) throw new Error('an instance is still applied: release it before applying the next group'); + const application = await locked(async () => { + const made = await throwaway.apply(group, progress); + if (made.created) await ownKeyAccepted(made.id, made.keys, progress); + return made; + }); + applied = { + keys: application.keys, + instance: { id: application.id, name: application.name }, + changed: application.changed, + stillApplied: application.stillApplied, + release: async () => { + applied = undefined; + await locked(application.release); + }, + }; + return applied; + }, + + finish: (ledger, options, progress) => (ledger.root === workspace.root ? locked(() => finish(ledger, options, progress)) : finish(ledger, options, progress)), + + async doctorChecks(options, progress) { + const failed = (error: unknown, prefix = ''): DoctorCheck => { + const failure = error instanceof VerifyFailure ? error : new VerifyFailure('NOT_READY', (error as Error).message, 'run `{cli} doctor` again'); + return check('instances', false, `${prefix}${failure.message}`, failure.fix); + }; + const held = throwaway.held(); + const holding = held.length === 0 ? 'none created yet' : held.map((h) => `${h.id} (${h.name}) on ${h.settings?.label ?? 'unknown settings'}`).join(', '); + let reaches: DoctorCheck; + try { + const open = await platform.open(); + reaches = check('instances', true, `${describeCredential(open.credential)} reaches the verification workspace ${open.workspace}; ${holding}`, ''); + } catch (error) { + if (openApplications(workspace).length === 0) return [failed(error)]; + reaches = failed(error, `${holding}; `); + } + if (options.live && reaches.ok) return [reaches, ...(await liveCheck(progress))]; + return [reaches, await apiCheck(held[0] ?? null), await settingsCheck()]; + }, + }; +} diff --git a/.claude/skills/verify-clerk-expo/src/core/instances/platform.ts b/.claude/skills/verify-clerk-expo/src/core/instances/platform.ts new file mode 100644 index 00000000000..bb935c4314d --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/instances/platform.ts @@ -0,0 +1,376 @@ +import { readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { sleep as defaultSleep, type Runner } from '../exec.ts'; +import { Secret } from '../secret.ts'; +import { VerifyFailure, type Brand, type Json, type PublishableKey } from '../types.ts'; +import { SettingsRefused } from './settings.ts'; + +export const THROWAWAY_PREFIX = 'verify-throwaway-'; + +const DEADLINE = /^verify-throwaway-until-(\d{4})(\d{2})(\d{2})t(\d{2})(\d{2})z-[0-9a-f]{8}$/; + +export function throwawayName(deadline: Date, random: string): string { + const stamp = deadline.toISOString().replace(/[-:]/g, '').slice(0, 13).toLowerCase(); + return `${THROWAWAY_PREFIX}until-${stamp}z-${random}`; +} + +export function deadlineOf(name: string): Date | null { + const match = DEADLINE.exec(name); + if (match === null) return null; + const [, year, month, day, hour, minute] = match; + return new Date(Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute))); +} + +const VERIFICATION_WORKSPACE = 'org_3KHungJxbvIscuSvy8oos5MHAli'; + +const REFERENCE_SHAPE = 'op:////credential'; +const REFERENCE_FILE = ['.verify', 'clerk-platform-key-reference'] as const; +const REFERENCED_ITEM = 'the 1Password item the reference names'; + +const PLATFORM_API = 'https://api.clerk.com/v1/platform'; +const SCOPES = 'applications:read, applications:manage, and applications:delete'; +const OP_WAIT_SECONDS = 60; +const GUARD_FRESH_MS = 30_000; +const RATE_LIMIT_WAITS = [2, 4, 8, 16, 30, 30] as const; +const RETRY_AFTER_CAP_SECONDS = 60; +const NOT_ABOUT_THE_BODY: ReadonlySet = new Set([401, 403, 404, 408]); + +export type PlatformCredential = + | { readonly via: 'environment'; readonly variable: 'CLERK_PLATFORM_API_KEY' | 'CLERK_PLATFORM_API_KEY_FILE'; readonly key: Secret<'clerk-platform-key'> } + | { readonly via: 'one-password'; readonly key: Secret<'clerk-platform-key'> }; + +export function describeCredential(credential: PlatformCredential): string { + switch (credential.via) { + case 'environment': + return credential.variable; + case 'one-password': + return '1Password'; + default: { + const exhaustive: never = credential; + return exhaustive; + } + } +} + +export type ThrowawayApplication = Brand<{ readonly id: string; readonly name: string }, 'ThrowawayApplication'>; + +export function throwawayApplication(id: string, name: string): ThrowawayApplication { + if (!name.startsWith(THROWAWAY_PREFIX)) throw new Error(`${name} lacks the ${THROWAWAY_PREFIX} prefix`); + return { id, name } as ThrowawayApplication; +} + +export interface CreatedApplication { + readonly application: ThrowawayApplication; + readonly instanceId: string; + readonly pk: PublishableKey; + readonly sk: Secret<'clerk-secret-key'>; +} + +export interface Listing { + readonly applications: readonly ThrowawayApplication[]; + readonly at: Date | null; +} + +export interface OpenWorkspace { + readonly credential: PlatformCredential; + readonly workspace: string; + readonly opened: Listing; + list(): Promise; + create(name: string): Promise; + configure(created: Pick, config: { readonly [key: string]: Json }, options?: { readonly dryRun?: boolean }): Promise<{ readonly after: Json }>; + delete(application: ThrowawayApplication): Promise; +} + +export interface Platform { + open(): Promise; + requests(): number; +} + +export interface PlatformDeps { + readonly env: Readonly>; + readonly runner: Runner; + readonly progress: (line: string) => void; + readonly fetch?: typeof fetch; + readonly sleep?: (ms: number) => Promise; + readonly now?: () => number; +} + +interface Answer { + readonly status: number; + readonly json: unknown; + readonly codes: readonly string[]; + readonly date: Date | null; +} + +type Key = Secret<'clerk-platform-key'>; + +const noCredential = (tried: readonly string[], onAMac: string): VerifyFailure => + new VerifyFailure( + 'KEYS_MISSING', + `no Clerk Platform API credential works here (${tried.join('; ')})`, + `on a Mac, ${onAMac}; anywhere, set CLERK_PLATFORM_API_KEY to the team key, or CLERK_PLATFORM_API_KEY_FILE to a file that holds it and that only you can read`, + ); + +type KeyReference = Secret<'one-password-reference'>; + +function keyReference(env: PlatformDeps['env']): KeyReference | null { + const held = (value: string, source: string): KeyReference => { + if (!value.startsWith('op://')) throw new VerifyFailure('USAGE', `${source} does not hold a 1Password secret reference`, `put a reference of the shape ${REFERENCE_SHAPE} there, or remove it`); + return new Secret('one-password-reference', value); + }; + const inline = env.VERIFY_PLATFORM_KEY_REFERENCE?.trim() ?? ''; + if (inline !== '') return held(inline, 'VERIFY_PLATFORM_KEY_REFERENCE'); + const home = env.HOME ?? ''; + if (home === '') return null; + const file = join(home, ...REFERENCE_FILE); + let text: string; + try { + text = readFileSync(file, 'utf8'); + } catch (error) { + const { code } = error as NodeJS.ErrnoException; + if (code === 'ENOENT') return null; + throw new VerifyFailure('USAGE', `${file} cannot be read (${code ?? (error as Error).message})`, `make it readable by you alone (chmod 600 ${file}), or remove it`); + } + const line = text.split('\n').map((each) => each.trim()).find((each) => each !== ''); + return line === undefined ? null : held(line, file); +} + +function withoutReference(text: string, reference: string): string { + const named = reference.slice('op://'.length).split('/').slice(0, 2); + return [reference, ...named].filter((part) => part !== '').reduce((out, part) => out.split(part).join(''), text); +} + +function keyFromText(text: string, source: string): Secret<'clerk-platform-key'> { + const value = text.trim(); + if (!/^ak_[A-Za-z0-9_-]+$/.test(value)) throw new VerifyFailure('KEYS_MISSING', `${source} does not hold a Clerk Platform API key (those start with ak_)`, `put the team key in ${source}, or unset it`); + return new Secret('clerk-platform-key', value); +} + +function environmentCredential(env: PlatformDeps['env']): Extract | null { + const inline = env.CLERK_PLATFORM_API_KEY; + if (inline !== undefined && inline.trim() !== '') return { via: 'environment', variable: 'CLERK_PLATFORM_API_KEY', key: keyFromText(inline, 'CLERK_PLATFORM_API_KEY') }; + const file = env.CLERK_PLATFORM_API_KEY_FILE; + if (file === undefined || file.trim() === '') return null; + let mode: number; + try { + mode = statSync(file).mode; + } catch { + throw new VerifyFailure('KEYS_MISSING', `CLERK_PLATFORM_API_KEY_FILE names ${file}, which does not exist`, 'unset CLERK_PLATFORM_API_KEY_FILE, or point it at a file that holds the key'); + } + if ((mode & 0o077) !== 0) throw new VerifyFailure('KEYS_MISSING', `${file} can be read by other users of this machine`, `chmod 600 ${file}`); + return { via: 'environment', variable: 'CLERK_PLATFORM_API_KEY_FILE', key: keyFromText(readFileSync(file, 'utf8'), file) }; +} + +export function createPlatform(deps: PlatformDeps): Platform { + const request = deps.fetch ?? fetch; + const sleep = deps.sleep ?? defaultSleep; + const now = deps.now ?? Date.now; + let sent = 0; + let opened: Promise | undefined; + + async function send(key: Key, method: string, path: string, body?: unknown): Promise { + for (let attempt = 0; ; attempt += 1) { + sent += 1; + const headers: Record = { 'User-Agent': 'verify-instances' }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + const call = (authorization: string) => + request(`${PLATFORM_API}${path}`, { + method, + headers: { ...headers, Authorization: authorization }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + signal: AbortSignal.timeout(30_000), + }); + let response: Response; + try { + response = await key.use('platform-authorization', (plain) => call(`Bearer ${plain}`)); + } catch (error) { + const cause = (error as { cause?: { code?: string } }).cause?.code; + throw new VerifyFailure('NOT_READY', `Clerk's Platform API did not answer ${method} ${path}: ${(error as Error).message}${cause === undefined ? '' : ` (${cause})`}`, 'check network access to api.clerk.com'); + } + const wait = RATE_LIMIT_WAITS[attempt]; + if (response.status === 429 && wait !== undefined) { + const asked = Number(response.headers.get('retry-after')); + const seconds = Number.isFinite(asked) && asked > 0 ? Math.min(asked, RETRY_AFTER_CAP_SECONDS) : wait; + deps.progress(`wait Clerk's Platform API is rate limiting the verification workspace (100 requests a minute, shared by every session); retrying ${method} ${path} in ${seconds}s`); + await response.arrayBuffer().catch(() => undefined); + await sleep(seconds * 1000); + continue; + } + const text = await response.text(); + let json: unknown = null; + try { + json = text === '' ? null : JSON.parse(text); + } catch { + json = null; + } + const errors = (json as { errors?: { code?: unknown }[] } | null)?.errors; + const date = Date.parse(response.headers.get('date') ?? ''); + return { status: response.status, json, codes: Array.isArray(errors) ? errors.map((e) => (typeof e.code === 'string' ? e.code : 'unknown')) : [], date: Number.isNaN(date) ? null : new Date(date) }; + } + } + + const said = (answer: Answer): string => `${answer.status}${answer.codes.length === 0 ? '' : ` ${[...new Set(answer.codes)].join(', ')}`}`; + + function refused(answer: Answer, what: string): VerifyFailure { + if (answer.status === 429) return new VerifyFailure('RATE_LIMITED', `Clerk's Platform API kept rate limiting ${what}`, 'wait a minute and rerun; the limit is 100 requests a minute for the whole verification workspace'); + if (answer.codes.includes('authorization_missing_scopes')) return new VerifyFailure('KEYS_MISSING', `the Platform API key lacks a scope that ${what} needs (${said(answer)})`, `use a key with ${SCOPES}`); + return new VerifyFailure('NOT_READY', `Clerk's Platform API answered ${said(answer)} to ${what}`, 'rerun; if it repeats, run `{cli} doctor`'); + } + + async function reaches(key: Key): Promise<{ readonly workspace: string } | { readonly refusal: string }> { + const answer = await send(key, 'GET', '/me'); + if (answer.status === 401 || answer.status === 403) return { refusal: said(answer) }; + const subject = (answer.json as { subject?: unknown } | null)?.subject; + if (answer.status !== 200 || typeof subject !== 'string') throw refused(answer, 'the request that asks which workspace the key belongs to'); + return { workspace: subject }; + } + + const keyOf = (credential: PlatformCredential): string => (credential.via === 'one-password' ? `the key in ${REFERENCED_ITEM}` : describeCredential(credential)); + + function wrongWorkspace(found: string, credential: PlatformCredential): VerifyFailure { + return new VerifyFailure( + 'KEYS_MISSING', + `${keyOf(credential)} belongs to workspace ${found}, and verification creates applications only in ${VERIFICATION_WORKSPACE}`, + 'use the team key; a key of any other workspace is refused', + ); + } + + async function resolve(): Promise { + let reference: KeyReference | null = null; + let unreadable: unknown; + try { + reference = keyReference(deps.env); + } catch (error) { + unreadable = error; + } + const checked = async (credential: PlatformCredential, key: Key, onRefusal: (refusal: string) => VerifyFailure): Promise => { + const reached = await reaches(key); + if ('refusal' in reached) throw onRefusal(reached.refusal); + if (reached.workspace !== VERIFICATION_WORKSPACE) throw wrongWorkspace(reached.workspace, credential); + return credential; + }; + + const fromEnvironment = environmentCredential(deps.env); + if (fromEnvironment !== null) { + return checked(fromEnvironment, fromEnvironment.key, (refusal) => new VerifyFailure('KEYS_MISSING', `${fromEnvironment.variable} is set, and Clerk's Platform API answered ${refusal} to it`, 'set it to the team key, or unset it so the next source is tried')); + } + const tried = ['CLERK_PLATFORM_API_KEY and CLERK_PLATFORM_API_KEY_FILE are not set']; + + if (unreadable !== undefined) throw unreadable; + if (reference === null) { + tried.push('no 1Password reference is set'); + throw noCredential( + tried, + `put the team key's 1Password secret reference (shape ${REFERENCE_SHAPE}; the team's private setup note has the real one) in VERIFY_PLATFORM_KEY_REFERENCE or as the one line of ~/${REFERENCE_FILE.join('/')}, with the 1Password CLI installed and its desktop app integration on`, + ); + } + if ((await deps.runner('op', ['--version'])).code !== 0) { + tried.push('the 1Password CLI (op) is not installed'); + throw noCredential(tried, 'install the 1Password CLI and turn on its desktop app integration'); + } + deps.progress(`wait reading the team key from 1Password; approve the request in the 1Password app within ${OP_WAIT_SECONDS}s`); + const read = await reference.use('one-password-read', async (plain) => { + const result = await deps.runner('op', ['read', '--no-newline', plain], { timeoutMs: OP_WAIT_SECONDS * 1000 }); + return { ...result, stderr: withoutReference(result.stderr, plain) }; + }); + if (read.code !== 0 || read.stdout.trim() === '') { + const why = read.code === 124 ? `was not approved within ${OP_WAIT_SECONDS}s` : `failed: ${read.stderr.trim().split('\n')[0] || `exit ${read.code}`}`; + throw new VerifyFailure('KEYS_MISSING', `op read of ${REFERENCED_ITEM} ${why}`, 'approve the request in the 1Password app (Settings, Developer, "Integrate with 1Password CLI" must be on) and rerun; or set CLERK_PLATFORM_API_KEY'); + } + const credential: PlatformCredential = { via: 'one-password', key: keyFromText(read.stdout, REFERENCED_ITEM) }; + return checked(credential, credential.key, (refusal) => new VerifyFailure('KEYS_MISSING', `Clerk's Platform API answered ${refusal} to the key in ${REFERENCED_ITEM}`, 'the item holds a key that no longer works; replace it with a current team key')); + } + + function parseEntries(json: unknown): readonly { readonly id: string; readonly name: string }[] | null { + if (!Array.isArray(json)) return null; + const out: { id: string; name: string }[] = []; + for (const item of json as readonly { application_id?: unknown; name?: unknown }[]) { + if (typeof item?.application_id !== 'string' || typeof item.name !== 'string') return null; + out.push({ id: item.application_id, name: item.name }); + } + return out; + } + + async function everyApplication(key: Key): Promise<{ readonly entries: readonly { readonly id: string; readonly name: string }[]; readonly date: Date | null }> { + const answer = await send(key, 'GET', '/applications'); + if (answer.status !== 200) throw refused(answer, 'the application list'); + const entries = parseEntries(answer.json); + if (entries === null) { + throw new VerifyFailure('NOT_READY', "Clerk's application list no longer has a shape this tool can read in full, so it cannot tell what the workspace holds", 'report this as a verify bug; nothing was created or deleted'); + } + return { entries, date: answer.date }; + } + + async function open(): Promise { + const credential = await resolve(); + const key: Key = credential.key; + let checkedAt = 0; + + async function list(): Promise { + const { entries, date } = await everyApplication(key); + const foreign = entries.filter((entry) => !entry.name.startsWith(THROWAWAY_PREFIX)); + if (foreign.length > 0) { + throw new VerifyFailure( + 'NOT_READY', + `workspace ${VERIFICATION_WORKSPACE} holds ${foreign.length} application(s) whose name does not start with ${THROWAWAY_PREFIX} (${foreign.map((entry) => entry.id).join(', ')}), so nothing is created or deleted there`, + 'the verification workspace must hold only throwaway applications; move those out of it', + ); + } + checkedAt = now(); + return { applications: entries.map((entry) => throwawayApplication(entry.id, entry.name)), at: date }; + } + + const guard = async (): Promise => { + if (now() - checkedAt <= GUARD_FRESH_MS) return; + const reached = await reaches(key); + if ('refusal' in reached) throw new VerifyFailure('KEYS_MISSING', `Clerk's Platform API answered ${reached.refusal} to ${keyOf(credential)}`, 'run `{cli} doctor`'); + if (reached.workspace !== VERIFICATION_WORKSPACE) throw wrongWorkspace(reached.workspace, credential); + await list(); + }; + + return { + credential, + workspace: VERIFICATION_WORKSPACE, + opened: await list(), + list, + async create(name) { + throwawayApplication('', name); + await guard(); + const answer = await send(key, 'POST', '/applications', { name }); + const body = answer.json as { application_id?: unknown; instances?: readonly { environment_type?: unknown; instance_id?: unknown; publishable_key?: unknown; secret_key?: unknown }[] } | null; + if (answer.status !== 200 && answer.status !== 201) throw refused(answer, `creating ${name}`); + const development = body?.instances?.find((instance) => instance.environment_type === 'development'); + if (typeof body?.application_id !== 'string' || typeof development?.instance_id !== 'string' || typeof development.publishable_key !== 'string' || typeof development.secret_key !== 'string') { + throw new VerifyFailure('NOT_READY', `Clerk created ${name} and the answer carries no development instance keys`, '{cli} down deletes it; then report this as a verify bug'); + } + return { + application: throwawayApplication(body.application_id, name), + instanceId: development.instance_id, + pk: development.publishable_key as PublishableKey, + sk: new Secret('clerk-secret-key', development.secret_key), + }; + }, + async configure(created, config, options = {}) { + const answer = await send(key, 'PATCH', `/applications/${created.application.id}/instances/${created.instanceId}/config${options.dryRun === true ? '?dry_run=true' : ''}`, config); + if (answer.status === 200) return { after: (answer.json as { after?: Json } | null)?.after ?? null }; + const first = (answer.json as { errors?: readonly { message?: unknown; long_message?: unknown; meta?: { param_name?: unknown } }[] } | null)?.errors?.[0]; + const param = typeof first?.meta?.param_name === 'string' ? first.meta.param_name : null; + if (answer.status < 400 || answer.status >= 500 || answer.status === 429 || (param === null && NOT_ABOUT_THE_BODY.has(answer.status))) throw refused(answer, `configuring ${created.application.name}`); + const message = typeof first?.long_message === 'string' ? first.long_message : typeof first?.message === 'string' ? first.message : null; + const what = param === null ? said(answer) : `${param} (${said(answer)})`; + const refusal = { param, said: message === null ? what : `${what}: ${message}` }; + throw new SettingsRefused(`Clerk's Platform API refused the config of ${created.application.name}: ${refusal.said}`, 'run `{cli} doctor`', refusal); + }, + async delete(application) { + await guard(); + const answer = await send(key, 'DELETE', `/applications/${application.id}`); + if (answer.status !== 200 && answer.status !== 404) throw refused(answer, `deleting ${application.name}`); + }, + }; + } + + return { + open: () => (opened ??= open()), + requests: () => sent, + }; +} diff --git a/.claude/skills/verify-clerk-expo/src/core/instances/settings.ts b/.claude/skills/verify-clerk-expo/src/core/instances/settings.ts new file mode 100644 index 00000000000..b507978ea05 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/instances/settings.ts @@ -0,0 +1,409 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { VerifyFailure, type InstanceSettings, type Json, type SpecRef } from '../types.ts'; +import type { Leaves } from './definitions.ts'; + +type JsonObject = { readonly [key: string]: Json }; + +export interface StandardFile { + readonly config: JsonObject; + readonly environment: Leaves; + readonly defaults: Leaves; +} + +export const STANDARD_FILE = 'src/core/instances/base.json'; + +const standard = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'base.json'), 'utf8')) as StandardFile; + +export const standardFile = (): StandardFile => standard; + +export interface Settings { + readonly key: string; + readonly label: string; + readonly declared: InstanceSettings | null; + readonly askedBy: string | null; +} + +export interface PlannedSpec extends SpecRef { + readonly sourceHash: string; +} + +export interface SettingsGroup { + readonly settings: Settings; + readonly specs: readonly PlannedSpec[]; +} + +export class SettingsRefused extends VerifyFailure { + readonly param: string | null; + readonly said: string; + constructor(message: string, fix: string, refusal: { readonly param: string | null; readonly said: string } = { param: null, said: message }) { + super('INSTANCE_MISCONFIGURED', message, fix); + this.param = refusal.param; + this.said = refusal.said; + } +} + +const isObject = (value: Json | undefined): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value); + +function canonical(value: Json): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (isObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key]!)}`).join(',')}}`; + return JSON.stringify(value); +} + +function merge(base: JsonObject, over: JsonObject): JsonObject { + const out: Record = { ...base }; + for (const [key, value] of Object.entries(over)) { + const under = out[key]; + out[key] = isObject(value) && isObject(under) ? merge(under, value) : value; + } + return out; +} + +export function configLeaves(value: Json, path = '', out: Record = {}): Leaves { + if (isObject(value) && Object.keys(value).length > 0) { + for (const [key, child] of Object.entries(value)) configLeaves(child, path === '' ? key : `${path}.${key}`, out); + } else { + out[path] = value; + } + return out; +} + +const unordered = (value: Json): Json => (Array.isArray(value) ? value.map(canonical).sort() : value); + +export const sameLeaf = (a: Json | undefined, b: Json | undefined): boolean => a !== undefined && b !== undefined && canonical(unordered(a)) === canonical(unordered(b)); + +const keyOf = (config: JsonObject): string => createHash('sha256').update(canonical(config)).digest('hex').slice(0, 12); + +export const STANDARD: Settings = { key: keyOf(standard.config), label: 'standard', declared: null, askedBy: null }; + +export const STANDARD_ENVIRONMENT_KEY = keyOf({ environment: standard.environment, defaults: standard.defaults }); + +export const sourceHash = (source: string): string => createHash('sha256').update(source).digest('hex'); + +const FORM = "export const instanceSettings: InstanceSettings = { config: { auth_multi_factor: { required_for_sign_up: true } }, environment: { 'user_settings.sign_up.mfa.required': true } };"; + +const unreadable = (specPath: string, why: string): VerifyFailure => + new VerifyFailure( + 'USAGE', + `${specPath}: ${why}`, + `write the settings once, as one plain literal: \`${FORM}\`. The tool reads it from the file's text and never runs the file, so no variable, spread, call, computed key, or \${} is allowed, and the word instanceSettings may appear nowhere else in the file`, + ); + +const ESCAPES: Readonly> = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', '/': '/', '\\': '\\', "'": "'", '"': '"', '`': '`' }; +const MAX_NESTING = 32; + +function hiddenAt(source: string, index: number): string | null { + const substitutions: number[] = []; + let inTemplate = false; + let at = 0; + while (at < index) { + const char = source[at]!; + if (inTemplate) { + if (char === '\\') at += 2; + else if (char === '$' && source[at + 1] === '{') { + substitutions.push(0); + inTemplate = false; + at += 2; + } else { + inTemplate = char !== '`'; + at += 1; + } + } else if (char === '/') { + const line = source[at + 1] === '/'; + if (!line && source[at + 1] !== '*') return 'a `/` before its instanceSettings export may start a regular expression, so the tool cannot tell whether the export is code; move the export above that line'; + const end = source.indexOf(line ? '\n' : '*/', at + 2); + if (end === -1 || end >= index) return 'its instanceSettings export is inside a comment'; + at = end + (line ? 1 : 2); + } else if (char === "'" || char === '"') { + at += 1; + while (source[at] !== char) { + if (at >= index) return 'its instanceSettings export is inside a string'; + if (source[at] === '\n') return 'a string before its instanceSettings export never ends on its line'; + at += source[at] === '\\' ? 2 : 1; + } + at += 1; + } else { + const open = substitutions.length - 1; + if (char === '`') inTemplate = true; + else if (char === '{' && open >= 0) substitutions[open]! += 1; + else if (char === '}' && open >= 0) { + if (substitutions[open] === 0) { + substitutions.pop(); + inTemplate = true; + } else substitutions[open]! -= 1; + } + at += 1; + } + } + return inTemplate || substitutions.length > 0 ? 'its instanceSettings export is inside a template string' : null; +} + +function parseLiteral(source: string, start: number, fail: (why: string) => never): { readonly value: Json; readonly end: number; readonly next: number } { + let at = start; + + const skip = (): void => { + for (;;) { + if (/\s/.test(source[at] ?? '')) at += 1; + else if (source.startsWith('//', at)) { + const end = source.indexOf('\n', at); + at = end === -1 ? source.length : end; + } else if (source.startsWith('/*', at)) { + const end = source.indexOf('*/', at + 2); + if (end === -1) fail('a comment inside instanceSettings never ends'); + at = end + 2; + } else return; + } + }; + + const text = (): string => { + const quote = source[at]!; + at += 1; + let out = ''; + for (;;) { + const char = source[at]; + if (char === undefined || (char === '\n' && quote !== '`')) fail('a string inside instanceSettings never ends'); + at += 1; + if (char === quote) return out; + if (quote === '`' && char === '$' && source[at] === '{') fail('instanceSettings holds a template string with ${}, which is not a plain value'); + if (char !== '\\') { + out += char; + continue; + } + const escaped = source[at]; + if (escaped === undefined) fail('a string inside instanceSettings never ends'); + at += 1; + if (escaped === 'u' || escaped === 'x') { + const braced = escaped === 'u' && source[at] === '{'; + const close = braced ? source.indexOf('}', at) : at + (escaped === 'u' ? 4 : 2); + const hex = source.slice(braced ? at + 1 : at, close); + if (close === -1 || !/^[0-9a-fA-F]+$/.test(hex) || Number.parseInt(hex, 16) > 0x10ffff) fail('a string inside instanceSettings has an escape that cannot be read'); + out += String.fromCodePoint(Number.parseInt(hex, 16)); + at = braced ? close + 1 : close; + } else { + const plain = ESCAPES[escaped]; + if (plain === undefined) fail(`a string inside instanceSettings has an escape this tool does not read (${JSON.stringify(`\\${escaped}`)}); it reads \\n \\t \\r \\b \\f \\/ \\\\ \\' \\" \\\` \\uXXXX \\xXX`); + out += plain; + } + } + }; + + const key = (): string => { + const char = source[at] ?? ''; + if (char === "'" || char === '"') return text(); + if (char === '[') fail('instanceSettings has a computed key; write the key itself'); + if (source.startsWith('...', at)) fail('instanceSettings has a spread; write every setting out'); + const name = /^[A-Za-z_$][\w$]*/.exec(source.slice(at, at + 200))?.[0]; + if (name === undefined) fail(`instanceSettings has something that is not a key at "${source.slice(at, at + 20).split('\n')[0]}"`); + at += name.length; + return name; + }; + + const value = (depth: number): Json => { + skip(); + const char = source[at] ?? ''; + if ((char === '{' || char === '[') && depth > MAX_NESTING) fail(`instanceSettings nests more than ${MAX_NESTING} levels deep`); + if (char === '{') { + at += 1; + const out: Record = {}; + for (;;) { + skip(); + if (source[at] === '}') break; + const name = key(); + if (name === '__proto__') fail('instanceSettings has a __proto__ key'); + if (Object.hasOwn(out, name)) fail(`instanceSettings sets ${name} twice`); + skip(); + if (source[at] !== ':') fail(`instanceSettings gives ${name} no value of its own; write \`${name}: \``); + at += 1; + out[name] = value(depth + 1); + skip(); + if (source[at] === ',') at += 1; + else if (source[at] !== '}') fail(`instanceSettings is not a plain literal after ${name}`); + } + at += 1; + return out; + } + if (char === '[') { + at += 1; + const out: Json[] = []; + for (;;) { + skip(); + if (source[at] === ']') break; + if (source.startsWith('...', at)) fail('instanceSettings has a spread; write every value out'); + out.push(value(depth + 1)); + skip(); + if (source[at] === ',') at += 1; + else if (source[at] !== ']') fail('instanceSettings holds a list that is not a plain literal'); + } + at += 1; + return out; + } + if (char === "'" || char === '"' || char === '`') return text(); + const number = /^-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?(?![\w$.])/.exec(source.slice(at, at + 64))?.[0]; + if (number !== undefined) { + if (!Number.isFinite(Number(number))) fail(`instanceSettings holds ${number}, which is not a finite number`); + at += number.length; + return Number(number); + } + const word = /^(?:true|false|null)(?![\w$])/.exec(source.slice(at, at + 6))?.[0]; + if (word !== undefined) { + at += word.length; + return word === 'null' ? null : word === 'true'; + } + return fail(`instanceSettings holds something that is not a plain value at "${source.slice(at, at + 24).split('\n')[0]}" (a variable, a shared constant, or a call)`); + }; + + const parsed = value(1); + let end = at; + for (;;) { + skip(); + const suffix = /^(?:as\s+const|satisfies\s+[A-Za-z_$][\w$.]*)(?![\w$])/.exec(source.slice(at, at + 200))?.[0]; + if (suffix === undefined) break; + at += suffix.length; + end = at; + } + return { value: parsed, end, next: at }; +} + +const EXPORT = /^export\s+const\s+instanceSettings\s*(?::\s*[A-Za-z_$][\w$.]*\s*)?=/gm; +const CONTINUES = /^(?:[([.`+\-*/%&|^<>=?,]|!=|(?:in|instanceof|as|satisfies)(?![\w$]))/; + +export function declaredIn(source: string, specPath: string): InstanceSettings | null { + const mentions = source.match(/(? never = (why) => { + throw unreadable(specPath, why); + }; + const exports = [...source.matchAll(EXPORT)]; + if (exports.length > 1) fail('it exports instanceSettings more than once'); + const found = exports[0]; + if (found === undefined) return fail('it mentions instanceSettings, and has no line that starts with `export const instanceSettings =` followed by the literal'); + if (mentions > 1) fail('it mentions instanceSettings in more than one place (a comment, a string, a second export, or a use of the value)'); + const hidden = hiddenAt(source, found.index); + if (hidden !== null) fail(hidden); + + const literal = parseLiteral(source, found.index + found[0].length, fail); + const after = source.slice(literal.end); + const rest = after.replace(/^[ \t]*;?[ \t]*(?:\/\/[^\n]*)?/, ''); + if (rest !== '' && !/^\r?\n/.test(rest)) fail('something follows the instanceSettings literal on the same line; end it with `;`'); + if (!/^[ \t]*;/.test(after) && CONTINUES.test(source.slice(literal.next, literal.next + 16))) fail('the line after the instanceSettings literal continues the expression, so JavaScript reads it as part of the value; end the literal with `;`'); + + const declared = literal.value; + if (!isObject(declared)) return fail('instanceSettings is not an object'); + const extra = Object.keys(declared).filter((name) => name !== 'config' && name !== 'environment'); + if (extra.length > 0) fail(`instanceSettings has ${extra.join(', ')}, and only config and environment are read`); + const { config, environment } = declared; + if (!isObject(config) || Object.keys(config).length === 0) return fail('instanceSettings needs a config object with the Platform API settings to change'); + if (!isObject(environment) || Object.keys(environment).length === 0) { + return fail('instanceSettings needs an environment object with at least one leaf of the instance\'s public environment that shows the change'); + } + for (const [leaf, value] of Object.entries(environment)) { + const scalar = (item: Json): boolean => typeof item !== 'object' || item === null; + if (!(scalar(value) || (Array.isArray(value) && value.every(scalar)))) fail(`environment leaf ${leaf} is an object; write each leaf by its full dotted path, as in 'user_settings.sign_up.mfa.required': true`); + } + return { config, environment }; +} + +const where = (askedBy: string | null): string => (askedBy === null ? 'the declaration' : askedBy); + +const KEYS_LISTED = 12; +const listed = (keys: readonly string[]): string => `${keys.slice(0, KEYS_LISTED).join(', ')}${keys.length > KEYS_LISTED ? `, and ${keys.length - KEYS_LISTED} more` : ''}`; + +function unpinned(leaf: string, value: Json, askedBy: string | null): VerifyFailure { + const segments = leaf.split('.'); + let found: Json = standard.config; + let depth = 0; + while (depth < segments.length && isObject(found) && Object.hasOwn(found, segments[depth]!)) { + found = found[segments[depth]!]!; + depth += 1; + } + if (depth === segments.length && isObject(found)) { + return new VerifyFailure( + 'INSTANCE_MISCONFIGURED', + `${where(askedBy)} declares ${leaf} as ${JSON.stringify(value)}, and the standard file has an object there`, + `declare the keys of \`${leaf}\` to change, each by its own name; the standard file has ${listed(Object.keys(found))} there`, + ); + } + const beside = isObject(found) ? `the standard file has ${listed(Object.keys(found))} ${depth === 0 ? 'at the top of `config`' : `under \`${segments.slice(0, depth).join('.')}\``}; ` : ''; + return new VerifyFailure( + 'INSTANCE_MISCONFIGURED', + `${where(askedBy)} declares ${leaf}, and the standard file has no value to return it to`, + `check the spelling against Clerk's Platform API config; ${beside}add the standard value of \`${leaf}\` to \`config\` in ${STANDARD_FILE} (shared core: run \`node src/core/manifest.ts --write\` and copy \`src/core\` to the other repos); a setting the Platform API config has no key for cannot be declared`, + ); +} + +export function settingsOf(declared: InstanceSettings | null, askedBy: string | null): Settings { + if (declared === null) return STANDARD; + const pinned = configLeaves(standard.config); + const leaves = configLeaves(declared.config); + for (const [leaf, value] of Object.entries(leaves)) { + if (!Object.hasOwn(pinned, leaf)) throw unpinned(leaf, value, askedBy); + } + const body = merge(standard.config, declared.config); + if (canonical(body) === canonical(standard.config)) { + throw new VerifyFailure('USAGE', `${where(askedBy)} declares only standard values (${Object.keys(leaves).join(', ')})`, 'remove the instanceSettings export; a spec with none runs on the standard instance'); + } + const known = { ...standard.defaults, ...standard.environment }; + for (const [leaf, value] of Object.entries(declared.environment)) { + if (!Object.hasOwn(known, leaf)) { + throw new VerifyFailure( + 'USAGE', + `${where(askedBy)} expects the environment leaf ${leaf}, which ${STANDARD_FILE} does not list`, + 'use a leaf of the instance\'s public /v1/environment by its full dotted path; a failed change lists the leaves a setting moves', + ); + } + if (sameLeaf(known[leaf], value)) { + throw new VerifyFailure( + 'USAGE', + `${where(askedBy)} expects ${leaf} to be ${JSON.stringify(value)}, which is its standard value, so it cannot show that the change took effect`, + 'declare a leaf the setting changes; a failed change lists the leaves it moved', + ); + } + } + const label = Object.entries(leaves).map(([leaf, value]) => `${leaf}=${JSON.stringify(value)}`).join(', '); + return { key: keyOf(body), label, declared, askedBy }; +} + +export function configFor(settings: Settings): JsonObject { + return settings.declared === null ? standard.config : merge(standard.config, settings.declared.config); +} + +export function expectedEnvironment(settings: Settings): Leaves { + const declared = Object.fromEntries(Object.entries(settings.declared?.environment ?? {}).map(([leaf, value]) => [leaf, Array.isArray(value) ? value.map(String).sort() : value])); + return { ...standard.defaults, ...standard.environment, ...declared }; +} + + +export function planGroups(specs: readonly { readonly spec: SpecRef; readonly source: string }[], applied: string | null): readonly SettingsGroup[] { + const groups = new Map }>(); + for (const { spec, source } of specs) { + const settings = settingsOf(declaredIn(source, spec.path), spec.path); + const planned: PlannedSpec = { ...spec, sourceHash: sourceHash(source) }; + const group = groups.get(settings.key); + if (group === undefined) { + groups.set(settings.key, { settings, specs: [planned], leafFrom: new Map(Object.keys(settings.declared?.environment ?? {}).map((leaf) => [leaf, spec.path])) }); + continue; + } + group.specs.push(planned); + if (settings.declared === null || group.settings.declared === null) continue; + const environment: Record = { ...group.settings.declared.environment }; + for (const [leaf, value] of Object.entries(settings.declared.environment)) { + const first = group.leafFrom.get(leaf); + if (first !== undefined && !sameLeaf(environment[leaf], value)) { + throw new VerifyFailure( + 'USAGE', + `${first} and ${spec.path} declare the same config and expect different values of ${leaf} (${JSON.stringify(environment[leaf])} and ${JSON.stringify(value)})`, + 'one config shows one environment: make the two declarations expect the same value', + ); + } + if (first === undefined) group.leafFrom.set(leaf, spec.path); + environment[leaf] = value; + } + group.settings = { ...group.settings, declared: { config: group.settings.declared.config, environment } }; + } + const ordered = [...groups.values()].map(({ settings, specs: files }): SettingsGroup => ({ settings, specs: files })); + const rank = (group: SettingsGroup): number => (group.settings.key === applied ? 0 : group.settings.key === STANDARD.key ? 2 : 1); + return ordered.map((group, index) => ({ group, index })).sort((a, b) => rank(a.group) - rank(b.group) || a.index - b.index).map(({ group }) => group); +} diff --git a/.claude/skills/verify-clerk-expo/src/core/instances/throwaway.ts b/.claude/skills/verify-clerk-expo/src/core/instances/throwaway.ts new file mode 100644 index 00000000000..5f102f65aa9 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/instances/throwaway.ts @@ -0,0 +1,547 @@ +import { randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { DEVELOPMENT_USER_LIMIT, REPLACE_AT_USERS, frontendApiHost, type ClerkBackend } from '../clerk.ts'; +import { sleep as defaultSleep } from '../exec.ts'; +import type { InstanceKeys } from '../keys.ts'; +import { Secret } from '../secret.ts'; +import { count } from '../state.ts'; +import { newEntryId, type Workspace } from '../workspace.ts'; +import { VerifyFailure, type ApplicationView, type InstanceView, type Json, type LedgerEntry, type ProcessRef, type PublishableKey } from '../types.ts'; +import { compareEnvironment, describeDifference, type EnvironmentComparison, type Leaves } from './definitions.ts'; +import { deadlineOf, throwawayApplication, throwawayName, type Listing, type OpenWorkspace, type Platform } from './platform.ts'; +import { STANDARD, STANDARD_ENVIRONMENT_KEY, STANDARD_FILE, SettingsRefused, configFor, configLeaves, expectedEnvironment, sameLeaf, settingsOf, standardFile, type Settings, type SettingsGroup } from './settings.ts'; + +type ApplicationEntry = Extract; + +interface CachedKeys { + readonly application: string; + readonly instanceId: string; + readonly pk: PublishableKey; + readonly sk: string; +} + +interface Held { + readonly entry: ApplicationEntry; + readonly keys: CachedKeys; +} + +interface ApplicationState { + readonly settings?: Settings; + readonly drift: readonly string[] | null; + readonly drivers: readonly ProcessRef[]; +} + +const DEFAULT_LIFETIME_HOURS = 6; +const LIFETIME_HOURS = { min: 2, max: 72 } as const; +const REPLACE_WITHIN_MS = 60 * 60_000; +const REAP_GRACE_MS = 5 * 60_000; +const REAP_PER_COMMAND = 5; +const SHOWN_WITHIN_MS = 15_000; +const MOVED_LEAVES_LISTED = 20; + +export interface ThrowawayDeps { + readonly workspace: Workspace; + readonly platform: Platform; + readonly clerk: (keys: () => InstanceKeys) => ClerkBackend; + readonly env: Readonly>; + readonly self: ProcessRef; + readonly isRunning: (driver: ProcessRef) => boolean; + readonly fetch?: typeof fetch; + readonly sleep?: (ms: number) => Promise; + readonly now?: () => number; +} + +export interface HeldApplication { + readonly id: string; + readonly name: string; + readonly keys: InstanceKeys; + readonly settings: Settings | null; +} + +export interface AppliedApplication { + readonly id: string; + readonly name: string; + readonly keys: InstanceKeys; + readonly created: boolean; + readonly changed: { readonly answeredMs: number; readonly visibleMs: number } | null; + stillApplied(): Promise; + release(): void; +} + +export interface Inspection { + readonly application: HeldApplication; + readonly found: EnvironmentComparison | 'gone'; +} + +export interface Throwaway { + held(): readonly HeldApplication[]; + ensure(options: { readonly willChange: boolean }, progress: (line: string) => void): Promise<{ readonly views: readonly InstanceView[]; readonly created: HeldApplication | null }>; + apply(group: SettingsGroup, progress: (line: string) => void): Promise; + inspect(): Promise; + finish(ledger: Workspace, progress: (line: string) => void): Promise; +} + +export const openApplications = (ledger: Workspace): readonly ApplicationEntry[] => ledger.unclosedEntries().filter((entry): entry is ApplicationEntry => entry.kind === 'application'); + +const keysDir = (ledger: Workspace): string => join(ledger.root, 'instances'); +const cachedKeysFile = (ledger: Workspace, name: string): string => join(keysDir(ledger), `${name}.json`); +const stateFile = (ledger: Workspace, name: string): string => join(keysDir(ledger), `${name}.state.json`); + +function readKeys(ledger: Workspace, name: string): CachedKeys | null { + try { + const raw = JSON.parse(readFileSync(cachedKeysFile(ledger, name), 'utf8')) as Partial; + if (typeof raw.application !== 'string' || typeof raw.instanceId !== 'string' || typeof raw.pk !== 'string' || typeof raw.sk !== 'string') return null; + return raw as CachedKeys; + } catch { + return null; + } +} + +const UNKNOWN: ApplicationState = { drift: null, drivers: [] }; + +function recordedSettings(raw: unknown): Settings | undefined { + const record = raw as Partial | null | undefined; + if (typeof record !== 'object' || record === null || typeof record.key !== 'string') return undefined; + try { + const fresh = settingsOf(record.declared ?? null, typeof record.askedBy === 'string' ? record.askedBy : null); + return fresh.key === record.key ? fresh : undefined; + } catch { + return undefined; + } +} + +function readState(ledger: Workspace, name: string): ApplicationState { + try { + const raw = JSON.parse(readFileSync(stateFile(ledger, name), 'utf8')) as { environmentKey?: unknown; settings?: unknown; drift?: unknown; drivers?: unknown } | null; + if (typeof raw !== 'object' || raw === null) return UNKNOWN; + const current = raw.environmentKey === STANDARD_ENVIRONMENT_KEY; + const settings = current ? recordedSettings(raw.settings) : undefined; + const drift = current && Array.isArray(raw.drift) && raw.drift.every((leaf) => typeof leaf === 'string') ? (raw.drift as string[]) : null; + const drivers = Array.isArray(raw.drivers) ? (raw.drivers as Partial[]).flatMap((driver) => (typeof driver?.pid === 'number' && typeof driver.startedAt === 'number' ? [{ pid: driver.pid, startedAt: driver.startedAt }] : [])) : []; + return { ...(settings === undefined ? {} : { settings }), drift, drivers }; + } catch { + return UNKNOWN; + } +} + +function writeState(ledger: Workspace, name: string, state: ApplicationState): void { + const file = stateFile(ledger, name); + const staged = `${file}.${randomBytes(4).toString('hex')}.tmp`; + writeFileSync(staged, `${JSON.stringify({ environmentKey: STANDARD_ENVIRONMENT_KEY, ...(state.settings === undefined ? {} : { settings: state.settings }), ...(state.drift === null ? {} : { drift: state.drift }), drivers: state.drivers })}\n`, { mode: 0o600 }); + renameSync(staged, file); +} + +function lifetimeMs(env: ThrowawayDeps['env']): number { + const raw = env.VERIFY_THROWAWAY_HOURS; + if (raw === undefined || raw === '') return DEFAULT_LIFETIME_HOURS * 3_600_000; + const hours = Number(raw); + if (!Number.isInteger(hours) || hours < LIFETIME_HOURS.min || hours > LIFETIME_HOURS.max) { + throw new VerifyFailure('USAGE', `VERIFY_THROWAWAY_HOURS=${raw} is not a whole number from ${LIFETIME_HOURS.min} to ${LIFETIME_HOURS.max}`, 'unset VERIFY_THROWAWAY_HOURS or set it within range'); + } + return hours * 3_600_000; +} + +const excused = (settings: Settings, drift: readonly string[]): readonly string[] => drift.filter((leaf) => !Object.hasOwn(settings.declared?.environment ?? {}, leaf)); + +function compareWith(settings: Settings, drift: readonly string[], live: Json): EnvironmentComparison { + const ignored = new Set(excused(settings, drift)); + const expected = Object.entries(expectedEnvironment(settings)); + return compareEnvironment({ environment: Object.fromEntries(expected.filter(([leaf]) => !ignored.has(leaf))), defaults: Object.fromEntries(expected.filter(([leaf]) => ignored.has(leaf))) }, live); +} + +interface Moved { + readonly compared: number; + readonly answeredMs: number; + readonly visibleMs: number; +} + +const nearDeadline = (name: string, at: number): boolean => { + const deadline = deadlineOf(name); + return deadline !== null && deadline.getTime() - at < REPLACE_WITHIN_MS; +}; + +const seconds = (ms: number, digits: number): string => `${(ms / 1000).toFixed(digits)}s`; + +const beyondDeclared = (compared: EnvironmentComparison, declared: Leaves): string | null => + compared.differing.some((d) => d.path in declared) ? null : JSON.stringify(compared.differing.map((d) => [d.path, d.found ?? null])); + +export function createThrowaway(deps: ThrowawayDeps): Throwaway { + const { workspace, platform, self } = deps; + const request = deps.fetch ?? fetch; + const sleep = deps.sleep ?? defaultSleep; + const now = deps.now ?? Date.now; + let listedOnce = false; + const lasting = new Set(); + + const close = (ledger: Workspace, entry: ApplicationEntry): void => { + rmSync(cachedKeysFile(ledger, entry.name), { force: true }); + rmSync(stateFile(ledger, entry.name), { force: true }); + const dir = keysDir(ledger); + const staged = existsSync(dir) ? readdirSync(dir).filter((file) => file.startsWith(`${entry.name}.state.json.`) && file.endsWith('.tmp')) : []; + for (const file of staged) rmSync(join(dir, file), { force: true }); + ledger.append({ id: newEntryId(), kind: 'done', ref: entry.id }); + }; + + function pool(): { readonly held: readonly Held[]; readonly strays: readonly ApplicationEntry[] } { + const held: Held[] = []; + const strays: ApplicationEntry[] = []; + for (const entry of openApplications(workspace)) { + const keys = readKeys(workspace, entry.name); + if (keys === null) strays.push(entry); + else held.push({ entry, keys }); + } + return { held, strays }; + } + + const keysOf = (app: Held): InstanceKeys => ({ pk: app.keys.pk, sk: new Secret('clerk-secret-key', app.keys.sk) }); + const describe = (app: Held, settings: Settings | undefined): HeldApplication => ({ id: app.keys.application, name: app.entry.name, keys: keysOf(app), settings: settings ?? null }); + const otherDrivers = (state: ApplicationState): readonly ProcessRef[] => state.drivers.filter((driver) => driver.pid !== self.pid && deps.isRunning(driver)); + + async function environment(pk: PublishableKey): Promise<{ readonly status: number; readonly json: Json; readonly at: number }> { + const response = await request(`https://${frontendApiHost(pk)}/v1/environment`, { signal: AbortSignal.timeout(15_000) }); + const text = await response.text(); + const date = Date.parse(response.headers.get('date') ?? ''); + let json: Json = null; + try { + json = JSON.parse(text) as Json; + } catch { + json = null; + } + return { status: response.status, json, at: Number.isNaN(date) ? now() : date }; + } + + async function listing(open: OpenWorkspace): Promise { + if (listedOnce) return open.list(); + listedOnce = true; + return open.opened; + } + + const unanswered = (app: Held, status: number): VerifyFailure => + new VerifyFailure('NOT_READY', `the Frontend API of ${app.keys.application} (${app.entry.name}) answered ${status}`, 'rerun'); + + const standardRefused = (said: string): VerifyFailure => + new VerifyFailure('INSTANCE_MISCONFIGURED', `Clerk's Platform API refused the standard settings in ${STANDARD_FILE}: ${said}`, 'the standard file needs a change of its own (shared core); the spec is not at fault'); + + async function blame(open: OpenWorkspace, app: Held, to: Settings, refusal: SettingsRefused): Promise { + if (to.declared === null) return standardRefused(refusal.said); + try { + await open.configure({ application: throwawayApplication(app.keys.application, app.entry.name), instanceId: app.keys.instanceId }, configFor(STANDARD), { dryRun: true }); + } catch (error) { + if (error instanceof SettingsRefused) return standardRefused(error.said); + throw error; + } + const { param } = refusal; + const declared = Object.keys(configLeaves(to.declared.config)); + const what = + param === null + ? `Clerk refused these together; remove or correct one of ${declared.join(', ')} in \`instanceSettings\` in ${to.askedBy}.` + : declared.some((leaf) => leaf === param || leaf.endsWith(`.${param}`)) + ? `correct or remove \`${param}\` in \`instanceSettings\` in ${to.askedBy}: Clerk says what is wrong with it above.` + : `add \`${param}\` to \`config\` in \`instanceSettings\` in ${to.askedBy} (Clerk requires it with what the spec declares), or remove what requires it.`; + return new SettingsRefused( + `${to.askedBy} declares ${to.label}, and Clerk's Platform API refused it: ${refusal.said}`, + `${what} Settings the Platform API cannot set today: reverification, the development-mode banner, test mode, PII protection off`, + refusal, + ); + } + + function notShown(app: Held, to: Settings, status: number, compared: EnvironmentComparison | null, afterMs: number): VerifyFailure { + const id = app.keys.application; + if (compared === null) return new VerifyFailure('NOT_READY', `the Frontend API of ${id} (${app.entry.name}) answered ${status} after its settings were changed`, 'rerun'); + if (to.declared === null) { + return new VerifyFailure( + 'INSTANCE_MISCONFIGURED', + `${id} (${app.entry.name}) does not match ${STANDARD_FILE} after it was configured: ${compared.differing.slice(0, 5).map((d) => describeDifference(d)).join('; ')}`, + '`{cli} down`, then rerun once; if it repeats, Clerk changed what a setting does, and the file needs a change of its own, apart from the work being verified', + ); + } + const declared = to.declared.environment; + const unmet = compared.differing.filter((d) => d.path in declared); + const moved = compared.differing.filter((d) => !(d.path in declared)); + const pairs = moved.slice(0, MOVED_LEAVES_LISTED).map((d) => `'${d.path}': ${d.found === undefined ? 'undefined' : JSON.stringify(d.found)}`).join(', '); + const parts = [ + ...(unmet.length === 0 ? [] : [`it does not show ${unmet.slice(0, 5).map((d) => describeDifference(d, 'the declaration expects')).join('; ')}`]), + ...(moved.length === 0 ? [] : [`the change moved ${count(moved.length, 'setting')} the declaration does not list: ${pairs}${moved.length > MOVED_LEAVES_LISTED ? `, and ${moved.length - MOVED_LEAVES_LISTED} more` : ''}`]), + ]; + return new SettingsRefused( + `${to.askedBy} declares ${to.label}, and ${seconds(afterMs, 1)} after Clerk accepted it on ${id} ${parts.join('; and ')}`, + moved.length === 0 + ? `correct the leaves under \`environment\` in ${to.askedBy} to what the setting shows in the instance's public environment` + : `one setting can move several leaves: add them to \`environment\` in ${to.askedBy}, as listed`, + ); + } + + async function moveTo(open: OpenWorkspace, app: Held, to: Settings): Promise { + const name = app.entry.name; + let before = readState(workspace, name); + if (before.drift === null && to.declared !== null) { + await moveTo(open, app, STANDARD); + before = readState(workspace, name); + } + writeState(workspace, name, { drift: before.drift, drivers: before.drivers }); + const body = configFor(to); + const started = now(); + let answer: { readonly after: Json }; + try { + answer = await open.configure({ application: throwawayApplication(app.keys.application, name), instanceId: app.keys.instanceId }, body); + } catch (error) { + if (!(error instanceof SettingsRefused)) throw error; + writeState(workspace, name, before); + throw await blame(open, app, to, error); + } + const answered = now(); + const after = configLeaves(answer.after ?? {}); + const differing = Object.entries(configLeaves(body)).filter(([leaf, value]) => !sameLeaf(after[leaf], value)); + const declared = to.declared === null ? {} : configLeaves(to.declared.config); + const altered = differing.find(([leaf]) => Object.hasOwn(declared, leaf) && after[leaf] !== undefined); + if (altered !== undefined) { + const [leaf, value] = altered; + throw new SettingsRefused(`${to.askedBy} declares ${leaf}=${JSON.stringify(value)}, and Clerk stored ${JSON.stringify(after[leaf])}`, `correct \`${leaf}\` in \`instanceSettings\` in ${to.askedBy} to a value Clerk keeps`); + } + const lost = differing[0]; + if (lost !== undefined) { + throw new VerifyFailure( + 'INSTANCE_MISCONFIGURED', + `Clerk accepted the settings for ${app.keys.application} and its answer does not hold ${lost[0]}=${JSON.stringify(lost[1])} (it has ${after[lost[0]] === undefined ? 'no such key' : JSON.stringify(after[lost[0]])})`, + 'rerun once; if it repeats, the Platform API changed how it answers a config change, which is a verify bug to report', + ); + } + const until = answered + SHOWN_WITHIN_MS; + let settled: string | null = null; + for (;;) { + const live = await environment(app.keys.pk); + const compared = live.status !== 200 ? null : before.drift === null ? compareEnvironment(standardFile(), live.json) : compareWith(to, before.drift, live.json); + if (compared !== null && compared.differing.length === 0) { + const drift = before.drift ?? compared.drifted.map((d) => d.path); + writeState(workspace, name, { settings: to, drift, drivers: before.drivers }); + return { compared: Object.keys(expectedEnvironment(to)).length - excused(to, drift).length, answeredMs: answered - started, visibleMs: now() - answered }; + } + const rest = compared === null || to.declared === null ? null : beyondDeclared(compared, to.declared.environment); + if ((rest !== null && rest === settled) || now() >= until) throw notShown(app, to, live.status, compared, now() - answered); + settled = rest; + await sleep(500); + } + } + + const changedLine = (settings: Settings, id: string, moved: Moved): string => + `settings ${settings.label} on ${id} in ${seconds(moved.answeredMs + moved.visibleMs, 1)} (Clerk answered in ${seconds(moved.answeredMs, 2)}, the instance showed it ${seconds(moved.visibleMs, 2)} later), ${moved.compared} settings match`; + + async function create(open: OpenWorkspace, at: Date | null, progress: (line: string) => void): Promise<{ readonly app: Held; readonly moved: Moved }> { + const name = throwawayName(new Date((at?.getTime() ?? now()) + lifetimeMs(deps.env)), randomBytes(4).toString('hex')); + const entry: ApplicationEntry = { id: newEntryId(), kind: 'application', name, workspace: open.workspace }; + workspace.append(entry); + progress(`instance creating ${name} in ${open.workspace}`); + const started = now(); + const created = await open.create(name); + mkdirSync(keysDir(workspace), { recursive: true, mode: 0o700 }); + const keys: CachedKeys = { application: created.application.id, instanceId: created.instanceId, pk: created.pk, sk: created.sk.use('instance-keys-file', (plain) => plain) }; + writeFileSync(cachedKeysFile(workspace, name), `${JSON.stringify(keys)}\n`, { mode: 0o600, flag: 'wx' }); + const app: Held = { entry, keys }; + lasting.add(name); + const moved = await moveTo(open, app, STANDARD); + progress(`instance ${keys.application} up in ${seconds(now() - started, 1)} on standard, ${moved.compared} settings match ${STANDARD_FILE}`); + return { app, moved }; + } + + async function reap(open: OpenWorkspace, listed: Listing, progress: (line: string) => void): Promise { + const at = listed.at; + if (at === null) { + progress("reap skipped: Clerk's answer carried no date, and this machine's clock never decides a deadline"); + return; + } + const mine = new Set(openApplications(workspace).map((entry) => entry.name)); + const expired = listed.applications.filter((application) => { + const deadline = deadlineOf(application.name); + return !mine.has(application.name) && deadline !== null && deadline.getTime() + REAP_GRACE_MS < at.getTime(); + }); + for (const application of expired.slice(0, REAP_PER_COMMAND)) { + try { + await open.delete(application); + progress(`reap ${application.name} (its deadline passed and the session that made it never deleted it)`); + } catch (error) { + progress(`reap ${application.name} left in place: ${(error as Error).message}`); + } + } + if (expired.length > REAP_PER_COMMAND) progress(`reap ${expired.length - REAP_PER_COMMAND} more expired applications are left for the next command`); + const undated = listed.applications.filter((application) => !mine.has(application.name) && deadlineOf(application.name) === null); + if (undated.length > 0) progress(`note ${count(undated.length, 'application')} in the workspace carry no deadline in their name and are never reaped: ${undated.map((application) => application.name).join(', ')}`); + } + + type Verdict = { readonly keep: 'as-is' | 'busy' } | { readonly repair: string } | { readonly retire: string }; + + async function judge(app: Held, state: ApplicationState): Promise { + if (otherDrivers(state).length > 0) return { keep: 'busy' }; + const live = await environment(app.keys.pk); + if (live.status === 404) return { retire: 'Clerk no longer serves it' }; + if (live.status !== 200) throw unanswered(app, live.status); + if (nearDeadline(app.entry.name, live.at)) return { retire: 'its deadline is near' }; + lasting.add(app.entry.name); + const users = await deps.clerk(() => keysOf(app)).userCount(); + if (users >= REPLACE_AT_USERS) return { retire: `it holds ${users} of the ${DEVELOPMENT_USER_LIMIT} users a development instance allows` }; + if (state.settings === undefined) return { repair: 'what it is on is not recorded' }; + if (compareWith(state.settings, state.drift ?? [], live.json).differing.length > 0) return { repair: `it no longer shows ${state.settings.label}` }; + return { keep: 'as-is' }; + } + + return { + held: () => pool().held.map((app) => describe(app, readState(workspace, app.entry.name).settings)), + + async inspect() { + return Promise.all( + pool().held.map(async (app): Promise => { + const state = readState(workspace, app.entry.name); + const live = await environment(app.keys.pk); + if (live.status !== 200 && live.status !== 404) throw new Error(`the Frontend API of ${app.keys.application} answered ${live.status}`); + return { application: describe(app, state.settings), found: live.status === 404 ? 'gone' : compareWith(state.settings ?? STANDARD, state.drift ?? [], live.json) }; + }), + ); + }, + + async ensure(options, progress) { + lifetimeMs(deps.env); + const { held, strays } = pool(); + const judged = await Promise.all( + held.map(async (app) => { + const state = readState(workspace, app.entry.name); + return { app, state, verdict: await judge(app, state) }; + }), + ); + const kept = judged.filter((one) => !('retire' in one.verdict)); + for (const { app, state, verdict } of kept) { + if ('keep' in verdict) progress(`instance ${app.keys.application} held, on ${state.settings?.label ?? 'unknown settings'}${verdict.keep === 'busy' ? ', and another run in this worktree is driving on it' : ''}`); + } + const view = (app: Held, created: boolean): InstanceView => ({ id: app.keys.application, name: app.entry.name, created, settings: readState(workspace, app.entry.name).settings?.label ?? 'unknown settings' }); + if (strays.length === 0 && kept.length > 0 && judged.every((one) => 'keep' in one.verdict) && !options.willChange) return { views: kept.map((one) => view(one.app, false)), created: null }; + + const open = await platform.open(); + const listed = await listing(open); + const byName = new Map(listed.applications.map((application) => [application.name, application])); + const retired = new Set(); + const retire = async (entry: ApplicationEntry, id: string | null, why: string): Promise => { + const application = byName.get(entry.name) ?? (id === null ? undefined : throwawayApplication(id, entry.name)); + if (application !== undefined) await open.delete(application); + close(workspace, entry); + retired.add(entry.name); + progress(`instance ${id ?? entry.name} retired (${why})`); + }; + for (const entry of strays) await retire(entry, null, 'its keys are lost'); + for (const one of judged) if ('retire' in one.verdict) await retire(one.app.entry, one.app.keys.application, one.verdict.retire); + + const failures: unknown[] = []; + for (const { app, state, verdict } of kept) { + if (!('repair' in verdict)) continue; + progress(`settings changing ${app.keys.application} from ${state.settings?.label ?? 'unknown settings'} to standard, because ${verdict.repair}`); + await moveTo(open, app, STANDARD).then((moved) => progress(changedLine(STANDARD, app.keys.application, moved)), (error: unknown) => failures.push(error)); + } + let created: Held | null = null; + if (kept.length === 0) created = await create(open, listed.at, progress).then((made) => made.app, (error: unknown) => (failures.push(error), null)); + + await reap(open, { ...listed, applications: listed.applications.filter((application) => !retired.has(application.name)) }, progress); + progress(`clerk Platform API: ${count(platform.requests(), 'request')} by this command so far`); + if (failures.length > 0) throw failures[0]; + return { views: [...kept.map((one) => view(one.app, false)), ...(created === null ? [] : [view(created, true)])], created: created === null ? null : describe(created, STANDARD) }; + }, + + async apply(group, progress) { + const to = group.settings; + const sent = platform.requests(); + const held = pool().held; + const lasts = await Promise.all(held.map(async ({ entry, keys }) => lasting.has(entry.name) || !nearDeadline(entry.name, (await environment(keys.pk)).at))); + const expiring = held.find((_, index) => !lasts[index]); + const candidates = held.filter((_, index) => lasts[index]).map((app) => ({ app, state: readState(workspace, app.entry.name) })); + for (const { app } of candidates) lasting.add(app.entry.name); + let chosen: Held | undefined; + let changed: Moved | null = null; + let created = false; + + for (const { app, state } of candidates) { + if (state.settings?.key !== to.key) continue; + const live = await environment(app.keys.pk); + if (live.status !== 200 || compareWith(to, state.drift ?? [], live.json).differing.length > 0) continue; + progress(`settings ${to.label} already on ${app.keys.application}`); + chosen = app; + break; + } + + if (chosen === undefined) { + const open = await platform.open(); + const change = async (app: Held, from: string): Promise => { + progress(`settings changing ${app.keys.application} from ${from} to ${to.declared === null ? `standard, for ${group.specs[0]?.path ?? 'this run'}` : `${to.label}, which ${to.askedBy} declares`}`); + const moved = await moveTo(open, app, to); + progress(changedLine(to, app.keys.application, moved)); + return moved; + }; + const free = candidates.find(({ state }) => otherDrivers(state).length === 0); + if (free !== undefined) { + changed = await change(free.app, free.state.settings?.label ?? 'unknown settings'); + chosen = free.app; + } else { + const busy = candidates[0]; + if (busy !== undefined) progress(`settings ${to.label} needs its own application, because ${busy.app.keys.application} is driving ${busy.state.settings?.label ?? 'unknown settings'} for another run in this worktree`); + else if (expiring !== undefined) progress(`settings ${to.label} needs its own application, because ${expiring.keys.application} is within an hour of its deadline`); + const made = await create(open, (await listing(open)).at, progress); + chosen = made.app; + created = true; + changed = to.declared === null ? made.moved : await change(made.app, 'standard'); + } + } + + const app = chosen; + const name = app.entry.name; + const state = readState(workspace, name); + writeState(workspace, name, { settings: to, drift: state.drift, drivers: [...otherDrivers(state), self] }); + if (platform.requests() > sent) progress(`clerk Platform API: ${count(platform.requests(), 'request')} by this command so far`); + return { + id: app.keys.application, + name, + keys: keysOf(app), + created, + changed: changed === null ? null : { answeredMs: changed.answeredMs, visibleMs: changed.visibleMs }, + async stillApplied() { + const live = await environment(app.keys.pk); + if (live.status !== 200) throw unanswered(app, live.status); + return compareWith(to, readState(workspace, name).drift ?? [], live.json).differing.length === 0; + }, + release() { + const current = readState(workspace, name); + writeState(workspace, name, { ...current, drivers: otherDrivers(current) }); + }, + }; + }, + + async finish(ledger, progress) { + const owned = openApplications(ledger); + if (owned.length === 0) return []; + const open = await platform.open(); + const foreign = owned.filter((entry) => entry.workspace !== open.workspace); + const mine = owned.filter((entry) => entry.workspace === open.workspace); + const listed = await listing(open); + const byName = new Map(listed.applications.map((application) => [application.name, application])); + const targets = mine.flatMap((entry) => { + const id = readKeys(ledger, entry.name)?.application; + return byName.get(entry.name) ?? (id === undefined ? [] : [throwawayApplication(id, entry.name)]); + }); + const deletions = await Promise.allSettled(targets.map((application) => open.delete(application))); + const after = await open.list(); + const remaining = new Set(after.applications.map((application) => application.name)); + const gone = mine.filter((entry) => !remaining.has(entry.name)); + for (const entry of gone) close(ledger, entry); + await reap(open, after, progress); + progress(`clerk Platform API: ${count(platform.requests(), 'request')} by this command so far, ${count(gone.length, 'application')} deleted`); + + const left = [...mine.filter((entry) => remaining.has(entry.name)), ...foreign]; + if (left.length > 0) { + const refusal = deletions.find((result) => result.status === 'rejected'); + const why = foreign.length > 0 ? `${foreign.map((entry) => entry.name).join(', ')} belong to workspace ${foreign[0]!.workspace}, which this credential does not reach` : refusal === undefined ? 'Clerk still lists them' : (refusal.reason as Error).message; + const one = foreign.length === 1; + const fixes = [ + ...(left.length > foreign.length ? ['{cli} down again; it deletes what is left'] : []), + ...(foreign.length === 0 ? [] : [`${foreign.map((entry) => entry.name).join(', ')} ${one ? 'belongs' : 'belong'} to workspace ${foreign[0]!.workspace}, which this credential cannot reach; only a credential of that workspace can delete ${one ? 'it' : 'them'}`]), + ]; + throw new VerifyFailure('NOT_READY', `${count(left.length, 'application')} of this worktree could not be deleted: ${why}`, fixes.join('. ')); + } + return gone.map((entry) => ({ name: entry.name })); + }, + }; +} diff --git a/.claude/skills/verify-clerk-expo/src/core/keys.ts b/.claude/skills/verify-clerk-expo/src/core/keys.ts new file mode 100644 index 00000000000..cc0709a7cb6 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/keys.ts @@ -0,0 +1,13 @@ +import type { Secret } from './secret.ts'; +import type { PublishableKey } from './types.ts'; + +export const PLATFORM_CREDENTIAL_VARIABLES: readonly string[] = ['CLERK_PLATFORM_API_KEY', 'CLERK_PLATFORM_API_KEY_FILE', 'VERIFY_PLATFORM_KEY_REFERENCE']; + +export interface InstanceKeys { + readonly pk: PublishableKey; + readonly sk: Secret<'clerk-secret-key'>; +} + +export function withoutClerkKeys>>(env: T): T { + return Object.fromEntries(Object.entries(env).filter(([name]) => !PLATFORM_CREDENTIAL_VARIABLES.includes(name))) as T; +} diff --git a/.claude/skills/verify-clerk-expo/src/core/ledgers.ts b/.claude/skills/verify-clerk-expo/src/core/ledgers.ts new file mode 100644 index 00000000000..ba90a6324fe --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/ledgers.ts @@ -0,0 +1,81 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Instances } from './instances/instances.ts'; +import { count } from './state.ts'; +import { isRunning } from './exec.ts'; +import { newEntryId, openWorkspace, type Workspace } from './workspace.ts'; +import type { LedgerEntry, Platform } from './types.ts'; + +export type ProcessEntry = Extract; + +export const openProcesses = (workspace: Workspace): readonly ProcessEntry[] => workspace.unclosedEntries().filter((entry): entry is ProcessEntry => entry.kind === 'process'); + +export const processesIn = (workspace: Workspace, scope: { readonly platforms: readonly Platform[]; readonly sharedByEveryLease: boolean }): readonly ProcessEntry[] => + openProcesses(workspace).filter((entry) => (entry.platform === undefined ? scope.sharedByEveryLease : scope.platforms.includes(entry.platform))); + +export function stopProcesses(workspace: Workspace, entries: readonly ProcessEntry[]): readonly string[] { + const stopped: string[] = []; + for (const entry of entries) { + if (isRunning({ pid: entry.pid, startedAt: Date.parse(entry.startedAt) })) { + try { + process.kill(entry.pid, entry.what === 'recorder' ? 'SIGINT' : 'SIGTERM'); + stopped.push(`${entry.what} ${entry.pid}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; + stopped.push(`${entry.what} ${entry.pid} had already exited`); + } + } else { + stopped.push(`${entry.what} ${entry.pid} had already exited`); + } + workspace.append({ id: newEntryId(), kind: 'done', ref: entry.id }); + } + return stopped; +} + +export interface DaemonInfo { + readonly pid: number; + readonly startedAt: number; +} + +export function readDaemonInfo(stateDir: string): DaemonInfo | null { + const file = join(stateDir, 'daemon.json'); + if (!existsSync(file)) return null; + try { + const raw = JSON.parse(readFileSync(file, 'utf8')) as { pid?: unknown; processStartTime?: unknown }; + if (typeof raw.pid !== 'number' || typeof raw.processStartTime !== 'string') return null; + return { pid: raw.pid, startedAt: Date.parse(raw.processStartTime) }; + } catch { + return null; + } +} + +export function ledgerAgentDeviceDaemon(workspace: Workspace): void { + const daemon = readDaemonInfo(workspace.agentDeviceDir); + if (daemon === null || !isRunning(daemon)) return; + const known = workspace.unclosedEntries().some((e) => e.kind === 'process' && e.what === 'agent-device' && e.pid === daemon.pid); + if (!known) workspace.append({ id: newEntryId(), kind: 'process', what: 'agent-device', pid: daemon.pid, startedAt: new Date(daemon.startedAt).toISOString() }); +} + +export async function finishOrphanLedgers( + home: string, + self: string, + instances: Instances, + progress: (line: string) => void, +): Promise { + const dir = join(home, 'ledgers'); + if (!existsSync(dir)) return; + for (const name of readdirSync(dir).filter((n) => n.endsWith('.owner'))) { + const [worktree = '', skillDir] = readFileSync(join(dir, name), 'utf8').trim().split('\n'); + if (worktree === self || existsSync(worktree)) continue; + const ledger = openWorkspace({ skillDir: skillDir ?? worktree, worktree, home }); + if (ledger.unclosedEntries().length === 0) continue; + try { + const stopped = stopProcesses(ledger, openProcesses(ledger)); + const deleted = await instances.finish(ledger, { keepApplications: false }, progress); + for (const entry of ledger.unclosedEntries()) ledger.append({ id: newEntryId(), kind: 'done', ref: entry.id }); + progress(`reap ledger of ${worktree} (worktree is gone) deleted ${count(deleted.length, 'application')}, stopped ${stopped.join(', ') || 'nothing'}`); + } catch (error) { + progress(`reap ledger of ${worktree} left open: ${(error as Error).message}`); + } + } +} diff --git a/.claude/skills/verify-clerk-expo/src/core/manifest.ts b/.claude/skills/verify-clerk-expo/src/core/manifest.ts new file mode 100644 index 00000000000..607773b6acf --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/manifest.ts @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto'; +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const CORE_DIR = dirname(fileURLToPath(import.meta.url)); +const MANIFEST_FILE = join(CORE_DIR, 'MANIFEST'); + +function coreFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return coreFiles(path); + return entry.name === 'MANIFEST' ? [] : [path]; + }); +} + +function computeManifest(): string { + return coreFiles(CORE_DIR) + .map((file) => `${createHash('sha256').update(readFileSync(file)).digest('hex')} ${relative(CORE_DIR, file).split('\\').join('/')}`) + .sort((a, b) => a.slice(66).localeCompare(b.slice(66))) + .join('\n') + .concat('\n'); +} + +export function manifestDrift(): readonly string[] { + const parse = (text: string) => new Map(text.split('\n').filter(Boolean).map((line) => [line.slice(66), line.slice(0, 64)] as const)); + let committed: Map; + try { + committed = parse(readFileSync(MANIFEST_FILE, 'utf8')); + } catch { + return ['MANIFEST']; + } + const actual = parse(computeManifest()); + const names = new Set([...committed.keys(), ...actual.keys()]); + return [...names].filter((name) => committed.get(name) !== actual.get(name)).sort(); +} + +if (import.meta.main && process.argv.includes('--write')) { + writeFileSync(MANIFEST_FILE, computeManifest()); +} diff --git a/.claude/skills/verify-clerk-expo/src/core/publish.ts b/.claude/skills/verify-clerk-expo/src/core/publish.ts new file mode 100644 index 00000000000..3472193181e --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/publish.ts @@ -0,0 +1,73 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { run as defaultRunner, type Runner } from './exec.ts'; +import type { Publishable } from './evidence.ts'; +import { describeState } from './state.ts'; +import { VerifyFailure, type AttachResult, type EvidencePath, type HostAdapter } from './types.ts'; + +interface Posted { + readonly pr: number; + readonly commentUrl: string; + readonly posted: readonly EvidencePath[]; +} + +export function commentBody(evidence: Publishable): string { + const passed = evidence.results.filter((r) => r.status === 'passed').length; + const lines = [ + `verify run \`${evidence.run}\` on ${evidence.platform} (${evidence.device}), build \`${evidence.build}\`, ${passed} of ${evidence.results.length} passed.`, + '', + ...evidence.results.map((r) => `- ${r.status}: \`${r.spec.path}\` ${r.title}`), + ]; + for (const group of evidence.settings) { + if (group.askedBy === null) continue; + const results = evidence.results.filter((r) => group.specs.includes(r.spec.path)); + lines.push('', `Instance settings \`${group.label}\` (declared by \`${group.askedBy}\`): ${results.filter((r) => r.status === 'passed').length} of ${results.length} passed.`); + } + if (evidence.lastState !== null) lines.push('', `Last state: \`${describeState(evidence.lastState)}\``); + return lines.join('\n'); +} + +const ATTACH_FIX = 'install a gh build whose `gh pr comment` has --attach'; + +export async function missingAttach(runner: Runner): Promise<{ readonly why: string; readonly fix: string } | null> { + const help = await runner('gh', ['pr', 'comment', '--help']); + if (help.code === 0 && help.stdout.includes('--attach')) return null; + return { why: help.code === 0 ? 'this gh has no `gh pr comment --attach`' : 'gh is not installed', fix: ATTACH_FIX }; +} + +export async function postToPullRequest( + evidence: Publishable, + dir: EvidencePath, + host: HostAdapter, + pr: number, + screenshots: 'all' | readonly string[], + runner: Runner = defaultRunner, +): Promise { + const postedFile = join(dir, `posted-${pr}.json`); + if (existsSync(postedFile)) { + const previous = JSON.parse(readFileSync(postedFile, 'utf8')) as Posted; + return { verb: 'attach', commentUrl: previous.commentUrl, posted: previous.posted, alreadyPosted: true }; + } + const chosen = + screenshots === 'all' + ? evidence.screenshots + : screenshots.map((label) => { + const shot = evidence.screenshots.find((s) => s.label === label); + if (shot === undefined) { + throw new VerifyFailure('USAGE', `run ${evidence.run} has no screenshot labelled ${label}`, `use one of: ${evidence.screenshots.map((s) => s.label).join(', ') || '(none)'}`); + } + return shot; + }); + const files = [...evidence.videos, ...chosen.map((s) => s.path)]; + const missing = files.length === 0 ? null : await missingAttach(runner); + if (missing !== null) throw new VerifyFailure('NOT_READY', `${missing.why}, so the video and screenshots of run ${evidence.run} cannot be posted`, missing.fix); + const args = ['pr', 'comment', String(pr), '--repo', host.githubRepo, '--body', commentBody(evidence), ...files.flatMap((f) => ['--attach', f])]; + const result = await runner('gh', args); + if (result.code !== 0) { + throw new VerifyFailure('NOT_READY', `gh pr comment failed: ${result.stderr.trim()}`, 'check `gh auth status` and that the PR exists'); + } + const commentUrl = /https:\/\/github\.com\/\S+/.exec(result.stdout)?.[0] ?? result.stdout.trim(); + const posted: Posted = { pr, commentUrl, posted: files }; + writeFileSync(postedFile, `${JSON.stringify(posted, null, 2)}\n`); + return { verb: 'attach', commentUrl, posted: files, alreadyPosted: false }; +} diff --git a/.claude/skills/verify-clerk-expo/src/core/secret.ts b/.claude/skills/verify-clerk-expo/src/core/secret.ts new file mode 100644 index 00000000000..9f68b3bcbb9 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/secret.ts @@ -0,0 +1,37 @@ +import type { SecretSink } from './types.ts'; + +const knownValues = new Set(); + +export function usedSecretValues(): readonly string[] { + return [...knownValues]; +} + +export function redact(text: string): string { + let out = text; + for (const value of knownValues) { + if (out.includes(value)) out = out.split(value).join(''); + } + return out; +} + +export class Secret { + readonly name: Name; + #value: string; + constructor(name: Name, value: string) { + this.name = name; + this.#value = value; + knownValues.add(value); + } + toString(): string { + return ``; + } + toJSON(): string { + return ``; + } + [Symbol.for('nodejs.util.inspect.custom')](): string { + return ``; + } + use(_sink: SecretSink, fn: (plain: string) => T): T { + return fn(this.#value); + } +} diff --git a/.claude/skills/verify-clerk-expo/src/core/slot.ts b/.claude/skills/verify-clerk-expo/src/core/slot.ts new file mode 100644 index 00000000000..68a89dcaf7f --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/slot.ts @@ -0,0 +1,73 @@ +import { randomUUID } from 'node:crypto'; +import { linkSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const NAME = /^\d{12}$/; +const FREE = 'free'; +const STALE_WRITER_WINDOW_MS = 60_000; + +const nameOf = (gen: number) => String(gen).padStart(12, '0'); + +function generations(dir: string): number[] { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return []; + } + return names.filter((n) => NAME.test(n)).map(Number).sort((a, b) => a - b); +} + +export interface SlotState { + readonly gen: number; + readonly value: string | null; +} + +export function readSlot(dir: string): SlotState { + for (;;) { + const gens = generations(dir); + const gen = gens.at(-1); + if (gen === undefined) return { gen: 0, value: null }; + try { + const text = readFileSync(join(dir, nameOf(gen)), 'utf8'); + return { gen, value: text === FREE ? null : text }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + } +} + +function linkedIntoPrunedGap(gens: readonly number[], linked: number): boolean { + return gens.some((g) => g > linked); +} + +export function compareAndSwapSlot(dir: string, from: number, value: string | null): boolean { + mkdirSync(dir, { recursive: true }); + const next = from + 1; + const file = join(dir, nameOf(next)); + const staged = join(dir, `.${randomUUID()}`); + writeFileSync(staged, value ?? FREE, { mode: 0o600 }); + try { + linkSync(staged, file); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false; + throw error; + } finally { + rmSync(staged, { force: true }); + } + const gens = generations(dir); + if (linkedIntoPrunedGap(gens, next)) { + rmSync(file, { force: true }); + return false; + } + const cutoff = Date.now() - STALE_WRITER_WINDOW_MS; + for (const g of gens) { + if (g >= next - 1) continue; + try { + if (statSync(join(dir, nameOf(g))).mtimeMs < cutoff) rmSync(join(dir, nameOf(g)), { force: true }); + } catch { + continue; + } + } + return true; +} diff --git a/.claude/skills/verify-clerk-expo/src/core/state.ts b/.claude/skills/verify-clerk-expo/src/core/state.ts new file mode 100644 index 00000000000..20e6889c65d --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/state.ts @@ -0,0 +1,203 @@ +import { + AUTH_MODES, + HOST_CONTRACT_VERSION, + STATE_TEXT_PREFIX, + VerifyFailure, + type HostEntry, + type HostLaunch, + type LaunchId, + type Platform, + type RunId, + type TicketState, + type VerifyState, +} from './types.ts'; + +const TICKETS: readonly TicketState[] = ['none', 'pending', 'succeeded', 'failed']; + +function malformed(detail: string): VerifyFailure { + return new VerifyFailure( + 'HOST_CONTRACT_MISMATCH', + `verify.state is not a VerifyState: ${detail}`, + 'rebuild the host with `{cli} up` so it matches this skill', + ); +} + +function nullableString(record: Record, key: string): string | null { + const value = record[key]; + if (value === null || value === undefined) return null; + if (typeof value !== 'string') throw malformed(`${key} is not a string`); + return value; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function parseVerifyState(text: string): VerifyState { + const trimmed = text.trim(); + const json = trimmed.startsWith(STATE_TEXT_PREFIX) ? trimmed.slice(STATE_TEXT_PREFIX.length) : trimmed; + let raw: unknown; + try { + raw = JSON.parse(json); + } catch { + throw malformed('the text after the prefix is not JSON'); + } + if (!isRecord(raw)) throw malformed('not an object'); + if (raw.v !== HOST_CONTRACT_VERSION) { + throw new VerifyFailure( + 'HOST_CONTRACT_MISMATCH', + `host reports contract v${String(raw.v)}, this skill speaks v${HOST_CONTRACT_VERSION}`, + 'rebuild the host from this worktree with `{cli} up`, or update the skill', + ); + } + if (typeof raw.screen !== 'string') throw malformed('screen is not a string'); + for (const key of ['environmentLoaded', 'signedIn'] as const) { + if (typeof raw[key] !== 'boolean') throw malformed(`${key} is not a boolean`); + } + const ticket = TICKETS.find((t) => t === raw.ticket); + if (ticket === undefined) throw malformed('ticket is not none, pending, succeeded, or failed'); + const sessionStatus = nullableString(raw, 'sessionStatus'); + if (sessionStatus !== null && sessionStatus !== 'active' && sessionStatus !== 'pending') { + throw malformed('sessionStatus is not active, pending, or null'); + } + const pendingTasks = raw.pendingTasks ?? []; + if (!Array.isArray(pendingTasks) || !pendingTasks.every((t) => typeof t === 'string')) { + throw malformed('pendingTasks is not a list of strings'); + } + let lastError: VerifyState['lastError'] = null; + if (raw.lastError !== null && raw.lastError !== undefined) { + const e = raw.lastError; + if (!isRecord(e) || typeof e.code !== 'string' || typeof e.message !== 'string') throw malformed('lastError is malformed'); + lastError = { code: e.code, message: e.message }; + } + let extra: VerifyState['extra']; + if (raw.extra !== undefined) { + if (!isRecord(raw.extra)) throw malformed('extra is not an object'); + const scalars: Record = {}; + for (const [key, value] of Object.entries(raw.extra)) { + if (value !== null && !['string', 'number', 'boolean'].includes(typeof value)) throw malformed(`extra.${key} is not a scalar`); + scalars[key] = value as string | number | boolean | null; + } + extra = scalars; + } + return { + v: HOST_CONTRACT_VERSION, + runId: nullableString(raw, 'runId') as RunId | null, + launchId: nullableString(raw, 'launchId') as LaunchId | null, + screen: raw.screen, + environmentLoaded: raw.environmentLoaded as boolean, + signedIn: raw.signedIn as boolean, + userId: nullableString(raw, 'userId'), + sessionId: nullableString(raw, 'sessionId'), + sessionStatus, + pendingTasks, + orgId: nullableString(raw, 'orgId'), + signInStatus: nullableString(raw, 'signInStatus'), + signUpStatus: nullableString(raw, 'signUpStatus'), + ticket, + lastError, + ...(extra === undefined ? {} : { extra }), + }; +} + +export const count = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`; + +export function describeState(state: VerifyState): string { + const parts = [`screen=${state.screen}`, `signedIn=${state.signedIn}`, `userId=${state.userId ?? 'null'}`]; + if (state.sessionStatus) parts.push(`session=${state.sessionStatus}`); + parts.push(`orgId=${state.orgId ?? 'null'}`); + if (state.pendingTasks.length > 0) parts.push(`tasks=${state.pendingTasks.join(',')}`); + if (state.signInStatus) parts.push(`signInStatus=${state.signInStatus}`); + if (state.signUpStatus) parts.push(`signUpStatus=${state.signUpStatus}`); + if (state.ticket !== 'none') parts.push(`ticket=${state.ticket}`); + parts.push(`lastError=${state.lastError === null ? 'null' : state.lastError.code}`); + return parts.join(' '); +} + +const PLIST_LEADERS = ['(', '{', '<', '"']; + +function checkedValue(key: string, value: string): string { + if (value.length === 0) throw new VerifyFailure('USAGE', `launch input ${key} is empty`, 'pass a non-empty value'); + if (PLIST_LEADERS.some((leader) => value.startsWith(leader))) { + throw new VerifyFailure( + 'USAGE', + `launch input ${key} starts with ${value[0]}, which iOS would parse as a property list`, + 'pass a plain string value', + ); + } + return value; +} + +export function encodeLaunchArguments(platform: Platform, launch: HostLaunch): readonly string[] { + if (launch.verifyAuthMode !== undefined && !AUTH_MODES.includes(launch.verifyAuthMode)) { + throw new VerifyFailure('USAGE', `unknown auth mode ${launch.verifyAuthMode}`, `use one of ${AUTH_MODES.join(', ')}`); + } + const pairs: [string, string][] = [ + ['verifyPublishableKey', launch.verifyPublishableKey], + ['verifyRunId', launch.verifyRunId], + ['verifyStorageScope', launch.verifyStorageScope], + ['verifyLaunchId', launch.verifyLaunchId], + ]; + if (launch.verifyScreen !== undefined) pairs.push(['verifyScreen', launch.verifyScreen]); + if (launch.verifyAuthMode !== undefined) pairs.push(['verifyAuthMode', launch.verifyAuthMode]); + if (launch.verifyLogLevel !== undefined) pairs.push(['verifyLogLevel', launch.verifyLogLevel]); + const ticket = launch.verifySignInTicket?.use('launch-argument', (plain) => plain); + if (ticket !== undefined) pairs.push(['verifySignInTicket', ticket]); + return pairs.flatMap(([key, value]) => { + const checked = checkedValue(key, value); + switch (platform) { + case 'ios': + return [`-${key}`, checked]; + case 'android': + return ['--es', key, checked]; + default: { + const exhaustive: never = platform; + return exhaustive; + } + } + }); +} + +export type AppStart = + | { readonly kind: 'open-app'; readonly launchArguments: readonly string[] } + | { readonly kind: 'adb'; readonly commands: readonly (readonly string[])[] }; + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +export function appStart(platform: Platform, appId: string, entry: HostEntry, launchArguments: readonly string[]): AppStart { + if (entry.kind === 'binary') return { kind: 'open-app', launchArguments }; + const all = [...entry.launchArguments, ...launchArguments]; + if (platform === 'ios') { + if (entry.openLink !== null) { + throw new VerifyFailure('NOT_READY', 'a dev-client entry on iOS cannot use openLink yet', 'pass the URL as a launch argument in entry.launchArguments, such as --initialUrl '); + } + return { kind: 'open-app', launchArguments: all }; + } + if (entry.androidActivity === null) { + throw new VerifyFailure('NOT_READY', 'a dev-client entry on Android needs androidActivity', 'set androidActivity in the entry src/host.ts returns'); + } + const start = ['am', 'start', '-W', '-n', `${appId}/${entry.androidActivity}`, ...(entry.openLink === null ? [] : ['-d', entry.openLink]), ...all]; + return { + kind: 'adb', + commands: [ + ['shell', `am force-stop ${shellQuote(appId)}`], + ['shell', start.map(shellQuote).join(' ')], + ], + }; +} + +export interface AppStartDriver { + openApp(appId: string, options?: { readonly relaunch: true; readonly launchArguments: readonly string[] }): Promise; + adb(args: readonly string[]): Promise; +} + +export async function performAppStart(start: AppStart, appId: string, driver: AppStartDriver): Promise { + if (start.kind === 'open-app') { + await driver.openApp(appId, { relaunch: true, launchArguments: start.launchArguments }); + return; + } + for (const command of start.commands) await driver.adb(command); + await driver.openApp(appId); +} diff --git a/.claude/skills/verify-clerk-expo/src/core/types.ts b/.claude/skills/verify-clerk-expo/src/core/types.ts new file mode 100644 index 00000000000..ca2f4777f4d --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/types.ts @@ -0,0 +1,500 @@ +import type { Locator } from 'e2e'; + +declare const brand: unique symbol; +export type Brand = T & { readonly [brand]: B }; + +export const HOST_CONTRACT_VERSION = 1 as const; + +export const CLI_PLACEHOLDER = '{cli}'; + +export type Platform = 'ios' | 'android'; + +export type AcquireLock = Brand<{ readonly platform: Platform }, 'AcquireLock'>; + +export type CoreScreen = 'home' | 'auth'; +export type NativeHostScreen = CoreScreen | 'userProfile' | 'orgSwitcher' | 'orgList' | 'orgProfile'; +export type AuthMode = 'signIn' | 'signUp' | 'signInOrUp'; +export const AUTH_MODES: readonly AuthMode[] = ['signIn', 'signUp', 'signInOrUp']; + +export type RunId = Brand; +export type LaunchId = Brand; +export type BuildKey = Brand; +export type StorageScope = Brand; +export type PublishableKey = Brand; +export type FeatureName = Brand; +export type TestEmail = Brand; +export type TestPhone = Brand; +export type EvidencePath = Brand; +export type ScratchPath = Brand; + +export type Json = null | boolean | number | string | readonly Json[] | { readonly [key: string]: Json }; + +export interface InstanceSettings { + readonly config: { readonly [key: string]: Json }; + readonly environment: { readonly [leaf: string]: Json }; +} + +export interface InstanceView { + readonly id: string; + readonly name: string; + readonly created: boolean; + readonly settings: string; +} + +export interface ApplicationView { + readonly name: string; +} + +export const STATE_ELEMENT_ID = 'verify.state'; +export const STATE_TEXT_PREFIX = 'verify '; + +export type TicketState = 'none' | 'pending' | 'succeeded' | 'failed'; + +export interface VerifyState { + readonly v: typeof HOST_CONTRACT_VERSION; + readonly runId: RunId | null; + readonly launchId: LaunchId | null; + readonly screen: string; + readonly environmentLoaded: boolean; + readonly signedIn: boolean; + readonly userId: string | null; + readonly sessionId: string | null; + readonly sessionStatus: 'active' | 'pending' | null; + readonly pendingTasks: readonly string[]; + readonly orgId: string | null; + readonly signInStatus: string | null; + readonly signUpStatus: string | null; + readonly ticket: TicketState; + readonly lastError: { readonly code: string; readonly message: string } | null; + readonly extra?: Readonly>; +} + +export interface HostLaunch { + readonly verifyPublishableKey: PublishableKey; + readonly verifyRunId: RunId; + readonly verifyStorageScope: StorageScope; + readonly verifyLaunchId: LaunchId; + readonly verifyScreen?: string; + readonly verifyAuthMode?: AuthMode; + readonly verifySignInTicket?: SecretLike; + readonly verifyLogLevel?: 'debug'; +} + +export interface SecretLike { + readonly name: string; + use(sink: SecretSink, fn: (plain: string) => T): T; +} + +export type SecretSink = 'bapi-authorization' | 'launch-argument' | 'platform-authorization' | 'instance-keys-file' | 'one-password-read'; + +export type BackendKind = 'local'; +export type OptInTag = 'form-entry' | 'known-bug'; +export const FORM_ENTRY_TAG = 'form-entry' satisfies OptInTag; +export const KNOWN_BUG_TAG = 'known-bug' satisfies OptInTag; + +export type SpecSelection = { readonly all: true } | { readonly selectors: readonly string[] }; + +export type Command = + | { readonly verb: 'doctor'; readonly platform?: Platform; readonly live: boolean } + | { readonly verb: 'up'; readonly platform?: Platform; readonly waitSeconds: number } + | { + readonly verb: 'run'; + readonly selection: SpecSelection; + readonly platform?: Platform; + readonly skip: readonly OptInTag[]; + readonly include: readonly OptInTag[]; + readonly grep?: string; + readonly video: boolean; + readonly waitSeconds: number; + } + | { readonly verb: 'screen'; readonly platform?: Platform; readonly png: boolean } + | { readonly verb: 'attach'; readonly run: RunId; readonly pr: number; readonly screenshots: 'all' | readonly string[] } + | { readonly verb: 'down'; readonly platform?: Platform; readonly stale: boolean; readonly dryRun: boolean }; + +export type Verb = Command['verb']; +export type RunCommand = Extract; + +export interface Invocation { + readonly command: Command; + readonly json: boolean; +} + +export type ErrorCode = + | 'USAGE' + | 'NOT_READY' + | 'POOL_FULL' + | 'LEASE_LOST' + | 'DEVICE_BUSY' + | 'BUILD_FAILED' + | 'KEYS_MISSING' + | 'INSTANCE_MISCONFIGURED' + | 'NOT_TEST_IDENTITY' + | 'HOST_CONTRACT_MISMATCH' + | 'NO_SPECS' + | 'E2E_CRASHED' + | 'EVIDENCE_UNSAFE' + | 'UNSUPPORTED' + | 'RATE_LIMITED'; + +export const RETRYABLE: ReadonlySet = new Set(['POOL_FULL', 'DEVICE_BUSY', 'LEASE_LOST', 'RATE_LIMITED']); + +export class VerifyFailure extends Error { + readonly code: ErrorCode; + readonly fix: string; + constructor(code: ErrorCode, message: string, fix: string) { + super(message); + this.code = code; + this.fix = fix; + } +} + +export type DoctorCheckId = + | 'node' | 'xcode' | 'jdk' | 'e2e-pins' | 'template' | 'proxy-trust' + | 'settings' | 'build' | 'gh-attach' | 'core-drift' | 'stale-claims' | 'feature-map' | 'agent-device-daemon' | 'lane-ports' + | 'instances' | 'clerk-api' + | 'live-instance'; + +interface DoctorCheckBase { + readonly id: DoctorCheckId; + readonly detail: string; + readonly fix?: string; +} +export type DoctorCheck = + | (DoctorCheckBase & { readonly ok: boolean; readonly state?: undefined }) + | (DoctorCheckBase & { readonly ok: true; readonly state: 'warning' | 'not-run' }); + +export interface DoctorReport { + readonly verb: 'doctor'; + readonly ok: boolean; + readonly backend: Readonly>>; + readonly checks: readonly DoctorCheck[]; +} + +export interface LeaseView { + readonly platform: Platform; + readonly backend: BackendKind; + readonly device: string; + readonly installedBuild: BuildKey | null; + readonly renewed: boolean; +} + +export interface BuildView { + readonly platform: Platform; + readonly key: BuildKey; + readonly source: BuildSource; + readonly reused: boolean; + readonly seconds: number; +} + +export interface UpResult { + readonly verb: 'up'; + readonly leases: readonly LeaseView[]; + readonly builds: readonly BuildView[]; + readonly instances: readonly InstanceView[]; +} + +export interface RunResult { + readonly verb: 'run'; + readonly dir: EvidencePath; + readonly record: EvidenceRecord; + readonly next: string; +} + +export interface ScreenNode { + readonly role: string; + readonly name: string | null; + readonly testId: string | null; + readonly text: string | null; + readonly depth: number; + readonly locator: string | null; +} + +export interface ScreenResult { + readonly verb: 'screen'; + readonly platform: Platform; + readonly device: string; + readonly nodes: readonly ScreenNode[]; + readonly state: VerifyState | null; + readonly png: ScratchPath | null; +} + +export interface AttachResult { + readonly verb: 'attach'; + readonly commentUrl: string; + readonly posted: readonly EvidencePath[]; + readonly alreadyPosted: boolean; +} + +export type DownResult = + | { + readonly verb: 'down'; + readonly dryRun: false; + readonly released: readonly LeaseView[]; + readonly deletedApplications: readonly ApplicationView[]; + readonly stoppedProcesses: readonly string[]; + readonly keptRuns: readonly RunId[]; + } + | { + readonly verb: 'down'; + readonly dryRun: true; + readonly wouldRelease: readonly LeaseView[]; + readonly wouldDelete: readonly DeletionTarget[]; + readonly wouldStop: readonly string[]; + readonly keptRuns: readonly RunId[]; + }; + +export interface DeletionTarget { + readonly kind: 'application'; + readonly name: string; +} + +export type VerbResult = DoctorReport | UpResult | RunResult | ScreenResult | AttachResult | DownResult; + +export type SpecKind = 'golden' | 'explored'; +export interface SpecRef { + readonly kind: SpecKind; + readonly path: string; + readonly feature: FeatureName | null; +} + +export type BuildSource = 'local'; + +export interface BuiltApp { + readonly platform: Platform; + readonly key: BuildKey; + readonly appId: string; + readonly path: ScratchPath; + readonly source: BuildSource; +} + +export type DeviceName = `verify-${Platform}-${number}`; + +interface LeaseBase { + readonly platform: Platform; + readonly acquiredAt: string; + readonly installedBuild: BuildKey | null; +} +export interface LocalLease extends LeaseBase { + readonly backend: 'local'; + readonly slot: number; + readonly deviceName: DeviceName; + readonly deviceId: string; + readonly claimNonce: string; +} +export type Lease = LocalLease; + +export interface SeededUser { + readonly id: string; + readonly email: TestEmail; + readonly phone: TestPhone | null; +} + +export type LedgerEntry = + | { readonly id: string; readonly kind: 'lease-intent'; readonly platform: Platform; readonly backend: BackendKind; readonly worktree: string } + | { readonly id: string; readonly kind: 'lease-held'; readonly platform: Platform; readonly backend: BackendKind; readonly deviceId: string | null } + | { readonly id: string; readonly kind: 'application'; readonly name: string; readonly workspace: string } + | { readonly id: string; readonly kind: 'identity'; readonly run: RunId; readonly email: TestEmail } + | { readonly id: string; readonly kind: 'user'; readonly run: RunId; readonly userId: string; readonly email: TestEmail } + | { readonly id: string; readonly kind: 'process'; readonly what: 'metro' | 'watch' | 'recorder' | 'agent-device'; readonly pid: number; readonly startedAt: string; readonly platform?: Platform } + | { readonly id: string; readonly kind: 'done'; readonly ref: string }; + +export type SpecStatus = 'passed' | 'failed' | 'skipped' | 'flaky' | 'interrupted'; +export interface SpecResult { + readonly spec: SpecRef; + readonly title: string; + readonly platform: Platform; + readonly status: SpecStatus; + readonly seconds: number; + readonly error: string | null; + readonly skipReason: string | null; + readonly skippedBy: 'tag' | 'platform' | null; + readonly tags: readonly string[]; + readonly failurePage: EvidencePath | null; + readonly failureScreen: EvidencePath | null; + readonly failureScreenshot: EvidencePath | null; +} + +export interface EvidenceRecord { + readonly run: RunId; + readonly startedAt: string; + readonly finishedAt: string; + readonly repo: HostAdapter['repo']; + readonly gitHead: string; + readonly dirty: boolean; + readonly platform: Platform; + readonly backend: BackendKind; + readonly device: string; + readonly build: BuildKey; + readonly results: readonly SpecResult[]; + readonly videos: readonly EvidencePath[]; + readonly screenshots: readonly { readonly label: string; readonly path: EvidencePath }[]; + readonly lastState: VerifyState | null; + readonly appLog: EvidencePath | null; + readonly e2eReport: EvidencePath; + readonly identities: readonly { readonly email: TestEmail; readonly userId: string | null }[]; + readonly instances: readonly { readonly application: string }[]; + readonly settings: readonly { + readonly label: string; + readonly askedBy: string | null; + readonly specs: readonly string[]; + readonly application: string | null; + readonly changed: boolean; + readonly held: boolean; + readonly e2eReport: EvidencePath | null; + }[]; + readonly tainted: readonly EvidencePath[]; + readonly sealed: true; +} + +export interface LaunchOptions { + readonly screen?: S; + readonly authMode?: AuthMode; + readonly debugLogs?: boolean; + readonly keepStorage?: boolean; + readonly signedInAs?: SeededUser; +} + +export interface HostFixture { + newEmail(): Promise; + seedUser(options?: { readonly phone?: boolean }): Promise; + launch(options: LaunchOptions): Promise; + state(): Promise; + waitForState(predicate: (state: VerifyState) => boolean, timeoutMs?: number): Promise; + screenshot(label: string): Promise; + tap(target: Locator): Promise; + fill(target: Locator, text: string): Promise; +} + +export const CLERK_TEST_CODE = '424242' as const; + +export type HostEntry = + | { readonly kind: 'binary' } + | { + readonly kind: 'dev-client'; + readonly launchArguments: readonly string[]; + readonly openLink: string | null; + readonly androidActivity: string | null; + }; + +export interface RuntimeProcess { + readonly what: 'metro' | 'watch'; + readonly pid: number; + readonly startedAt: number; + readonly platform?: Platform; +} + +export interface HostRuntime { + readonly entry: HostEntry; + readonly processes: readonly RuntimeProcess[]; +} + +export interface RunTarget { + readonly platform: Platform; + readonly appId: string; + readonly appPath: string; + readonly leaseFile: string; + readonly entry: HostEntry; +} + +export type RunContext = { + readonly v: 1; + readonly workspace: string; + readonly agentDeviceSession: string; + readonly targets: readonly RunTarget[]; +} & ( + | { readonly run: RunId; readonly broker: { readonly url: string; readonly tokenFile: string } } + | { readonly run: null; readonly broker: null } +); +export type ActiveRunContext = Extract; + +export interface E2EInvocation { + readonly args: readonly string[]; + readonly env: Readonly>; +} + +export interface BrokerLaunchRequest { + readonly platform: Platform; + readonly user: SeededUser | null; + readonly screen: string | null; + readonly authMode: AuthMode | null; + readonly debugLogs: boolean; + readonly storageScope: StorageScope | null; +} +export interface BrokerLaunchResponse { + readonly launchId: LaunchId; + readonly storageScope: StorageScope; + readonly launchArguments: readonly string[]; +} + +export interface DeviceWait { + readonly seconds: number; + readonly busyFix: string; + readonly onWait?: (owner: ProcessRef) => void; +} + +export interface AcquireRequest { + readonly platform: Platform; + readonly worktree: string; + readonly waitSeconds: number; + readonly retryWith: string; + readonly progress: (line: string) => void; +} + +export interface ProcessRef { + readonly pid: number; + readonly startedAt: number; +} + +export interface Recording { + readonly process: ProcessRef; + stop(): Promise; +} + +export interface Availability { + readonly usable: boolean; + readonly why: string; +} + +export interface DeviceBackend { + readonly kind: BackendKind; + readonly platform: Platform; + availability(): Availability; + acquire(request: AcquireRequest): Promise; + check(lease: L): Promise<'held' | 'lost'>; + install(lease: L, app: BuiltApp): Promise; + release(lease: L): Promise; + reapable(owner?: string): Promise; + startRecording(lease: L, into: EvidencePath): Promise; + logs(lease: L, since: Date, extraPredicate?: string): Promise; + describe(lease: L): string; + readonly requirement: string; + doctorChecks(): Promise<{ readonly toolchain: readonly DoctorCheck[]; readonly device: readonly DoctorCheck[] }>; +} + +export const LOCAL_POOL: Readonly> = { ios: 4, android: 2 }; + +interface HostBase { + readonly repo: 'clerk-ios' | 'clerk-android' | 'clerk-expo'; + readonly cli: string; + readonly platforms: readonly Platform[]; + readonly screens: readonly S[]; + readonly githubRepo: string; + appId(platform: Platform): string; + buildInputs(platform: Platform): readonly string[]; + build(platform: Platform, key: BuildKey, into: ScratchPath, progress: (line: string) => void): Promise; + readonly logPredicates?: Readonly>>; + readonly features: readonly string[]; + readonly backends: readonly DeviceBackend[]; +} + +interface HostWithFixedEntry { + entry(platform: Platform): HostEntry; + readonly runtime?: undefined; +} + +interface HostWithRuntime { + runtime(lease: Lease, progress: (line: string) => void): Promise; + readonly entry?: undefined; +} + +export type HostAdapter = HostBase & (HostWithFixedEntry | HostWithRuntime); diff --git a/.claude/skills/verify-clerk-expo/src/core/verbs.ts b/.claude/skills/verify-clerk-expo/src/core/verbs.ts new file mode 100644 index 00000000000..b16b706ca46 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/verbs.ts @@ -0,0 +1,758 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { isOrphaned, readClaims } from './claims.ts'; +import type { AppliedInstance, Instances } from './instances/instances.ts'; +import { STANDARD, SettingsRefused, planGroups, sourceHash, type SettingsGroup } from './instances/settings.ts'; +import { withoutClerkKeys } from './keys.ts'; +import { openApplications } from './instances/throwaway.ts'; +import { agentDeviceFor } from './agent-device.ts'; +import { backendFor, computeBuildKey, ensureLease, leaseLine, leaseView, readBuiltApp, releaseLease, selectBackend, type LeaseOutcome } from './devices.ts'; +import { assertSomethingRan, collectScreenshots, contextFile, e2eOutputDir, excludedTagNames, invokeE2E, parseE2EReport, planE2E, resolveSpecs, writeRunContext } from './e2e.ts'; +import { startBroker } from './broker.ts'; +import { assertPublishable, readRecord, readStates, sealEvidence } from './evidence.ts'; +import { isRunning, type Runner } from './exec.ts'; +import { ledgerAgentDeviceDaemon, processesIn, readDaemonInfo, stopProcesses, type ProcessEntry } from './ledgers.ts'; +import { manifestDrift } from './manifest.ts'; +import { missingAttach, postToPullRequest } from './publish.ts'; +import { redact } from './secret.ts'; +import { count, parseVerifyState } from './state.ts'; +import { newEntryId, parseRunId, type Workspace } from './workspace.ts'; +import { + STATE_ELEMENT_ID, + VerifyFailure, + type ActiveRunContext, + type AttachResult, + type Command, + type DeviceBackend, + type ProcessRef, + type Recording, + type DoctorCheck, + type DoctorReport, + type DownResult, + type EvidencePath, + type EvidenceRecord, + type HostAdapter, + type HostEntry, + type InstanceView, + type Lease, + type LeaseView, + type LedgerEntry, + type Platform, + type RunContext, + type RunId, + type RunResult, + type ScratchPath, + type ScreenNode, + type ScreenResult, + type SpecRef, + type SpecResult, + type UpResult, + type VerifyState, +} from './types.ts'; + +export interface Deps { + readonly host: HostAdapter; + readonly workspace: Workspace; + readonly runner: Runner; + readonly env: Readonly>; + readonly progress: (line: string) => void; + readonly instances: Instances; +} + +async function leaseWithInstances(deps: Deps, instances: { readonly willChange: boolean }, lease: () => Promise): Promise<[LeaseOutcome, readonly InstanceView[]]> { + await deps.instances.access(); + if (instances.willChange) { + const ensured = await deps.instances.ensure(instances, deps.progress); + return [await lease(), ensured]; + } + const [leased, ensured] = await Promise.allSettled([lease(), deps.instances.ensure(instances, deps.progress)]); + if (leased.status === 'rejected') throw leased.reason; + if (ensured.status === 'rejected') { + const failure = ensured.reason instanceof VerifyFailure ? ensured.reason : new VerifyFailure('NOT_READY', (ensured.reason as Error).message, 'run `{cli} doctor`'); + throw new VerifyFailure(failure.code, failure.message, `${failure.fix}; the device stays leased until \`{cli} down\``); + } + return [leased.value, ensured.value]; +} + +const platformOf = (host: HostAdapter, platform: Platform | undefined): Platform => { + const chosen = platform ?? host.platforms[0]; + if (chosen === undefined || !host.platforms.includes(chosen)) { + throw new VerifyFailure('USAGE', `${host.repo} does not support ${platform}`, `use --platform ${host.platforms.join(' or ')}`); + } + return chosen; +}; + +function check(id: DoctorCheck['id'], ok: boolean, detail: string, fix: string): DoctorCheck { + return ok ? { id, ok, detail } : { id, ok, detail, fix }; +} + +function readJson(file: string): Record | null { + return existsSync(file) ? (JSON.parse(readFileSync(file, 'utf8')) as Record) : null; +} + +function daemonScriptCandidates(command: string): readonly string[] { + const end = command.lastIndexOf('daemon.js'); + if (end < 0) return []; + const stop = end + 'daemon.js'.length; + const starts = [...command.matchAll(/(?:^| )\//g)].map((m) => (m.index ?? 0) + (m[0].startsWith(' ') ? 1 : 0)).filter((i) => i < end); + return starts.map((i) => command.slice(i, stop)); +} + +export function agentDeviceDaemonCheck(stateDirs: readonly { readonly label: string; readonly dir: string }[]): DoctorCheck { + const broken: { readonly pid: number; readonly text: string }[] = []; + const details: string[] = []; + for (const { label, dir } of stateDirs) { + const daemon = readDaemonInfo(dir); + if (daemon === null || !isRunning(daemon)) { + details.push(`${label}: no daemon running`); + continue; + } + let command = ''; + try { + command = execFileSync('ps', ['-o', 'command=', '-p', String(daemon.pid)], { encoding: 'utf8' }).trim(); + } catch { + details.push(`${label}: no daemon running`); + continue; + } + const candidates = daemonScriptCandidates(command); + const script = candidates.find((path) => existsSync(path)); + if (candidates.length > 0 && script === undefined) { + broken.push({ pid: daemon.pid, text: `${label}: pid ${daemon.pid} runs ${candidates[0]}, which no longer exists` }); + } else { + details.push(`${label}: pid ${daemon.pid} from ${script ?? command}`); + } + } + if (broken.length > 0) { + return check('agent-device-daemon', false, [...broken.map((b) => b.text), ...details].join('; '), `kill ${broken.map((b) => b.pid).join(' ')}; agent-device starts a new daemon on the next command`); + } + return check('agent-device-daemon', true, details.join('; '), ''); +} + +export function featureMapCheck(skillDir: string, features: readonly string[]): DoctorCheck { + const missing = features.flatMap((feature) => { + const gaps: string[] = []; + if (!existsSync(join(skillDir, 'features', `${feature}.md`))) gaps.push(`features/${feature}.md`); + const golden = join(skillDir, 'specs', 'golden', feature); + if (!existsSync(golden) || !readdirSync(golden).some((f) => f.endsWith('.e2e.ts'))) gaps.push(`specs/golden/${feature}/*.e2e.ts`); + return gaps; + }); + return check( + 'feature-map', + missing.length === 0, + missing.length === 0 ? `${features.length} features, each with a feature file and golden specs` : `missing ${missing.join(', ')}`, + 'add the missing feature file or golden spec, or drop the feature from features in src/host.ts', + ); +} + +export async function doctor(deps: Deps, command: Extract): Promise { + const { host, workspace, runner } = deps; + const platform = platformOf(host, command.platform); + const backend = selectBackend(host, platform); + const skill = workspace.skillDir; + const checks: DoctorCheck[] = []; + + const node = process.versions.node; + checks.push(check('node', node.startsWith('24.'), node, 'install Node 24 (nvm install 24)')); + const backendChecks = await backend.doctorChecks(); + checks.push(...backendChecks.toolchain); + + const pkg = readJson(join(skill, 'package.json')); + const pins = (pkg?.devDependencies ?? {}) as Record; + const installed = (name: string) => readJson(join(skill, 'node_modules', name, 'package.json'))?.version as string | undefined; + const pinned = ['e2e', '@e2e-dev/mobile'].map((name) => ({ name, want: pins[name], have: installed(name) })); + checks.push( + check( + 'e2e-pins', + pinned.every((p) => p.want !== undefined && p.want === p.have), + pinned.map((p) => `${p.name} ${p.have ?? 'missing'}${p.have === p.want ? '' : ` (pinned ${p.want})`}`).join(', '), + `cd ${skill} && npm ci`, + ), + ); + checks.push(...backendChecks.device); + + checks.push(...(await deps.instances.doctorChecks({ live: command.live }, deps.progress))); + + const key = await computeBuildKey(host, platform, workspace.worktree); + const up = ['{cli} up', ...(host.platforms.length > 1 ? [`--platform ${platform}`] : [])].join(' '); + const built = readBuiltApp(workspace, key); + checks.push(check('build', built !== null, built === null ? `no ${host.appId(platform)} build for ${key}` : `${key} at ${built.path}`, up)); + + const noAttach = await missingAttach(runner); + checks.push( + noAttach === null + ? check('gh-attach', true, 'gh pr comment supports --attach', '') + : { id: 'gh-attach', ok: true, state: 'warning', detail: `${noAttach.why}, so \`{cli} attach\` cannot post a run's video and screenshots from this machine`, fix: noAttach.fix }, + ); + + const stale = readClaims(workspace.claimsDir, platform).filter(isOrphaned); + checks.push(check('stale-claims', stale.length === 0, stale.length === 0 ? 'none' : `${stale.map((c) => c.deviceName).join(', ')} belong to deleted worktrees`, '{cli} down --stale')); + + checks.push( + agentDeviceDaemonCheck([ + { label: 'machine-wide ~/.agent-device', dir: join(homedir(), '.agent-device') }, + { label: 'this worktree .verify/agent-device', dir: workspace.agentDeviceDir }, + ]), + ); + checks.push(featureMapCheck(skill, host.features)); + + const drift = manifestDrift(); + checks.push(check('core-drift', drift.length === 0, drift.length === 0 ? 'src/core matches MANIFEST' : `changed: ${drift.join(', ')}`, 'node src/core/manifest.ts --write, and copy src/core to clerk-android and clerk/javascript')); + + return { verb: 'doctor', ok: checks.every((c) => c.ok), backend: { [platform]: backend.kind }, checks }; +} + +type RuntimeOutcome = LeaseOutcome & { readonly entry: HostEntry; readonly instances: readonly InstanceView[] }; + +async function startRuntime(deps: Deps, lease: Lease): Promise { + if (deps.host.runtime === undefined) return deps.host.entry(lease.platform); + const runtime = await deps.host.runtime(lease, deps.progress); + const open = deps.workspace.unclosedEntries(); + for (const process of runtime.processes) { + if (open.some((e) => e.kind === 'process' && e.what === process.what && e.pid === process.pid)) continue; + deps.workspace.append({ id: newEntryId(), kind: 'process', what: process.what, pid: process.pid, startedAt: new Date(process.startedAt).toISOString(), ...(process.platform === undefined ? {} : { platform: process.platform }) }); + } + return runtime.entry; +} + +function writeLeaseContext(deps: Deps, outcome: RuntimeOutcome): void { + const context: RunContext = { + v: 1, + run: null, + workspace: deps.workspace.root, + broker: null, + agentDeviceSession: agentDeviceSession(deps.workspace, outcome.lease.platform), + targets: [targetOf(deps, outcome)], + }; + writeRunContext(join(deps.workspace.root, 'context.json'), context); +} + +const agentDeviceSession = (workspace: Workspace, platform: Platform) => `verify-${platform}-${workspace.worktreeId}`; + +function targetOf(deps: Deps, outcome: RuntimeOutcome): RunContext['targets'][number] { + const platform = outcome.lease.platform; + return { + platform, + appId: deps.host.appId(platform), + appPath: outcome.app.path, + leaseFile: deps.workspace.leaseFile(platform), + entry: outcome.entry, + }; +} + +export async function up(deps: Deps, command: Extract): Promise { + const platform = platformOf(deps.host, command.platform); + selectBackend(deps.host, platform); + return deps.workspace.withAcquireLock(platform, async (lock) => { + const [leased, instances] = await leaseWithInstances(deps, { willChange: false }, () => + ensureLease(lock, deps.workspace, deps.host, { waitSeconds: command.waitSeconds, progress: deps.progress, instances: deps.instances, retryWith: '{cli} up --wait ' }), + ); + const outcome = { ...leased, entry: await startRuntime(deps, leased.lease), instances }; + writeLeaseContext(deps, outcome); + return { verb: 'up', leases: [outcome.view], builds: [outcome.build], instances: outcome.instances }; + }, (owner) => deps.progress(`wait another {cli} in this worktree (pid ${owner.pid}) is leasing the device; waiting for it, with no time limit`)); +} + +async function gitFacts(runner: Runner, worktree: string): Promise<{ head: string; dirty: boolean }> { + const head = await runner('git', ['rev-parse', 'HEAD'], { cwd: worktree }); + const status = await runner('git', ['status', '--porcelain'], { cwd: worktree }); + return { head: head.stdout.trim(), dirty: status.stdout.trim().length > 0 }; +} + +type Identity = EvidenceRecord['identities'][number]; + +async function newIdentities(deps: Deps, run: RunId, known: readonly Identity[]): Promise { + const entries = deps.workspace.entries(); + const reserved = entries.flatMap((e) => (e.kind === 'identity' && e.run === run && !known.some((identity) => identity.email === e.email) ? [e] : [])); + const out: Identity[] = []; + for (const identity of reserved) { + let userId = entries.find((e): e is Extract => e.kind === 'user' && e.email === identity.email)?.userId ?? null; + if (userId === null) userId = await deps.instances.clerk().findUserId(identity.email).catch(() => null); + out.push({ email: identity.email, userId }); + } + return out; +} + +function lastState(dir: EvidencePath): VerifyState | null { + try { + return readStates(dir).at(-1) ?? null; + } catch { + return null; + } +} + +export async function endRun( + workspace: Workspace, + run: { + readonly recording: Recording | null; + readonly recorderEntry: string | null; + readonly broker: { stop(): Promise }; + readonly scratch: ScratchPath; + }, +): Promise { + const steps: (() => unknown)[] = [ + () => run.recording?.stop(), + () => { + if (run.recorderEntry !== null) workspace.append({ id: newEntryId(), kind: 'done', ref: run.recorderEntry }); + }, + () => run.broker.stop(), + () => workspace.removeScratch(run.scratch), + ]; + const errors: unknown[] = []; + for (const step of steps) { + try { + await step(); + } catch (error) { + errors.push(error); + } + } + if (errors.length > 0) throw errors[0]; +} + +export function nextStep(run: RunId, dir: EvidencePath, results: readonly SpecResult[], selection: string): string { + const ran = assertSomethingRan(results, selection); + const failed = results.filter((r) => r.status === 'failed' || r.status === 'interrupted'); + if (failed.length > 0) return failed[0]?.failurePage ?? join(dir, 'e2e.log'); + if (ran === 'all-left-out') return 'nothing ran: every selected spec was left out by tag or platform, so the run proves nothing to post'; + return `{cli} attach ${run} --pr `; +} + +export async function leaseForRun(deps: Deps, platform: Platform, command: Extract, instances: { readonly willChange: boolean }, drive: (outcome: RuntimeOutcome) => Promise): Promise { + selectBackend(deps.host, platform); + const key = await computeBuildKey(deps.host, platform, deps.workspace.worktree); + const retryWith = `{cli} run ${'all' in command.selection ? '--all' : command.selection.selectors.join(' ')} --wait `; + const deviceWait = { + seconds: command.waitSeconds, + busyFix: `let the other run in this worktree finish, or rerun with a wait: ${retryWith}`, + onWait: (owner: ProcessRef) => deps.progress(`wait another {cli} run in this worktree (pid ${owner.pid}) is driving the device; waiting up to ${command.waitSeconds}s`), + }; + return deps.workspace.withAcquireThenDevice( + platform, + deviceWait, + async (lock) => { + const [leased, up] = await leaseWithInstances(deps, instances, () => + ensureLease(lock, deps.workspace, deps.host, { waitSeconds: command.waitSeconds, progress: deps.progress, instances: deps.instances, retryWith }), + ); + const outcome: RuntimeOutcome = { ...leased, entry: await startRuntime(deps, leased.lease), instances: up }; + writeLeaseContext(deps, outcome); + deps.progress(leaseLine(outcome.view)); + return outcome; + }, + drive, + (owner) => deps.progress(`wait another {cli} in this worktree (pid ${owner.pid}) is building ${key} or leasing the device; waiting for it, with no time limit`), + ); +} + +interface GroupRun { + readonly run: RunId; + readonly dir: EvidencePath; + readonly log: EvidencePath; + readonly context: ActiveRunContext; + readonly command: Extract; + readonly platform: Platform; +} + +interface GroupOutcome { + readonly record: EvidenceRecord['settings'][number]; + readonly instance: EvidenceRecord['instances'][number] | null; + readonly results: readonly SpecResult[]; + readonly screenshots: EvidenceRecord['screenshots']; + readonly identities: readonly Identity[]; + readonly failure: VerifyFailure | null; + readonly stopsTheRun: boolean; +} + +const notRun = (spec: SpecRef, platform: Platform, why: string): SpecResult => ({ + spec, + title: 'not run', + platform, + status: 'failed', + seconds: 0, + error: redact(why), + skipReason: null, + skippedBy: null, + tags: [], + failurePage: null, + failureScreen: null, + failureScreenshot: null, +}); + +class SpecEdited extends VerifyFailure {} + +const asFailure = (error: unknown): VerifyFailure => (error instanceof VerifyFailure ? error : new VerifyFailure('NOT_READY', (error as Error).message ?? String(error), 'run `{cli} doctor`, then rerun')); + +async function runGroup(deps: Deps, the: GroupRun, group: SettingsGroup, index: number, known: readonly Identity[], stopped: VerifyFailure | null): Promise { + const { workspace } = deps; + const { settings } = group; + const specs = group.specs.map((spec): SpecRef => ({ kind: spec.kind, path: spec.path, feature: spec.feature })); + const outputDir = e2eOutputDir(the.dir, index); + const reportFile = join(outputDir, 'report.json') as EvidencePath; + let applied: AppliedInstance | null = null; + let invoked: { readonly exitCode: number } | null = null; + let held = false; + let unread: string | null = null; + let identities: readonly Identity[] = []; + let failure: VerifyFailure | null = null; + let stopsTheRun = false; + let lostItsSettings = false; + const refuseEdited = (): void => { + const edited = group.specs.find((spec) => sourceHash(readFileSync(join(workspace.skillDir, spec.path), 'utf8')) !== spec.sourceHash); + if (edited !== undefined) throw new SpecEdited('USAGE', `${edited.path} changed while the run was in progress`, 'rerun; a run plans its groups from the spec files as they are when it starts'); + }; + try { + if (stopped !== null) throw new VerifyFailure(stopped.code, `an earlier group of this run failed, so this one did not run: ${stopped.message}`, stopped.fix); + refuseEdited(); + try { + applied = await deps.instances.apply(group, deps.progress); + } catch (error) { + stopsTheRun = !(error instanceof SettingsRefused); + throw error; + } + try { + refuseEdited(); + if (index > 0) appendFileSync(the.log, `settings ${settings.label}: ${count(specs.length, 'spec file')}\n`); + invoked = await invokeE2E(planE2E(the.context, specs, the.command, the.platform, workspace.skillDir, outputDir), the.log, workspace.skillDir, deps.progress); + identities = await newIdentities(deps, the.run, known); + try { + held = await applied.stillApplied(); + } catch (error) { + unread = (error as Error).message ?? String(error); + } + refuseEdited(); + } finally { + await applied.release(); + } + } catch (error) { + failure = asFailure(error); + stopsTheRun ||= stopped === null && applied !== null && !(error instanceof SpecEdited); + } + + let results: readonly SpecResult[] = []; + let screenshots: EvidenceRecord['screenshots'] = []; + if (invoked !== null && failure === null) { + try { + const report: unknown = existsSync(reportFile) ? JSON.parse(readFileSync(reportFile, 'utf8')) : null; + if (report === null) throw new VerifyFailure('E2E_CRASHED', `e2e exited ${invoked.exitCode} before writing a report for ${settings.label}`, `read ${the.log}`); + results = parseE2EReport(report, specs, outputDir, excludedTagNames(the.command)); + screenshots = collectScreenshots(report, the.dir, outputDir); + } catch (error) { + failure = error instanceof VerifyFailure ? error : new VerifyFailure('E2E_CRASHED', `e2e's report could not be read: ${(error as Error).message}`, `read ${reportFile}`); + results = []; + screenshots = []; + } + if (failure === null && !held) { + lostItsSettings = true; + failure = + unread === null + ? new VerifyFailure('INSTANCE_MISCONFIGURED', `the instance no longer showed ${settings.label} when its specs ended, so what they saw is unknown`, 'rerun; if another command in this worktree changed the instance, let one finish before the other starts') + : new VerifyFailure('NOT_READY', `the instance's environment could not be read after the specs on ${settings.label} ended, so what they saw is unknown: ${unread}`, 'rerun'); + } + } + const unreported = invoked === null || invoked.exitCode === 0 ? 'e2e reported no result for this file: it registers no test' : `e2e exited ${invoked.exitCode} and reported no result for this file: it failed to load or registers no test; e2e.log in the run directory says which`; + const why = failure?.message ?? unreported; + const missing = specs.filter((spec) => !results.some((result) => result.spec.path === spec.path)); + const reported = lostItsSettings ? specs.filter((spec) => !missing.includes(spec)) : []; + return { + record: { + label: settings.label, + askedBy: settings.askedBy, + specs: specs.map((spec) => spec.path), + application: applied?.instance.id ?? null, + changed: applied !== null && applied.changed !== null, + held, + e2eReport: invoked === null ? null : reportFile, + }, + instance: applied === null ? null : { application: applied.instance.id }, + results: [...results, ...[...missing, ...reported].map((spec) => notRun(spec, the.platform, why))], + screenshots, + identities, + failure, + stopsTheRun, + }; +} + +export async function runVerb(deps: Deps, command: Extract): Promise { + const { host, workspace } = deps; + const platform = platformOf(host, command.platform); + const specs = resolveSpecs(workspace.skillDir, command.selection); + const recorded = deps.instances.recordedKey(); + const sources = specs.map((spec) => ({ spec, source: readFileSync(join(workspace.skillDir, spec.path), 'utf8') })); + const groups = planGroups(sources, recorded ?? STANDARD.key); + if (groups.length > 1) deps.progress(`settings ${groups.length} groups in this run: ${groups.map((group, index) => `${group.settings.label} (${index === 0 ? count(group.specs.length, 'spec file') : group.specs.length})`).join(', ')}`); + return leaseForRun(deps, platform, command, { willChange: groups.some((group) => group.settings.key !== recorded) }, async (outcome) => { + try { + const { lease, backend } = outcome; + const { run, dir, scratch } = workspace.newRun(); + const startedAt = new Date(); + deps.progress(`run ${run} ${platform} ${outcome.view.backend} ${outcome.view.device} build ${outcome.app.key}`); + for (const { spec, source } of sources) { + mkdirSync(dirname(join(dir, spec.path)), { recursive: true }); + writeFileSync(join(dir, spec.path), source); + } + + const broker = await startBroker(run, workspace, scratch, { + clerk: () => deps.instances.clerk(), + publishableKey: () => deps.instances.keys().pk, + screens: host.screens, + platforms: [platform], + }); + const context: ActiveRunContext = { + v: 1, + run, + workspace: workspace.root, + broker: { url: broker.url, tokenFile: broker.tokenFile }, + agentDeviceSession: agentDeviceSession(workspace, platform), + targets: [targetOf(deps, outcome)], + }; + writeRunContext(contextFile(context), context); + + let recording: Recording | null = null; + let recorderEntry: string | null = null; + const outcomes: GroupOutcome[] = []; + try { + if (command.video) { + recording = await backend.startRecording(lease, dir); + recorderEntry = newEntryId(); + workspace.append({ id: recorderEntry, kind: 'process', what: 'recorder', pid: recording.process.pid, startedAt: new Date(recording.process.startedAt).toISOString(), platform }); + } + const the: GroupRun = { run, dir, log: join(dir, 'e2e.log') as EvidencePath, context, command, platform }; + for (const [index, group] of groups.entries()) { + const stopped = outcomes.find((done) => done.stopsTheRun)?.failure ?? null; + outcomes.push(await runGroup(deps, the, group, index, outcomes.flatMap((done) => done.identities), stopped)); + } + } finally { + await endRun(workspace, { recording, recorderEntry, broker, scratch }); + } + + const appLog = join(dir, 'app.log') as EvidencePath; + writeFileSync(appLog, redact(await backend.logs(lease, startedAt, host.logPredicates?.[platform]))); + const state = lastState(dir); + if (state !== null) writeFileSync(join(dir, 'state.json'), `${JSON.stringify(state, null, 2)}\n`); + + const results = outcomes.flatMap((done) => done.results); + const instances = new Map(outcomes.flatMap((done) => (done.instance === null ? [] : [[JSON.stringify(done.instance), done.instance] as const]))); + const screenshots = new Map(outcomes.flatMap((done) => done.screenshots.map((shot) => [shot.label, shot] as const))); + const git = await gitFacts(deps.runner, workspace.worktree); + const record = sealEvidence(dir, { + run, + startedAt: startedAt.toISOString(), + finishedAt: new Date().toISOString(), + repo: host.repo, + gitHead: git.head, + dirty: git.dirty, + platform, + backend: lease.backend, + device: outcome.view.device, + build: outcome.app.key, + results, + videos: existsSync(join(dir, 'video.mp4')) ? [join(dir, 'video.mp4') as EvidencePath] : [], + screenshots: [...screenshots.values()], + lastState: state, + appLog, + e2eReport: join(e2eOutputDir(dir, 0), 'report.json') as EvidencePath, + identities: outcomes.flatMap((done) => done.identities), + instances: [...instances.values()], + settings: outcomes.map((done) => done.record), + }); + const failed = outcomes.flatMap((done) => (done.failure === null ? [] : [done.failure])); + if (failed[0] !== undefined) { + deps.progress(`evidence ${dir} sealed; ${count(failed.length, 'group')} of ${groups.length} did not run in full, and run.json has a failed result for each of their spec files`); + throw failed[0]; + } + const next = nextStep(run, dir, results, 'all' in command.selection ? '--all' : command.selection.selectors.join(' ')); + return { verb: 'run', dir, record, next }; + } finally { + ledgerAgentDeviceDaemon(workspace); + } + }); +} + +interface SnapshotNode { + readonly kind?: string; + readonly type?: string; + readonly label?: string; + readonly value?: string; + readonly identifier?: string; + readonly depth?: number; +} + +function heldLease(deps: Deps, platform: Platform): { lease: Lease; backend: DeviceBackend } { + const lease = deps.workspace.readLease(platform); + if (lease === null) throw new VerifyFailure('NOT_READY', `this worktree holds no ${platform} device`, '{cli} up'); + return { lease, backend: backendFor(deps.host, platform, lease.backend) }; +} + +function screenNodes(snapshot: readonly SnapshotNode[]): readonly ScreenNode[] { + const counts = new Map(); + for (const node of snapshot) if (node.identifier) counts.set(node.identifier, (counts.get(node.identifier) ?? 0) + 1); + return snapshot.map((node) => { + const testId = node.identifier || null; + const text = node.value || null; + return { + role: (node.kind ?? node.type ?? 'node').toLowerCase(), + name: node.label || null, + testId, + text, + depth: node.depth ?? 0, + locator: testId !== null && counts.get(testId) === 1 ? `screen.getByTestId('${testId}')` : null, + }; + }); +} + +async function screen(deps: Deps, command: Extract): Promise { + const platform = platformOf(deps.host, command.platform); + const { lease, backend } = heldLease(deps, platform); + if ((await backend.check(lease)) === 'lost') throw new VerifyFailure('LEASE_LOST', `${backend.describe(lease)} is gone`, '{cli} up'); + const env = withoutClerkKeys(deps.env); + const target = agentDeviceFor(lease); + const agentDevice = (args: readonly string[]) => + deps.runner(join(deps.workspace.skillDir, 'node_modules', '.bin', 'agent-device'), args, { env: { ...env, AGENT_DEVICE_STATE_DIR: deps.workspace.agentDeviceDir } }); + const screenWait = { seconds: 10, busyFix: 'let the run in this worktree finish, then rerun {cli} screen' }; + return deps.workspace.withDevice(platform, screenWait, async () => { + const selector = target.selector; + const session = ['--session', `${agentDeviceSession(deps.workspace, platform)}-screen`]; + const attachedWithoutRelaunch = await agentDevice(['open', deps.host.appId(platform), '--json', ...selector, ...session]); + if (attachedWithoutRelaunch.code !== 0) throw new VerifyFailure('NOT_READY', `agent-device open failed: ${redact(attachedWithoutRelaunch.stdout.trim() || attachedWithoutRelaunch.stderr.trim())}`, '{cli} up, then retry'); + const snap = await agentDevice(['snapshot', '--json', ...selector, ...session]); + if (snap.code !== 0) throw new VerifyFailure('NOT_READY', `agent-device snapshot failed: ${redact(snap.stderr.trim() || snap.stdout.trim())}`, 'run a spec first so the app is open, then retry'); + const parsed = JSON.parse(snap.stdout) as { data?: { nodes?: SnapshotNode[] } }; + const nodes = screenNodes(parsed.data?.nodes ?? []); + const stateNode = nodes.find((n) => n.testId === STATE_ELEMENT_ID); + const stateText = stateNode?.text ?? stateNode?.name ?? null; + let state: VerifyState | null = null; + if (stateText !== null) { + try { + state = parseVerifyState(stateText); + } catch { + state = null; + } + } + let png: ScratchPath | null = null; + if (command.png) { + const dir = join(deps.workspace.root, 'scratch', 'screens'); + mkdirSync(dir, { recursive: true }); + png = join(dir, `${new Date().toISOString().replace(/[:.]/g, '-')}.png`) as ScratchPath; + const shot = await agentDevice(['screenshot', png, ...selector, ...session]); + if (shot.code !== 0) throw new VerifyFailure('NOT_READY', `agent-device screenshot failed: ${redact(shot.stderr.trim())}`, 'retry, or check `{cli} doctor`'); + } + await agentDevice(['close', ...selector, ...session]); + ledgerAgentDeviceDaemon(deps.workspace); + return { verb: 'screen', platform, device: backend.describe(lease), nodes, state, png }; + }); +} + +export async function attach(deps: Deps, command: Extract): Promise { + const run = parseRunId(command.run); + const dir = deps.workspace.runDir(run); + if (!existsSync(dir)) throw new VerifyFailure('USAGE', `no run ${run} in ${deps.workspace.root}/runs`, 'pass a run id that `{cli} run` printed'); + const record = readRecord(dir); + const publishable = assertPublishable(record, readStates(dir)); + return postToPullRequest(publishable, dir, deps.host, command.pr, command.screenshots, deps.runner); +} + +interface DownPlan { + readonly leases: readonly { readonly lease: Lease; readonly backend: DeviceBackend; readonly view: LeaseView; readonly origin: 'lease-file' | 'stale-claim' }[]; + readonly stillLeased: readonly Platform[]; + readonly processes: readonly ProcessEntry[]; + readonly staleIntents: readonly string[]; +} + +const leaseIdentity = (lease: Lease): string => `local:${lease.claimNonce}`; + +async function planDown(deps: Deps, command: Extract): Promise { + const { host, workspace } = deps; + const platforms = command.platform === undefined ? host.platforms : [platformOf(host, command.platform)]; + const leases: DownPlan['leases'][number][] = []; + for (const platform of platforms) { + const lease = workspace.readLease(platform); + if (lease !== null) { + const backend = backendFor(host, platform, lease.backend); + leases.push({ lease, backend, view: leaseView(backend, lease, false), origin: 'lease-file' }); + } + if (command.stale) { + for (const backend of host.backends.filter((b) => b.platform === platform)) { + for (const orphan of await backend.reapable(workspace.worktree)) { + if (leases.some((l) => leaseIdentity(l.lease) === leaseIdentity(orphan))) continue; + leases.push({ lease: orphan, backend, view: leaseView(backend, orphan, false), origin: 'stale-claim' }); + } + } + } + } + const stillLeased = host.platforms.filter((platform) => !platforms.includes(platform) && workspace.readLease(platform) !== null); + return { + leases, + stillLeased, + processes: processesIn(workspace, { platforms, sharedByEveryLease: stillLeased.length === 0 }), + staleIntents: command.stale ? workspace.unclosedEntries().filter((e) => e.kind === 'lease-intent').map((e) => e.id) : [], + }; +} + +export function down(deps: Deps, command: Extract): Promise { + if (command.dryRun) return downUnlocked(deps, command); + const platforms = command.platform === undefined ? deps.host.platforms : [platformOf(deps.host, command.platform)]; + const locked = platforms.reduce<() => Promise>( + (inner, platform) => () => + deps.workspace.withAcquireLock( + platform, + () => + deps.workspace.withDevice( + platform, + { + seconds: Number.POSITIVE_INFINITY, + busyFix: '', + onWait: (owner) => deps.progress(`wait another {cli} run in this worktree (pid ${owner.pid}) is driving the device; down waits for it, with no time limit`), + }, + inner, + ), + (owner) => deps.progress(`wait another {cli} in this worktree (pid ${owner.pid}) is building or leasing the device; down waits for it, with no time limit`), + ), + () => downUnlocked(deps, command), + ); + return locked(); +} + +function runningDaemon(workspace: Workspace): readonly string[] { + const daemon = readDaemonInfo(workspace.agentDeviceDir); + return daemon !== null && isRunning(daemon) ? [`agent-device ${daemon.pid}`] : []; +} + +async function downUnlocked(deps: Deps, command: Extract): Promise { + const { workspace } = deps; + if (!command.dryRun) ledgerAgentDeviceDaemon(workspace); + const plan = await planDown(deps, command); + const sharedStays = plan.stillLeased.length > 0; + if (command.dryRun) { + return { + verb: 'down', + dryRun: true, + wouldRelease: plan.leases.map((l) => l.view), + wouldDelete: sharedStays ? [] : openApplications(workspace).map((entry) => ({ kind: 'application', name: entry.name })), + wouldStop: [...new Set([...plan.processes.filter((p) => isRunning({ pid: p.pid, startedAt: Date.parse(p.startedAt) })).map((p) => `${p.what} ${p.pid}`), ...(sharedStays ? [] : runningDaemon(workspace))])], + keptRuns: workspace.runs(), + }; + } + const stoppedProcesses = stopProcesses(workspace, plan.processes); + const unreleased: unknown[] = []; + for (const { lease, backend, origin } of plan.leases) { + await (origin === 'lease-file' ? releaseLease(workspace, backend, lease) : backend.release(lease)).catch((error: unknown) => unreleased.push(error)); + } + if (sharedStays && openApplications(workspace).length > 0) deps.progress(`kept this worktree's throwaway instances, which its ${plan.stillLeased.join(' and ')} lease still uses`); + const deleted = await deps.instances.finish(workspace, { keepApplications: sharedStays }, deps.progress).catch((error: unknown) => { + unreleased.push(error); + return []; + }); + for (const ref of plan.staleIntents) workspace.append({ id: newEntryId(), kind: 'done', ref }); + if (plan.leases.some((l) => l.origin === 'lease-file')) rmSync(join(workspace.root, 'context.json'), { force: true }); + if (unreleased.length > 0) throw unreleased[0]; + return { + verb: 'down', + dryRun: false, + released: plan.leases.map((l) => l.view), + deletedApplications: deleted, + stoppedProcesses, + keptRuns: workspace.runs(), + }; +} + +export const verbs = { doctor, up, run: runVerb, screen, attach, down } as const; diff --git a/.claude/skills/verify-clerk-expo/src/core/workspace.ts b/.claude/skills/verify-clerk-expo/src/core/workspace.ts new file mode 100644 index 00000000000..a4e50db16e6 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/core/workspace.ts @@ -0,0 +1,225 @@ +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, relative, resolve } from 'node:path'; +import { currentProcess, isRunning, sleep, type ProcessRef } from './exec.ts'; +import { compareAndSwapSlot, readSlot } from './slot.ts'; +import { + VerifyFailure, + type AcquireLock, + type DeviceWait, + type EvidencePath, + type Lease, + type LedgerEntry, + type Platform, + type RunId, + type ScratchPath, +} from './types.ts'; + +export interface WorkspaceOptions { + readonly skillDir: string; + readonly worktree: string; + readonly home?: string; +} + +export interface Workspace { + readonly root: string; + readonly skillDir: string; + readonly worktree: string; + readonly worktreeId: string; + readonly home: string; + readonly ledgerFile: string; + readonly claimsDir: string; + readonly agentDeviceDir: string; + newRun(): { readonly run: RunId; readonly dir: EvidencePath; readonly scratch: ScratchPath }; + runDir(run: RunId): EvidencePath; + runs(): readonly RunId[]; + buildsDir(): ScratchPath; + leaseFile(platform: Platform): string; + readLease(platform: Platform): Lease | null; + writeLease(lease: Lease): void; + clearLease(platform: Platform): void; + append(entry: LedgerEntry): void; + entries(): readonly LedgerEntry[]; + unclosedEntries(): readonly LedgerEntry[]; + withAcquireLock(platform: Platform, fn: (lock: AcquireLock) => Promise, onWait?: (owner: ProcessRef) => void): Promise; + withDevice(platform: Platform, wait: DeviceWait, fn: () => Promise): Promise; + withAcquireThenDevice( + platform: Platform, + deviceWait: DeviceWait, + prepare: (lock: AcquireLock) => Promise, + drive: (prepared: A) => Promise, + onAcquireWait?: (owner: ProcessRef) => void, + ): Promise; + removeScratch(path: ScratchPath): void; +} + +export const newEntryId = (): string => randomUUID(); + +export const agentDeviceStateDir = (workspaceRoot: string): string => join(workspaceRoot, 'agent-device'); + +function worktreeIdOf(worktree: string): string { + return createHash('sha256').update(resolve(worktree)).digest('hex').slice(0, 12); +} + +export function newRunId(): RunId { + const now = new Date(); + const pad = (n: number) => String(n).padStart(2, '0'); + const date = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`; + const time = `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + return `r${date}-${time}-${randomBytes(2).toString('hex')}` as RunId; +} + +const RUN_ID = /^r\d{8}-\d{6}-[0-9a-f]{4}$/; +export function parseRunId(value: string): RunId { + if (!RUN_ID.test(value)) throw new VerifyFailure('USAGE', `${value} is not a run id`, 'pass an id like r20261002-141210-7c1e from `{cli} run`'); + return value as RunId; +} + +function isPlatform(value: unknown): value is Platform { + return value === 'ios' || value === 'android'; +} + +function parseLease(text: string, file: string): Lease { + const raw: unknown = JSON.parse(text); + const bad = () => new VerifyFailure('LEASE_LOST', `${file} is not a lease`, '{cli} down, then {cli} up'); + if (typeof raw !== 'object' || raw === null) throw bad(); + const r = raw as Record; + if (!isPlatform(r.platform) || typeof r.acquiredAt !== 'string') throw bad(); + const installedBuild = typeof r.installedBuild === 'string' ? r.installedBuild : null; + if (r.backend === 'local') { + if (typeof r.slot !== 'number' || typeof r.deviceName !== 'string' || typeof r.deviceId !== 'string' || typeof r.claimNonce !== 'string') throw bad(); + if (r.deviceName !== `verify-${r.platform}-${r.slot}`) throw bad(); + return { ...(r as object), installedBuild } as Lease; + } + throw bad(); +} + +function writePrivate(file: string, text: string): void { + writeFileSync(file, text, { mode: 0o600 }); +} + +export async function takeSlotLock(dir: string, timeoutMs: number, onTimeout: () => VerifyFailure, onWait?: (owner: ProcessRef) => void): Promise<() => void> { + const deadline = Date.now() + timeoutMs; + const me = currentProcess(); + let announced = false; + for (;;) { + const state = readSlot(dir); + const running = state.value !== null && isRunning(JSON.parse(state.value) as ProcessRef); + if (!running && compareAndSwapSlot(dir, state.gen, JSON.stringify(me))) { + const held = state.gen + 1; + return () => void compareAndSwapSlot(dir, held, null); + } + if (running) { + if (Date.now() >= deadline) throw onTimeout(); + if (!announced && onWait !== undefined) onWait(JSON.parse(state.value!) as ProcessRef); + announced = true; + await sleep(250); + } + } +} + +async function withSlotLock(dir: string, timeoutMs: number, onTimeout: () => VerifyFailure, fn: () => Promise, onWait?: (owner: ProcessRef) => void): Promise { + const release = await takeSlotLock(dir, timeoutMs, onTimeout, onWait); + try { + return await fn(); + } finally { + release(); + } +} + +const deviceBusy = (platform: Platform, fix: string) => + new VerifyFailure('DEVICE_BUSY', `another {cli} process in this worktree is driving the ${platform} device`, fix); + +export function openWorkspace(options: WorkspaceOptions): Workspace { + const root = join(options.skillDir, '.verify'); + const home = options.home ?? join(homedir(), '.verify'); + const worktreeId = worktreeIdOf(options.worktree); + const ledgerFile = join(home, 'ledgers', `${worktreeId}.jsonl`); + const claimsDir = join(home, 'claims'); + const dir = (...parts: string[]) => { + const path = join(root, ...parts); + mkdirSync(path, { recursive: true }); + return path; + }; + const readEntries = (): LedgerEntry[] => { + if (!existsSync(ledgerFile)) return []; + return readFileSync(ledgerFile, 'utf8') + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line) as LedgerEntry); + }; + + const acquireDir = (platform: Platform) => join(dir('locks'), `acquire-${platform}`); + const unreachable = () => new VerifyFailure('DEVICE_BUSY', 'unreachable', ''); + + return { + root, + skillDir: options.skillDir, + worktree: options.worktree, + worktreeId, + home, + ledgerFile, + claimsDir, + agentDeviceDir: agentDeviceStateDir(root), + newRun() { + const run = newRunId(); + return { run, dir: dir('runs', run) as EvidencePath, scratch: dir('scratch', run) as ScratchPath }; + }, + runDir: (run) => join(root, 'runs', run) as EvidencePath, + runs: () => (existsSync(join(root, 'runs')) ? readdirSync(join(root, 'runs')).filter((name) => RUN_ID.test(name)).sort() as RunId[] : []), + buildsDir: () => join(root, 'builds') as ScratchPath, + leaseFile: (platform) => join(root, 'leases', `${platform}.json`), + readLease(platform) { + const file = join(root, 'leases', `${platform}.json`); + return existsSync(file) ? parseLease(readFileSync(file, 'utf8'), file) : null; + }, + writeLease(lease) { + writePrivate(join(dir('leases'), `${lease.platform}.json`), `${JSON.stringify(lease, null, 2)}\n`); + }, + clearLease(platform) { + rmSync(join(root, 'leases', `${platform}.json`), { force: true }); + }, + append(entry) { + mkdirSync(join(home, 'ledgers'), { recursive: true }); + const owner = join(home, 'ledgers', `${worktreeId}.owner`); + if (!existsSync(owner)) writePrivate(owner, `${resolve(options.worktree)}\n${resolve(options.skillDir)}\n`); + appendFileSync(ledgerFile, `${JSON.stringify(entry)}\n`, { mode: 0o600, flag: 'a' }); + }, + entries: readEntries, + unclosedEntries() { + const entries = readEntries(); + const closed = new Set(entries.flatMap((e) => (e.kind === 'done' ? [e.ref] : []))); + return entries.filter((e) => e.kind !== 'done' && !closed.has(e.id)); + }, + withAcquireLock(platform, fn, onWait) { + return withSlotLock(acquireDir(platform), Number.POSITIVE_INFINITY, unreachable, () => fn({ platform } as AcquireLock), onWait); + }, + withDevice(platform, wait, fn) { + return withSlotLock(join(dir('locks'), `device-${platform}`), wait.seconds * 1000, () => deviceBusy(platform, wait.busyFix), fn, wait.onWait); + }, + async withAcquireThenDevice(platform, deviceWait, prepare, drive, onAcquireWait) { + const releaseAcquire = await takeSlotLock(acquireDir(platform), Number.POSITIVE_INFINITY, unreachable, onAcquireWait); + let prepared; + let releaseDevice; + try { + prepared = await prepare({ platform } as AcquireLock); + releaseDevice = await takeSlotLock(join(dir('locks'), `device-${platform}`), deviceWait.seconds * 1000, () => deviceBusy(platform, deviceWait.busyFix), deviceWait.onWait); + } finally { + releaseAcquire(); + } + try { + return await drive(prepared); + } finally { + releaseDevice(); + } + }, + removeScratch(path) { + const rel = relative(root, path); + if (!(rel.startsWith('scratch') || rel.startsWith('builds')) || rel.includes('..')) { + throw new VerifyFailure('EVIDENCE_UNSAFE', `${path} is not scratch`, 'only .verify/scratch and .verify/builds are deletable'); + } + rmSync(path, { recursive: true, force: true }); + }, + }; +} diff --git a/.claude/skills/verify-clerk-expo/src/platform/android/emulator.ts b/.claude/skills/verify-clerk-expo/src/platform/android/emulator.ts new file mode 100644 index 00000000000..ee38deb6c8c --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/platform/android/emulator.ts @@ -0,0 +1,29 @@ +import { AVD_NAME } from './sdk.ts'; + +export const RECORD_SIZE = '720x1608'; +export const LOG_FILTER = ['ClerkVerify:V', 'ClerkLog:V', 'OkHttp:V', 'ReactNativeJS:V', 'AndroidRuntime:E', '*:S']; + +const SOFTWARE_GPU = ['-gpu', 'swiftshader_indirect']; +const WITHOUT_THE_180_SECOND_CAP = ['--time-limit', '0']; +const SHELL_WRITABLE_BEFORE_STORAGE_MOUNTS = '/data/local/tmp'; + +export function emulatorArgs(port: number, os: NodeJS.Platform): readonly string[] { + return ['-avd', AVD_NAME, '-read-only', '-no-window', '-no-audio', '-no-boot-anim', ...(os === 'linux' ? SOFTWARE_GPU : []), '-port', String(port)]; +} + +export const installArgs = (apk: string): readonly string[] => ['install', '-r', '-t', apk]; + +export function logFilter(extraPredicate?: string | null): readonly string[] { + const extra = extraPredicate?.split(/\s+/).filter((spec) => spec.length > 0) ?? []; + return [...LOG_FILTER.slice(0, -1), ...extra, '*:S']; +} + +export function logcatSince(since: Date): string { + return (since.getTime() / 1000).toFixed(3); +} + +export const logcatArgs = (since: Date, extraPredicate?: string | null): readonly string[] => ['logcat', '-d', '-v', 'threadtime', '-T', logcatSince(since), ...logFilter(extraPredicate)]; + +export const recordingOnDevice = (name: string): string => `${SHELL_WRITABLE_BEFORE_STORAGE_MOUNTS}/verify-${name}.mp4`; + +export const screenrecordArgs = (deviceFile: string): readonly string[] => ['shell', 'screenrecord', '--size', RECORD_SIZE, ...WITHOUT_THE_180_SECOND_CAP, deviceFile]; diff --git a/.claude/skills/verify-clerk-expo/src/platform/android/local.ts b/.claude/skills/verify-clerk-expo/src/platform/android/local.ts new file mode 100644 index 00000000000..7f6f030f94f --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/platform/android/local.ts @@ -0,0 +1,427 @@ +import { execFileSync, spawn } from 'node:child_process'; +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { defaultClaimsDir, freeSlot, isOrphaned, readClaim, readClaims, takeSlot, type Claim } from '../../core/claims.ts'; +import { isRunning, run, sleep, type ProcessRef, type Runner } from '../../core/exec.ts'; +import { + LOCAL_POOL, + VerifyFailure, + type AcquireRequest, + type DeviceBackend, + type DoctorCheck, + type EvidencePath, + type LocalLease, + type Recording, +} from '../../core/types.ts'; +import { emulatorArgs, installArgs, logcatArgs, recordingOnDevice, screenrecordArgs } from './emulator.ts'; +import { AVD_NAME, ensureLaneAvd, jdkCheck, localAvailability, sdkTool, thisMachine, type Machine } from './sdk.ts'; + +const LOCALE = 'en-US'; +const BOOT_TIMEOUT_MS = 240_000; +const STILL_WAITING_MS = 60_000; +const LANE_PROPERTY = 'debug.verify.lane'; + +export const lanePort = (slot: number): number => 5558 + 2 * slot; +export const laneSerial = (slot: number): string => `emulator-${lanePort(slot)}`; + +export function parseAdbDevices(stdout: string): ReadonlyMap { + return new Map( + stdout + .split('\n') + .slice(1) + .map((line) => line.trim().split(/\s+/)) + .filter((parts): parts is [string, string, ...string[]] => parts.length >= 2 && parts[0] !== '') + .map(([serial, state]) => [serial, state]), + ); +} + +const listsAvd = (stdout: string) => stdout.split('\n').some((line) => line.trim() === AVD_NAME); + +export interface LocalAndroidOptions { + readonly claimsDir?: string; + readonly adbBin?: string; + readonly emulatorBin?: string; + readonly emulatorsDir?: string; + readonly machine?: Machine; +} + +interface SpawnedEmulator extends ProcessRef { + readonly nonce: string; +} + +function commandOf(pid: number): string { + try { + return execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }).trim(); + } catch { + return ''; + } +} + +const hasArgument = (command: string, flag: string, value: string) => new RegExp(`(^|\\s)${flag} ${value}(\\s|$)`).test(command); + +function retryFix(request: AcquireRequest): string { + return request.retryWith.replace('', String(Math.max(600, request.waitSeconds * 2))); +} + +export function localAndroidBackend(options: LocalAndroidOptions = {}): DeviceBackend { + const claimsDir = options.claimsDir ?? defaultClaimsDir(); + const exec = run; + const machine = options.machine ?? thisMachine(); + const emulatorLogDir = options.emulatorsDir ?? join(machine.home, '.verify', 'emulators'); + const adbBin = options.adbBin ?? sdkTool('adb', machine); + const emulatorBin = options.emulatorBin ?? sdkTool('emulator', machine); + + const adb = (serial: string, args: readonly string[]) => exec(adbBin, ['-s', serial, ...args]); + const shell = async (serial: string, command: string) => (await adb(serial, ['shell', command])).stdout.trim(); + + async function devices(): Promise> { + const listed = await exec(adbBin, ['devices']); + if (listed.code !== 0) throw new VerifyFailure('NOT_READY', `adb devices failed: ${listed.stderr.trim()}`, 'install the Android SDK platform-tools, or set ANDROID_HOME'); + return parseAdbDevices(listed.stdout); + } + + async function avdName(serial: string): Promise { + const result = await adb(serial, ['emu', 'avd', 'name']); + return result.code === 0 ? (result.stdout.split('\n')[0]?.trim() ?? null) : null; + } + + async function isOwnLane(serial: string, nonce: string): Promise { + return (await avdName(serial)) === AVD_NAME && (await shell(serial, `getprop ${LANE_PROPERTY}`)) === nonce; + } + + async function describeForeign(serial: string): Promise { + return `${serial} (${(await avdName(serial)) ?? 'unknown AVD'}, not a verify lane)`; + } + + const pidFile = (slot: number) => join(emulatorLogDir, `android-${slot}.pid`); + + function ownedProcess(slot: number, nonce: string): SpawnedEmulator | null { + let recorded: SpawnedEmulator; + try { + recorded = JSON.parse(readFileSync(pidFile(slot), 'utf8')) as SpawnedEmulator; + } catch { + return null; + } + if (recorded.nonce !== nonce || !isRunning(recorded)) return null; + const command = commandOf(recorded.pid); + return hasArgument(command, '-avd', AVD_NAME) && hasArgument(command, '-port', String(lanePort(slot))) ? recorded : null; + } + + function forgetProcess(slot: number, nonce: string): void { + try { + if ((JSON.parse(readFileSync(pidFile(slot), 'utf8')) as SpawnedEmulator).nonce === nonce) rmSync(pidFile(slot), { force: true }); + } catch { + return; + } + } + + async function killEmulator(slot: number, nonce: string): Promise { + const serial = laneSerial(slot); + const marked = (await devices()).has(serial) && (await isOwnLane(serial, nonce)); + const owned = ownedProcess(slot, nonce); + if (marked) await adb(serial, ['emu', 'kill']); + else if (owned !== null) process.kill(-owned.pid, 'SIGTERM'); + else { + forgetProcess(slot, nonce); + return; + } + const deadline = Date.now() + 30_000; + while ((await devices()).has(serial) || (owned !== null && isRunning(owned))) { + if (Date.now() >= deadline) { + if (owned === null) throw new VerifyFailure('NOT_READY', `${serial} did not exit after \`adb emu kill\``, `adb -s ${serial} emu kill, then rerun the verb`); + process.kill(-owned.pid, 'SIGKILL'); + } + await sleep(1000); + } + forgetProcess(slot, nonce); + } + + async function waitForBoot(serial: string, exited: () => string | null): Promise { + const deadline = Date.now() + BOOT_TIMEOUT_MS; + for (;;) { + const failure = exited(); + if (failure !== null) throw new VerifyFailure('NOT_READY', `the ${AVD_NAME} emulator exited before it booted: ${failure}`, 'run `{cli} doctor`, then `{cli} up` again'); + if ((await shell(serial, 'getprop sys.boot_completed')) === '1' && (await shell(serial, 'getprop init.svc.bootanim')) !== 'running') return; + if (Date.now() >= deadline) throw new VerifyFailure('NOT_READY', `${serial} did not finish booting in ${BOOT_TIMEOUT_MS / 1000}s`, '{cli} down, then {cli} up'); + await sleep(2000); + } + } + + async function pinLocale(serial: string, progress: (line: string) => void): Promise { + const locale = (await shell(serial, 'getprop persist.sys.locale')) || (await shell(serial, 'getprop ro.product.locale')); + if (locale === LOCALE) return; + progress(`device ${serial} setting locale ${LOCALE}`); + await shell(serial, `setprop persist.sys.locale ${LOCALE}; setprop ctl.restart zygote`); + await sleep(3000); + const deadline = Date.now() + 120_000; + while ((await shell(serial, 'getprop init.svc.zygote')) !== 'running' || !(await shell(serial, 'pm path android')).startsWith('package:')) { + if (Date.now() >= deadline) throw new VerifyFailure('NOT_READY', `${serial} did not come back after the locale change`, '{cli} down, then {cli} up'); + await sleep(2000); + } + } + + async function lanePortsCheck(): Promise { + const running = await devices(); + const live = readClaims(claimsDir, 'android').filter((c) => !isOrphaned(c)); + const foreign: string[] = []; + for (let slot = 1; slot <= LOCAL_POOL.android; slot += 1) { + const serial = laneSerial(slot); + if (!running.has(serial)) continue; + const claim = live.find((c) => c.slot === slot); + const booting = claim !== undefined && ownedProcess(slot, claim.nonce) !== null; + if (claim === undefined || (!booting && !(await isOwnLane(serial, claim.nonce)))) foreign.push(serial); + } + if (foreign.length === 0) return { id: 'lane-ports', ok: true, detail: `ports ${lanePort(1)} to ${lanePort(LOCAL_POOL.android)} hold only verify lanes` }; + return { + id: 'lane-ports', + ok: false, + detail: `${(await Promise.all(foreign.map(describeForeign))).join(', ')} sits on a lane port and takes a lane from every worktree`, + fix: `${foreign.map((serial) => `adb -s ${serial} emu kill`).join('; ')}, but only if that emulator is yours; verify never kills it`, + }; + } + + async function claimSlot(request: AcquireRequest): Promise { + const startedWaiting = Date.now(); + const deadline = startedWaiting + request.waitSeconds * 1000; + let lastWait = ''; + let lastPrinted = 0; + for (;;) { + const running = await devices(); + const live = readClaims(claimsDir, 'android').filter((c) => !isOrphaned(c)); + const foreign = Array.from({ length: LOCAL_POOL.android }, (_, i) => i + 1).filter( + (slot) => running.has(laneSerial(slot)) && !live.some((c) => c.slot === slot), + ); + const inUse = [ + ...live.map((c) => `${c.deviceName} (held by ${c.worktree})`), + ...(await Promise.all(foreign.map((slot) => describeForeign(laneSerial(slot))))), + ].sort(); + const foreignFix = foreign.length === 0 ? '' : `; ${foreign.map((slot) => `\`adb -s ${laneSerial(slot)} emu kill\``).join(' or ')} frees a lane, but only if that emulator is yours`; + if (inUse.length < LOCAL_POOL.android) { + for (let slot = 1; slot <= LOCAL_POOL.android; slot += 1) { + if (foreign.includes(slot)) continue; + const { gen, claim: holder } = readClaim(claimsDir, 'android', slot); + if (holder !== null && !isOrphaned(holder)) continue; + const claim = takeSlot(claimsDir, 'android', slot, gen, request.worktree); + if (claim !== null) return claim; + } + } + if (Date.now() >= deadline) { + throw new VerifyFailure( + 'POOL_FULL', + `${inUse.length} of ${LOCAL_POOL.android} Android lanes are in use on this machine (${inUse.join(', ')})`, + `rerun with a longer --wait than ${request.waitSeconds}s, for example ${retryFix(request)}, or run {cli} down in a worktree that no longer needs its lane${foreignFix}`, + ); + } + const waiting = `wait all ${LOCAL_POOL.android} Android lanes are in use (${inUse.join(', ')}); waiting up to ${request.waitSeconds}s for one to free`; + if (waiting !== lastWait || Date.now() - lastPrinted >= STILL_WAITING_MS) { + request.progress(waiting === lastWait ? `wait still waiting after ${Math.round((Date.now() - startedWaiting) / 1000)}s (${inUse.join(', ')})` : waiting); + lastPrinted = Date.now(); + } + lastWait = waiting; + await sleep(5000); + } + } + + async function clearSlot(claim: Claim, worktree: string): Promise { + const reaper = takeSlot(claimsDir, 'android', claim.slot, claim.gen, worktree, true); + if (reaper === null) return; + await killEmulator(claim.slot, claim.nonce); + freeSlot(claimsDir, reaper); + } + + function bootEmulator(slot: number, nonce: string): { readonly pid: number | undefined; readonly exited: () => string | null; readonly stop: () => void } { + mkdirSync(emulatorLogDir, { recursive: true }); + const logFile = join(emulatorLogDir, `android-${slot}.log`); + const out = openSync(logFile, 'w'); + const child = spawn(emulatorBin, [...emulatorArgs(lanePort(slot), machine.os)], { detached: true, stdio: ['ignore', out, out] }); + closeSync(out); + if (child.pid !== undefined) { + const spawned: SpawnedEmulator = { nonce, pid: child.pid, startedAt: Date.now() }; + writeFileSync(pidFile(slot), JSON.stringify(spawned)); + } + let exit: string | null = null; + child.on('error', (error) => (exit = error.message)); + child.on('exit', (code) => { + const tail = existsSync(logFile) ? readFileSync(logFile, 'utf8').trim().split('\n').slice(-5).join(' | ') : ''; + exit = `exit ${code}${tail ? `: ${tail}` : ''}`; + }); + child.unref(); + return { + pid: child.pid, + exited: () => exit, + stop: () => { + if (exit === null && child.pid !== undefined) process.kill(-child.pid, 'SIGTERM'); + }, + }; + } + + const backend: DeviceBackend = { + kind: 'local', + platform: 'android', + availability: () => localAvailability(machine), + requirement: 'a machine with the Android SDK emulator, adb, and an Android 36 Google APIs system image, and on Linux a /dev/kvm this user can open', + + async acquire(request) { + if (ensureLaneAvd(machine) === 'created') request.progress(`device wrote the ${AVD_NAME} AVD, which this machine did not have`); + const listed = await exec(emulatorBin, ['-list-avds']); + if (!listsAvd(listed.stdout)) { + throw new VerifyFailure('NOT_READY', `the emulator does not list ${AVD_NAME}: ${(listed.stderr || listed.stdout).trim() || `exit ${listed.code}`}`, 'run `{cli} doctor`; if ANDROID_AVD_HOME is set, the AVD must be under it'); + } + const claim = await claimSlot(request); + const serial = laneSerial(claim.slot); + let boot: ReturnType | null = null; + const interrupted = (signal: NodeJS.Signals) => { + if (boot !== null) { + boot.stop(); + request.progress(`device boot cancelled; stopped emulator ${boot.pid} on ${serial}`); + } + process.removeListener('SIGINT', interrupted); + process.removeListener('SIGTERM', interrupted); + process.kill(process.pid, signal); + }; + process.once('SIGINT', interrupted); + process.once('SIGTERM', interrupted); + try { + if ((await devices()).has(serial)) { + throw new VerifyFailure('POOL_FULL', `${await describeForeign(serial)} took the lane port while it was being claimed`, retryFix(request)); + } + request.progress(`device ${claim.deviceName} booting ${AVD_NAME} -read-only on port ${lanePort(claim.slot)}`); + boot = bootEmulator(claim.slot, claim.nonce); + await waitForBoot(serial, boot.exited); + const died = boot.exited(); + if (died !== null) { + throw new VerifyFailure('NOT_READY', `the ${AVD_NAME} emulator verify started exited (${died}), so ${serial} is someone else's`, retryFix(request)); + } + await shell(serial, `setprop ${LANE_PROPERTY} ${claim.nonce}`); + if (!(await isOwnLane(serial, claim.nonce))) { + const marker = (await shell(serial, 'getprop')).split('\n').filter((line) => line.includes('verify.lane')).join(' ') || 'no verify.lane property'; + throw new VerifyFailure('NOT_READY', `${serial} is not the ${AVD_NAME} lane this claim booted (${marker})`, retryFix(request)); + } + await pinLocale(serial, request.progress); + } catch (error) { + boot?.stop(); + await clearSlot(claim, request.worktree).catch(() => undefined); + throw error; + } finally { + process.removeListener('SIGINT', interrupted); + process.removeListener('SIGTERM', interrupted); + } + return { + backend: 'local', + platform: 'android', + slot: claim.slot, + deviceName: claim.deviceName, + deviceId: serial, + claimNonce: claim.nonce, + acquiredAt: new Date().toISOString(), + installedBuild: null, + }; + }, + + async check(lease) { + if (readClaim(claimsDir, 'android', lease.slot).claim?.nonce !== lease.claimNonce) return 'lost'; + if ((await devices()).get(lease.deviceId) !== 'device') return 'lost'; + if (!(await isOwnLane(lease.deviceId, lease.claimNonce))) return 'lost'; + return (await shell(lease.deviceId, 'getprop sys.boot_completed')) === '1' ? 'held' : 'lost'; + }, + + async install(lease, app) { + const result = await adb(lease.deviceId, installArgs(app.path)); + if (result.code !== 0 || !/Success/.test(result.stdout)) { + throw new VerifyFailure('NOT_READY', `adb install on ${lease.deviceName} failed: ${(result.stderr || result.stdout).trim()}`, '{cli} down, then {cli} up'); + } + return lease; + }, + + async release(lease) { + const { claim } = readClaim(claimsDir, 'android', lease.slot); + if (claim !== null && claim.nonce === lease.claimNonce) { + await clearSlot(claim, claim.worktree); + } + }, + + async reapable(owner) { + return readClaims(claimsDir, 'android') + .filter((claim) => isOrphaned(claim) || (owner !== undefined && claim.worktree === owner)) + .map((claim) => ({ + backend: 'local' as const, + platform: 'android' as const, + slot: claim.slot, + deviceName: claim.deviceName, + deviceId: laneSerial(claim.slot), + claimNonce: claim.nonce, + acquiredAt: claim.createdAt, + installedBuild: null, + })); + }, + + async startRecording(lease, into) { + return startScreenrecord({ adbBin, serial: lease.deviceId, into, exec }); + }, + + async logs(lease, since, extraPredicate) { + const result = await adb(lease.deviceId, logcatArgs(since, extraPredicate)); + return result.stdout; + }, + + describe: (lease) => `${lease.deviceName} (${lease.deviceId})`, + + async doctorChecks() { + const device: DoctorCheck[] = []; + const avds = await exec(emulatorBin, ['-list-avds']); + const adbVersion = await exec(adbBin, ['version']); + const ports = `lanes boot it -read-only on ports ${lanePort(1)} to ${lanePort(LOCAL_POOL.android)}`; + if (avds.code !== 0 || adbVersion.code !== 0) { + device.push({ id: 'template', ok: false, detail: 'the Android SDK emulator or adb does not run', fix: 'install the Android SDK (Android Studio) and set ANDROID_HOME' }); + } else { + device.push({ id: 'template', ok: true, detail: listsAvd(avds.stdout) ? `${AVD_NAME}; ${ports}` : `no ${AVD_NAME} AVD yet; the first up writes it, and ${ports}` }); + } + device.push(await lanePortsCheck()); + return { toolchain: [jdkCheck(machine.env)], device }; + }, + }; + return backend; +} + +interface ScreenrecordOptions { + readonly adbBin: string; + readonly serial: string; + readonly into: EvidencePath; + readonly exec: Runner; +} + +export async function startScreenrecord(options: ScreenrecordOptions): Promise { + const { adbBin, serial, into, exec } = options; + const shell = async (command: string) => (await exec(adbBin, ['-s', serial, 'shell', command])).stdout.trim(); + const remote = recordingOnDevice(basename(into)); + const child = spawn(adbBin, ['-s', serial, ...screenrecordArgs(remote)], { stdio: ['ignore', 'pipe', 'pipe'] }); + const startedAt = Date.now(); + let output = ''; + child.stdout.on('data', (chunk: Buffer) => (output += chunk.toString())); + child.stderr.on('data', (chunk: Buffer) => (output += chunk.toString())); + const done = new Promise((resolve) => child.on('close', (code) => resolve(code ?? 1))); + const deadline = Date.now() + 10_000; + while ((await shell('pidof screenrecord')) === '') { + const exited = await Promise.race([done, sleep(500).then(() => null)]); + if (exited !== null || Date.now() >= deadline) { + child.kill(); + throw new VerifyFailure('NOT_READY', `screenrecord did not start on ${serial}: ${output.trim() || `exit ${exited}`}`, 'rerun with --no-video, or `{cli} down` and `{cli} up`'); + } + } + return { + process: { pid: child.pid ?? 0, startedAt }, + async stop() { + await shell('pkill -INT screenrecord'); + const stopDeadline = Date.now() + 30_000; + while ((await shell('pidof screenrecord')) !== '' && Date.now() < stopDeadline) await sleep(500); + await Promise.race([done, sleep(5000)]); + const video = join(into, 'video.mp4') as EvidencePath; + const pulled = await exec(adbBin, ['-s', serial, 'pull', remote, video]); + await shell(`rm -f ${remote}`); + if (pulled.code !== 0 || !existsSync(video)) { + throw new VerifyFailure('NOT_READY', `adb pull of the recording from ${serial} failed: ${(pulled.stderr || pulled.stdout).trim()}`, 'rerun the spec, or rerun with --no-video'); + } + return video; + }, + }; +} diff --git a/.claude/skills/verify-clerk-expo/src/platform/android/sdk.ts b/.claude/skills/verify-clerk-expo/src/platform/android/sdk.ts new file mode 100644 index 00000000000..24a1405053e --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/platform/android/sdk.ts @@ -0,0 +1,167 @@ +import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { delimiter, dirname, join } from 'node:path'; +import type { Availability, DoctorCheck } from '../../core/types.ts'; + +export const AVD_NAME = 'Clerk_Verify_Pixel'; +const IMAGE_API = 36; +const IMAGE_TAG = 'google_apis'; +const MIN_JAVA = 21; + +type Env = Readonly>; + +export interface Machine { + readonly os: NodeJS.Platform; + readonly arch: string; + readonly home: string; + readonly env: Env; + readonly kvm: string; +} + +export const thisMachine = (): Machine => ({ os: process.platform, arch: process.arch, home: homedir(), env: process.env, kvm: '/dev/kvm' }); + +const emulatorIn = (root: string) => join(root, 'emulator', 'emulator'); +const adbIn = (root: string) => join(root, 'platform-tools', 'adb'); + +function sdkCandidates(machine: Machine): readonly string[] { + const fromPath = (machine.env.PATH ?? '') + .split(delimiter) + .filter((entry) => /[\\/](platform-tools|emulator)[\\/]?$/.test(entry)) + .map((entry) => dirname(entry.replace(/[\\/]$/, ''))); + const byDefault = machine.os === 'darwin' ? join(machine.home, 'Library', 'Android', 'sdk') : join(machine.home, 'Android', 'Sdk'); + return [...new Set([machine.env.ANDROID_HOME, machine.env.ANDROID_SDK_ROOT, byDefault, ...fromPath].filter((root): root is string => root !== undefined && root !== ''))]; +} + +export function sdkRoot(machine: Machine = thisMachine()): string { + const candidates = sdkCandidates(machine); + return candidates.find((root) => existsSync(emulatorIn(root)) && existsSync(adbIn(root))) ?? candidates[0]!; +} + +export function sdkTool(tool: 'adb' | 'emulator', machine: Machine = thisMachine()): string { + const path = tool === 'adb' ? adbIn(sdkRoot(machine)) : emulatorIn(sdkRoot(machine)); + return existsSync(path) ? path : tool; +} + +const imageOf = (abi: string): string => `system-images;android-${IMAGE_API};${IMAGE_TAG};${abi}`; +const dirOf = (image: string): string => `${image.split(';').join('/')}/`; + +function abiOf(machine: Machine): string { + const own = machine.arch === 'arm64' ? 'arm64-v8a' : 'x86_64'; + const other = own === 'x86_64' ? 'arm64-v8a' : 'x86_64'; + const installed = machine.os === 'darwin' ? [own, other].find((abi) => existsSync(join(sdkRoot(machine), dirOf(imageOf(abi)), 'system.img'))) : undefined; + return installed ?? own; +} + +export const systemImage = (machine: Machine = thisMachine()): string => imageOf(abiOf(machine)); + +const avdHome = (machine: Machine): string => machine.env.ANDROID_AVD_HOME || join(machine.env.ANDROID_USER_HOME || join(machine.home, '.android'), 'avd'); +const avdPointer = (machine: Machine): string => join(avdHome(machine), `${AVD_NAME}.ini`); + +function laneAvdImageDirInSdk(machine: Machine): string | null { + try { + const dir = /^path\s*=\s*(.+)$/m.exec(readFileSync(avdPointer(machine), 'utf8'))?.[1]?.trim() ?? join(avdHome(machine), `${AVD_NAME}.avd`); + return /^image\.sysdir\.1\s*=\s*(.+)$/m.exec(readFileSync(join(dir, 'config.ini'), 'utf8'))?.[1]?.trim() ?? null; + } catch { + return null; + } +} + +export function ensureLaneAvd(machine: Machine = thisMachine()): 'exists' | 'created' { + if (existsSync(avdPointer(machine))) return 'exists'; + const dir = join(avdHome(machine), `${AVD_NAME}.avd`); + mkdirSync(dir, { recursive: true }); + const abi = abiOf(machine); + const config = { + AvdId: AVD_NAME, + 'PlayStore.enabled': 'false', + 'abi.type': abi, + 'avd.ini.encoding': 'UTF-8', + 'disk.dataPartition.size': '6G', + 'hw.cpu.arch': abi === 'x86_64' ? 'x86_64' : 'arm64', + 'hw.cpu.ncore': '4', + 'hw.gpu.enabled': 'yes', + 'hw.gpu.mode': 'auto', + 'hw.keyboard': 'yes', + 'hw.lcd.density': '480', + 'hw.lcd.height': '2856', + 'hw.lcd.width': '1280', + 'hw.ramSize': '2048', + 'image.sysdir.1': dirOf(imageOf(abi)), + 'tag.display': 'Google APIs', + 'tag.id': IMAGE_TAG, + 'vm.heapSize': '256', + }; + writeFileSync(join(dir, 'config.ini'), Object.entries(config).map(([key, value]) => `${key}=${value}\n`).join('')); + writeFileSync(avdPointer(machine), `avd.ini.encoding=UTF-8\npath=${dir}\npath.rel=avd/${AVD_NAME}.avd\ntarget=android-${IMAGE_API}\n`); + return 'created'; +} + +function opensReadWrite(path: string): boolean { + try { + closeSync(openSync(path, 'r+')); + return true; + } catch { + return false; + } +} + +export function localAvailability(machine: Machine = thisMachine()): Availability { + if (machine.os !== 'darwin' && machine.os !== 'linux') return { usable: false, why: `the lane emulator runs on macOS and Linux, and this machine runs ${machine.os}` }; + if (machine.os === 'linux' && !existsSync(machine.kvm)) return { usable: false, why: `there is no ${machine.kvm}, so this machine has no hardware virtualization for the emulator` }; + const root = sdkRoot(machine); + if (!existsSync(emulatorIn(root)) || !existsSync(adbIn(root))) { + return { + usable: false, + why: `no Android SDK with an emulator and adb (looked in ${sdkCandidates(machine).join(', ')})`, + }; + } + const named = laneAvdImageDirInSdk(machine); + const image = named ?? dirOf(systemImage(machine)); + if (!existsSync(join(root, image, 'system.img'))) { + const wanted = image.replace(/\/$/, '').split('/').join(';'); + return { usable: false, why: `the SDK at ${root} has no system image ${wanted}${named === null ? '' : `, which the ${AVD_NAME} AVD names`}` }; + } + if (machine.os === 'linux' && !opensReadWrite(machine.kvm)) { + return { usable: false, why: `this user cannot open ${machine.kvm} for reading and writing, so the emulator would have no hardware acceleration` }; + } + return { usable: true, why: machine.os === 'linux' ? `this machine runs the emulator itself: ${machine.kvm} opens for reading and writing and the SDK at ${root} has the system image` : `this Mac runs the emulator itself, from the SDK at ${root}` }; +} + +function javaMajor(home: string): number | null { + try { + const release = readFileSync(join(home, 'release'), 'utf8'); + const version = /^JAVA_VERSION="([^"]+)"/m.exec(release)?.[1]; + if (version === undefined) return null; + const major = Number(version.startsWith('1.') ? version.split('.')[1] : version.split(/[.+-]/)[0]); + return Number.isNaN(major) ? null : major; + } catch { + return null; + } +} + +const STUDIO_JBR = process.platform === 'darwin' ? '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/Applications/Android Studio.app/Contents/jbr/Contents/Home' : null; + +export type JavaHome = { readonly ok: true; readonly home: string; readonly detail: string } | { readonly ok: false; readonly detail: string; readonly fix: string }; + +export function resolveJavaHome(env: Env = process.env, studioJbr: string | null = STUDIO_JBR): JavaHome { + const studio = studioJbr === null ? null : javaMajor(studioJbr); + const fix = + studioJbr !== null && studio !== null && studio >= MIN_JAVA + ? `export JAVA_HOME="${studioJbr}"` + : studioJbr === null + ? `install a Java ${MIN_JAVA} JDK and export JAVA_HOME to it` + : `install Android Studio (its bundled JBR at ${studioJbr} is Java ${MIN_JAVA}) or a Java ${MIN_JAVA} JDK, then export JAVA_HOME to it`; + const requested = env.JAVA_HOME; + if (requested !== undefined && requested !== '') { + const major = javaMajor(requested); + if (major !== null && major >= MIN_JAVA) return { ok: true, home: requested, detail: `Java ${major} from JAVA_HOME (${requested})` }; + return { ok: false, detail: `JAVA_HOME is ${major === null ? 'not a JDK' : `Java ${major}`} (${requested}); the Android build needs Java ${MIN_JAVA}`, fix }; + } + if (studioJbr !== null && studio !== null && studio >= MIN_JAVA) return { ok: true, home: studioJbr, detail: `Java ${studio} from the Android Studio JBR` }; + return { ok: false, detail: `JAVA_HOME is unset${studioJbr === null ? '' : ` and there is no Java ${MIN_JAVA} Android Studio JBR`}`, fix }; +} + +export function jdkCheck(env: Env = process.env, studioJbr: string | null = STUDIO_JBR): DoctorCheck { + const java = resolveJavaHome(env, studioJbr); + return java.ok ? { id: 'jdk', ok: true, detail: java.detail } : { id: 'jdk', ok: false, detail: java.detail, fix: java.fix }; +} diff --git a/.claude/skills/verify-clerk-expo/src/platform/ios/local.ts b/.claude/skills/verify-clerk-expo/src/platform/ios/local.ts new file mode 100644 index 00000000000..65216144cdb --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/platform/ios/local.ts @@ -0,0 +1,285 @@ +import { spawn } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { defaultClaimsDir, freeSlot, isOrphaned, readClaim, readClaims, takeSlot, type Claim } from '../../core/claims.ts'; +import { run, sleep } from '../../core/exec.ts'; +import { + LOCAL_POOL, + VerifyFailure, + type AcquireRequest, + type DeviceBackend, + type DoctorCheck, + type EvidencePath, + type LocalLease, + type Recording, +} from '../../core/types.ts'; + +import { recordVideo, showLogs } from './simulator.ts'; + +const TEMPLATE_NAME = 'Clerk Verify Template iOS'; +const LANE_NAME = /^verify-ios-(\d+)$/; + +interface Simulator { + readonly udid: string; + readonly name: string; + readonly state: string; + readonly runtime: string; +} + +async function listSimulators(): Promise { + const listed = await run('xcrun', ['simctl', 'list', 'devices', '-j']); + if (listed.code !== 0) throw new VerifyFailure('NOT_READY', `simctl list failed: ${listed.stderr.trim()}`, 'install Xcode and run `xcode-select -p`'); + const parsed = JSON.parse(listed.stdout) as { devices: Record }; + return Object.entries(parsed.devices).flatMap(([runtime, devices]) => devices.map((d) => ({ udid: d.udid, name: d.name, state: d.state, runtime }))); +} + +async function simctl(args: readonly string[], what: string): Promise { + const result = await run('xcrun', ['simctl', ...args]); + if (result.code !== 0) throw new VerifyFailure('NOT_READY', `${what} failed: ${result.stderr.trim() || result.stdout.trim()}`, 'run `{cli} doctor`'); + return result.stdout; +} + +function simulatorDataDir(udid: string): string { + return join(homedir(), 'Library', 'Developer', 'CoreSimulator', 'Devices', udid, 'data'); +} + +async function deleteSimulators(match: (device: Simulator) => boolean): Promise { + for (const device of (await listSimulators()).filter((d) => LANE_NAME.test(d.name) && match(d))) { + if (device.state !== 'Shutdown') await run('xcrun', ['simctl', 'shutdown', device.udid]); + await simctl(['delete', device.udid], `simctl delete ${device.name}`); + } +} + +export function localIosBackend(options: { readonly os?: NodeJS.Platform } = {}): DeviceBackend { + const claimsDir = defaultClaimsDir(); + const os = options.os ?? process.platform; + + async function claimSlot(request: AcquireRequest): Promise { + const deadline = Date.now() + request.waitSeconds * 1000; + let lastWait = ''; + for (;;) { + const devices = await listSimulators(); + const claims = readClaims(claimsDir, 'ios'); + const orphans = new Set(claims.filter(isOrphaned).map((c) => c.deviceName as string)); + const bootedLanes = devices.filter((d) => d.runtime.includes('iOS') && d.state === 'Booted' && d.name.startsWith('verify-')).map((d) => d.name); + const inUse = new Set([...bootedLanes, ...claims.map((c) => c.deviceName as string)].filter((name) => !orphans.has(name))); + if (inUse.size < LOCAL_POOL.ios) { + for (let slot = 1; slot <= LOCAL_POOL.ios; slot += 1) { + const { gen, claim: holder } = readClaim(claimsDir, 'ios', slot); + if (holder !== null && !isOrphaned(holder)) continue; + const claim = takeSlot(claimsDir, 'ios', slot, gen, request.worktree); + if (claim !== null) return claim; + } + } + if (Date.now() >= deadline) { + throw new VerifyFailure( + 'POOL_FULL', + `${inUse.size} of ${LOCAL_POOL.ios} iOS lanes are in use on this Mac (${[...inUse].sort().join(', ')})`, + `rerun with a longer --wait than ${request.waitSeconds}s, for example ${request.retryWith.replace('', String(Math.max(600, request.waitSeconds * 2)))}, or run {cli} down in a worktree that no longer needs its lane`, + ); + } + const changing = LOCAL_POOL.ios - inUse.size; + const names = `${[...inUse].sort().join(', ')}${changing > 0 ? `, and ${changing} changing hands` : ''}`; + const waiting = `wait no free iOS lane of ${LOCAL_POOL.ios} (${names}); waiting up to ${request.waitSeconds}s for one`; + if (waiting !== lastWait) request.progress(waiting); + lastWait = waiting; + await sleep(5000); + } + } + + async function clearSlot(claim: Claim, worktree: string): Promise { + const reaper = takeSlot(claimsDir, 'ios', claim.slot, claim.gen, worktree, true); + if (reaper === null) return; + await deleteSimulators((d) => d.name === claim.deviceName); + freeSlot(claimsDir, reaper); + } + + const backend: DeviceBackend = { + kind: 'local', + platform: 'ios', + availability: () => (os === 'darwin' ? { usable: true, why: 'this Mac runs the simulator itself' } : { usable: false, why: `the iOS simulator needs macOS and this machine runs ${os}` }), + requirement: 'a Mac with Xcode', + + async acquire(request) { + const template = (await listSimulators()).find((d) => d.name === TEMPLATE_NAME); + if (template === undefined) { + throw new VerifyFailure('NOT_READY', `no simulator named ${TEMPLATE_NAME}`, `xcrun simctl clone "iPhone Air" "${TEMPLATE_NAME}"`); + } + if (template.state !== 'Shutdown') { + throw new VerifyFailure('NOT_READY', `${TEMPLATE_NAME} is ${template.state}; it can only be cloned while shut down`, `xcrun simctl shutdown "${TEMPLATE_NAME}"`); + } + const claim = await claimSlot(request); + let udid = ''; + try { + await deleteSimulators((d) => d.name === claim.deviceName); + request.progress(`device ${claim.deviceName} cloning ${TEMPLATE_NAME}`); + udid = (await simctl(['clone', template.udid, claim.deviceName], `simctl clone ${claim.deviceName}`)).trim(); + await simctl(['boot', udid], `simctl boot ${claim.deviceName}`); + await simctl(['bootstatus', udid, '-b'], `simctl bootstatus ${claim.deviceName}`); + } catch (error) { + await clearSlot(claim, request.worktree).catch(() => undefined); + throw error; + } + return { + backend: 'local', + platform: 'ios', + slot: claim.slot, + deviceName: claim.deviceName, + deviceId: udid, + claimNonce: claim.nonce, + acquiredAt: new Date().toISOString(), + installedBuild: null, + }; + }, + + async check(lease) { + if (readClaim(claimsDir, 'ios', lease.slot).claim?.nonce !== lease.claimNonce) return 'lost'; + const device = (await listSimulators()).find((d) => d.udid === lease.deviceId); + if (device === undefined || device.name !== lease.deviceName) return 'lost'; + if (device.state !== 'Booted') { + await simctl(['boot', lease.deviceId], `simctl boot ${lease.deviceName}`).catch(() => undefined); + await simctl(['bootstatus', lease.deviceId, '-b'], `simctl bootstatus ${lease.deviceName}`); + } + return 'held'; + }, + + async install(lease, app) { + await simctl(['install', lease.deviceId, app.path], `simctl install on ${lease.deviceName}`); + return lease; + }, + + async release(lease) { + const { claim } = readClaim(claimsDir, 'ios', lease.slot); + if (claim !== null && claim.nonce === lease.claimNonce) { + await clearSlot(claim, claim.worktree); + return; + } + if (lease.deviceId !== '') await deleteSimulators((d) => d.udid === lease.deviceId); + }, + + async reapable(owner) { + return readClaims(claimsDir, 'ios') + .filter((claim) => isOrphaned(claim) || (owner !== undefined && claim.worktree === owner)) + .map((claim) => ({ + backend: 'local' as const, + platform: 'ios' as const, + slot: claim.slot, + deviceName: claim.deviceName, + deviceId: '', + claimNonce: claim.nonce, + acquiredAt: claim.createdAt, + installedBuild: null, + })); + }, + + async startRecording(lease, into) { + const file = join(into, 'video.mp4') as EvidencePath; + const record = recordVideo(lease.deviceId, file); + const child = spawn(record.command, [...record.args], { stdio: ['ignore', 'pipe', 'pipe'] }); + const spawnedAt = Date.now(); + const exited = new Promise((resolve) => child.on('close', (code) => resolve(code ?? 1))); + await new Promise((resolve, reject) => { + let seen = ''; + const timer = setTimeout(resolve, 10_000); + const onData = (chunk: Buffer) => { + seen += chunk.toString(); + if (/recording started/i.test(seen)) { + clearTimeout(timer); + resolve(); + } + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + child.on('close', (code) => { + clearTimeout(timer); + reject(new VerifyFailure('NOT_READY', `simctl recordVideo exited ${code}: ${seen.trim()}`, 'rerun with --no-video, or `{cli} down` and `{cli} up`')); + }); + }); + const recording: Recording = { + process: { pid: child.pid ?? 0, startedAt: spawnedAt }, + async stop() { + child.kill('SIGINT'); + let timer: NodeJS.Timeout | undefined; + const code = await Promise.race([exited, new Promise((resolve) => (timer = setTimeout(() => resolve(null), 30_000)))]); + clearTimeout(timer); + if (code === null) { + child.kill('SIGKILL'); + await exited; + } + return file; + }, + }; + return recording; + }, + + async logs(lease, since, extraPredicate) { + const show = showLogs(lease.deviceId, since, extraPredicate ?? null); + return (await run(show.command, show.args)).stdout; + }, + + describe: (lease) => lease.deviceName, + + async doctorChecks() { + const xcode = await run('xcodebuild', ['-version']); + const toolchain: DoctorCheck[] = [ + xcode.code === 0 + ? { id: 'xcode', ok: true, detail: xcode.stdout.split('\n')[0]?.replace('Xcode ', '') ?? '' } + : { id: 'xcode', ok: false, detail: 'xcodebuild is not available', fix: 'install Xcode and run `sudo xcode-select -s /Applications/Xcode.app`' }, + ]; + const template = xcode.code === 0 ? (await listSimulators()).find((d) => d.name === TEMPLATE_NAME) : undefined; + const device: DoctorCheck[] = [ + template === undefined + ? { id: 'template', ok: false, detail: `no simulator named ${TEMPLATE_NAME}`, fix: `xcrun simctl clone "iPhone Air" "${TEMPLATE_NAME}", then boot it once, trust your proxy CA, and shut it down` } + : template.state === 'Shutdown' + ? { id: 'template', ok: true, detail: `${TEMPLATE_NAME} (${template.runtime.replace(/^.*SimRuntime\./, '')})` } + : { id: 'template', ok: false, detail: `${TEMPLATE_NAME} is ${template.state}; lanes clone it only while it is shut down`, fix: `xcrun simctl shutdown "${TEMPLATE_NAME}"` }, + await proxyTrustCheck(template), + await lanePortsCheck(claimsDir), + ]; + return { toolchain, device }; + }, + }; + return backend; +} + +const TRUST_STORE_LOCATIONS = [ + ['private', 'var', 'protected', 'trustd', 'private', 'TrustStore.sqlite3'], + ['Library', 'Keychains', 'TrustStore.sqlite3'], +]; + +async function proxyTrustCheck(template: Simulator | undefined): Promise { + const proxy = await run('scutil', ['--proxy']); + const httpsEnabled = /HTTPSEnable\s*:\s*1/.test(proxy.stdout); + if (!httpsEnabled) return { id: 'proxy-trust', ok: true, detail: 'no system HTTPS proxy' }; + const where = /HTTPSProxy\s*:\s*(\S+)/.exec(proxy.stdout)?.[1] ?? 'unknown'; + const port = /HTTPSPort\s*:\s*(\d+)/.exec(proxy.stdout)?.[1] ?? ''; + const proxyName = `${where}${port ? `:${port}` : ''}`; + if (template === undefined) return { id: 'proxy-trust', ok: false, detail: `HTTPS proxy ${proxyName} is on and there is no template to check`, fix: 'create the template first (see the template check)' }; + const store = TRUST_STORE_LOCATIONS.map((parts) => join(simulatorDataDir(template.udid), ...parts)).find((path) => existsSync(path)); + if (store === undefined) { + return { id: 'proxy-trust', ok: false, detail: `HTTPS proxy ${proxyName} is on and ${TEMPLATE_NAME} has no trust store`, fix: `boot ${TEMPLATE_NAME}, install and trust the proxy CA, then shut it down` }; + } + const rows = await run('sqlite3', [store, 'select count(*) from tsettings']); + const count = Number(rows.stdout.trim()); + return rows.code === 0 && count >= 1 + ? { id: 'proxy-trust', ok: true, detail: `${TEMPLATE_NAME} trusts ${count} custom CA${count === 1 ? '' : 's'} (proxy ${proxyName})` } + : { id: 'proxy-trust', ok: false, detail: `HTTPS proxy ${proxyName} is on and ${TEMPLATE_NAME} trusts no custom CA`, fix: `boot ${TEMPLATE_NAME}, install and trust the proxy CA, then shut it down` }; +} + +async function lanePortsCheck(claimsDir: string): Promise { + const booted = (await listSimulators()).filter((d) => d.state === 'Booted' && LANE_NAME.test(d.name)); + const claimed = new Set( + readClaims(claimsDir, 'ios') + .filter((c) => !isOrphaned(c)) + .map((c) => c.deviceName as string), + ); + const foreign = booted.filter((d) => !claimed.has(d.name)); + if (foreign.length === 0) return { id: 'lane-ports', ok: true, detail: 'every booted verify-ios- lane has a live claim' }; + return { + id: 'lane-ports', + ok: false, + detail: `booted with no live claim: ${foreign.map((d) => `${d.name} (${d.udid})`).join(', ')}`, + fix: `if it is yours, ${foreign.map((d) => `xcrun simctl shutdown ${d.udid} && xcrun simctl delete ${d.udid}`).join('; ')}`, + }; +} diff --git a/.claude/skills/verify-clerk-expo/src/platform/ios/simulator.ts b/.claude/skills/verify-clerk-expo/src/platform/ios/simulator.ts new file mode 100644 index 00000000000..737e42bf53a --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/platform/ios/simulator.ts @@ -0,0 +1,15 @@ +import type { CommandLine } from '../../core/exec.ts'; + +const LOG_PREDICATE = 'subsystem == "com.clerk.verify" OR subsystem == "com.clerk.sdk"'; + +function localTime(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +export const recordVideo = (udid: string, file: string): CommandLine => ({ command: 'xcrun', args: ['simctl', 'io', udid, 'recordVideo', '--codec=h264', '--force', file] }); + +export const showLogs = (udid: string, since: Date, extraPredicate: string | null): CommandLine => ({ + command: 'xcrun', + args: ['simctl', 'spawn', udid, 'log', 'show', '--style', 'compact', '--start', localTime(since), '--predicate', extraPredicate === null ? LOG_PREDICATE : `${LOG_PREDICATE} OR (${extraPredicate})`], +}); diff --git a/.claude/skills/verify-clerk-expo/test/android.test.ts b/.claude/skills/verify-clerk-expo/test/android.test.ts new file mode 100644 index 00000000000..f3f6e3c38a4 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/android.test.ts @@ -0,0 +1,450 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { isRunning, run } from '../src/core/exec.ts'; +import { encodeLaunchArguments } from '../src/core/state.ts'; +import type { EvidencePath, LaunchId, LocalLease, PublishableKey, RunId, StorageScope } from '../src/core/types.ts'; +import { takeSlot } from '../src/core/claims.ts'; +import { LOG_FILTER, RECORD_SIZE, emulatorArgs, logFilter, logcatSince } from '../src/platform/android/emulator.ts'; +import { lanePort, localAndroidBackend, laneSerial, parseAdbDevices, startScreenrecord } from '../src/platform/android/local.ts'; +import { ensureLaneAvd, jdkCheck, localAvailability, resolveJavaHome, sdkRoot, systemImage, type Machine } from '../src/platform/android/sdk.ts'; + +function machineHomedInScratch(dir: string, overrides: Partial = {}): Machine { + return { os: 'darwin', arch: 'arm64', home: dir, env: {}, kvm: join(dir, 'kvm'), ...overrides }; +} + +function fakeJdk(root: string, version: string): string { + const home = join(root, `jdk-${version}`); + mkdirSync(home, { recursive: true }); + writeFileSync(join(home, 'release'), `IMPLEMENTOR="test"\nJAVA_VERSION="${version}"\n`); + return home; +} + +describe('android lanes', () => { + it('puts slot 1 on emulator-5560 and slot 2 on emulator-5562', () => { + assert.equal(lanePort(1), 5560); + assert.equal(laneSerial(2), 'emulator-5562'); + }); + + it('reads adb devices, skipping the header and blank lines', () => { + const devices = parseAdbDevices('List of devices attached\nemulator-5560\tdevice\nemulator-5562\toffline\n\n'); + assert.deepEqual([...devices], [['emulator-5560', 'device'], ['emulator-5562', 'offline']]); + }); + + it('asks logcat for lines since the run started, in epoch seconds', () => { + assert.equal(logcatSince(new Date(1_791_014_000_123)), '1791014000.123'); + }); + + it('keeps the host, SDK, network, and React Native console tags and silences the rest', () => { + assert.deepEqual(LOG_FILTER.slice(-1), ['*:S']); + for (const tag of ['ClerkVerify:V', 'ClerkLog:V', 'OkHttp:V', 'ReactNativeJS:V']) assert.ok(LOG_FILTER.includes(tag), tag); + }); + + it('adds a host log predicate before the final silence spec', () => { + assert.deepEqual(logFilter('Expo:V ReactNative:W').slice(-3), ['Expo:V', 'ReactNative:W', '*:S']); + assert.deepEqual(logFilter(), LOG_FILTER); + }); + + it('boots with no -prop, which the emulator refuses outside qemu.* and never shows in getprop, so the lane is marked after boot', () => { + for (const os of ['darwin', 'linux'] as const) assert.equal(emulatorArgs(5560, os).includes('-prop'), false, os); + }); + + it('boots a headless lane with software graphics on Linux and leaves the Mac lane as it was', () => { + assert.deepEqual(emulatorArgs(5560, 'darwin'), ['-avd', 'Clerk_Verify_Pixel', '-read-only', '-no-window', '-no-audio', '-no-boot-anim', '-port', '5560']); + assert.deepEqual(emulatorArgs(5560, 'linux'), ['-avd', 'Clerk_Verify_Pixel', '-read-only', '-no-window', '-no-audio', '-no-boot-anim', '-gpu', 'swiftshader_indirect', '-port', '5560']); + }); + + it('records at the panel\'s aspect ratio, because the codec refuses 1280x2856 and screenrecord then falls back to 720x1280', () => { + const [width, height] = RECORD_SIZE.split('x').map(Number) as [number, number]; + assert.ok(Math.abs(width / height - 1280 / 2856) < 0.001, RECORD_SIZE); + }); + + it('encodes launch inputs as am start string extras', () => { + const args = encodeLaunchArguments('android', { + verifyPublishableKey: 'pk_test_abc' as PublishableKey, + verifyRunId: 'r20261003-040814-846e' as RunId, + verifyStorageScope: 'scope1' as StorageScope, + verifyLaunchId: 'launch1' as LaunchId, + verifyScreen: 'userProfile', + }); + assert.deepEqual(args.slice(0, 3), ['--es', 'verifyPublishableKey', 'pk_test_abc']); + assert.deepEqual(args.slice(-3), ['--es', 'verifyScreen', 'userProfile']); + }); +}); + +describe('android JDK', () => { + const root = mkdtempSync(join(tmpdir(), 'verify-jdk-')); + const jbr = fakeJdk(root, '21.0.8'); + const java17 = fakeJdk(root, '17.0.17'); + + it('fails JAVA_HOME on Java 17 with a fix that names the Java 21 JBR', () => { + const check = jdkCheck({ JAVA_HOME: java17 }, jbr); + assert.equal(check.ok, false); + assert.match(check.detail, /Java 17/); + assert.equal(check.fix, `export JAVA_HOME="${jbr}"`); + }); + + it('builds with the JBR when JAVA_HOME is unset, and with JAVA_HOME when it is 21', () => { + assert.deepEqual(resolveJavaHome({}, jbr), { ok: true, home: jbr, detail: 'Java 21 from the Android Studio JBR' }); + const java21 = fakeJdk(root, '21.0.2'); + const chosen = resolveJavaHome({ JAVA_HOME: java21 }, join(root, 'missing')); + assert.equal(chosen.ok && chosen.home, java21); + }); + + it('fails with an install fix when there is no JBR and no JAVA_HOME', () => { + const check = jdkCheck({}, join(root, 'missing')); + assert.equal(check.ok, false); + assert.match(check.fix ?? '', /install Android Studio/); + }); +}); + +describe('android screenrecord', () => { + it('stops on the device, waits for pidof to empty, and only then pulls, because a recording stopped from the host has no moov atom', async () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-adb-')); + const log = join(dir, 'adb.log'); + const adb = join(dir, 'adb'); + writeFileSync( + adb, + [ + '#!/bin/bash', + `echo "$*" >> ${log}`, + `state=${dir}/recording`, + 'case "$*" in', + ' *"shell screenrecord"*) touch "$state"; while [ -f "$state" ]; do sleep 0.1; done ;;', + ' *"shell pidof screenrecord"*) [ -f "$state" ] && echo 4242 ;;', + ' *"shell pkill -INT screenrecord"*) rm -f "$state" ;;', + ' *" pull "*) echo mp4 > "${@: -1}" ;;', + 'esac', + '', + ].join('\n'), + ); + chmodSync(adb, 0o755); + const into = join(dir, 'r20261003-040814-846e') as EvidencePath; + mkdirSync(into); + + const recording = await startScreenrecord({ adbBin: adb, serial: 'emulator-5560', into, exec: run }); + const video = await recording.stop(); + + assert.equal(video, join(into, 'video.mp4')); + assert.ok(existsSync(video)); + const calls = readFileSync(log, 'utf8').trim().split('\n'); + const record = calls.findIndex((c) => c.includes('shell screenrecord')); + const kill = calls.findIndex((c) => c.includes('pkill -INT screenrecord')); + const pull = calls.findIndex((c) => c.includes(' pull ')); + const lastPidof = calls.findLastIndex((c) => c.includes('pidof screenrecord')); + assert.match(calls[record]!, new RegExp(`--size ${RECORD_SIZE} --time-limit 0 /data/local/tmp/verify-r20261003-040814-846e\\.mp4`)); + assert.ok(record < kill && kill < lastPidof && lastPidof < pull, calls.join('\n')); + assert.ok(calls.some((c) => c.includes('rm -f /data/local/tmp/verify-r20261003-040814-846e.mp4')), 'removes the device copy'); + }); + + it('fails stop with adb\'s error when the pull fails', async () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-adb-')); + const adb = fakeTool(dir, 'adb', [ + `state=${dir}/recording`, + 'case "$*" in', + ' *"shell screenrecord"*) touch "$state"; while [ -f "$state" ]; do sleep 0.1; done ;;', + ' *"shell pidof screenrecord"*) [ -f "$state" ] && echo 4242 ;;', + ' *"shell pkill -INT screenrecord"*) rm -f "$state" ;;', + ' *" pull "*) echo "adb: error: remote object does not exist" >&2; exit 1 ;;', + 'esac', + ]); + const into = join(dir, 'r20261003-040814-846e') as EvidencePath; + mkdirSync(into); + const recording = await startScreenrecord({ adbBin: adb, serial: 'emulator-5560', into, exec: run }); + await assert.rejects(recording.stop(), { code: 'NOT_READY', message: /remote object does not exist/ }); + }); +}); + +function fakeTool(dir: string, name: string, body: readonly string[]): string { + const path = join(dir, name); + writeFileSync(path, ['#!/bin/bash', `echo "$*" >> ${join(dir, 'calls.log')}`, ...body, ''].join('\n')); + chmodSync(path, 0o755); + return path; +} + +describe('android lane ownership', () => { + function setup(avd: string, laneProperty: (ownNonce: string) => string) { + const dir = mkdtempSync(join(tmpdir(), 'verify-lane-')); + const claimsDir = join(dir, 'claims'); + const claim = takeSlot(claimsDir, 'android', 1, 0, join(dir, 'worktree'))!; + const killed = join(dir, 'killed'); + const adbBin = fakeTool(dir, 'adb', [ + 'case "$*" in', + ` "devices") echo "List of devices attached"; [ -f ${killed} ] || printf "emulator-5560\\tdevice\\n" ;;`, + ` *"emu avd name"*) printf "${avd}\\nOK\\n" ;;`, + ` *"getprop debug.verify.lane"*) echo "${laneProperty(claim.nonce)}" ;;`, + ' *"getprop sys.boot_completed"*) echo 1 ;;', + ` *"emu kill"*) touch ${killed} ;;`, + 'esac', + ]); + const emulatorBin = fakeTool(dir, 'emulator', ['echo Clerk_Verify_Pixel']); + const lease: LocalLease = { backend: 'local', platform: 'android', slot: 1, deviceName: 'verify-android-1', deviceId: 'emulator-5560', claimNonce: claim.nonce, acquiredAt: '', installedBuild: null }; + const calls = () => readFileSync(join(dir, 'calls.log'), 'utf8'); + return { backend: localAndroidBackend({ claimsDir, adbBin, emulatorBin, machine: machineHomedInScratch(dir) }), lease, dir, calls }; + } + + it('never kills another AVD that sits on a lane port, and frees the claim', async () => { + const { backend, lease, calls } = setup('Pixel_9_Pro', () => ''); + await backend.release(lease); + assert.doesNotMatch(calls(), /emu kill/); + assert.equal(await backend.check(lease), 'lost'); + }); + + it('never kills a Clerk_Verify_Pixel emulator booted for another claim', async () => { + const { backend, lease, calls } = setup('Clerk_Verify_Pixel', () => 'someone-elses-claim'); + await backend.release(lease); + assert.doesNotMatch(calls(), /emu kill/); + }); + + it('kills the lane it booted', async () => { + const { backend, lease, calls } = setup('Clerk_Verify_Pixel', (own) => own); + assert.equal(await backend.check(lease), 'held'); + await backend.release(lease); + assert.match(calls(), /-s emulator-5560 emu kill/); + }); + + it('doctor flags a foreign emulator on a lane port with a kill command for its owner', async () => { + const { backend } = setup('Pixel_9_Pro', () => ''); + const lanePorts = (await backend.doctorChecks()).device.find((c) => c.id === 'lane-ports'); + assert.equal(lanePorts?.ok, false); + assert.match(lanePorts?.detail ?? '', /emulator-5560 \(Pixel_9_Pro, not a verify lane\)/); + assert.match(lanePorts?.fix ?? '', /^adb -s emulator-5560 emu kill, but only if that emulator is yours/); + }); + + it('doctor passes a lane port that holds this claim\'s own lane', async () => { + const { backend } = setup('Clerk_Verify_Pixel', (own) => own); + assert.equal((await backend.doctorChecks()).device.find((c) => c.id === 'lane-ports')?.ok, true); + }); + + it('reports a foreign emulator on a lane port in POOL_FULL instead of claiming it', async () => { + const { backend, lease, dir } = setup('Pixel_9_Pro', () => ''); + await backend.release(lease); + takeSlot(join(dir, 'claims'), 'android', 2, 0, join(dir, 'other')); + await assert.rejects(backend.acquire({ platform: 'android', worktree: join(dir, 'worktree'), waitSeconds: 0, retryWith: '.claude/skills/verify-clerk-android/bin/control-clerk-android up --wait ', progress: () => undefined }), { + code: 'POOL_FULL', + message: /emulator-5560 \(Pixel_9_Pro, not a verify lane\), verify-android-2 \(held by .*other\)/, + fix: /`adb -s emulator-5560 emu kill` frees a lane, but only if that emulator is yours/, + }); + }); +}); + +describe('android lanes verify spawned', () => { + function fakeEmulatorProcess(port: number): { readonly pid: number; readonly startedAt: number } { + const child = spawn('bash', ['-c', `exec -a "qemu-system-aarch64-headless -avd Clerk_Verify_Pixel -read-only -port ${port}" sleep 60`], { detached: true, stdio: 'ignore' }); + child.unref(); + return { pid: child.pid!, startedAt: Date.now() }; + } + + async function setup(pidRecord: (own: string) => object | null) { + const dir = mkdtempSync(join(tmpdir(), 'verify-spawned-')); + const emulatorsDir = join(dir, 'emulators'); + mkdirSync(emulatorsDir); + const claimsDir = join(dir, 'claims'); + const worktree = join(dir, 'worktree'); + const claim = takeSlot(claimsDir, 'android', 1, 0, worktree)!; + const adbBin = fakeTool(dir, 'adb', ['case "$*" in', ' "devices") echo "List of devices attached" ;;', 'esac']); + const emulatorBin = fakeTool(dir, 'emulator', ['echo Clerk_Verify_Pixel']); + const emulator = fakeEmulatorProcess(5560); + await new Promise((resolve) => setTimeout(resolve, 300)); + const record = pidRecord(claim.nonce); + if (record !== null) writeFileSync(join(emulatorsDir, 'android-1.pid'), JSON.stringify({ ...emulator, ...record })); + const backend = localAndroidBackend({ claimsDir, adbBin, emulatorBin, emulatorsDir, machine: machineHomedInScratch(dir) }); + return { backend, emulator, emulatorsDir, worktree }; + } + + it('reclaims an interrupted boot that left its pid file, through the next up\'s reap', async () => { + const { backend, emulator, emulatorsDir, worktree } = await setup((own) => ({ nonce: own })); + assert.ok(isRunning(emulator)); + const [stale] = await backend.reapable(worktree); + await backend.release(stale!); + assert.equal(isRunning(emulator), false, 'the emulator verify spawned is gone'); + assert.equal(existsSync(join(emulatorsDir, 'android-1.pid')), false, 'the pid file is removed'); + }); + + it('never kills a hand-booted Clerk_Verify_Pixel with no pid file', async () => { + const { backend, emulator, worktree } = await setup(() => null); + const [stale] = await backend.reapable(worktree); + await backend.release(stale!); + assert.ok(isRunning(emulator)); + process.kill(-emulator.pid, 'SIGKILL'); + }); + + it('never kills a Clerk_Verify_Pixel whose pid file names another claim', async () => { + const { backend, emulator, worktree } = await setup(() => ({ nonce: 'another-claim' })); + const [stale] = await backend.reapable(worktree); + await backend.release(stale!); + assert.ok(isRunning(emulator)); + process.kill(-emulator.pid, 'SIGKILL'); + }); + + it('doctor does not flag a claimed lane that is still booting, before its marker is set', async () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-booting-')); + const emulatorsDir = join(dir, 'emulators'); + mkdirSync(emulatorsDir); + const claim = takeSlot(join(dir, 'claims'), 'android', 1, 0, join(dir, 'worktree'))!; + const adbBin = fakeTool(dir, 'adb', [ + 'case "$*" in', + ' "devices") printf "List of devices attached\\nemulator-5560\\toffline\\n" ;;', + ' *"emu avd name"*) printf "Clerk_Verify_Pixel\\nOK\\n" ;;', + 'esac', + ]); + const emulator = fakeEmulatorProcess(5560); + await new Promise((resolve) => setTimeout(resolve, 300)); + writeFileSync(join(emulatorsDir, 'android-1.pid'), JSON.stringify({ ...emulator, nonce: claim.nonce })); + const backend = localAndroidBackend({ claimsDir: join(dir, 'claims'), adbBin, emulatorBin: fakeTool(dir, 'emulator', ['echo Clerk_Verify_Pixel']), emulatorsDir, machine: machineHomedInScratch(dir) }); + assert.equal((await backend.doctorChecks()).device.find((c) => c.id === 'lane-ports')?.ok, true); + process.kill(-emulator.pid, 'SIGKILL'); + }); + + it('never kills a reused pid that is no longer the emulator it recorded', async () => { + const { backend, emulator, worktree } = await setup((own) => ({ nonce: own, startedAt: Date.now() - 600_000 })); + const [stale] = await backend.reapable(worktree); + await backend.release(stale!); + assert.ok(isRunning(emulator)); + process.kill(-emulator.pid, 'SIGKILL'); + }); +}); + +describe('whether this machine can run the emulator', () => { + function fakeSdk(dir: string, machine: Machine, options: { readonly image?: boolean } = {}): string { + const root = join(dir, 'sdk'); + for (const tool of [join('emulator', 'emulator'), join('platform-tools', 'adb')]) { + mkdirSync(join(root, tool, '..'), { recursive: true }); + writeFileSync(join(root, tool), ''); + } + if (options.image !== false) { + const image = join(root, ...systemImage(machine).split(';')); + mkdirSync(image, { recursive: true }); + writeFileSync(join(image, 'system.img'), ''); + } + return root; + } + const scratch = () => mkdtempSync(join(tmpdir(), 'verify-machine-')); + + it('says yes on a Mac that has the SDK and the system image, lane AVD or not', () => { + const dir = scratch(); + const mac = machineHomedInScratch(dir); + const root = fakeSdk(dir, mac); + const found = localAvailability({ ...mac, env: { ANDROID_HOME: root } }); + assert.deepEqual(found, { usable: true, why: `this Mac runs the emulator itself, from the SDK at ${root}` }); + }); + + it('finds an SDK in the OS default place and through the tools on PATH', () => { + const dir = scratch(); + const linux = machineHomedInScratch(dir, { os: 'linux', arch: 'x64' }); + const root = fakeSdk(dir, linux); + assert.equal(sdkRoot({ ...linux, env: { PATH: `/usr/bin:${join(root, 'platform-tools')}` } }), root); + const home = scratch(); + mkdirSync(join(home, 'Android'), { recursive: true }); + assert.equal(sdkRoot(machineHomedInScratch(home, { os: 'linux' })), join(home, 'Android', 'Sdk')); + assert.equal(sdkRoot(machineHomedInScratch(home, { os: 'darwin' })), join(home, 'Library', 'Android', 'sdk')); + }); + + it('says no and names what is missing when there is no SDK or no system image', () => { + const dir = scratch(); + const none = localAvailability(machineHomedInScratch(dir)); + assert.equal(none.usable, false); + assert.match(none.why, /^no Android SDK with an emulator and adb \(looked in .*Library\/Android\/sdk\)$/); + + const linux = machineHomedInScratch(dir, { os: 'linux', arch: 'x64' }); + writeFileSync(linux.kvm, ''); + const root = fakeSdk(dir, linux, { image: false }); + const noImage = localAvailability({ ...linux, env: { ANDROID_HOME: root } }); + assert.equal(noImage.why, `the SDK at ${root} has no system image system-images;android-36;google_apis;x86_64`); + }); + + it('on Linux needs a /dev/kvm this user can open for reading and writing', () => { + const dir = scratch(); + const linux = machineHomedInScratch(dir, { os: 'linux', arch: 'x64' }); + const withSdk = { ...linux, env: { ANDROID_HOME: fakeSdk(dir, linux) } }; + + const missing = { usable: false, why: `there is no ${linux.kvm}, so this machine has no hardware virtualization for the emulator` }; + assert.deepEqual(localAvailability(withSdk), missing); + assert.deepEqual(localAvailability(linux), missing, 'with no SDK either, it names the one thing no install fixes, and offers no fix'); + + writeFileSync(linux.kvm, ''); + chmodSync(linux.kvm, 0o000); + const rootOpensAnyFile = process.getuid?.() === 0; + if (!rootOpensAnyFile) { + const closed = localAvailability(withSdk); + assert.equal(closed.usable, false); + assert.match(closed.why, /^this user cannot open .*kvm for reading and writing/); + } + + chmodSync(linux.kvm, 0o666); + const open = localAvailability(withSdk); + assert.equal(open.usable, true); + assert.match(open.why, /kvm opens for reading and writing and the SDK at .* has the system image$/); + }); + + it('says no on an OS the lanes do not run on', () => { + assert.deepEqual(localAvailability(machineHomedInScratch(scratch(), { os: 'win32' })), { usable: false, why: 'the lane emulator runs on macOS and Linux, and this machine runs win32' }); + }); + + it('writes the lane AVD once, on this machine\'s system image, and never touches one that exists', () => { + const dir = scratch(); + const linux = machineHomedInScratch(dir, { os: 'linux', arch: 'x64' }); + assert.equal(ensureLaneAvd(linux), 'created'); + const config = readFileSync(join(dir, '.android', 'avd', 'Clerk_Verify_Pixel.avd', 'config.ini'), 'utf8'); + for (const line of ['image.sysdir.1=system-images/android-36/google_apis/x86_64/', 'hw.lcd.width=1280', 'hw.lcd.height=2856', 'hw.lcd.density=480', 'abi.type=x86_64']) assert.ok(config.includes(`${line}\n`), line); + assert.match(readFileSync(join(dir, '.android', 'avd', 'Clerk_Verify_Pixel.ini'), 'utf8'), new RegExp(`^path=${join(dir, '.android', 'avd', 'Clerk_Verify_Pixel.avd')}$`, 'm')); + + writeFileSync(join(dir, '.android', 'avd', 'Clerk_Verify_Pixel.avd', 'config.ini'), 'image.sysdir.1=system-images/android-36/google_apis/arm64-v8a/\nhand=made\n'); + assert.equal(ensureLaneAvd(linux), 'exists'); + assert.match(readFileSync(join(dir, '.android', 'avd', 'Clerk_Verify_Pixel.avd', 'config.ini'), 'utf8'), /hand=made/); + }); + + it('finds an AVD that lives where its pointer file says, and leaves both files alone', () => { + const dir = scratch(); + const mac = machineHomedInScratch(dir); + const root = fakeSdk(dir, mac); + const elsewhere = join(dir, 'other-volume', 'Clerk_Verify_Pixel.avd'); + mkdirSync(elsewhere, { recursive: true }); + mkdirSync(join(dir, '.android', 'avd'), { recursive: true }); + const pointer = `avd.ini.encoding=UTF-8\npath=${elsewhere}\ntarget=android-35\n`; + writeFileSync(join(dir, '.android', 'avd', 'Clerk_Verify_Pixel.ini'), pointer); + writeFileSync(join(elsewhere, 'config.ini'), 'image.sysdir.1=system-images/android-35/google_apis/arm64-v8a/\n'); + assert.equal(ensureLaneAvd(mac), 'exists'); + assert.equal(readFileSync(join(dir, '.android', 'avd', 'Clerk_Verify_Pixel.ini'), 'utf8'), pointer); + assert.equal(existsSync(join(dir, '.android', 'avd', 'Clerk_Verify_Pixel.avd')), false); + assert.match(localAvailability({ ...mac, env: { ANDROID_HOME: root } }).why, /has no system image system-images;android-35;google_apis;arm64-v8a, which the Clerk_Verify_Pixel AVD names$/); + }); + + it('uses the ARM image on a Mac whose Node reports an Intel CPU, as it does under Rosetta', () => { + const dir = scratch(); + const root = fakeSdk(dir, machineHomedInScratch(dir)); + const rosetta = machineHomedInScratch(dir, { arch: 'x64', env: { ANDROID_HOME: root } }); + assert.equal(systemImage(rosetta), 'system-images;android-36;google_apis;arm64-v8a'); + assert.equal(localAvailability(rosetta).usable, true); + ensureLaneAvd(rosetta); + assert.match(readFileSync(join(dir, '.android', 'avd', 'Clerk_Verify_Pixel.avd', 'config.ini'), 'utf8'), /^abi\.type=arm64-v8a$/m); + }); + + it('checks the system image an existing lane AVD names, not the default one', () => { + const dir = scratch(); + const mac = machineHomedInScratch(dir); + const root = fakeSdk(dir, mac); + mkdirSync(join(dir, '.android', 'avd', 'Clerk_Verify_Pixel.avd'), { recursive: true }); + writeFileSync(join(dir, '.android', 'avd', 'Clerk_Verify_Pixel.ini'), ''); + writeFileSync(join(dir, '.android', 'avd', 'Clerk_Verify_Pixel.avd', 'config.ini'), 'image.sysdir.1=system-images/android-35/google_apis_playstore/arm64-v8a/\n'); + const found = localAvailability({ ...mac, env: { ANDROID_HOME: root } }); + assert.equal(found.usable, false); + assert.match(found.why, /has no system image system-images;android-35;google_apis_playstore;arm64-v8a, which the Clerk_Verify_Pixel AVD names$/); + }); + + it('looks for the AVD where the emulator does: ANDROID_AVD_HOME, then ANDROID_USER_HOME, and an empty value is unset', () => { + const dir = scratch(); + const written = (env: Record) => { + const home = mkdtempSync(join(dir, 'home-')); + ensureLaneAvd(machineHomedInScratch(home, { env })); + return { home, at: (root: string) => existsSync(join(root, 'Clerk_Verify_Pixel.ini')) }; + }; + assert.ok(written({ ANDROID_AVD_HOME: join(dir, 'avds') }).at(join(dir, 'avds'))); + assert.ok(written({ ANDROID_USER_HOME: join(dir, 'user') }).at(join(dir, 'user', 'avd'))); + const unset = written({ ANDROID_AVD_HOME: '' }); + assert.ok(unset.at(join(unset.home, '.android', 'avd'))); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/app-start.test.ts b/.claude/skills/verify-clerk-expo/test/app-start.test.ts new file mode 100644 index 00000000000..d87abcc4750 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/app-start.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { appStart, performAppStart } from '../src/core/state.ts'; +import type { HostEntry } from '../src/core/types.ts'; + +const devClient: HostEntry = { + kind: 'dev-client', + launchArguments: ['--ez', 'EXDevMenuIsOnboardingFinished', 'true'], + openLink: 'exp+app://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8082', + androidActivity: '.MainActivity', +}; + +describe('appStart', () => { + it('opens a binary host with only the launch arguments', () => { + assert.deepEqual(appStart('ios', 'com.clerk.E2EHost', { kind: 'binary' }, ['-verifyScreen', 'auth']), { kind: 'open-app', launchArguments: ['-verifyScreen', 'auth'] }); + }); + + it('prepends the dev-client arguments on iOS', () => { + const entry: HostEntry = { ...devClient, launchArguments: ['-EXDevMenuIsOnboardingFinished', 'YES'], openLink: null }; + assert.deepEqual(appStart('ios', 'com.clerk.expo', entry, ['-verifyScreen', 'auth']), { + kind: 'open-app', + launchArguments: ['-EXDevMenuIsOnboardingFinished', 'YES', '-verifyScreen', 'auth'], + }); + }); + + it('force-stops and starts the activity with am on Android, where the launcher intent agent-device sends crashes expo-dev-launcher, quoting every token for the device shell', () => { + const start = appStart('android', 'com.clerk.expo', devClient, ['--es', 'verifyScreen', "it's auth"]); + assert.deepEqual(start, { + kind: 'adb', + commands: [ + ['shell', "am force-stop 'com.clerk.expo'"], + [ + 'shell', + "'am' 'start' '-W' '-n' 'com.clerk.expo/.MainActivity' '-d' 'exp+app://expo-development-client/?url=http%3A%2F%2F10.0.2.2%3A8082' '--ez' 'EXDevMenuIsOnboardingFinished' 'true' '--es' 'verifyScreen' 'it'\\''s auth'", + ], + ], + }); + }); + + it('refuses an iOS dev client with an openLink, which it could not pass', () => { + assert.throws(() => appStart('ios', 'com.clerk.expo', devClient, []), { code: 'NOT_READY' }); + }); + + it('refuses an Android dev client with no activity', () => { + assert.throws(() => appStart('android', 'com.clerk.expo', { ...devClient, androidActivity: null }, []), { code: 'NOT_READY' }); + }); + + it('after the adb commands, opens the app with no options so the agent-device session binds without a relaunch', async () => { + const calls: string[] = []; + const driver = { + openApp: async (appId: string, options?: { readonly relaunch: true; readonly launchArguments: readonly string[] }) => + void calls.push(options === undefined ? `openApp ${appId}` : `openApp ${appId} relaunch ${options.launchArguments.join(' ')}`), + adb: async (args: readonly string[]) => void calls.push(`adb ${args.join(' ')}`), + }; + await performAppStart(appStart('android', 'com.clerk.expo', devClient, []), 'com.clerk.expo', driver); + assert.equal(calls.length, 3); + assert.match(calls[0]!, /^adb shell am force-stop/); + assert.match(calls[1]!, /^adb shell 'am' 'start'/); + assert.equal(calls[2], 'openApp com.clerk.expo'); + calls.length = 0; + await performAppStart(appStart('ios', 'com.clerk.E2EHost', { kind: 'binary' }, ['-verifyScreen', 'auth']), 'com.clerk.E2EHost', driver); + assert.deepEqual(calls, ['openApp com.clerk.E2EHost relaunch -verifyScreen auth']); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/broker.test.ts b/.claude/skills/verify-clerk-expo/test/broker.test.ts new file mode 100644 index 00000000000..ef25ca22851 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/broker.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { startBroker } from '../src/core/broker.ts'; +import type { ClerkBackend } from '../src/core/clerk.ts'; +import { openWorkspace } from '../src/core/workspace.ts'; +import type { PublishableKey } from '../src/core/types.ts'; + +describe('broker', () => { + it('launches only on the platform this run drives', async () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-broker-')); + const workspace = openWorkspace({ skillDir: dir, worktree: dir, home: join(dir, 'home') }); + const { run, scratch } = workspace.newRun(); + const broker = await startBroker(run, workspace, scratch, { + clerk: () => ({}) as ClerkBackend, + publishableKey: () => 'pk_test_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk' as PublishableKey, + screens: ['home', 'auth'], + platforms: ['ios'], + }); + const launch = (platform: string) => + fetch(`${broker.url}/launch`, { + method: 'POST', + headers: { Authorization: `Bearer ${readFileSync(broker.tokenFile, 'utf8')}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ platform, user: null, screen: 'auth', authMode: null, debugLogs: false, storageScope: null }), + }); + try { + const ios = await launch('ios'); + assert.equal(ios.status, 200); + assert.ok(((await ios.json()) as { launchArguments: string[] }).launchArguments.includes('-verifyScreen')); + const android = await launch('android'); + assert.equal(android.status, 400); + assert.equal(((await android.json()) as { code: string }).code, 'USAGE'); + } finally { + await broker.stop(); + } + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/claims.test.ts b/.claude/skills/verify-clerk-expo/test/claims.test.ts new file mode 100644 index 00000000000..4ea8936c7d2 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/claims.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { freeSlot, isOrphaned, readClaim, takeSlot } from '../src/core/claims.ts'; +import { currentProcess, isRunning } from '../src/core/exec.ts'; + +const taker = join(import.meta.dirname, '..', 'testing', 'claim-taker.ts'); + +function take(dir: string, worktree: string, startAt: number, from: number): Promise { + return new Promise((resolve, reject) => { + execFile(process.execPath, [taker, dir, worktree, String(startAt), String(from)], (error, stdout) => (error === null ? resolve(stdout) : reject(error))); + }); +} + +describe('device claims', () => { + it('lets only one of several processes take an orphaned slot', async () => { + for (let round = 0; round < 4; round += 1) { + const dir = mkdtempSync(join(tmpdir(), 'verify-claims-')); + const gone = mkdtempSync(join(tmpdir(), 'verify-gone-')); + const orphan = takeSlot(dir, 'ios', 1, 0, gone)!; + rmSync(gone, { recursive: true }); + assert.equal(isOrphaned({ ...orphan, owner: { pid: 1, startedAt: 0 } }), true); + const startAt = Date.now() + 1500; + const results = await Promise.all(Array.from({ length: 6 }, (_, i) => take(dir, join(dir, `worktree-${i}`), startAt, orphan.gen))); + assert.equal(results.filter((r) => r === 'won').length, 1, `round ${round}: ${results.join(' ')}`); + } + }); + + it('never lets a stale holder overwrite or free a slot another worktree now holds', () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-claims-')); + const first = takeSlot(dir, 'ios', 1, 0, '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/worktrees/a')!; + assert.equal(freeSlot(dir, first), true); + const second = takeSlot(dir, 'ios', 1, readClaim(dir, 'ios', 1).gen, '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/worktrees/b')!; + assert.equal(takeSlot(dir, 'ios', 1, first.gen, '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/worktrees/a'), null, 'a retake from a stale generation fails'); + assert.equal(freeSlot(dir, first), false, 'a stale free fails'); + assert.equal(readClaim(dir, 'ios', 1).claim?.nonce, second.nonce); + }); + + it('treats a reused pid as a different process', () => { + const me = currentProcess(); + assert.equal(isRunning(me), true); + assert.equal(isRunning({ pid: me.pid, startedAt: me.startedAt - 3_600_000 }), false); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/clerk.test.ts b/.claude/skills/verify-clerk-expo/test/clerk.test.ts new file mode 100644 index 00000000000..610eb71eef9 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/clerk.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { REPLACE_AT_USERS, createClerkBackends, newTestEmail } from '../src/core/clerk.ts'; +import { Secret } from '../src/core/secret.ts'; +import { VerifyFailure, type PublishableKey, type RunId } from '../src/core/types.ts'; + +function fakeBapi(routes: Record) { + const calls: string[] = []; + const fetchImpl = (async (url: string | URL, init?: RequestInit) => { + const key = `${init?.method ?? 'GET'} ${String(url).replace('https://api.clerk.com/v1', '').split('?')[0]}`; + calls.push(key); + const route = routes[key] ?? { status: 404, body: { errors: [{ code: 'resource_not_found' }] } }; + return new Response(JSON.stringify(route.body), { status: route.status }); + }) as typeof fetch; + const keys = { pk: 'pk_test_x' as PublishableKey, sk: new Secret('clerk-secret-key', 'sk_test_x') }; + return { calls, backend: createClerkBackends(fetchImpl)(() => keys) }; +} + +describe('the users of a development instance', () => { + const email = newTestEmail('r20261003-000000-abcd' as RunId, 1); + + it('counts them with the instance\'s own key', async () => { + const { calls, backend } = fakeBapi({ 'GET /users/count': { status: 200, body: { object: 'total_count', total_count: 61 } } }); + assert.equal(await backend.userCount(), 61); + assert.deepEqual(calls, ['GET /users/count']); + }); + + it('turns the refusal of one user too many into a fix a person can follow', async () => { + const { backend } = fakeBapi({ 'POST /users': { status: 403, body: { errors: [{ code: 'user_quota_exceeded' }] } } }); + await assert.rejects(backend.createUser(email, null), (error: VerifyFailure) => error instanceof VerifyFailure && error.code === 'INSTANCE_MISCONFIGURED' && error.fix === `the next \`{cli} run\` replaces the instance once it holds ${REPLACE_AT_USERS} users; rerun`); + const other = fakeBapi({ 'POST /users': { status: 403, body: { errors: [{ code: 'something_else' }] } } }); + await assert.rejects(other.backend.createUser(email, null), (error: Error) => !(error instanceof VerifyFailure) && /answered 403/.test(error.message)); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/cli.test.ts b/.claude/skills/verify-clerk-expo/test/cli.test.ts new file mode 100644 index 00000000000..dd9ca53f22e --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/cli.test.ts @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { createOutput, exitCodeFor, parseArgv } from '../src/core/cli.ts'; +import { Secret } from '../src/core/secret.ts'; +import { featureMapCheck } from '../src/core/verbs.ts'; +import { VerifyFailure, type Command, type DoctorReport, type DownResult } from '../src/core/types.ts'; + +function usageError(argv: readonly string[]): VerifyFailure { + try { + parseArgv(argv); + } catch (error) { + assert.ok(error instanceof VerifyFailure, `${argv.join(' ')} threw a non-VerifyFailure`); + return error; + } + assert.fail(`${argv.join(' ')} parsed`); +} + +describe('parseArgv', () => { + it('parses every verb', () => { + assert.deepEqual(parseArgv(['doctor']).command, { verb: 'doctor', live: false }); + assert.deepEqual(parseArgv(['doctor', '--platform', 'ios']).command, { verb: 'doctor', platform: 'ios', live: false }); + assert.deepEqual(parseArgv(['doctor', '--live']).command, { verb: 'doctor', live: true }); + assert.deepEqual(parseArgv(['up', '--wait', '300']).command, { verb: 'up', waitSeconds: 300 }); + assert.deepEqual(parseArgv(['up']).command, { verb: 'up', waitSeconds: 0 }); + assert.deepEqual(parseArgv(['run', 'auth-start', 'sign-up/request-code']).command, { + verb: 'run', + selection: { selectors: ['auth-start', 'sign-up/request-code'] }, + skip: [], + include: [], + video: true, + waitSeconds: 0, + }); + assert.deepEqual(parseArgv(['run', '--all', '--no-video', '--grep', 'profile']).command, { + verb: 'run', + selection: { all: true }, + skip: [], + include: [], + grep: 'profile', + video: false, + waitSeconds: 0, + }); + assert.equal((parseArgv(['run', 'auth-start', '--wait', '300']).command as { waitSeconds: number }).waitSeconds, 300); + assert.deepEqual((parseArgv(['run', 'auth-start', '--include', 'known-bug']).command as Extract).include, ['known-bug']); + assert.throws(() => parseArgv(['run', 'auth-start', '--include', 'form-entry']), { code: 'USAGE' }); + assert.deepEqual(parseArgv(['screen', '--png']).command, { verb: 'screen', png: true }); + assert.deepEqual(parseArgv(['attach', 'r20261002-141210-7c1e', '--pr', '412', '--screenshot', 'a', '--screenshot=b']).command, { + verb: 'attach', + run: 'r20261002-141210-7c1e', + pr: 412, + screenshots: ['a', 'b'], + }); + assert.deepEqual(parseArgv(['attach', 'r20261002-141210-7c1e', '--pr=7']).command, { verb: 'attach', run: 'r20261002-141210-7c1e', pr: 7, screenshots: 'all' }); + assert.deepEqual(parseArgv(['down', '--stale', '--dry-run']).command, { verb: 'down', stale: true, dryRun: true }); + }); + + it('reads --json on every verb', () => { + for (const argv of [['doctor'], ['up'], ['run', 'x'], ['screen'], ['attach', 'r20261002-141210-7c1e', '--pr', '1'], ['down']]) { + assert.equal(parseArgv([...argv, '--json']).json, true, argv[0]); + assert.equal(parseArgv(argv).json, false, argv[0]); + } + }); + + it('maps --skip form-entry and refuses other tags', () => { + const command = parseArgv(['run', 'auth-start', '--skip', 'form-entry']).command; + assert.equal(command.verb, 'run'); + assert.deepEqual(command.verb === 'run' && command.skip, ['form-entry']); + assert.equal(usageError(['run', 'auth-start', '--skip', 'slow']).code, 'USAGE'); + }); + + it('refuses unknown verbs, flags, and malformed values with USAGE', () => { + for (const argv of [ + [], + ['frobnicate'], + ['Doctor'], + ['doctor', '--wait', '3'], + ['up', '--png'], + ['run'], + ['run', 'x', '--all'], + ['run', 'x', '--video'], + ['screen', 'extra'], + ['attach', 'r20261002-141210-7c1e'], + ['attach', 'not-a-run', '--pr', '1'], + ['attach', 'r20261002-141210-7c1e', '--pr', 'abc'], + ['down', '--platform', 'windows'], + ['up', '--wait'], + ]) { + assert.equal(usageError(argv).code, 'USAGE', argv.join(' ')); + } + }); +}); + +describe('Output', () => { + it('masks every value a secret was used with, in human and JSON output', () => { + const sk = new Secret('clerk-secret-key', 'sk_test_unitTestValue123456'); + sk.use('bapi-authorization', () => undefined); + let written = ''; + const sink = { write: (text: string) => (written += text) }; + const human = createOutput(false, '/tmp', 'bin/control-x', sink, sink); + human.progress('calling with sk_test_unitTestValue123456 now'); + human.failure(new VerifyFailure('NOT_READY', 'header was Bearer sk_test_unitTestValue123456', 'retry')); + const json = createOutput(true, '/tmp', 'bin/control-x', sink, sink); + json.failure(new VerifyFailure('NOT_READY', 'sk_test_unitTestValue123456', 'retry')); + assert.ok(!written.includes('sk_test_unitTestValue123456'), written); + assert.equal(written.match(//g)?.length, 3); + assert.equal(String(sk), ''); + assert.equal(JSON.stringify({ sk }), '{"sk":""}'); + }); + + it('prints one envelope under --json and keeps progress off stdout', () => { + let out = ''; + let err = ''; + const output = createOutput(true, '/tmp', 'bin/control-x', { write: (t: string) => (out += t) }, { write: (t: string) => (err += t) }); + const report: DoctorReport = { verb: 'doctor', ok: false, backend: { ios: 'local' }, checks: [{ id: 'build', ok: false, detail: 'none', fix: '{cli} up' }] }; + output.progress('building'); + output.result(report); + assert.equal(err, ''); + const parsed = JSON.parse(out) as { ok: boolean; checks: unknown[] }; + assert.equal(parsed.ok, false); + assert.equal(parsed.checks.length, 1); + assert.equal(exitCodeFor(report), 3); + }); +}); + +describe('doctor output', () => { + const report: DoctorReport = { + verb: 'doctor', + ok: true, + backend: { ios: 'local' }, + checks: [ + { id: 'node', ok: true, detail: '24.15.0' }, + { id: 'gh-attach', ok: true, state: 'warning', detail: 'gh pr comment has no --attach', fix: 'install one that has' }, + { id: 'live-instance', ok: true, state: 'not-run', detail: 'not run: needs --live' }, + ], + }; + + it('labels a warning and a check that was not run, and neither fails doctor', () => { + let out = ''; + createOutput(false, '/tmp', 'bin/control-x', { write: (t: string) => (out += t) }, { write: () => true }).result(report); + assert.deepEqual(out.trimEnd().split('\n'), ['ok node 24.15.0', 'warn gh-attach gh pr comment has no --attach', ' fix: install one that has', 'skip live-instance not run: needs --live']); + assert.equal(exitCodeFor(report), 0); + }); + + it('carries the state of each check under --json', () => { + let out = ''; + createOutput(true, '/tmp', 'bin/control-x', { write: (t: string) => (out += t) }, { write: () => true }).result(report); + assert.deepEqual((JSON.parse(out) as DoctorReport).checks.map((c) => c.state ?? null), [null, 'warning', 'not-run']); + }); +}); + +describe('doctor feature-map check', () => { + it('fails when a mapped feature has no feature file or no golden spec', () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-map-')); + mkdirSync(join(dir, 'features')); + mkdirSync(join(dir, 'specs', 'golden', 'auth-start'), { recursive: true }); + writeFileSync(join(dir, 'features', 'auth-start.md'), '# Auth start'); + writeFileSync(join(dir, 'specs', 'golden', 'auth-start', 'opens.e2e.ts'), ''); + writeFileSync(join(dir, 'features', 'sign-up.md'), '# Sign up'); + assert.equal(featureMapCheck(dir, ['auth-start']).ok, true); + const gap = featureMapCheck(dir, ['auth-start', 'sign-up', 'organizations']); + assert.equal(gap.ok, false); + assert.equal(gap.detail, 'missing specs/golden/sign-up/*.e2e.ts, features/organizations.md, specs/golden/organizations/*.e2e.ts'); + }); +}); + +describe('down output', () => { + const text = (result: DownResult) => { + let out = ''; + createOutput(false, '/tmp', 'bin/control-x', { write: (t: string) => (out += t) }, { write: () => true }).result(result); + return out; + }; + const render = (stoppedProcesses: readonly string[]) => text({ verb: 'down', dryRun: false, released: [], deletedApplications: [{ name: 'verify-throwaway-one' }], stoppedProcesses, keptRuns: [] }); + + it('says a ledgered daemon had already exited instead of claiming none ran', () => { + const out = render(['agent-device 4242 had already exited']); + assert.match(out, /stopped agent-device 4242 had already exited/); + assert.doesNotMatch(out, /no agent-device daemon running/); + assert.match(out, /deleted 1 application \(verify-throwaway-one, with every test user in it\)/); + }); + + it('names each application a dry run would delete', () => { + const out = text({ verb: 'down', dryRun: true, wouldRelease: [], wouldDelete: [{ kind: 'application', name: 'verify-throwaway-one' }], wouldStop: [], keptRuns: [] }); + assert.match(out, /would delete 1 application\n application verify-throwaway-one \(with every test user in it\)\n/); + }); + + it('says no daemon ran when the ledger had none', () => { + assert.match(render([]), /stopped nothing; no agent-device daemon running/); + }); +}); + +describe('the CLI name', () => { + it('prints fixes, usage, and next hints with the host command, never a built-in name', () => { + let out = ''; + const sink = { write: (t: string) => (out += t) }; + const output = createOutput(false, '/tmp', 'tools/bin/control-acme', sink, sink); + output.failure(usageError(['run'])); + output.failure(new VerifyFailure('NOT_READY', 'no device is leased', '{cli} up')); + output.progress('wait another {cli} run in this worktree is driving the device'); + assert.match(out, /fix: tools\/bin\/control-acme up/); + assert.match(out, /tools\/bin\/control-acme run (json += t) }, sink).failure(new VerifyFailure('NOT_READY', 'x', '{cli} doctor')); + assert.equal((JSON.parse(json) as { error: { fix: string } }).error.fix, 'control-acme doctor'); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/device-command.test.ts b/.claude/skills/verify-clerk-expo/test/device-command.test.ts new file mode 100644 index 00000000000..0db311b2209 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/device-command.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { deviceCommand } from '../src/core/device-command.ts'; +import { run } from '../src/core/exec.ts'; +import type { LocalLease } from '../src/core/types.ts'; + +describe('deviceCommand', () => { + it('runs adb from the SDK on this machine for a local lease', async () => { + const sdk = mkdtempSync(join(tmpdir(), 'verify-sdk-')); + const ran: unknown[] = []; + const lease = { backend: 'local', platform: 'android', deviceId: 'emulator-5560' } as LocalLease; + const runner: typeof run = async (command, args) => (ran.push([command, args]), { code: 0, stdout: 'ok', stderr: '' }); + assert.deepEqual(await deviceCommand(lease, ['shell', 'ls'], { runner, env: { ANDROID_HOME: sdk } }), { code: 0, stdout: 'ok', stderr: '' }); + assert.deepEqual(ran, [['adb', ['-s', 'emulator-5560', 'shell', 'ls']]], 'an SDK with no adb falls back to PATH'); + mkdirSync(join(sdk, 'platform-tools')); + writeFileSync(join(sdk, 'platform-tools', 'adb'), ''); + await deviceCommand(lease, ['shell', 'id'], { runner, env: { ANDROID_HOME: sdk } }); + assert.deepEqual(ran[1], [join(sdk, 'platform-tools', 'adb'), ['-s', 'emulator-5560', 'shell', 'id']]); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/down.test.ts b/.claude/skills/verify-clerk-expo/test/down.test.ts new file mode 100644 index 00000000000..190cb6c3db3 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/down.test.ts @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { heldInstances } from '../testing/fake-instances.ts'; +import { describe, it } from 'node:test'; +import { openProcesses } from '../src/core/ledgers.ts'; +import { down, type Deps } from '../src/core/verbs.ts'; +import { newEntryId, openWorkspace } from '../src/core/workspace.ts'; +import type { DeviceBackend, HostAdapter, LocalLease, Platform } from '../src/core/types.ts'; + +function setup() { + const skillDir = mkdtempSync(join(tmpdir(), 'verify-down-')); + const workspace = openWorkspace({ skillDir, worktree: skillDir, home: join(skillDir, 'home') }); + const released: string[] = []; + const finished: string[] = []; + const backend = { + kind: 'local', + platform: 'ios', + availability: () => ({ usable: true, why: 'test' }), + release: async (lease: LocalLease) => void released.push(lease.deviceId), + reapable: async () => [], + describe: (lease: LocalLease) => lease.deviceName, + } as unknown as DeviceBackend; + const host = { repo: 'clerk-ios', platforms: ['ios'], backends: [backend] } as unknown as HostAdapter; + const instances = heldInstances({ finish: async (ledger) => (finished.push(ledger.worktree), []) }); + const deps: Deps = { host, workspace, runner: async () => assert.fail('down runs no commands'), env: {}, progress: () => undefined, instances }; + + const lease: LocalLease = { backend: 'local', platform: 'ios', slot: 2, deviceName: 'verify-ios-2', deviceId: 'UDID-2', claimNonce: 'claim-2', acquiredAt: '2026-10-03T00:00:00Z', installedBuild: null }; + workspace.writeLease(lease); + workspace.append({ id: newEntryId(), kind: 'process', what: 'watch', pid: 2147483646, startedAt: '2026-10-03T00:00:00Z' }); + const { run } = workspace.newRun(); + return { deps, workspace, released, finished, run }; +} + +describe('down', () => { + it('--dry-run reports the plan and changes nothing', async () => { + const { deps, workspace, released, finished, run } = setup(); + const ledgerBefore = readFileSync(workspace.ledgerFile, 'utf8'); + const result = await down(deps, { verb: 'down', stale: false, dryRun: true }); + assert.ok(result.dryRun); + assert.equal('released' in result, false, 'a dry run reports nothing in the past tense'); + assert.deepEqual(result.wouldRelease.map((l) => l.device), ['verify-ios-2']); + assert.deepEqual(result.keptRuns, [run]); + assert.deepEqual(released, []); + assert.deepEqual(finished, []); + assert.ok(existsSync(workspace.leaseFile('ios')), 'lease file kept'); + assert.equal(readFileSync(workspace.ledgerFile, 'utf8'), ledgerBefore, 'ledger untouched'); + }); + + it('releases the lease, finishes the instances of this worktree, tombstones, and keeps runs', async () => { + const { deps, workspace, released, finished, run } = setup(); + const result = await down(deps, { verb: 'down', stale: false, dryRun: false }); + assert.ok(!result.dryRun); + assert.deepEqual(released, ['UDID-2']); + assert.deepEqual(finished, [workspace.worktree]); + assert.deepEqual(result.stoppedProcesses, ['watch 2147483646 had already exited']); + assert.equal(workspace.readLease('ios'), null); + assert.deepEqual(workspace.unclosedEntries(), []); + assert.ok(existsSync(workspace.runDir(run)), 'evidence survives'); + const again = await down(deps, { verb: 'down', stale: false, dryRun: false }); + assert.ok(!again.dryRun); + assert.deepEqual(again.released, []); + }); +}); + +describe('down --stale', () => { + it('asks a backend this machine can no longer use, because a claim made before that still has to be finished', async () => { + const { deps } = setup(); + const orphan: LocalLease = { backend: 'local', platform: 'ios', slot: 1, deviceName: 'verify-ios-1', deviceId: 'UDID-1', claimNonce: 'claim-1', acquiredAt: '', installedBuild: null }; + const unusable = { ...deps.host.backends[0]!, availability: () => ({ usable: false, why: 'the SDK was removed' }), reapable: async () => [orphan] } as DeviceBackend; + const result = await down({ ...deps, host: { ...deps.host, backends: [unusable] } }, { verb: 'down', stale: true, dryRun: true }); + assert.ok(result.dryRun); + assert.deepEqual(result.wouldRelease.map((l) => l.device), ['verify-ios-2', 'verify-ios-1']); + }); +}); + +describe('down --platform on a host with two platforms', () => { + function twoPlatforms(leased: readonly Platform[]) { + const skillDir = mkdtempSync(join(tmpdir(), 'verify-down-two-')); + const workspace = openWorkspace({ skillDir, worktree: skillDir, home: join(skillDir, 'home') }); + const backends = (['ios', 'android'] as const).map( + (platform) => ({ kind: 'local', platform, availability: () => ({ usable: true, why: 'test' }), release: async () => undefined, reapable: async () => [], describe: (lease: LocalLease) => lease.deviceName }) as unknown as DeviceBackend, + ); + const host = { repo: 'clerk-expo', platforms: ['ios', 'android'], backends } as unknown as HostAdapter; + const keptApplications: boolean[] = []; + const instances = heldInstances({ finish: async (_ledger, options) => (keptApplications.push(options.keepApplications), []) }); + const deps: Deps = { host, workspace, runner: async () => assert.fail('down runs no commands'), env: {}, progress: () => undefined, instances }; + for (const platform of leased) { + workspace.writeLease({ backend: 'local', platform, slot: 1, deviceName: `verify-${platform}-1`, deviceId: `${platform}-device`, claimNonce: `claim-${platform}`, acquiredAt: '2026-10-03T00:00:00Z', installedBuild: null }); + } + workspace.append({ id: newEntryId(), kind: 'application', name: 'verify-throwaway-two', workspace: 'a workspace' }); + const children: ChildProcess[] = []; + const start = (): { readonly pid: number; readonly startedAt: string } => { + const child = spawn('sleep', ['30'], { stdio: 'ignore' }); + children.push(child); + return { pid: child.pid!, startedAt: new Date().toISOString() }; + }; + const ledgered = (what: 'metro' | 'watch', platform?: Platform): string => { + const { pid, startedAt } = start(); + workspace.append({ id: newEntryId(), kind: 'process', what, pid, startedAt, ...(platform === undefined ? {} : { platform }) }); + return `${what} ${pid}`; + }; + const watch = ledgered('watch'); + const iosMetro = ledgered('metro', 'ios'); + const androidMetro = ledgered('metro', 'android'); + const daemon = start(); + mkdirSync(workspace.agentDeviceDir, { recursive: true }); + writeFileSync(join(workspace.agentDeviceDir, 'daemon.json'), JSON.stringify({ pid: daemon.pid, processStartTime: daemon.startedAt })); + const exited = async (line: string, withinMs: number): Promise => { + const child = children.find((c) => c.pid === Number(line.split(' ')[1]))!; + for (let waited = 0; waited < withinMs && child.exitCode === null && child.signalCode === null; waited += 20) await new Promise((resolve) => setTimeout(resolve, 20)); + return child.exitCode !== null || child.signalCode !== null; + }; + const stopped = (line: string) => exited(line, 2000); + const stillRuns = async (...lines: string[]) => (await Promise.all(lines.map((line) => exited(line, 200)))).every((gone) => !gone); + const stop = (platform: Platform, dryRun: boolean) => down(deps, { verb: 'down', platform, stale: false, dryRun }); + return { workspace, keptApplications, watch, iosMetro, androidMetro, daemon: `agent-device ${daemon.pid}`, stopped, stillRuns, stop, cleanup: () => children.forEach((child) => child.kill()) }; + } + + it('stops only the processes of the platform asked for while the other platform stays leased, and a dry run lists the same', async () => { + const w = twoPlatforms(['ios', 'android']); + try { + const planned = await w.stop('ios', true); + assert.ok(planned.dryRun); + assert.deepEqual(planned.wouldStop, [w.iosMetro]); + assert.deepEqual(planned.wouldDelete, [], 'the android lease still uses the application'); + const result = await w.stop('ios', false); + assert.ok(!result.dryRun); + assert.deepEqual(result.stoppedProcesses, planned.wouldStop); + assert.equal(await w.stopped(w.iosMetro), true); + assert.equal(await w.stillRuns(w.watch, w.androidMetro, w.daemon), true, 'the watch build, the other Metro, and the daemon keep running'); + assert.deepEqual(openProcesses(w.workspace).map((entry) => `${entry.what} ${entry.pid}`), [w.watch, w.androidMetro, w.daemon]); + assert.deepEqual(w.keptApplications, [true]); + assert.notEqual(w.workspace.readLease('android'), null); + } finally { + w.cleanup(); + } + }); + + it('stops what every lease shares once no other platform is leased, and leaves a process of a platform it was not asked about', async () => { + const w = twoPlatforms(['android']); + try { + const planned = await w.stop('android', true); + assert.ok(planned.dryRun); + assert.deepEqual(planned.wouldStop, [w.watch, w.androidMetro, w.daemon]); + assert.deepEqual(planned.wouldDelete, [{ kind: 'application', name: 'verify-throwaway-two' }]); + const result = await w.stop('android', false); + assert.ok(!result.dryRun); + assert.deepEqual(result.stoppedProcesses, planned.wouldStop); + assert.deepEqual([await w.stopped(w.watch), await w.stopped(w.androidMetro), await w.stopped(w.daemon)], [true, true, true]); + assert.equal(await w.stillRuns(w.iosMetro), true); + assert.deepEqual(openProcesses(w.workspace).map((entry) => `${entry.what} ${entry.pid}`), [w.iosMetro]); + assert.deepEqual(w.keptApplications, [false]); + } finally { + w.cleanup(); + } + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/e2e.test.ts b/.claude/skills/verify-clerk-expo/test/e2e.test.ts new file mode 100644 index 00000000000..476e4a8b49d --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/e2e.test.ts @@ -0,0 +1,144 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { collectScreenshots, e2eOutputDir, excludedTags, parseE2EReport, planE2E, resolveSpecs } from '../src/core/e2e.ts'; +import { manifestDrift } from '../src/core/manifest.ts'; +import type { ActiveRunContext, EvidencePath, RunId } from '../src/core/types.ts'; + +function skill(): string { + const dir = mkdtempSync(join(tmpdir(), 'verify-specs-')); + for (const file of ['specs/golden/auth-start/opens.e2e.ts', 'specs/golden/sign-up/request-code.e2e.ts', 'specs/explored/probe.e2e.ts', 'specs/fixtures.ts']) { + mkdirSync(join(dir, file, '..'), { recursive: true }); + writeFileSync(join(dir, file), ''); + } + return dir; +} + +describe('resolveSpecs', () => { + it('expands features, feature/spec, paths, and --all', () => { + const dir = skill(); + assert.deepEqual(resolveSpecs(dir, { selectors: ['auth-start'] }), [{ kind: 'golden', path: 'specs/golden/auth-start/opens.e2e.ts', feature: 'auth-start' }]); + assert.deepEqual(resolveSpecs(dir, { selectors: ['sign-up/request-code'] }).map((s) => s.path), ['specs/golden/sign-up/request-code.e2e.ts']); + assert.deepEqual(resolveSpecs(dir, { selectors: ['specs/explored/probe.e2e.ts'] }, dir), [{ kind: 'explored', path: 'specs/explored/probe.e2e.ts', feature: null }]); + assert.deepEqual(resolveSpecs(dir, { all: true }).map((s) => s.path), ['specs/golden/auth-start/opens.e2e.ts', 'specs/golden/sign-up/request-code.e2e.ts']); + }); + + it('names the nearest feature for an unknown selector', () => { + assert.throws(() => resolveSpecs(skill(), { selectors: ['auth-strat'] }), { code: 'NO_SPECS', fix: /auth-start/ }); + }); +}); + +describe('planE2E', () => { + it('passes selection flags through and points output inside the run', () => { + const context: ActiveRunContext = { + v: 1, + run: 'r20261002-141210-7c1e' as RunId, + workspace: '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/skill/.verify', + broker: { url: 'http://127.0.0.1:1', tokenFile: '/skill/.verify/scratch/r/broker-token' }, + agentDeviceSession: 'verify-ios-abc', + targets: [], + }; + const plan = planE2E(context, [{ kind: 'golden', path: 'specs/golden/a/b.e2e.ts', feature: null }], { verb: 'run', selection: { all: true }, skip: ['form-entry'], include: [], grep: 'x', video: true, waitSeconds: 0 }, 'ios', '/skill', e2eOutputDir('/skill/.verify/runs/r20261002-141210-7c1e' as EvidencePath, 0)); + assert.deepEqual(plan.args, [ + 'run', 'specs/golden/a/b.e2e.ts', '--config', 'e2e.config.ts', '--target', 'ios', + '--output', '.verify/runs/r20261002-141210-7c1e/e2e', '--reporter', 'list,markdown', + '--exclude-tag', 'form-entry,known-bug', '--grep', 'x', '--pass-with-no-tests', + ]); + assert.equal(plan.env.VERIFY_CONTEXT, '/skill/.verify/scratch/r20261002-141210-7c1e/context.json'); + assert.equal(plan.env.E2E_TELEMETRY_DISABLED, '1'); + const later = planE2E(context, [], { verb: 'run', selection: { all: true }, skip: [], include: ['known-bug'], video: true, waitSeconds: 0 }, 'ios', '/skill', e2eOutputDir('/skill/.verify/runs/r20261002-141210-7c1e' as EvidencePath, 2)); + assert.equal(later.args[later.args.indexOf('--output') + 1], '.verify/runs/r20261002-141210-7c1e/e2e-3', 'a later group of the run writes beside the first'); + assert.equal(later.args.includes('--pass-with-no-tests'), true, 'every invocation is one group of a run, and a group with nothing left to run must not fail it'); + }); + + it('excludes known-bug specs by default and keeps them with --include known-bug', () => { + assert.deepEqual(excludedTags({ skip: [], include: [] }), ['--exclude-tag', 'known-bug']); + assert.deepEqual(excludedTags({ skip: ['form-entry'], include: [] }), ['--exclude-tag', 'form-entry,known-bug']); + assert.deepEqual(excludedTags({ skip: [], include: ['known-bug'] }), []); + assert.deepEqual(excludedTags({ skip: ['form-entry'], include: ['known-bug'] }), ['--exclude-tag', 'form-entry']); + }); +}); + +describe('parseE2EReport', () => { + const report = { + schemaVersion: 'report-1', + run: { + results: [ + { + id: 'aaaaaaaa11', kind: 'test', titlePath: ['opens'], file: 'specs/golden/auth-start/opens.e2e.ts', platform: 'ios', tags: [], status: 'passed', + attempts: [{ status: 'passed', durationMs: 9100, artifacts: [{ kind: 'screenshot', path: 'ios/x/attempt-0/screenshots/001-auth.png', producer: { kind: 'step', stepId: 's1' } }], steps: [{ id: 's1', api: 'app.screenshot', label: 'auth' }] }], + }, + { + id: 'bbbbbbbb22', kind: 'test', titlePath: ['completes'], file: 'specs/golden/sign-up/complete.e2e.ts', platform: 'ios', tags: ['form-entry'], status: 'skipped', + skip: { cause: 'filtered', reason: 'excluded by --exclude-tag form-entry' }, attempts: [], + }, + { + id: 'eeeeeeee55', kind: 'test', titlePath: ['email code sign-in drops the session'], file: 'specs/golden/sign-up/request-code.e2e.ts', platform: 'ios', tags: ['known-bug', 'form-entry'], status: 'skipped', + skip: { cause: 'filtered', reason: 'carries an excluded tag' }, attempts: [], + }, + { + id: 'ffffffff66', kind: 'test', titlePath: ['ios-only screen'], file: 'specs/golden/sign-up/request-code.e2e.ts', platform: 'android', tags: [], status: 'skipped', + skip: { cause: 'platform-unavailable', reason: 'test declares platforms [ios]' }, attempts: [], + }, + { + id: 'dddddddd44', kind: 'test', titlePath: ['never ran'], file: 'specs/golden/auth-start/opens.e2e.ts', platform: 'ios', tags: [], status: 'skipped', + skip: { cause: 'infrastructure-unavailable', reason: 'the device could not be opened' }, attempts: [], + }, + { + id: 'cccccccc33', kind: 'test', titlePath: ['fails'], file: 'specs/explored/probe.e2e.ts', platform: 'ios', status: 'timed-out', + attempts: [{ status: 'timed-out', durationMs: 5000, error: { message: 'expect.toBeVisible failed\nobserved: no node' }, failure: { screen: 'art-1', screenshot: 'art-2' }, artifacts: [{ id: 'art-1', kind: 'other', path: 'ios/p/attempt-0/screen.txt' }, { id: 'art-2', kind: 'screenshot', path: 'ios/p/attempt-0/screenshots/001-failure.png' }] }], + }, + ], + }, + }; + + it('maps statuses, skip reasons, errors, and failure pages, in the directory its invocation wrote to', () => { + const run = mkdtempSync(join(tmpdir(), 'verify-report-')) as EvidencePath; + const dir = e2eOutputDir(run, 1); + assert.equal(dir, join(run, 'e2e-2')); + mkdirSync(join(dir, 'failures'), { recursive: true }); + writeFileSync(join(dir, 'failures', 'specs_explored_probe-fails-cccccccc.md'), ''); + mkdirSync(join(dir, 'artifacts', 'ios/p/attempt-0'), { recursive: true }); + writeFileSync(join(dir, 'artifacts', 'ios/p/attempt-0/screen.txt'), ''); + const results = parseE2EReport(report, [], dir, ['form-entry', 'known-bug']); + assert.deepEqual(results.map((r) => r.status), ['passed', 'skipped', 'skipped', 'skipped', 'failed', 'failed']); + assert.equal(results[4]!.error, 'not run: infrastructure-unavailable the device could not be opened'); + assert.equal(results[0]!.seconds, 9.1); + assert.equal(results[0]!.spec.feature, null); + assert.equal(results[1]!.skipReason, 'skipped by --skip form-entry'); + assert.equal(results[2]!.skipReason, 'skipped: known-bug', 'known-bug wins over form-entry'); + assert.equal(results[2]!.title, 'email code sign-in drops the session'); + assert.equal(results[3]!.skipReason, 'skipped: ios only', 'a platform-scoped spec is skipped, not failed'); + const included = parseE2EReport(report, [], dir, ['form-entry']); + assert.equal(included[2]!.skipReason, 'skipped by --skip form-entry', 'with --include known-bug, the reason is the tag that was excluded'); + assert.equal(results[5]!.error, 'expect.toBeVisible failed; observed: no node'); + assert.equal(results[5]!.failureScreen, join(dir, 'artifacts', 'ios/p/attempt-0/screen.txt')); + assert.equal(results[5]!.failureScreenshot, join(dir, 'artifacts', 'ios/p/attempt-0/screenshots/001-failure.png'), 'the full path, never truncated'); + const selected = parseE2EReport(report, [{ kind: 'golden', path: 'specs/golden/auth-start/opens.e2e.ts', feature: null }], dir); + assert.deepEqual(selected.map((r) => r.spec.path), ['specs/golden/auth-start/opens.e2e.ts', 'specs/golden/auth-start/opens.e2e.ts'], 'files e2e lists but the run did not select are dropped'); + assert.equal(results[5]!.failurePage, join(dir, 'failures', 'specs_explored_probe-fails-cccccccc.md')); + }); + + it('refuses a report of another schema', () => { + assert.throws(() => parseE2EReport({ schemaVersion: 'report-2', run: { results: [] } }, [], '/x' as EvidencePath), { code: 'E2E_CRASHED' }); + }); + + it('copies app.screenshot artifacts under their labels, a later group replacing an earlier one with the same label', () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-shots-')) as EvidencePath; + for (const [index, bytes] of [[0, 'first'], [1, 'second']] as const) { + mkdirSync(join(e2eOutputDir(dir, index), 'artifacts', 'ios/x/attempt-0/screenshots'), { recursive: true }); + writeFileSync(join(e2eOutputDir(dir, index), 'artifacts', 'ios/x/attempt-0/screenshots/001-auth.png'), bytes); + assert.deepEqual(collectScreenshots(report, dir, e2eOutputDir(dir, index)), [{ label: 'auth', path: join(dir, 'screenshots', 'auth.png') }]); + assert.equal(readFileSync(join(dir, 'screenshots', 'auth.png'), 'utf8'), bytes); + } + }); +}); + +describe('src/core MANIFEST', () => { + it('matches the files, so doctor reports no drift', () => { + assert.deepEqual(manifestDrift(), []); + }); +}); + diff --git a/.claude/skills/verify-clerk-expo/test/end-run.test.ts b/.claude/skills/verify-clerk-expo/test/end-run.test.ts new file mode 100644 index 00000000000..39b61ed3b24 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/end-run.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { VerifyFailure, type EvidencePath, type Recording } from '../src/core/types.ts'; +import { endRun } from '../src/core/verbs.ts'; +import { newEntryId, openWorkspace } from '../src/core/workspace.ts'; + +describe('endRun', () => { + it('stops the broker, removes scratch, and closes the recorder entry even when the recorder fails, then reports the failure', async () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-end-')); + const workspace = openWorkspace({ skillDir: dir, worktree: dir, home: join(dir, 'home') }); + const { scratch } = workspace.newRun(); + const recorderEntry = newEntryId(); + workspace.append({ id: recorderEntry, kind: 'process', what: 'recorder', pid: 1, startedAt: new Date().toISOString() }); + let brokerStopped = false; + const recording: Recording = { + process: { pid: 1, startedAt: 0 }, + stop: async (): Promise => { + throw new VerifyFailure('NOT_READY', 'adb pull failed', 'rerun with --no-video'); + }, + }; + await assert.rejects( + endRun(workspace, { recording, recorderEntry, broker: { stop: async () => void (brokerStopped = true) }, scratch }), + { code: 'NOT_READY', message: 'adb pull failed' }, + ); + assert.equal(brokerStopped, true, 'broker stopped'); + assert.equal(existsSync(scratch), false, 'scratch, with the broker token, removed'); + assert.deepEqual(workspace.unclosedEntries(), [], 'recorder entry closed'); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/evidence.test.ts b/.claude/skills/verify-clerk-expo/test/evidence.test.ts new file mode 100644 index 00000000000..0665f82ab45 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/evidence.test.ts @@ -0,0 +1,192 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { heldInstances } from '../testing/fake-instances.ts'; +import { describe, it } from 'node:test'; +import { newTestEmail, parseTestEmail, parseTestPhone } from '../src/core/clerk.ts'; +import { assertPublishable, sealEvidence } from '../src/core/evidence.ts'; +import type { Runner } from '../src/core/exec.ts'; +import { postToPullRequest } from '../src/core/publish.ts'; +import { Secret } from '../src/core/secret.ts'; +import { parseVerifyState } from '../src/core/state.ts'; +import { attach } from '../src/core/verbs.ts'; +import { newRunId, openWorkspace } from '../src/core/workspace.ts'; +import type { BuildKey, EvidencePath, EvidenceRecord, HostAdapter, RunId, VerifyState } from '../src/core/types.ts'; + +const OWN_USER = 'user_own'; + +function state(userId: string | null): VerifyState { + return parseVerifyState( + `verify {"environmentLoaded":true,"launchId":"l1","lastError":null,"orgId":null,"pendingTasks":[],"runId":"r","screen":"home","sessionId":null,"sessionStatus":"active","signInStatus":null,"signUpStatus":null,"signedIn":${userId !== null},"ticket":"succeeded","userId":${JSON.stringify(userId)},"v":1}`, + ); +} + +function partialRecord(dir: EvidencePath, run: RunId): Omit { + return { + run, + startedAt: '2026-10-03T00:00:00.000Z', + finishedAt: '2026-10-03T00:00:10.000Z', + repo: 'clerk-ios', + gitHead: 'abc', + dirty: false, + platform: 'ios', + backend: 'local', + device: 'verify-ios-1', + build: 'ios-000000000000' as BuildKey, + results: [ + { spec: { kind: 'explored', path: 'specs/explored/a.e2e.ts', feature: null }, title: 'a', platform: 'ios', status: 'passed', seconds: 1, error: null, skipReason: null, skippedBy: null, tags: [], failurePage: null, failureScreen: null, failureScreenshot: null }, + ], + videos: [join(dir, 'video.mp4') as EvidencePath], + screenshots: [{ label: 'profile', path: join(dir, 'screenshots', 'profile.png') as EvidencePath }], + lastState: state(OWN_USER), + appLog: join(dir, 'app.log') as EvidencePath, + e2eReport: join(dir, 'e2e', 'report.json') as EvidencePath, + identities: [{ email: newTestEmail(run, 1), userId: OWN_USER }], + instances: [{ application: 'app_1' }], + settings: [{ label: 'standard', askedBy: null, specs: ['specs/explored/a.e2e.ts'], application: null, changed: false, held: true, e2eReport: join(dir, 'e2e', 'report.json') as EvidencePath }], + }; +} + +function runDir(): { dir: EvidencePath; run: RunId } { + const run = newRunId(); + const dir = join(mkdtempSync(join(tmpdir(), 'verify-evidence-')), run) as EvidencePath; + mkdirSync(join(dir, 'e2e'), { recursive: true }); + mkdirSync(join(dir, 'screenshots'), { recursive: true }); + writeFileSync(join(dir, 'video.mp4'), 'mp4'); + writeFileSync(join(dir, 'screenshots', 'profile.png'), 'png'); + writeFileSync(join(dir, 'app.log'), 'log line\n'); + writeFileSync(join(dir, 'e2e', 'report.json'), '{}'); + writeFileSync(join(dir, 'states.jsonl'), `${JSON.stringify(state(OWN_USER))}\n`); + return { dir, run }; +} + +describe('test identities', () => { + it('mints and accepts only +clerk_test emails and 555-01xx phones', () => { + const run = 'r20261002-141210-7c1e' as RunId; + assert.equal(newTestEmail(run, 3), 'verify_r20261002_141210_7c1e_3+clerk_test@example.com'); + assert.throws(() => parseTestEmail('someone@example.com'), { code: 'NOT_TEST_IDENTITY' }); + assert.throws(() => parseTestEmail('verify_1@example.com'), { code: 'NOT_TEST_IDENTITY' }); + assert.equal(parseTestPhone('+1 (201) 555-0142'), '+12015550142'); + assert.throws(() => parseTestPhone('+1 201 555 0200'), { code: 'NOT_TEST_IDENTITY' }); + }); +}); + +describe('sealEvidence', () => { + it('writes a sealed run.json beside the evidence layout', () => { + const { dir, run } = runDir(); + const record = sealEvidence(dir, partialRecord(dir, run), []); + for (const file of ['run.json', 'video.mp4', 'screenshots/profile.png', 'states.jsonl', 'app.log', 'e2e/report.json']) { + assert.ok(existsSync(join(dir, file)), file); + } + const written = JSON.parse(readFileSync(join(dir, 'run.json'), 'utf8')) as EvidenceRecord; + assert.equal(written.sealed, true); + assert.deepEqual(written.tainted, []); + assert.deepEqual(record, written); + }); + + it('marks a file holding a used secret as tainted without rewriting it', () => { + const { dir, run } = runDir(); + const ticket = new Secret('ticket', 'tkt_planted_secret_value'); + const plain = ticket.use('launch-argument', (value) => value); + writeFileSync(join(dir, 'e2e', 'report.json'), `{"label":"-verifySignInTicket ${plain}"}`); + const record = sealEvidence(dir, partialRecord(dir, run)); + assert.deepEqual(record.tainted, [join(dir, 'e2e', 'report.json')]); + assert.ok(readFileSync(join(dir, 'e2e', 'report.json'), 'utf8').includes(plain), 'sealing leaves e2e files as written'); + assert.throws(() => assertPublishable(record, [state(OWN_USER)]), { code: 'EVIDENCE_UNSAFE', message: /secret/ }); + }); +}); + +describe('assertPublishable', () => { + it('passes a clean run whose states show only its own users', () => { + const { dir, run } = runDir(); + const record = sealEvidence(dir, partialRecord(dir, run), []); + assert.equal(assertPublishable(record, [state(null), state(OWN_USER)]).run, run); + }); + + it('rejects a state with a user the run did not create', () => { + const { dir, run } = runDir(); + const record = sealEvidence(dir, partialRecord(dir, run), []); + assert.throws(() => assertPublishable(record, [state(OWN_USER), state('user_someone_else')]), { code: 'EVIDENCE_UNSAFE', message: /user_someone_else/ }); + }); + + it('rejects a run with a non-test identity or a failing spec', () => { + const { dir, run } = runDir(); + const base = sealEvidence(dir, partialRecord(dir, run), []); + assert.throws(() => assertPublishable({ ...base, identities: [{ email: 'someone@example.com' as never, userId: OWN_USER }] }, []), { code: 'NOT_TEST_IDENTITY' }); + const failing = { ...base, results: base.results.map((r) => ({ ...r, status: 'failed' as const })) }; + assert.throws(() => assertPublishable(failing, []), { code: 'EVIDENCE_UNSAFE', message: /failing/ }); + }); +}); + +describe('assertPublishable and the groups of a run', () => { + it('rejects a run with a group that lost its settings or never invoked e2e, though every result passed', () => { + const { dir, run } = runDir(); + const base = sealEvidence(dir, partialRecord(dir, run), []); + const group = base.settings[0]!; + const other = { ...group, label: 'auth_multi_factor.required_for_sign_up=true' }; + assert.throws(() => assertPublishable({ ...base, settings: [group, { ...other, held: false }] }, []), { code: 'EVIDENCE_UNSAFE', message: /1 group that did not run in full on its settings: auth_multi_factor\.required_for_sign_up=true/ }); + assert.throws(() => assertPublishable({ ...base, settings: [{ ...group, e2eReport: null }, other] }, []), { code: 'EVIDENCE_UNSAFE', message: /did not run in full on its settings: standard/ }); + }); +}); + +describe('attach', () => { + const host = { repo: 'clerk-ios', githubRepo: 'clerk/clerk-ios' } as HostAdapter; + + function recordingRunner(attachFlag = true): { runner: Runner; calls: (readonly string[])[] } { + const calls: (readonly string[])[] = []; + const runner: Runner = async (command, args) => { + if (args.includes('--help')) return { code: 0, stdout: attachFlag ? ' --attach file Attach a file\n' : ' -b, --body text The comment body text\n', stderr: '' }; + calls.push([command, ...args]); + return { code: 0, stdout: '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/clerk/clerk-ios/pull/9#issuecomment-1\n', stderr: '' }; + }; + return { runner, calls }; + } + + it('never calls gh for a run that fails the gate', async () => { + const skillDir = mkdtempSync(join(tmpdir(), 'verify-attach-')); + const workspace = openWorkspace({ skillDir, worktree: skillDir, home: join(skillDir, 'home') }); + const { run, dir } = workspace.newRun(); + for (const file of ['video.mp4', 'app.log']) writeFileSync(join(dir, file), 'x'); + sealEvidence(dir, partialRecord(dir, run), []); + writeFileSync(join(dir, 'states.jsonl'), `${JSON.stringify(state('user_foreign'))}\n`); + const { runner, calls } = recordingRunner(); + const deps = { host, workspace, runner, env: {}, progress: () => undefined, instances: heldInstances() }; + await assert.rejects(attach(deps, { verb: 'attach', run, pr: 9, screenshots: 'all' }), { code: 'EVIDENCE_UNSAFE' }); + assert.equal(calls.length, 0); + }); + + it('posts once with --repo and --attach, then reports alreadyPosted', async () => { + const { dir, run } = runDir(); + const record = sealEvidence(dir, partialRecord(dir, run), []); + const publishable = assertPublishable(record, [state(OWN_USER)]); + const { runner, calls } = recordingRunner(); + const first = await postToPullRequest(publishable, dir, host, 9, 'all', runner); + const second = await postToPullRequest(publishable, dir, host, 9, 'all', runner); + assert.equal(calls.length, 1); + const args = calls[0]!; + assert.deepEqual(args.slice(0, 6), ['gh', 'pr', 'comment', '9', '--repo', 'clerk/clerk-ios']); + assert.deepEqual(args.filter((_, i) => args[i - 1] === '--attach'), [join(dir, 'video.mp4'), join(dir, 'screenshots', 'profile.png')]); + assert.equal(first.alreadyPosted, false); + assert.equal(second.alreadyPosted, true); + assert.equal(second.commentUrl, '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/clerk/clerk-ios/pull/9#issuecomment-1'); + assert.ok(existsSync(join(dir, 'posted-9.json'))); + const other = await postToPullRequest(publishable, dir, host, 10, 'all', runner); + assert.equal(other.alreadyPosted, false); + assert.equal(calls.length, 2); + assert.equal(calls[1]![3], '10'); + }); + + it('posts nothing and names the fix when gh pr comment has no --attach', async () => { + const { dir, run } = runDir(); + const publishable = assertPublishable(sealEvidence(dir, partialRecord(dir, run), []), [state(OWN_USER)]); + const { runner, calls } = recordingRunner(false); + await assert.rejects(postToPullRequest(publishable, dir, host, 9, 'all', runner), { + code: 'NOT_READY', + message: `this gh has no \`gh pr comment --attach\`, so the video and screenshots of run ${run} cannot be posted`, + fix: 'install a gh build whose `gh pr comment` has --attach', + }); + assert.deepEqual(calls, []); + assert.equal(existsSync(join(dir, 'posted-9.json')), false); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/fixtures.test.ts b/.claude/skills/verify-clerk-expo/test/fixtures.test.ts new file mode 100644 index 00000000000..2928f582902 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/fixtures.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +describe('specs/fixtures.ts', () => { + it('names no host-specific screen union, and takes its screens from the host it imports', () => { + const source = readFileSync(join(import.meta.dirname, '..', 'specs', 'fixtures.ts'), 'utf8'); + for (const union of ['NativeHostScreen', 'ExpoHostScreen']) assert.equal(source.includes(union), false, `fixtures.ts names ${union}`); + assert.match(source, /import type \{ host as hostAdapter \} from '\.\.\/src\/host\.ts';/); + }); + + it('taps a position inside the node and fills through that tap, because agent-device 0.21.18 refuses a plain tap or fill under the full-screen Toolbar node an iOS 27 SwiftUI toolbar adds', () => { + const source = readFileSync(join(import.meta.dirname, '..', 'specs', 'fixtures.ts'), 'utf8'); + assert.match(source, /await target\.tap\(\{ position: \{ x: box\.width \/ 2, y: box\.height \/ 2 \} \}\);/); + assert.match(source, /async fill\(field, text\) \{\n\s+await host\.tap\(field\);/); + assert.doesNotMatch(source, /\.fill\(/); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/instances.test.ts b/.claude/skills/verify-clerk-expo/test/instances.test.ts new file mode 100644 index 00000000000..a7f15070ee7 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/instances.test.ts @@ -0,0 +1,1589 @@ +import assert from 'node:assert/strict'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { startBroker } from '../src/core/broker.ts'; +import { BACKEND_API_HOSTS, createClerkBackends, newTestEmail } from '../src/core/clerk.ts'; +import type { ExecResult, Runner } from '../src/core/exec.ts'; +import { compareEnvironment, flattenEnvironment } from '../src/core/instances/definitions.ts'; +import { createInstances } from '../src/core/instances/instances.ts'; +import { THROWAWAY_PREFIX, createPlatform, deadlineOf, describeCredential, throwawayApplication, throwawayName } from '../src/core/instances/platform.ts'; +import { STANDARD, STANDARD_ENVIRONMENT_KEY, STANDARD_FILE, SettingsRefused, planGroups, standardFile, type SettingsGroup } from '../src/core/instances/settings.ts'; +import { openApplications } from '../src/core/instances/throwaway.ts'; +import { withoutClerkKeys } from '../src/core/keys.ts'; +import { finishOrphanLedgers } from '../src/core/ledgers.ts'; +import { createOutput, takePlatformKey } from '../src/core/cli.ts'; +import { Secret, redact } from '../src/core/secret.ts'; +import { down, type Deps } from '../src/core/verbs.ts'; +import { newEntryId, newRunId, openWorkspace, takeSlotLock } from '../src/core/workspace.ts'; +import { RETRYABLE, VerifyFailure, type DeviceBackend, type HostAdapter, type InstanceSettings, type InstanceView, type LocalLease, type PublishableKey, type ScratchPath } from '../src/core/types.ts'; +import { PLATFORM_KEY, WORKSPACE, fakeClerk, type FakeClerkOptions } from '../testing/fake-clerk.ts'; + +const T0 = Date.parse('2026-10-05T12:00:00Z'); +const HOUR = 3_600_000; +const host = { repo: 'clerk-ios', platforms: ['ios'] } as unknown as HostAdapter; + +const MFA: InstanceSettings = { config: { auth_multi_factor: { required_for_sign_up: true } }, environment: { 'user_settings.sign_up.mfa.required': true } }; +const FORCED_ORG: InstanceSettings = { config: { organization_settings: { force_organization_selection: true } }, environment: { 'organization_settings.force_organization_selection': true } }; +const MFA_LABEL = 'auth_multi_factor.required_for_sign_up=true'; +const ORG_LABEL = 'organization_settings.force_organization_selection=true'; +const MFA_BODY = { ...standardFile().config, auth_multi_factor: { ...(standardFile().config.auth_multi_factor as object), required_for_sign_up: true } }; +const MFA_SPEC = 'specs/golden/session-tasks/setup-mfa.e2e.ts'; +const ORG_SPEC = 'specs/golden/session-tasks/choose-organization.e2e.ts'; +const STANDARD_SPEC = 'specs/golden/auth-start/auth-start.e2e.ts'; + +const groupOf = (declared: InstanceSettings | null, path: string): SettingsGroup => + planGroups([{ spec: { kind: 'golden', path, feature: null }, source: declared === null ? "test('x', () => {});\n" : `export const instanceSettings = ${JSON.stringify(declared)};\n` }], null)[0]!; +const standardGroup = groupOf(null, STANDARD_SPEC); +const mfaGroup = groupOf(MFA, MFA_SPEC); +const orgGroup = groupOf(FORCED_ORG, ORG_SPEC); + +const idOf = (view: InstanceView | undefined): string | null => view?.id ?? null; +const quiet = () => undefined; +const NOTHING_NEW = { willChange: false } as const; + +const noOp: Runner = async () => ({ code: 127, stdout: '', stderr: 'spawn op ENOENT' }); +const REFERENCE = 'op://fake-vault/fake-item/credential'; +const REFERENCE_SHAPE = 'op:////credential'; +const namesTheItem = (text: string): boolean => [REFERENCE, 'fake-vault', 'fake-item'].some((part) => text.includes(part)); +const opAnswers = (read: ExecResult): Runner => async (_command, args) => (args[0] === '--version' ? { code: 0, stdout: '2.30.0', stderr: '' } : read); + +function world(options: { readonly env?: Record; readonly clerk?: FakeClerkOptions; readonly runner?: Runner; readonly dir?: string; readonly home?: string; readonly shared?: ReturnType } = {}) { + const dir = options.dir ?? mkdtempSync(join(tmpdir(), 'verify-instances-')); + const clock = { at: T0 }; + const clerk = options.shared ?? fakeClerk({ now: () => clock.at, ...options.clerk }); + const workspace = openWorkspace({ skillDir: dir, worktree: dir, home: options.home ?? join(dir, 'home') }); + const lines: string[] = []; + const slept: number[] = []; + const env = options.env ?? { CLERK_PLATFORM_API_KEY: PLATFORM_KEY }; + const alive = new Set(); + const open = (pid = 1000, fetchImpl: typeof fetch = clerk.fetch) => + createInstances({ + workspace, + env, + runner: options.runner ?? noOp, + progress: (line) => lines.push(line), + fetch: fetchImpl, + sleep: async (ms) => { + slept.push(ms); + clock.at += ms; + }, + now: () => clock.at, + drivers: { self: { pid, startedAt: T0 }, isRunning: (driver) => alive.has(driver.pid) }, + }); + const say = (line: string) => void lines.push(line); + return { dir, clock, clerk, workspace, lines, slept, env, alive, open, say, instances: open() }; +} + +type World = ReturnType; + +const platformCalls = (w: World) => w.clerk.platformRequests().map((request) => `${request.method} ${request.url.replace('api.clerk.com/v1/platform', '').split('?')[0]!.replace(/app_\w+/, '{app}').replace(/ins_\w+/, '{ins}')}`); +const writes = (w: World) => w.clerk.platformRequests().filter((request) => request.method !== 'GET').map((request) => `${request.method} ${request.url.replace('api.clerk.com/v1/platform', '').replace(/\/instances\/ins_\w+/, '')}`); +const patches = (w: World) => w.clerk.platformRequests().filter((request) => request.method === 'PATCH'); +const instancesDir = (w: World) => join(w.workspace.root, 'instances'); +const stateOf = (w: World, name: string) => JSON.parse(readFileSync(join(instancesDir(w), `${name}.state.json`), 'utf8')) as { environmentKey?: string; settings?: { key: string; label: string; askedBy: string | null }; drift?: string[]; drivers: { pid: number }[] }; + +function heldWithoutState(w: World, random: string) { + const application = w.clerk.plant(throwawayName(new Date(T0 + 6 * HOUR), random)); + w.workspace.append({ id: newEntryId(), kind: 'application', name: application.name, workspace: WORKSPACE }); + mkdirSync(instancesDir(w), { recursive: true }); + writeFileSync(join(instancesDir(w), `${application.name}.json`), `${JSON.stringify({ application: application.id, instanceId: application.instanceId, pk: application.pk, sk: application.sk })}\n`, { mode: 0o600 }); + return application; +} + +describe('comparing a public environment with the standard file', () => { + it('pins the settings a spec cannot run without, which no config key sets', () => { + const { environment } = standardFile(); + for (const leaf of ['auth_config.test_mode', 'auth_config.native_settings.api_enabled', 'user_settings.attributes.ticket.enabled', 'user_settings.actions.create_organization']) assert.equal(environment[leaf], true, leaf); + }); + + it('separates a required difference from drift and from a setting the file does not know', () => { + const live = { auth_config: { test_mode: false, reverification: false, brand_new: 1, id: 'aac_1' }, display_config: { home_url: 'https://x' } }; + const compared = compareEnvironment(standardFile(), live); + assert.ok(compared.differing.some((d) => d.path === 'auth_config.test_mode' && d.found === false)); + assert.ok(compared.drifted.some((d) => d.path === 'auth_config.reverification')); + assert.deepEqual(compared.unknown, ['auth_config.brand_new'], 'ids and URLs are not compared'); + }); + + it('reads a list of plain values as one leaf whatever its order', () => { + assert.deepEqual(flattenEnvironment({ a: ['b', 'a'], c: [{ d: 1 }] }), { a: ['a', 'b'], 'c[0].d': 1 }); + }); +}); + +describe('throwaway names', () => { + it('carry the deadline, and only names this tool made parse', () => { + const name = throwawayName(new Date('2026-10-06T03:12:59Z'), '9c1f04ab'); + assert.equal(name, 'verify-throwaway-until-20261006t0312z-9c1f04ab'); + assert.equal(deadlineOf(name)?.toISOString(), '2026-10-06T03:12:00.000Z'); + assert.equal(deadlineOf('verify-throwaway-9c1f04ab77e2'), null); + assert.equal(deadlineOf('my-app-until-20261006t0312z-9c1f04ab'), null); + }); +}); + +describe('platform credential', () => { + const open = (env: Record, clerk: FakeClerkOptions = {}, runner: Runner = noOp) => { + const fake = fakeClerk(clerk); + const lines: string[] = []; + const ran: string[][] = []; + const platform = createPlatform({ env, runner: (command, args, options) => (ran.push([command, ...args]), runner(command, args, options)), progress: (line) => lines.push(line), fetch: fake.fetch, sleep: async () => undefined }); + return { fake, lines, ran, platform }; + }; + const op = opAnswers; + const referenced = { VERIFY_PLATFORM_KEY_REFERENCE: REFERENCE }; + const homeWith = (text: string | null): string => { + const home = mkdtempSync(join(tmpdir(), 'verify-home-')); + if (text !== null) { + mkdirSync(join(home, '.verify')); + writeFileSync(join(home, '.verify', 'clerk-platform-key-reference'), text); + } + return home; + }; + const readsOf = (ran: readonly string[][]) => ran.filter((command) => command[1] === 'read'); + + it('uses the environment variable and asks 1Password nothing', async () => { + const { fake, ran, platform } = open({ CLERK_PLATFORM_API_KEY: PLATFORM_KEY }); + const workspace = await platform.open(); + assert.equal(workspace.credential.via, 'environment'); + assert.equal(workspace.workspace, WORKSPACE); + assert.deepEqual(fake.requests.map((r) => `${r.method} ${r.url}`), ['GET api.clerk.com/v1/platform/me', 'GET api.clerk.com/v1/platform/applications']); + assert.deepEqual(ran, []); + assert.equal(platform.requests(), 2); + }); + + it('fails on a set variable that does not work, with no fall-through', async () => { + const { ran, platform } = open({ CLERK_PLATFORM_API_KEY: 'ak_someOtherKey' }); + await assert.rejects(platform.open(), (error: VerifyFailure) => error.code === 'KEYS_MISSING' && /CLERK_PLATFORM_API_KEY is set/.test(error.message)); + assert.deepEqual(ran, []); + }); + + it('reads the key from a private file, and refuses one other users can read', async () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-key-')); + const file = join(dir, 'key'); + writeFileSync(file, `${PLATFORM_KEY}\n`, { mode: 0o600 }); + assert.equal((await open({ CLERK_PLATFORM_API_KEY_FILE: file }).platform.open()).credential.via, 'environment'); + chmodSync(file, 0o644); + await assert.rejects(open({ CLERK_PLATFORM_API_KEY_FILE: file }).platform.open(), (error: VerifyFailure) => error.fix === `chmod 600 ${file}`); + }); + + it('asks 1Password last, once, by reference, and never says what the reference is', async () => { + const { ran, lines, platform } = open(referenced, {}, op({ code: 0, stdout: PLATFORM_KEY, stderr: '' })); + const workspace = await platform.open(); + await platform.open(); + assert.deepEqual(Object.keys(workspace.credential).sort(), ['key', 'via']); + assert.equal(describeCredential(workspace.credential), '1Password'); + assert.deepEqual(readsOf(ran), [['op', 'read', '--no-newline', REFERENCE]], 'one read for the whole process'); + assert.equal(ran.flat().includes(PLATFORM_KEY), false, 'the key is never an argument'); + assert.deepEqual(lines, ['wait reading the team key from 1Password; approve the request in the 1Password app within 60s']); + }); + + it('prints no line that holds the reference when a command reads the key from 1Password', async () => { + const w = world({ env: referenced, runner: op({ code: 0, stdout: PLATFORM_KEY, stderr: '' }) }); + const up = await w.instances.ensure(NOTHING_NEW, w.say); + assert.ok(w.lines.includes(`instances 1Password reaches the verification workspace ${WORKSPACE}`)); + assert.ok(w.lines.length > 1); + assert.deepEqual([...w.lines, JSON.stringify(up)].filter(namesTheItem), []); + }); + + it('takes the reference from the variable first, then from the first line of the file under HOME', async () => { + const home = homeWith('\n op://file-vault/file-item/credential \nop://second-line/ignored/credential\n'); + const read = op({ code: 0, stdout: PLATFORM_KEY, stderr: '' }); + const both = open({ ...referenced, HOME: home }, {}, read); + await both.platform.open(); + assert.deepEqual(readsOf(both.ran), [['op', 'read', '--no-newline', REFERENCE]], 'the variable wins over the file'); + const fileOnly = open({ HOME: home }, {}, read); + await fileOnly.platform.open(); + assert.deepEqual(readsOf(fileOnly.ran), [['op', 'read', '--no-newline', 'op://file-vault/file-item/credential']]); + const blankVariable = open({ VERIFY_PLATFORM_KEY_REFERENCE: ' ', HOME: home }, {}, read); + await blankVariable.platform.open(); + assert.equal(readsOf(blankVariable.ran)[0]![3], 'op://file-vault/file-item/credential', 'an empty variable is not set'); + }); + + it('never runs op when no reference is set, and says how to set one without naming the item', async () => { + const unset: readonly Record[] = [{}, { HOME: homeWith(null) }, { HOME: homeWith('\n\n') }]; + for (const env of unset) { + const { ran, platform } = open(env, {}, op({ code: 0, stdout: PLATFORM_KEY, stderr: '' })); + await assert.rejects(platform.open(), (error: VerifyFailure) => { + assert.equal(error.code, 'KEYS_MISSING'); + assert.equal(error.message, 'no Clerk Platform API credential works here (CLERK_PLATFORM_API_KEY and CLERK_PLATFORM_API_KEY_FILE are not set; no 1Password reference is set)'); + assert.equal( + error.fix, + `on a Mac, put the team key's 1Password secret reference (shape ${REFERENCE_SHAPE}; the team's private setup note has the real one) in VERIFY_PLATFORM_KEY_REFERENCE or as the one line of ~/.verify/clerk-platform-key-reference, with the 1Password CLI installed and its desktop app integration on; anywhere, set CLERK_PLATFORM_API_KEY to the team key, or CLERK_PLATFORM_API_KEY_FILE to a file that holds it and that only you can read`, + ); + return true; + }); + assert.deepEqual(ran, []); + } + }); + + it('refuses a reference that is not an op:// reference, naming where it was and not what it was', async () => { + const bad = 'Fake Vault/fake item/credential'; + const file = homeWith(`${bad}\n`); + for (const [env, source] of [[{ VERIFY_PLATFORM_KEY_REFERENCE: bad }, 'VERIFY_PLATFORM_KEY_REFERENCE'], [{ HOME: file }, join(file, '.verify', 'clerk-platform-key-reference')]] as const) { + const { ran, platform } = open(env, {}, op({ code: 0, stdout: PLATFORM_KEY, stderr: '' })); + await assert.rejects(platform.open(), (error: VerifyFailure) => { + assert.equal(error.code, 'USAGE'); + assert.equal(error.message, `${source} does not hold a 1Password secret reference`); + assert.equal(error.fix, `put a reference of the shape ${REFERENCE_SHAPE} there, or remove it`); + return true; + }); + assert.deepEqual(ran, []); + } + }); + + it('does not fail over a malformed reference when a source it tries before 1Password works', async () => { + const malformed = { VERIFY_PLATFORM_KEY_REFERENCE: 'Fake Vault/fake item/credential' }; + const keyed = open({ ...malformed, CLERK_PLATFORM_API_KEY: PLATFORM_KEY }); + assert.equal((await keyed.platform.open()).credential.via, 'environment'); + assert.deepEqual(keyed.ran, []); + }); + + it('says which reference file it cannot read and what to do, and only when 1Password is the source left to try', async () => { + const home = homeWith(null); + const file = join(home, '.verify', 'clerk-platform-key-reference'); + mkdirSync(file, { recursive: true }); + const keyed = open({ HOME: home, CLERK_PLATFORM_API_KEY: PLATFORM_KEY }); + assert.equal((await keyed.platform.open()).credential.via, 'environment'); + const { ran, platform } = open({ HOME: home }, {}, op({ code: 0, stdout: PLATFORM_KEY, stderr: '' })); + await assert.rejects(platform.open(), (error: VerifyFailure) => { + assert.equal(error.code, 'USAGE'); + assert.equal(error.message, `${file} cannot be read (EISDIR)`); + assert.equal(error.fix, `make it readable by you alone (chmod 600 ${file}), or remove it`); + return true; + }); + assert.deepEqual(ran, []); + }); + + it('treats a missing op as no credential, and an unapproved read as an error', async () => { + await assert.rejects(open(referenced).platform.open(), (error: VerifyFailure) => error.code === 'KEYS_MISSING' && /op\) is not installed/.test(error.message) && error.fix.startsWith('on a Mac, install the 1Password CLI and turn on its desktop app integration; ') && !namesTheItem(error.fix)); + await assert.rejects(open(referenced, {}, op({ code: 124, stdout: '', stderr: '' })).platform.open(), (error: VerifyFailure) => error.message === 'op read of the 1Password item the reference names was not approved within 60s'); + await assert.rejects(open(referenced, {}, op({ code: 1, stdout: '', stderr: '[ERROR] authorization denied\nmore' })).platform.open(), (error: VerifyFailure) => error.message.endsWith('failed: [ERROR] authorization denied')); + }); + + it('does not print the reference, the vault, or the item when op repeats them in its error', async () => { + const reference = 'op://vault-only-here/item-only-here/credential'; + const stderr = `[ERROR] could not read secret '${reference}': "item-only-here" isn't an item in the "vault-only-here" vault\n`; + const { platform } = open({ VERIFY_PLATFORM_KEY_REFERENCE: reference }, {}, op({ code: 1, stdout: '', stderr })); + const failure = await platform.open().then(() => assert.fail('the read failed'), (error: VerifyFailure) => error); + assert.equal(failure.message, 'op read of the 1Password item the reference names failed: [ERROR] could not read secret \'\': "" isn\'t an item in the "" vault'); + let printed = ''; + const sink = { write: (text: string) => ((printed += text), true) }; + const output = createOutput(false, '/tmp', 'bin/control-x', sink, sink); + output.failure(failure); + output.progress(`op said ${stderr}`); + assert.equal(printed.includes(reference), false, 'the reference is a secret to every line the CLI prints'); + assert.match(printed, /op said \[ERROR\] could not read secret ''/); + }); + + it('calls a key from 1Password that fails the key in the item the reference names', async () => { + const elsewhere = open(referenced, { workspace: 'org_someRealWorkspace' }, op({ code: 0, stdout: PLATFORM_KEY, stderr: '' })); + await assert.rejects(elsewhere.platform.open(), (error: VerifyFailure) => error.message.startsWith('the key in the 1Password item the reference names belongs to workspace org_someRealWorkspace') && error.fix === 'use the team key; a key of any other workspace is refused'); + const stale = open(referenced, {}, op({ code: 0, stdout: 'ak_noLongerWorks', stderr: '' })); + await assert.rejects(stale.platform.open(), (error: VerifyFailure) => error.message === "Clerk's Platform API answered 401 could_not_authenticate_request to the key in the 1Password item the reference names" && !namesTheItem(`${error.message} ${error.fix}`)); + }); + + it('refuses a key of any other workspace before it lists or creates anything', async () => { + const { fake, platform } = open({ CLERK_PLATFORM_API_KEY: PLATFORM_KEY }, { workspace: 'org_someRealWorkspace' }); + await assert.rejects(platform.open(), (error: VerifyFailure) => error.code === 'KEYS_MISSING' && error.message.includes('org_someRealWorkspace') && error.message.includes(WORKSPACE)); + assert.deepEqual(fake.requests.map((r) => r.url), ['api.clerk.com/v1/platform/me']); + }); + + it('refuses a workspace that holds an application without the prefix', async () => { + const { fake, platform } = open({ CLERK_PLATFORM_API_KEY: PLATFORM_KEY }); + fake.plant('Production Dashboard'); + await assert.rejects(platform.open(), (error: VerifyFailure) => error.code === 'NOT_READY' && /does not start with verify-throwaway-/.test(error.message) && !error.message.includes('Production Dashboard')); + assert.ok(fake.requests.every((request) => request.method === 'GET')); + }); + + it('checks the workspace again before a create that comes long after the last check', async () => { + let at = T0; + const fake = fakeClerk({ now: () => at }); + const platform = createPlatform({ env: { CLERK_PLATFORM_API_KEY: PLATFORM_KEY }, runner: noOp, progress: () => undefined, fetch: fake.fetch, now: () => at }); + const workspace = await platform.open(); + await workspace.create(throwawayName(new Date(T0 + HOUR), 'aaaaaaaa')); + assert.equal(platform.requests(), 3, 'a create right after the check adds one request'); + at += 60_000; + fake.plant('Someone Else'); + await assert.rejects(workspace.create(throwawayName(new Date(T0 + HOUR), 'bbbbbbbb')), /does not start with verify-throwaway-/); + assert.equal(fake.live().length, 2, 'nothing was created after the workspace changed'); + }); + + it('waits out a rate limit for as long as Clerk asks, then succeeds', async () => { + const fake = fakeClerk(); + const waits: number[] = []; + const lines: string[] = []; + const platform = createPlatform({ env: { CLERK_PLATFORM_API_KEY: PLATFORM_KEY }, runner: noOp, progress: (line) => lines.push(line), fetch: fake.fetch, sleep: async (ms) => void waits.push(ms) }); + fake.rateLimit(2, '7'); + await platform.open(); + assert.deepEqual(waits, [7000, 7000]); + assert.equal(lines.filter((line) => line.startsWith('wait Clerk\'s Platform API is rate limiting')).length, 2); + fake.rateLimit(1); + waits.length = 0; + await (await platform.open()).list(); + assert.deepEqual(waits, [2000], 'with no Retry-After it backs off on its own schedule'); + fake.rateLimit(1, '3600'); + waits.length = 0; + await (await platform.open()).list(); + assert.deepEqual(waits, [60_000], 'and never sleeps longer than a minute on Clerk\'s word'); + }); + + it('gives up on a rate limit that does not lift, with an error a caller may retry', async () => { + const fake = fakeClerk(); + const platform = createPlatform({ env: { CLERK_PLATFORM_API_KEY: PLATFORM_KEY }, runner: noOp, progress: () => undefined, fetch: fake.fetch, sleep: async () => undefined }); + fake.rateLimit(50); + await assert.rejects(platform.open(), (error: VerifyFailure) => error.code === 'RATE_LIMITED' && RETRYABLE.has(error.code)); + assert.ok(fake.requests.length > 1 && fake.requests.length <= 10, `it retried a bounded number of times (${fake.requests.length} requests)`); + }); + + it('refuses a list that is not one array, because a partial list would hide what the workspace holds', async () => { + for (const shape of ['envelope', 'unreadable'] as const) { + const { fake, platform } = open({ CLERK_PLATFORM_API_KEY: PLATFORM_KEY }); + fake.plant(throwawayName(new Date(T0), 'aaaaaaaa')); + fake.plant('Production Dashboard'); + fake.state.listShape = shape; + fake.state.pageSize = 1; + await assert.rejects(platform.open(), /no longer has a shape this tool can read in full/, shape); + assert.ok(fake.requests.every((request) => request.method === 'GET')); + } + }); + + it('checks the workspace again before a delete that comes long after the last check', async () => { + let at = T0; + const fake = fakeClerk({ now: () => at }); + const platform = createPlatform({ env: { CLERK_PLATFORM_API_KEY: PLATFORM_KEY }, runner: noOp, progress: () => undefined, fetch: fake.fetch, now: () => at }); + const workspace = await platform.open(); + const created = await workspace.create(throwawayName(new Date(T0 + HOUR), 'aaaaaaaa')); + at += 60_000; + fake.plant('Someone Else'); + await assert.rejects(workspace.delete(created.application), /does not start with verify-throwaway-/); + assert.equal(fake.live().length, 2, 'nothing was deleted after the workspace changed'); + }); + + it('cannot be asked to delete a name without the prefix', () => { + assert.throws(() => throwawayApplication('app_1', 'Production Dashboard'), /lacks the verify-throwaway- prefix/); + }); +}); + +describe('what Clerk says to a config change', () => { + const opened = async () => { + const fake = fakeClerk(); + const platform = createPlatform({ env: { CLERK_PLATFORM_API_KEY: PLATFORM_KEY }, runner: noOp, progress: quiet, fetch: fake.fetch, sleep: async () => undefined }); + const workspace = await platform.open(); + const created = await workspace.create(throwawayName(new Date(T0 + HOUR), 'aaaaaaaa')); + return { fake, workspace, created }; + }; + + it('answers with the whole object of each key the body named, and a dry run changes nothing', async () => { + const { fake, workspace, created } = await opened(); + const dry = await workspace.configure(created, MFA.config, { dryRun: true }); + assert.deepEqual(dry.after, { auth_multi_factor: { required_for_sign_up: true } }); + assert.equal(fake.live()[0]!.environment['user_settings.sign_up.mfa.required'], false); + await workspace.configure(created, standardFile().config); + const real = await workspace.configure(created, MFA.config); + assert.deepEqual(real.after, { auth_multi_factor: { ...(standardFile().config.auth_multi_factor as object), required_for_sign_up: true } }, 'a PATCH merges into what the instance has'); + assert.equal(fake.live()[0]!.environment['user_settings.sign_up.mfa.required'], true); + }); + + it('turns a refused body into a refusal that names the key when Clerk names it', async () => { + const { fake, workspace, created } = await opened(); + fake.state.refusals.push({ path: 'auth_email.no_such_toggle', status: 400, code: 'unknown_config_key', param: 'auth_email.no_such_toggle', message: 'is not a config key' }); + await assert.rejects(workspace.configure(created, { auth_email: { no_such_toggle: true } }), (error: SettingsRefused) => error instanceof SettingsRefused && error.code === 'INSTANCE_MISCONFIGURED' && error.param === 'auth_email.no_such_toggle' && error.said === 'auth_email.no_such_toggle (400 unknown_config_key): is not a config key'); + fake.state.refusals.push({ path: 'auth_attack_protection.pii_protection_enabled', value: false, status: 409, code: 'user_settings_invalid', message: 'the settings are not valid together' }); + await assert.rejects(workspace.configure(created, { auth_attack_protection: { pii_protection_enabled: false } }), (error: SettingsRefused) => error instanceof SettingsRefused && error.param === null && error.said === '409 user_settings_invalid: the settings are not valid together'); + fake.state.refusals.push({ path: 'compliance.legal_consent.enabled', value: true, status: 422, code: 'form_param_missing', param: ['terms_of_service_url', 'privacy_policy_url'], message: 'is required' }); + await assert.rejects(workspace.configure(created, { compliance: { legal_consent: { enabled: true } } }), (error: SettingsRefused) => error instanceof SettingsRefused && error.param === 'terms_of_service_url' && error.said === 'terms_of_service_url (422 form_param_missing): is required', 'any 4xx that is about the body is a refusal, and a code two errors share is said once'); + assert.deepEqual(fake.live()[0]!.config, {}, 'a refused body applied nothing'); + }); + + it('reads a 4xx that names a parameter as a refusal of the body whatever its status, except a rate limit', async () => { + const { fake, workspace, created } = await opened(); + for (const status of [401, 403, 404, 408]) { + fake.state.refusals = [{ path: 'auth_email.no_such_toggle', status, code: 'form_param_unknown', param: 'no_such_toggle', message: 'is unknown' }]; + await assert.rejects(workspace.configure(created, { auth_email: { no_such_toggle: true } }), (error: SettingsRefused) => error instanceof SettingsRefused && error.param === 'no_such_toggle' && error.said === `no_such_toggle (${status} form_param_unknown): is unknown`, String(status)); + fake.state.refusals = [{ path: 'auth_email.no_such_toggle', status, code: 'not_about_the_body', message: 'names no parameter' }]; + await assert.rejects(workspace.configure(created, { auth_email: { no_such_toggle: true } }), (error: VerifyFailure) => !(error instanceof SettingsRefused) && error.code === 'NOT_READY' && error.message.includes(`answered ${status} not_about_the_body`), String(status)); + } + fake.state.refusals = [{ path: 'auth_email.no_such_toggle', status: 429, code: 'too_many_requests', param: 'no_such_toggle', message: 'slow down' }]; + await assert.rejects(workspace.configure(created, { auth_email: { no_such_toggle: true } }), (error: VerifyFailure) => !(error instanceof SettingsRefused) && error.code === 'RATE_LIMITED'); + }); + + it('keeps an outage, a rate limit, and a credential problem what they were', async () => { + const { fake, workspace, created } = await opened(); + fake.state.failConfigure = 1; + await assert.rejects(workspace.configure(created, MFA.config), (error: VerifyFailure) => !(error instanceof SettingsRefused) && error.code === 'NOT_READY' && /answered 500/.test(error.message)); + fake.rateLimit(50); + await assert.rejects(workspace.configure(created, MFA.config), (error: VerifyFailure) => !(error instanceof SettingsRefused) && error.code === 'RATE_LIMITED'); + fake.rateLimit(0); + fake.live()[0]!.deleted = true; + await assert.rejects(workspace.configure(created, MFA.config), (error: VerifyFailure) => !(error instanceof SettingsRefused) && /answered 404/.test(error.message), 'an application that is gone is not a refused declaration'); + }); +}); + +describe('one throwaway application', () => { + it('is created by up, put on the whole standard config with one PATCH, and recorded', async () => { + const w = world(); + const up = await w.instances.ensure(NOTHING_NEW, w.say); + const application = w.clerk.live()[0]!; + assert.deepEqual(up, [{ id: application.id, name: application.name, created: true, settings: 'standard' }]); + assert.deepEqual(platformCalls(w), ['GET /me', 'GET /applications', 'POST /applications', 'PATCH /applications/{app}/instances/{ins}/config']); + assert.deepEqual(patches(w)[0]!.body, standardFile().config); + assert.equal(deadlineOf(application.name)?.getTime(), T0 + 6 * HOUR); + assert.ok(w.lines.includes(`instance creating ${application.name} in ${WORKSPACE}`)); + assert.ok(w.lines.includes(`instance ${application.id} up in 0.0s on standard, 212 settings match ${STANDARD_FILE}`), w.lines.join('\n')); + assert.ok(w.lines.includes('clerk Backend API on api.clerk.com')); + assert.ok(w.lines.includes('clerk Platform API: 4 requests by this command so far')); + assert.deepEqual(stateOf(w, application.name), { environmentKey: STANDARD_ENVIRONMENT_KEY, settings: STANDARD, drift: [], drivers: [] }); + assert.equal(w.instances.recordedKey(), STANDARD.key); + }); + + it('makes no Platform API request when it is already up, and says what it is on', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const before = w.clerk.platformRequests().length; + const again = await w.open().ensure(NOTHING_NEW, w.say); + assert.equal(w.clerk.platformRequests().length, before); + assert.deepEqual(again.map((view) => [view.created, view.settings]), [[false, 'standard']]); + assert.ok(w.lines.includes('instances this worktree already holds throwaway instances')); + }); + + it('opens the credential when the run will change settings, though nothing needs creating', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const before = w.clerk.platformRequests().length; + await w.open().ensure({ willChange: true }, quiet); + assert.deepEqual(platformCalls(w).slice(before), ['GET /me', 'GET /applications'], 'a missing credential or a 1Password prompt would have come here, before any device is leased'); + const noCredential = createInstances({ workspace: w.workspace, env: {}, runner: noOp, progress: quiet, fetch: w.clerk.fetch }); + await assert.rejects(noCredential.ensure({ willChange: true }, quiet), (error: VerifyFailure) => error.code === 'KEYS_MISSING'); + await noCredential.ensure(NOTHING_NEW, quiet); + }); + + it('writes the name to the ledger before the create call, so a lost answer is still deleted', async () => { + const w = world(); + w.clerk.state.loseCreateAnswer = true; + await assert.rejects(w.instances.ensure(NOTHING_NEW, quiet), /answered 500/); + const [entry] = openApplications(w.workspace); + assert.equal(w.clerk.live()[0]?.name, entry?.name, 'Clerk has the application and the ledger has its name'); + assert.equal('instance' in (entry ?? {}), false, 'a new entry names no instance'); + w.clerk.state.loseCreateAnswer = false; + const finished = await w.open().finish(w.workspace, { keepApplications: false }, quiet); + assert.deepEqual(finished, [{ name: entry?.name }]); + assert.equal(w.clerk.live().length, 0); + assert.deepEqual(openApplications(w.workspace), []); + }); + + it('replaces an application whose keys were lost instead of leaking it', async () => { + const w = world(); + w.clerk.state.loseCreateAnswer = true; + await assert.rejects(w.instances.ensure(NOTHING_NEW, quiet)); + w.clerk.state.loseCreateAnswer = false; + const lost = w.clerk.live()[0]!.id; + const up = await w.open().ensure(NOTHING_NEW, w.say); + assert.deepEqual(w.clerk.live().map((a) => a.id), [idOf(up[0])]); + assert.notEqual(idOf(up[0]), lost); + assert.equal(openApplications(w.workspace).length, 1); + assert.ok(w.lines.some((line) => line.includes('retired (its keys are lost)'))); + }); + + it('recovers from a crash between create and configure by configuring the same application again', async () => { + const w = world(); + w.clerk.state.failConfigure = 1; + await assert.rejects(w.instances.ensure(NOTHING_NEW, quiet), /configuring verify-throwaway-/); + const created = w.clerk.live()[0]!; + assert.equal(created.environment['user_settings.password_settings.min_length'], 15, 'still as Clerk made it'); + assert.equal(w.open().recordedKey(), null); + const up = await w.open().ensure(NOTHING_NEW, w.say); + assert.deepEqual(up.map((view) => [idOf(view), view.created]), [[created.id, false]]); + assert.equal(w.clerk.applications.length, 1, 'no second application'); + assert.equal(created.environment['user_settings.password_settings.min_length'], 8); + assert.ok(w.lines.includes(`settings changing ${created.id} from unknown settings to standard, because what it is on is not recorded`)); + assert.ok(w.lines.some((line) => line.startsWith(`settings standard on ${created.id} in 0.0s (Clerk answered in 0.00s, the instance showed it 0.00s later), 212 settings match`))); + }); + + it('fails when a required setting does not show after the PATCH', async () => { + const w = world(); + w.clerk.state.ignoreConfigKey = 'auth_config.single_session_mode'; + await assert.rejects(w.instances.ensure(NOTHING_NEW, quiet), (error: VerifyFailure) => error.code === 'INSTANCE_MISCONFIGURED' && !(error instanceof SettingsRefused) && error.message.includes(`does not match ${STANDARD_FILE}`) && error.message.includes('auth_config.single_session_mode is true, and the file says false')); + assert.ok(w.slept.length > 0, 'it waited for the setting to take effect before failing'); + assert.equal(openApplications(w.workspace).length, 1, 'the application stays in the ledger for down'); + assert.equal(w.open().recordedKey(), null, 'and nothing records it as being on the standard settings'); + }); + + it('records what a new application shows differently from the defaults, and ignores those leaves from then on', async () => { + const w = world(); + w.clerk.state.newApplicationDefaults = { 'auth_config.reverification': false }; + await w.instances.ensure(NOTHING_NEW, w.say); + const application = w.clerk.live()[0]!; + assert.deepEqual(stateOf(w, application.name).drift, ['auth_config.reverification']); + assert.ok(w.lines.includes(`instance ${application.id} up in 0.0s on standard, 211 settings match ${STANDARD_FILE}`)); + const applied = await w.instances.apply(mfaGroup, w.say); + assert.equal(await applied.stillApplied(), true, 'a default Clerk changed does not fail a run'); + await applied.release(); + assert.equal((await w.open().ensure(NOTHING_NEW, quiet)).length, 1); + assert.equal(patches(w).length, 2, 'the drift caused no repair'); + assert.ok(w.lines.some((line) => line.startsWith(`settings ${MFA_LABEL} on ${application.id}`) && line.endsWith('211 settings match'))); + }); + + it('compares a leaf the declaration names even when the application drifted in it', async () => { + const w = world(); + w.clerk.state.newApplicationDefaults = { 'auth_config.reverification': false }; + await w.instances.ensure(NOTHING_NEW, quiet); + const application = w.clerk.live()[0]!; + assert.deepEqual(stateOf(w, application.name).drift, ['auth_config.reverification']); + const declared: InstanceSettings = { config: MFA.config, environment: { ...MFA.environment, 'auth_config.reverification': false } }; + const applied = await w.instances.apply(groupOf(declared, MFA_SPEC), w.say); + assert.ok(w.lines.some((line) => line.startsWith(`settings ${MFA_LABEL} on ${application.id}`) && line.endsWith('212 settings match')), 'the drifted leaf is one of the settings compared'); + assert.equal(await applied.stillApplied(), true); + application.environment['auth_config.reverification'] = true; + assert.equal(await applied.stillApplied(), false, 'drift excuses only a leaf no declaration names'); + }); + + it('never writes a keys file over one that is there', async () => { + const w = world(); + const first = 'the first write\n'; + let cachedKeys = ''; + const writeFirst = (line: string) => { + const creating = /^instance creating (\S+) in /.exec(line); + if (creating === null) return; + cachedKeys = join(instancesDir(w), `${creating[1]}.json`); + mkdirSync(instancesDir(w), { recursive: true }); + writeFileSync(cachedKeys, first); + }; + await assert.rejects(w.instances.ensure(NOTHING_NEW, writeFirst), { code: 'EEXIST' }); + assert.equal(readFileSync(cachedKeys, 'utf8'), first); + }); + + it('creates a new application when Clerk no longer serves the old one', async () => { + const w = world(); + const first = await w.instances.ensure(NOTHING_NEW, quiet); + w.clerk.live()[0]!.deleted = true; + const second = await w.open().ensure(NOTHING_NEW, w.say); + assert.notEqual(idOf(second[0]), idOf(first[0])); + assert.equal(openApplications(w.workspace).length, 1); + assert.ok(w.lines.includes(`instance ${idOf(first[0])} retired (Clerk no longer serves it)`)); + }); + + it('replaces an application whose deadline is near, before a run starts on it', async () => { + const w = world(); + const first = await w.instances.ensure(NOTHING_NEW, quiet); + w.clock.at = T0 + 6 * HOUR - 20 * 60_000; + const second = await w.open().ensure(NOTHING_NEW, w.say); + assert.notEqual(idOf(second[0]), idOf(first[0])); + assert.deepEqual(w.clerk.live().map((a) => a.id), [idOf(second[0])], 'the old one was deleted, not left for the reaper'); + assert.ok(w.lines.some((line) => line.includes('its deadline is near'))); + }); + + it('replaces an application that holds 60 of the 100 users a development instance allows', async () => { + const w = world(); + const first = await w.instances.ensure(NOTHING_NEW, quiet); + w.clerk.live()[0]!.users = 59; + assert.equal(idOf((await w.open().ensure(NOTHING_NEW, quiet))[0]), idOf(first[0]), '59 users is still room for a run'); + w.clerk.live()[0]!.users = 60; + const second = await w.open().ensure(NOTHING_NEW, w.say); + assert.notEqual(idOf(second[0]), idOf(first[0])); + assert.deepEqual(w.clerk.live().map((a) => a.id), [idOf(second[0])]); + assert.ok(w.lines.includes(`instance ${idOf(first[0])} retired (it holds 60 of the 100 users a development instance allows)`)); + }); + + it('deletes an application it retires by its id when the listing it holds was taken before the application existed', async () => { + const w = world(); + const first = await w.instances.ensure(NOTHING_NEW, quiet); + w.clerk.live()[0]!.users = 60; + const stale = (async (input: string | URL, init?: RequestInit) => ((init?.method ?? 'GET') === 'GET' && String(input).endsWith('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/platform/applications') ? new Response('[]', { status: 200 }) : w.clerk.fetch(input, init))) as typeof fetch; + const second = await w.open(1000, stale).ensure(NOTHING_NEW, w.say); + assert.ok(w.lines.includes(`instance ${idOf(first[0])} retired (it holds 60 of the 100 users a development instance allows)`)); + assert.deepEqual(w.clerk.live().map((a) => a.id), [idOf(second[0])], 'the retired application is gone from Clerk, not only from the ledger'); + }); + + it('does not treat a Frontend API error as a deleted application', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const broken = (async (input: string | URL, init?: RequestInit) => (String(input).includes('.clerk.accounts.dev') ? new Response('bad gateway', { status: 502 }) : w.clerk.fetch(input, init))) as typeof fetch; + await assert.rejects(w.open(1000, broken).ensure(NOTHING_NEW, quiet), /answered 502/); + assert.equal(w.clerk.live().length, 1); + }); + + it('sets the deadline by Clerk\'s clock when this machine\'s clock is wrong', async () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-instances-')); + const clerk = fakeClerk({ now: () => T0 }); + const instances = createInstances({ workspace: openWorkspace({ skillDir: dir, worktree: dir, home: join(dir, 'home') }), env: { CLERK_PLATFORM_API_KEY: PLATFORM_KEY }, runner: noOp, progress: quiet, fetch: clerk.fetch, now: () => T0 - 5 * HOUR }); + await instances.ensure(NOTHING_NEW, quiet); + assert.equal(deadlineOf(clerk.live()[0]!.name)?.getTime(), T0 + 6 * HOUR); + }); + + it('waits for a new key to be accepted, and fails with the credential fix when neither API name ever accepts it', async () => { + const slow = world(); + slow.clerk.state.keyRefusedTimes = 4; + await slow.instances.ensure(NOTHING_NEW, slow.say); + assert.ok(slow.lines.includes('clerk Backend API on api.clerk.com')); + assert.ok(slow.slept.filter((ms) => ms === 1000).length >= 2, 'it waited between tries'); + + const never = world(); + never.clerk.state.refuseInstanceKeys = true; + await assert.rejects(never.instances.ensure(NOTHING_NEW, quiet), (error: VerifyFailure) => error.code === 'NOT_READY' && /neither api\.clerk\.com nor api\.clerk\.dev accepts/.test(error.message) && error.fix.includes('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/v1/platform/') && error.fix.includes('api.clerk.dev')); + assert.equal(openApplications(never.workspace).length, 1, 'the application stays in the ledger for down'); + }); + + it('waits the same way for the key of an application it creates while it applies a group', async () => { + const slow = world(); + slow.clerk.state.keyRefusedTimes = 4; + await slow.instances.apply(mfaGroup, slow.say); + assert.ok(slow.lines.includes('clerk Backend API on api.clerk.com'), slow.lines.join('\n')); + assert.ok(slow.slept.filter((ms) => ms === 1000).length >= 2, 'it waited between tries'); + assert.equal(await slow.instances.clerk().userCount(), 0, 'the first call a spec makes finds the key accepted'); + + const never = world(); + never.clerk.state.refuseInstanceKeys = true; + await assert.rejects(never.instances.apply(mfaGroup, quiet), (error: VerifyFailure) => error.code === 'NOT_READY' && /neither api\.clerk\.com nor api\.clerk\.dev accepts the own secret key of app_fake1/.test(error.message)); + }); + + it('rejects a lifetime shorter than a session can last', async () => { + const w = world({ env: { CLERK_PLATFORM_API_KEY: PLATFORM_KEY, VERIFY_THROWAWAY_HOURS: '0' } }); + await assert.rejects(w.instances.ensure(NOTHING_NEW, quiet), (error: VerifyFailure) => error.code === 'USAGE'); + assert.equal(w.clerk.applications.length, 0); + }); + + it('serves keys only while an instance is applied', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + assert.throws(() => w.instances.keys(), (error: VerifyFailure) => error.code === 'NOT_READY' && /no instance is applied/.test(error.message)); + assert.throws(() => w.instances.clerk(), /no instance is applied/); + const applied = await w.instances.apply(standardGroup, quiet); + assert.equal(w.instances.keys().pk, w.clerk.live()[0]!.pk); + assert.equal(await w.instances.clerk().userCount(), 0); + await assert.rejects(w.instances.apply(mfaGroup, quiet), /still applied/, 'one run drives on one instance at a time'); + await applied.release(); + assert.throws(() => w.instances.keys(), /no instance is applied/); + }); + + it('keeps keys and state in private files, never rewrites the keys file, and keeps the platform key nowhere', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, w.say); + const dir = instancesDir(w); + const cachedKeys = join(dir, `${w.clerk.live()[0]!.name}.json`); + const written = { bytes: readFileSync(cachedKeys, 'utf8'), inode: statSync(cachedKeys).ino }; + for (const group of [mfaGroup, orgGroup, standardGroup]) await (await w.instances.apply(group, w.say)).release(); + await w.open().ensure({ willChange: true }, w.say); + assert.deepEqual({ bytes: readFileSync(cachedKeys, 'utf8'), inode: statSync(cachedKeys).ino }, written, 'the only copy of the secret key is written once'); + assert.deepEqual(readdirSync(dir).sort(), [`${w.clerk.live()[0]!.name}.json`, `${w.clerk.live()[0]!.name}.state.json`], 'no staging file is left behind'); + for (const file of readdirSync(dir)) assert.equal(statSync(join(dir, file)).mode & 0o777, 0o600); + const everything = [...w.lines, readFileSync(w.workspace.ledgerFile, 'utf8'), ...readdirSync(dir).map((file) => readFileSync(join(dir, file), 'utf8'))].join('\n'); + assert.equal(everything.includes(PLATFORM_KEY), false); + assert.equal(readFileSync(w.workspace.ledgerFile, 'utf8').includes('sk_test_'), false); + assert.equal(readFileSync(join(dir, `${w.clerk.live()[0]!.name}.state.json`), 'utf8').includes('sk_test_'), false); + assert.equal(redact(`leaked ${PLATFORM_KEY}`), 'leaked ', 'a used platform key is redacted from any output line'); + }); +}); + +describe('putting the application on the settings a group declares', () => { + it('drives three groups on one application with one create and three PATCHes in all', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, w.say); + const application = w.clerk.live()[0]!; + const seen: (readonly [string, boolean, unknown, unknown])[] = []; + for (const group of [standardGroup, mfaGroup, orgGroup]) { + const applied = await w.instances.apply(group, w.say); + assert.deepEqual(applied.instance, { id: application.id, name: application.name }); + assert.equal(applied.keys.pk, application.pk); + seen.push([group.settings.label, applied.changed !== null, application.environment['user_settings.sign_up.mfa.required'], application.environment['organization_settings.force_organization_selection']]); + assert.equal(await applied.stillApplied(), true); + await applied.release(); + } + assert.deepEqual(seen, [['standard', false, false, false], [MFA_LABEL, true, true, false], [ORG_LABEL, true, false, true]], 'the next group returns what the last one changed to standard'); + assert.deepEqual(writes(w), ['POST /applications', `PATCH /applications/${application.id}/config`, `PATCH /applications/${application.id}/config`, `PATCH /applications/${application.id}/config`]); + assert.equal(w.clerk.applications.length, 1, 'never a second application'); + assert.deepEqual(patches(w)[1]!.body, MFA_BODY); + assert.deepEqual((patches(w)[2]!.body as { auth_multi_factor: unknown }).auth_multi_factor, standardFile().config.auth_multi_factor, 'the body is always the whole standard config with the declaration laid over it'); + assert.deepEqual(stateOf(w, application.name).settings, orgGroup.settings); + assert.ok(w.lines.includes(`settings standard already on ${application.id}`)); + assert.ok(w.lines.includes(`settings changing ${application.id} from standard to ${MFA_LABEL}, which ${MFA_SPEC} declares`)); + assert.ok(w.lines.includes(`settings ${MFA_LABEL} on ${application.id} in 0.0s (Clerk answered in 0.00s, the instance showed it 0.00s later), 212 settings match`)); + assert.ok(w.lines.includes(`settings changing ${application.id} from ${MFA_LABEL} to ${ORG_LABEL}, which ${ORG_SPEC} declares`)); + assert.equal(w.lines.filter((line) => line.startsWith('clerk Platform API: ')).length, 3, 'a request count after up and after each change'); + }); + + it('returns to standard for the first spec of a standard group, and a later run finds the settings recorded', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const id = w.clerk.live()[0]!.id; + await (await w.instances.apply(mfaGroup, quiet)).release(); + const later = w.open(); + assert.equal(later.recordedKey(), mfaGroup.settings.key); + assert.deepEqual((await later.ensure(NOTHING_NEW, quiet)).map((view) => view.settings), [MFA_LABEL], 'up leaves the settings alone and says what they are'); + const before = patches(w).length; + await (await later.apply(mfaGroup, w.say)).release(); + assert.equal(patches(w).length, before, 'the same settings again cost no PATCH'); + await (await later.apply(standardGroup, w.say)).release(); + assert.ok(w.lines.includes(`settings changing ${id} from ${MFA_LABEL} to standard, for ${STANDARD_SPEC}`)); + assert.equal(w.clerk.live()[0]!.environment['user_settings.sign_up.mfa.required'], false); + }); + + it('sends the PATCH again after a crash between the PATCH and the record', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const application = w.clerk.live()[0]!; + let patched = false; + const dying = (async (input: string | URL, init?: RequestInit) => { + if (patched && String(input).includes('.clerk.accounts.dev')) throw new Error('the process died here'); + patched ||= init?.method === 'PATCH'; + return w.clerk.fetch(input, init); + }) as typeof fetch; + await assert.rejects(w.open(1000, dying).apply(mfaGroup, quiet), /the process died here/); + assert.equal(application.environment['user_settings.sign_up.mfa.required'], true, 'Clerk applied the change'); + assert.equal(stateOf(w, application.name).settings, undefined, 'and nothing records it'); + const before = patches(w).length; + await (await w.open().apply(mfaGroup, w.say)).release(); + assert.equal(patches(w).length, before + 1); + assert.ok(w.lines.includes(`settings changing ${application.id} from unknown settings to ${MFA_LABEL}, which ${MFA_SPEC} declares`)); + assert.equal(w.open().recordedKey(), mfaGroup.settings.key); + }); + + it('repairs recorded settings that the live environment contradicts', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const application = w.clerk.live()[0]!; + application.environment['user_settings.sign_up.mfa.required'] = true; + await w.open().ensure(NOTHING_NEW, w.say); + assert.ok(w.lines.includes(`settings changing ${application.id} from standard to standard, because it no longer shows standard`)); + assert.equal(application.environment['user_settings.sign_up.mfa.required'], false); + + await (await w.open().apply(mfaGroup, quiet)).release(); + application.environment['user_settings.sign_up.mfa.required'] = false; + const before = patches(w).length; + const applied = await w.open().apply(mfaGroup, quiet); + assert.equal(patches(w).length, before + 1, 'a record is trusted only after the live environment agrees'); + assert.notEqual(applied.changed, null); + assert.equal(application.environment['user_settings.sign_up.mfa.required'], true); + }); + + it('says after a group whether the settings still hold', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const applied = await w.instances.apply(mfaGroup, quiet); + assert.equal(await applied.stillApplied(), true); + w.clerk.live()[0]!.environment['user_settings.sign_up.mfa.required'] = false; + assert.equal(await applied.stillApplied(), false); + }); + + it('never retires an application over a torn or missing state file, and repairs it instead', async () => { + for (const damage of ['torn', 'missing'] as const) { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const application = w.clerk.live()[0]!; + const stateFile = join(instancesDir(w), `${application.name}.state.json`); + if (damage === 'torn') writeFileSync(stateFile, '{"settings":{"key":"'); + else rmSync(stateFile); + const keys = readFileSync(join(instancesDir(w), `${application.name}.json`), 'utf8'); + assert.equal(w.open().recordedKey(), null, damage); + const up = await w.open().ensure(NOTHING_NEW, quiet); + assert.deepEqual(up.map((view) => [idOf(view), view.created]), [[application.id, false]], damage); + assert.deepEqual(writes(w).slice(1), [`PATCH /applications/${application.id}/config`, `PATCH /applications/${application.id}/config`], `${damage}: one PATCH to create it and one to repair it, and no delete`); + assert.equal(readFileSync(join(instancesDir(w), `${application.name}.json`), 'utf8'), keys, damage); + assert.equal(w.open().recordedKey(), STANDARD.key, damage); + } + }); + + it('treats a record made against another standard file as no record', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const name = w.clerk.live()[0]!.name; + writeFileSync(join(instancesDir(w), `${name}.state.json`), JSON.stringify({ settings: { ...STANDARD, key: '000000000000' }, drift: [], drivers: [] })); + assert.equal(w.open().recordedKey(), null); + }); + + it('derives drift again for an application recorded against another standard environment, and does not fail on what that file expected', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const application = w.clerk.live()[0]!; + const OTHER_RUN = 4242; + writeFileSync(join(instancesDir(w), `${application.name}.state.json`), JSON.stringify({ environmentKey: '000000000000', settings: STANDARD, drift: [], drivers: [{ pid: OTHER_RUN, startedAt: T0 }] })); + application.environment['auth_config.reverification'] = false; + assert.equal(w.open().recordedKey(), null, 'what the application is on is unknown'); + + w.alive.add(OTHER_RUN); + const before = patches(w).length; + await w.open().ensure(NOTHING_NEW, quiet); + assert.equal(patches(w).length, before, 'the run recorded as driving on it is still believed'); + + w.alive.clear(); + await w.open().ensure(NOTHING_NEW, w.say); + assert.ok(w.lines.includes(`settings changing ${application.id} from unknown settings to standard, because what it is on is not recorded`)); + assert.deepEqual(stateOf(w, application.name), { environmentKey: STANDARD_ENVIRONMENT_KEY, settings: STANDARD, drift: ['auth_config.reverification'], drivers: [{ pid: OTHER_RUN, startedAt: T0 }] }); + }); + + it('puts an application whose state file is lost on the standard file first, records its drift, then moves it', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const application = w.clerk.live()[0]!; + rmSync(join(instancesDir(w), `${application.name}.state.json`)); + application.environment['auth_config.reverification'] = false; + const before = patches(w).length; + await (await w.open().apply(mfaGroup, quiet)).release(); + assert.deepEqual(patches(w).slice(before).map((patch) => patch.body), [standardFile().config, MFA_BODY]); + const state = stateOf(w, application.name); + assert.deepEqual([state.settings, state.drift], [mfaGroup.settings, ['auth_config.reverification']]); + assert.equal(application.environment['user_settings.sign_up.mfa.required'], true); + }); + + it('drives and deletes three applications the worktree holds with no state file, without creating one', async () => { + const w = world(); + const old = [heldWithoutState(w, 'aaaaaaaa'), heldWithoutState(w, 'bbbbbbbb'), heldWithoutState(w, 'cccccccc')]; + const instances = w.open(); + const up = await instances.ensure(NOTHING_NEW, w.say); + assert.deepEqual(up.map((view) => [idOf(view), view.created, view.settings]), old.map((application) => [application.id, false, 'standard'])); + const applied = await instances.apply(mfaGroup, quiet); + assert.ok(old.some((application) => application.id === applied.instance.id)); + await applied.release(); + const finished = await w.open().finish(w.workspace, { keepApplications: false }, quiet); + assert.deepEqual(finished.map((application) => application.name).sort(), old.map((application) => application.name).sort()); + assert.equal(w.clerk.live().length, 0); + assert.equal(writes(w).some((call) => call.startsWith('POST')), false, 'no create'); + assert.deepEqual(readdirSync(instancesDir(w)), []); + }); +}); + +describe('two runs in one worktree', () => { + const FIRST = 1000; + const SECOND = 2000; + + it('gives a run with other settings its own application while the first still drives, and down deletes both', async () => { + const w = world(); + const first = w.open(FIRST); + await first.ensure(NOTHING_NEW, quiet); + const held = await first.apply(mfaGroup, quiet); + w.alive.add(FIRST); + const second = w.open(SECOND); + await second.ensure({ willChange: true }, quiet); + const own = await second.apply(orgGroup, w.say); + assert.notDeepEqual(own.instance, held.instance); + assert.equal(w.clerk.live().length, 2); + const [one, two] = w.clerk.live(); + assert.ok(w.lines.includes(`settings ${ORG_LABEL} needs its own application, because ${one!.id} is driving ${MFA_LABEL} for another run in this worktree`)); + assert.deepEqual([one!.environment['user_settings.sign_up.mfa.required'], one!.environment['organization_settings.force_organization_selection']], [true, false], 'the first run\'s instance was not touched'); + assert.deepEqual([two!.environment['user_settings.sign_up.mfa.required'], two!.environment['organization_settings.force_organization_selection']], [false, true]); + assert.deepEqual(stateOf(w, two!.name).drift, [], 'a new application goes on the standard file first, so its drift is known'); + assert.deepEqual(writes(w).slice(-3), ['POST /applications', `PATCH /applications/${two!.id}/config`, `PATCH /applications/${two!.id}/config`]); + assert.equal(await held.stillApplied(), true); + await own.release(); + await held.release(); + w.alive.clear(); + await w.open().finish(w.workspace, { keepApplications: false }, quiet); + assert.equal(w.clerk.live().length, 0); + assert.deepEqual(openApplications(w.workspace), []); + }); + + it('takes over the application of a run that died', async () => { + const w = world(); + const first = w.open(FIRST); + await first.ensure(NOTHING_NEW, quiet); + await first.apply(mfaGroup, quiet); + const application = w.clerk.live()[0]!; + assert.deepEqual(stateOf(w, application.name).drivers, [{ pid: FIRST, startedAt: T0 }]); + const own = await w.open(SECOND).apply(orgGroup, quiet); + assert.deepEqual(own.instance, { id: application.id, name: application.name }); + assert.equal(w.clerk.applications.length, 1); + assert.deepEqual(stateOf(w, application.name).drivers.map((driver) => driver.pid), [SECOND], 'a dead driver counts as nobody'); + }); + + it('shares one application between two runs on the same settings', async () => { + const w = world(); + const first = w.open(FIRST); + await first.ensure(NOTHING_NEW, quiet); + const held = await first.apply(mfaGroup, quiet); + w.alive.add(FIRST); + w.alive.add(SECOND); + const before = w.clerk.platformRequests().length; + const shared = await w.open(SECOND).apply(mfaGroup, w.say); + assert.deepEqual(shared.instance, held.instance); + assert.equal(w.clerk.platformRequests().length, before, 'sharing asks the Platform API nothing'); + const name = w.clerk.live()[0]!.name; + assert.deepEqual(stateOf(w, name).drivers.map((driver) => driver.pid), [FIRST, SECOND]); + await held.release(); + assert.deepEqual(stateOf(w, name).drivers.map((driver) => driver.pid), [SECOND], 'one run ending leaves the other driving'); + }); + + it('neither shares nor takes over an application that is within an hour of its deadline and that only another run checked', async () => { + for (const how of ['share', 'take over'] as const) { + const w = world(); + const first = w.open(FIRST); + await first.ensure(NOTHING_NEW, quiet); + await first.apply(mfaGroup, quiet); + w.alive.add(FIRST); + const old = w.clerk.live()[0]!; + w.clock.at = T0 + 6 * HOUR - 20 * 60_000; + const second = w.open(SECOND); + await second.ensure({ willChange: true }, quiet); + if (how === 'take over') w.alive.clear(); + const own = await second.apply(how === 'share' ? mfaGroup : orgGroup, w.say); + assert.equal(w.clerk.live().length, 2, `${how}: it made its own application`); + const made = w.clerk.live()[1]!; + assert.deepEqual(own.instance, { id: made.id, name: made.name }, how); + assert.equal(deadlineOf(made.name)!.getTime(), w.clock.at + 6 * HOUR, how); + assert.ok(w.lines.includes(`settings ${how === 'share' ? MFA_LABEL : ORG_LABEL} needs its own application, because ${old.id} is within an hour of its deadline`), w.lines.join('\n')); + assert.deepEqual(stateOf(w, old.name).drivers.map((driver) => driver.pid), [FIRST], `${how}: the old application was not touched`); + assert.equal(old.environment['organization_settings.force_organization_selection'], false, how); + } + }); + + it('keeps driving on the application it checked when it started, though its deadline comes within the hour meanwhile', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + w.clock.at = T0 + 6 * HOUR - 20 * 60_000; + for (const group of [mfaGroup, mfaGroup, orgGroup]) await (await w.instances.apply(group, quiet)).release(); + assert.equal(w.clerk.applications.length, 1, 'a run that started with more than an hour left ends before a reap can come'); + }); + + it('waits for the lock another command in the worktree holds before it ensures, applies, or releases', async () => { + const w = world(); + mkdirSync(join(w.workspace.root, 'locks'), { recursive: true }); + const whileLocked = async (what: string, start: () => Promise, untouched: () => void): Promise => { + const unlock = await takeSlotLock(join(w.workspace.root, 'locks', 'instances'), 0, () => new VerifyFailure('NOT_READY', 'the lock is taken', '')); + w.lines.length = 0; + let settled = false; + const pending = start().finally(() => (settled = true)); + while (!settled && !w.lines.includes(`wait another {cli} in this worktree (pid ${process.pid}) is creating, changing, or deleting instances; waiting for it, with no time limit`)) await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(settled, false, `${what} went ahead while the lock was held`); + untouched(); + unlock(); + return pending; + }; + + await whileLocked('ensure', () => w.instances.ensure(NOTHING_NEW, quiet), () => assert.deepEqual(w.clerk.requests, [])); + const application = w.clerk.live()[0]!; + const applied = await whileLocked('apply', () => w.instances.apply(mfaGroup, quiet), () => assert.equal(patches(w).length, 1, 'only the PATCH that put it on the standard file')); + assert.equal(application.environment['user_settings.sign_up.mfa.required'], true); + await whileLocked('release', () => applied.release(), () => assert.deepEqual(stateOf(w, application.name).drivers.map((driver) => driver.pid), [1000])); + assert.deepEqual(stateOf(w, application.name).drivers, []); + }); + + it('leaves an application another run is driving completely alone before a run', async () => { + const w = world(); + const first = w.open(FIRST); + await first.ensure(NOTHING_NEW, quiet); + await first.apply(mfaGroup, quiet); + w.alive.add(FIRST); + const application = w.clerk.live()[0]!; + application.users = 99; + application.environment['user_settings.sign_up.mfa.required'] = false; + w.clock.at = T0 + 8 * HOUR; + const before = writes(w).length; + const up = await w.open(SECOND).ensure({ willChange: true }, w.say); + assert.deepEqual(writes(w).slice(before), [], 'not retired near its deadline or its user limit, not repaired, not reaped, not changed'); + assert.deepEqual(up.map((view) => [idOf(view), view.settings]), [[application.id, MFA_LABEL]]); + assert.equal(application.deleted, false); + }); +}); + +describe('a declaration Clerk or the instance does not take', () => { + const ready = async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + return { w, application: w.clerk.live()[0]! }; + }; + + it('names the key Clerk refused and the spec that declared it, and leaves the application as it was', async () => { + const { w, application } = await ready(); + w.clerk.state.refusals.push({ path: 'auth_multi_factor.required_for_sign_up', value: true, status: 400, code: 'unknown_config_key', param: 'auth_multi_factor.required_for_sign_up', message: 'is not a config key' }); + await assert.rejects( + w.instances.apply(mfaGroup, quiet), + (error: SettingsRefused) => + error instanceof SettingsRefused && + error.code === 'INSTANCE_MISCONFIGURED' && + error.message === `${MFA_SPEC} declares ${MFA_LABEL}, and Clerk's Platform API refused it: auth_multi_factor.required_for_sign_up (400 unknown_config_key): is not a config key` && + error.fix === `correct or remove \`auth_multi_factor.required_for_sign_up\` in \`instanceSettings\` in ${MFA_SPEC}: Clerk says what is wrong with it above. Settings the Platform API cannot set today: reverification, the development-mode banner, test mode, PII protection off`, + ); + assert.deepEqual(writes(w).slice(-2), [`PATCH /applications/${application.id}/config`, `PATCH /applications/${application.id}/config?dry_run=true`], 'one dry run of the standard file alone, to see whose fault it is'); + assert.equal(w.instances.recordedKey(), STANDARD.key, 'a refused PATCH applied nothing, so the record stands'); + const before = patches(w).length; + await (await w.instances.apply(standardGroup, w.say)).release(); + assert.equal(patches(w).length, before, 'and the next group finds its settings without a PATCH'); + assert.throws(() => w.open().keys(), /no instance is applied/); + }); + + it('says to add what Clerk named when the declaration does not set it, and to correct it when the declaration sets it by its last segment', async () => { + const { w } = await ready(); + const consent: InstanceSettings = { config: { compliance: { legal_consent: { enabled: true } } }, environment: { 'user_settings.sign_up.legal_consent_enabled': true } }; + w.clerk.state.refusals.push({ path: 'compliance.legal_consent.enabled', value: true, status: 422, code: 'form_param_missing', param: ['terms_of_service_url', 'privacy_policy_url'], message: 'is required' }); + await assert.rejects( + w.instances.apply(groupOf(consent, MFA_SPEC), quiet), + (error: SettingsRefused) => + error instanceof SettingsRefused && + error.message === `${MFA_SPEC} declares compliance.legal_consent.enabled=true, and Clerk's Platform API refused it: terms_of_service_url (422 form_param_missing): is required` && + error.fix === `add \`terms_of_service_url\` to \`config\` in \`instanceSettings\` in ${MFA_SPEC} (Clerk requires it with what the spec declares), or remove what requires it. Settings the Platform API cannot set today: reverification, the development-mode banner, test mode, PII protection off`, + ); + w.clerk.state.refusals.push({ path: 'auth_multi_factor.required_for_sign_up', value: true, status: 422, code: 'form_param_value_invalid', param: 'required_for_sign_up', message: 'is not allowed' }); + await assert.rejects(w.instances.apply(mfaGroup, quiet), (error: SettingsRefused) => error instanceof SettingsRefused && error.fix.startsWith(`correct or remove \`required_for_sign_up\` in \`instanceSettings\` in ${MFA_SPEC}: Clerk says what is wrong with it above.`)); + }); + + it('names every declared leaf when Clerk names no key', async () => { + const { w } = await ready(); + const both: InstanceSettings = { config: { auth_multi_factor: { required_for_sign_up: true }, auth_attack_protection: { pii_protection_enabled: false } }, environment: MFA.environment }; + w.clerk.state.refusals.push({ path: 'auth_attack_protection.pii_protection_enabled', value: false, status: 409, code: 'user_settings_invalid', message: 'the settings are not valid together' }); + await assert.rejects(w.instances.apply(groupOf(both, MFA_SPEC), quiet), (error: SettingsRefused) => error instanceof SettingsRefused && error.message.endsWith('refused it: 409 user_settings_invalid: the settings are not valid together') && error.fix === `Clerk refused these together; remove or correct one of auth_multi_factor.required_for_sign_up, auth_attack_protection.pii_protection_enabled in \`instanceSettings\` in ${MFA_SPEC}. Settings the Platform API cannot set today: reverification, the development-mode banner, test mode, PII protection off`); + }); + + it('blames the standard file, not the spec, when Clerk refuses the standard file alone too', async () => { + const { w } = await ready(); + w.clerk.state.refusals.push({ path: 'auth_password.min_length', status: 400, code: 'config_key_body_invalid', param: 'auth_password.min_length', message: 'must be at least 10' }); + await assert.rejects( + w.instances.apply(mfaGroup, quiet), + (error: VerifyFailure) => + !(error instanceof SettingsRefused) && + error.code === 'INSTANCE_MISCONFIGURED' && + error.message === `Clerk's Platform API refused the standard settings in ${STANDARD_FILE}: auth_password.min_length (400 config_key_body_invalid): must be at least 10` && + error.fix === 'the standard file needs a change of its own (shared core); the spec is not at fault', + ); + const fresh = world(); + fresh.clerk.state.refusals.push({ path: 'auth_password.min_length', status: 400, code: 'config_key_body_invalid', param: 'auth_password.min_length', message: 'must be at least 10' }); + await assert.rejects(fresh.instances.ensure(NOTHING_NEW, quiet), (error: VerifyFailure) => !(error instanceof SettingsRefused) && error.message.startsWith(`Clerk's Platform API refused the standard settings in ${STANDARD_FILE}`)); + assert.equal(writes(fresh).some((call) => call.includes('dry_run')), false, 'the standard file was the body, so no second request is needed'); + }); + + it('fails a change that moved a setting the declaration does not list as soon as two reads agree, and lists it ready to paste', async () => { + const { w, application } = await ready(); + w.clerk.state.alsoMoves = { 'auth_config.reverification': false, 'user_settings.sign_up.progressive': false }; + w.slept.length = 0; + await assert.rejects( + w.instances.apply(mfaGroup, quiet), + (error: SettingsRefused) => + error instanceof SettingsRefused && + error.message === `${MFA_SPEC} declares ${MFA_LABEL}, and 0.5s after Clerk accepted it on ${application.id} the change moved 2 settings the declaration does not list: 'auth_config.reverification': false, 'user_settings.sign_up.progressive': false` && + error.fix === `one setting can move several leaves: add them to \`environment\` in ${MFA_SPEC}, as listed`, + ); + assert.deepEqual(w.slept, [500], 'the second read showed the same as the first, so it did not wait out the 15s'); + assert.equal(w.instances.recordedKey(), null, 'the application is on settings nobody declared, so the next use sends its own'); + w.clerk.state.alsoMoves = {}; + application.environment['auth_config.reverification'] = true; + application.environment['user_settings.sign_up.progressive'] = true; + const both: InstanceSettings = { config: MFA.config, environment: { ...MFA.environment } }; + await (await w.instances.apply(groupOf(both, MFA_SPEC), quiet)).release(); + }); + + it('keeps waiting while a declared leaf does not show, and stops only when two reads in a row show the same rest', async () => { + const { w, application } = await ready(); + w.clerk.state.ignoreConfigKey = 'user_settings.sign_up.mfa.required'; + w.clerk.state.alsoMoves = { 'auth_config.reverification': false, 'user_settings.sign_up.progressive': false }; + let reads = 0; + let patched = false; + const slow: typeof fetch = async (input, init) => { + patched ||= init?.method === 'PATCH'; + if (patched && String(input).endsWith('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/v1/environment')) { + reads += 1; + if (reads === 3) application.environment['user_settings.sign_up.mfa.required'] = true; + if (reads === 4) application.environment['user_settings.sign_up.progressive'] = true; + } + return w.clerk.fetch(input, init); + }; + w.slept.length = 0; + await assert.rejects( + w.open(1000, slow).apply(mfaGroup, quiet), + (error: SettingsRefused) => error instanceof SettingsRefused && error.message === `${MFA_SPEC} declares ${MFA_LABEL}, and 2.0s after Clerk accepted it on ${application.id} the change moved 1 setting the declaration does not list: 'auth_config.reverification': false`, + ); + assert.equal(reads, 5, 'two reads without the declared leaf, one with it, one where the rest changed, and one that agreed with it'); + assert.deepEqual(w.slept, [500, 500, 500, 500]); + }); + + it('fails a change whose declared leaf never shows after the full wait, naming what the instance shows instead', async () => { + const { w, application } = await ready(); + w.clerk.state.ignoreConfigKey = 'user_settings.sign_up.mfa.required'; + w.slept.length = 0; + await assert.rejects( + w.instances.apply(mfaGroup, quiet), + (error: SettingsRefused) => error instanceof SettingsRefused && error.message === `${MFA_SPEC} declares ${MFA_LABEL}, and 15.0s after Clerk accepted it on ${application.id} it does not show user_settings.sign_up.mfa.required is false, and the declaration expects true` && error.fix.includes(`\`environment\` in ${MFA_SPEC}`), + ); + assert.equal(w.slept.reduce((sum, ms) => sum + ms, 0), 15_000, 'identical reads that lack a declared leaf are not an answer yet'); + }); + + it('fails when Clerk accepts a change and its answer does not hold it', async () => { + const { w } = await ready(); + w.clerk.state.dropFromAfter = 'auth_multi_factor.required_for_sign_up'; + await assert.rejects(w.instances.apply(mfaGroup, quiet), (error: VerifyFailure) => error.code === 'INSTANCE_MISCONFIGURED' && !(error instanceof SettingsRefused) && error.message.includes('its answer does not hold auth_multi_factor.required_for_sign_up=true (it has no such key)')); + }); + + it('blames the declaration when Clerk accepts a declared value and stores another, and the tool when it is a value no declaration set', async () => { + const { w } = await ready(); + const long: InstanceSettings = { config: { auth_password: { min_length: 200 } }, environment: { 'user_settings.password_settings.min_length': 200 } }; + w.clerk.state.storesInstead = { 'auth_password.min_length': 72 }; + await assert.rejects( + w.instances.apply(groupOf(long, MFA_SPEC), quiet), + (error: SettingsRefused) => + error instanceof SettingsRefused && + error.message === `${MFA_SPEC} declares auth_password.min_length=200, and Clerk stored 72` && + error.fix === `correct \`auth_password.min_length\` in \`instanceSettings\` in ${MFA_SPEC} to a value Clerk keeps`, + ); + assert.equal(w.instances.recordedKey(), null, 'the application is on a value nobody declared, so the next use sends its own settings'); + await assert.rejects(w.instances.apply(mfaGroup, quiet), (error: VerifyFailure) => error.code === 'INSTANCE_MISCONFIGURED' && !(error instanceof SettingsRefused) && error.message.includes('its answer does not hold auth_password.min_length=8 (it has 72)')); + }); + + it('keeps a rate limit a rate limit, which is not the declaration\'s fault', async () => { + const { w } = await ready(); + const later = w.open(); + await later.ensure({ willChange: true }, quiet); + w.clerk.rateLimit(50); + await assert.rejects(later.apply(mfaGroup, quiet), (error: VerifyFailure) => !(error instanceof SettingsRefused) && error.code === 'RATE_LIMITED'); + }); +}); + +describe('finishing a ledger', () => { + it('deletes every application, confirms each is absent from the list, and is safe to repeat', async () => { + const w = world(); + heldWithoutState(w, 'aaaaaaaa'); + const killed = heldWithoutState(w, 'bbbbbbbb'); + await w.open().ensure(NOTHING_NEW, quiet); + writeFileSync(join(instancesDir(w), `${killed.name}.state.json.0a1b2c3d.tmp`), '{}'); + const finished = await w.open().finish(w.workspace, { keepApplications: false }, w.say); + assert.equal(finished.length, 2); + assert.equal(w.clerk.live().length, 0); + assert.deepEqual(w.workspace.unclosedEntries(), []); + assert.deepEqual(readdirSync(instancesDir(w)), [], 'the key and state files die with the applications, and so does a state file a killed process left half written'); + const calls = platformCalls(w); + assert.equal(calls.filter((call) => call.startsWith('DELETE')).length, 2); + assert.equal(calls.at(-1), 'GET /applications', 'the list is read again after the deletes'); + const before = w.clerk.requests.length; + assert.deepEqual(await w.open().finish(w.workspace, { keepApplications: false }, quiet), []); + assert.equal(w.clerk.requests.length, before, 'a second finish finds nothing open and asks nothing'); + }); + + it('keeps the ledger entries when Clerk still lists the application', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + w.clerk.state.refuseDelete = true; + await assert.rejects(w.open().finish(w.workspace, { keepApplications: false }, quiet), (error: VerifyFailure) => /could not be deleted/.test(error.message) && error.fix.includes('down again')); + assert.equal(openApplications(w.workspace).length, 1); + w.clerk.state.refuseDelete = false; + await w.open().finish(w.workspace, { keepApplications: false }, quiet); + assert.equal(w.clerk.live().length, 0); + }); + + it('closes the identities that lived in an application with no Backend API call', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const run = newRunId(); + w.workspace.append({ id: newEntryId(), kind: 'identity', run, email: newTestEmail(run, 1) }); + w.workspace.append({ id: newEntryId(), kind: 'user', run, userId: 'user_1', email: newTestEmail(run, 1) }); + + const before = w.clerk.requests.length; + await w.open().finish(w.workspace, { keepApplications: false }, quiet); + assert.deepEqual(w.workspace.unclosedEntries(), []); + assert.deepEqual(w.clerk.requests.slice(before).filter((request) => !request.url.startsWith('api.clerk.com/v1/platform/')), [], 'the users went with the application, so none was looked up or deleted'); + }); + + it('refuses an entry made in another workspace and leaves it open', async () => { + const w = world(); + const name = throwawayName(new Date(T0 + HOUR), 'aaaaaaaa'); + w.workspace.append({ id: newEntryId(), kind: 'application', name, workspace: 'org_elsewhere' }); + await assert.rejects( + w.instances.finish(w.workspace, { keepApplications: false }, quiet), + (error: VerifyFailure) => /belong to workspace org_elsewhere, which this credential does not reach/.test(error.message) && error.fix === `${name} belongs to workspace org_elsewhere, which this credential cannot reach; only a credential of that workspace can delete it`, + ); + assert.equal(openApplications(w.workspace).length, 1); + assert.ok(w.clerk.requests.every((request) => request.method === 'GET')); + }); + + it('removes the keys file before it closes the entry, so a kill between the two leaves no secret key on disk', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const name = w.clerk.live()[0]!.name; + const append = w.workspace.append; + w.workspace.append = (entry) => { + if (entry.kind === 'done') throw new Error('the process died here'); + append(entry); + }; + await assert.rejects(w.open().finish(w.workspace, { keepApplications: false }, quiet), /the process died here/); + w.workspace.append = append; + assert.deepEqual(readdirSync(instancesDir(w)), [], 'the secret key is gone, though the entry is still open'); + assert.deepEqual(openApplications(w.workspace).map((entry) => entry.name), [name]); + await w.open().ensure(NOTHING_NEW, w.say); + assert.ok(w.lines.includes(`instance ${name} retired (its keys are lost)`)); + assert.equal(openApplications(w.workspace).some((entry) => entry.name === name), false); + }); + + it('still sends the delete when the list does not show an application it has the id for', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + let hidden = true; + const hiding = (async (input: string | URL, init?: RequestInit) => (hidden && (init?.method ?? 'GET') === 'GET' && String(input).endsWith('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/platform/applications') ? new Response('[]', { status: 200 }) : w.clerk.fetch(input, init))) as typeof fetch; + await w.open(1000, hiding).finish(w.workspace, { keepApplications: false }, quiet); + hidden = false; + assert.equal(w.clerk.live().length, 0, 'a list that was wrongly empty did not leave a live application behind'); + }); + + it('finishes the ledger of a worktree that is gone', async () => { + const home = mkdtempSync(join(tmpdir(), 'verify-instances-home-')); + const gone = world({ home }); + await gone.instances.ensure(NOTHING_NEW, quiet); + rmSync(gone.dir, { recursive: true, force: true }); + const here = world({ home, shared: gone.clerk }); + await finishOrphanLedgers(home, here.dir, here.instances, here.say); + assert.equal(gone.clerk.live().length, 0); + assert.deepEqual(gone.workspace.unclosedEntries(), []); + assert.ok(here.lines.some((line) => /^reap ledger of .* 1 application/.test(line))); + assert.equal(here.lines.some((line) => line.startsWith('wait another')), false, 'another worktree\'s ledger waits for no lock here'); + }); + + it('leaves the applications alone when asked to keep them, and still closes their identities', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const run = newRunId(); + w.workspace.append({ id: newEntryId(), kind: 'identity', run, email: newTestEmail(run, 1) }); + await w.open().finish(w.workspace, { keepApplications: true }, quiet); + assert.equal(w.clerk.live().length, 1); + assert.deepEqual(w.workspace.unclosedEntries().map((entry) => entry.kind), ['application']); + }); + +}); + +describe('reaping', () => { + it('deletes only applications past their own deadline, never this worktree\'s, and at most five a command', async () => { + const w = world(); + const expired = Array.from({ length: 7 }, (_, n) => w.clerk.plant(throwawayName(new Date(T0 - HOUR), `0000000${n}`))); + const fresh = w.clerk.plant(throwawayName(new Date(T0 + HOUR), 'ffffffff')); + const justPast = w.clerk.plant(throwawayName(new Date(T0 - 60_000), 'eeeeeeee')); + const undated = w.clerk.plant(`${THROWAWAY_PREFIX}9c1f04ab77e2`); + await w.instances.ensure(NOTHING_NEW, w.say); + assert.equal(expired.filter((a) => a.deleted).length, 5); + assert.equal(fresh.deleted, false, 'another session\'s live instance'); + assert.equal(justPast.deleted, false, 'inside the grace after its deadline'); + assert.equal(undated.deleted, false); + assert.equal(w.clerk.live().filter((a) => a.id.startsWith('app_fake')).length, 1, 'its own new instance'); + assert.ok(w.lines.some((line) => line.startsWith('reap 2 more expired applications'))); + assert.ok(w.lines.some((line) => line.includes('carry no deadline in their name and are never reaped'))); + }); + + it('does not reap the application it has just retired, or spend one of its five reaps on it', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const retired = w.clerk.live()[0]!; + const expired = Array.from({ length: 5 }, (_, n) => w.clerk.plant(throwawayName(new Date(T0 + HOUR), `0000000${n}`))); + w.clock.at = T0 + 7 * HOUR; + await w.open().ensure(NOTHING_NEW, w.say); + assert.ok(w.lines.includes(`instance ${retired.id} retired (its deadline is near)`)); + assert.equal(w.clerk.platformRequests().filter((request) => request.method === 'DELETE' && request.url.endsWith(`/${retired.id}`)).length, 1, 'one delete for the retired application'); + assert.deepEqual(expired.map((application) => application.deleted), [true, true, true, true, true], 'and all five reaps for the applications other sessions left'); + assert.deepEqual(w.lines.filter((line) => line.startsWith('reap ')).map((line) => line.split(/ +/)[1]), expired.map((application) => application.name)); + }); + + it('judges a deadline by Clerk\'s clock, not this machine\'s', async () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-instances-')); + const clerk = fakeClerk({ now: () => T0 }); + const other = clerk.plant(throwawayName(new Date(T0 + HOUR), 'aaaaaaaa')); + const fastClock = T0 + 5 * HOUR; + const instances = createInstances({ workspace: openWorkspace({ skillDir: dir, worktree: dir, home: join(dir, 'home') }), env: { CLERK_PLATFORM_API_KEY: PLATFORM_KEY }, runner: noOp, progress: quiet, fetch: clerk.fetch, now: () => fastClock }); + await instances.ensure(NOTHING_NEW, quiet); + assert.equal(other.deleted, false); + }); + + it('reaps nothing when Clerk\'s answer carries no date', async () => { + const w = world(); + const expired = w.clerk.plant(throwawayName(new Date(T0 - 10 * HOUR), 'aaaaaaaa')); + w.clerk.state.noDate = true; + await w.instances.ensure(NOTHING_NEW, w.say); + assert.equal(expired.deleted, false); + assert.ok(w.lines.some((line) => line.startsWith('reap skipped'))); + }); + + it('does not fail the command when a reap is refused', async () => { + const w = world(); + w.clerk.plant(throwawayName(new Date(T0 - HOUR), 'bbbbbbbb')); + const refusing = (async (input: string | URL, init?: RequestInit) => (init?.method === 'DELETE' ? new Response(JSON.stringify({ errors: [{ code: 'internal' }] }), { status: 500 }) : w.clerk.fetch(input, init))) as typeof fetch; + await w.open(1000, refusing).ensure(NOTHING_NEW, w.say); + assert.ok(w.lines.some((line) => line.includes('left in place'))); + }); +}); + +describe('a machine with no Platform API credential', () => { + + it('gets no access to an instance and creates nothing, with every way to supply a key named', async () => { + const w = world({ env: {} }); + const named = (error: VerifyFailure) => error.code === 'KEYS_MISSING' && ['1Password CLI', 'CLERK_PLATFORM_API_KEY', 'CLERK_PLATFORM_API_KEY_FILE'].every((part) => error.fix.includes(part)); + await assert.rejects(w.instances.access(), named); + await assert.rejects(w.instances.ensure(NOTHING_NEW, quiet), named); + assert.equal(w.clerk.applications.length, 0); + assert.deepEqual(w.workspace.unclosedEntries(), []); + }); + +}); + +describe('Backend API host', () => { + const keys = { pk: 'pk_test_x' as PublishableKey, sk: new Secret('clerk-secret-key', 'sk_test_ownInstanceKey123') }; + const answering = (byHost: Record) => { + const calls: string[] = []; + const fetchImpl = (async (url: string | URL) => { + const hostName = new URL(String(url)).host; + calls.push(hostName); + const status = byHost[hostName] ?? 500; + return new Response(JSON.stringify(status === 200 ? [] : { errors: [{ code: status === 401 ? 'clerk_key_invalid' : 'internal' }] }), { status }); + }) as typeof fetch; + const notes: string[] = []; + const backends = createClerkBackends(fetchImpl, (line) => notes.push(line)); + return { calls, notes, backends, backend: backends(() => keys) }; + }; + + it('stays on api.clerk.com when it accepts the instance key', async () => { + const { calls, backend } = answering({ 'api.clerk.com': 200 }); + assert.equal(await backend.apiHost(), BACKEND_API_HOSTS[0]); + assert.deepEqual(calls, ['api.clerk.com']); + }); + + it('moves every backend of the process to api.clerk.dev when api.clerk.com answers 401 and the other name accepts the key', async () => { + const { calls, notes, backends, backend } = answering({ 'api.clerk.com': 401, 'api.clerk.dev': 200 }); + assert.equal(await backend.apiHost(), 'api.clerk.dev'); + await backend.findUserId(newTestEmail(newRunId(), 1)); + await backends(() => ({ ...keys, sk: new Secret('clerk-secret-key', 'sk_test_anotherInstance456') })).findUserId(newTestEmail(newRunId(), 2)); + assert.deepEqual(calls, ['api.clerk.com', 'api.clerk.dev', 'api.clerk.dev', 'api.clerk.dev']); + assert.equal(notes.length, 1); + assert.match(notes[0]!, /something replaces the Authorization header on api\.clerk\.com; using api\.clerk\.dev/); + }); + + it('does not move when the other name answers anything but success, and reports the first refusal', async () => { + const calls: string[] = []; + const blocked = (async (url: string | URL) => { + const hostName = new URL(String(url)).host; + calls.push(hostName); + return hostName === 'api.clerk.com' ? new Response(JSON.stringify({ errors: [{ code: 'clerk_key_invalid' }] }), { status: 401 }) : new Response('blocked', { status: 403 }); + }) as typeof fetch; + const notes: string[] = []; + const backend = createClerkBackends(blocked, (line) => notes.push(line))(() => keys); + await assert.rejects(backend.apiHost(), /answered 401 \(clerk_key_invalid\)/); + await assert.rejects(backend.apiHost(), /answered 401/); + assert.deepEqual(calls, ['api.clerk.com', 'api.clerk.dev', 'api.clerk.com', 'api.clerk.dev'], 'it keeps trying api.clerk.com first'); + assert.deepEqual(notes, []); + }); + + it('does not move on a failure that is not a refused key, or when both names refuse it', async () => { + const outage = answering({ 'api.clerk.com': 500, 'api.clerk.dev': 200 }); + await assert.rejects(outage.backend.apiHost(), /answered 500/); + assert.deepEqual(outage.calls, ['api.clerk.com']); + const badKey = answering({ 'api.clerk.com': 401, 'api.clerk.dev': 401 }); + await assert.rejects(badKey.backend.apiHost(), /answered 401 \(clerk_key_invalid\)/); + assert.deepEqual(badKey.notes, []); + }); +}); + +describe('doctor', () => { + const specFile = (w: World, path: string, source: string) => { + mkdirSync(join(w.dir, path, '..'), { recursive: true }); + writeFileSync(join(w.dir, path), source); + }; + const settingsCheck = async (w: World) => (await w.open().doctorChecks({ live: false }, quiet)).find((c) => c.id === 'settings')!; + + it('says which credential reaches which workspace and creates nothing', async () => { + const w = world(); + const checks = await w.instances.doctorChecks({ live: false }, quiet); + assert.deepEqual(checks.map((c) => [c.id, c.ok]), [['instances', true], ['clerk-api', true], ['settings', true]]); + assert.match(checks[0]!.detail, /^CLERK_PLATFORM_API_KEY reaches the verification workspace org_3KHungJxbvIscuSvy8oos5MHAli; none created yet$/); + assert.match(checks[1]!.detail, /^not observed yet/); + assert.equal(checks[2]!.detail, `none created yet; up creates one application from ${STANDARD_FILE}; 0 spec files declare settings`); + assert.deepEqual(platformCalls(w), ['GET /me', 'GET /applications']); + }); + + it('names what a held application is on and which spec asked, and reports drift and unknown leaves without failing', async () => { + const w = world(); + specFile(w, MFA_SPEC, `export const instanceSettings = ${JSON.stringify(MFA)};\n`); + specFile(w, STANDARD_SPEC, "test('x', () => {});\n"); + w.clerk.state.newApplicationDefaults = { 'auth_config.reverification': false }; + await w.instances.ensure(NOTHING_NEW, quiet); + const application = w.clerk.live()[0]!; + application.environment['auth_config.brand_new'] = 1; + const standard = await w.open().doctorChecks({ live: false }, quiet); + assert.match(standard[0]!.detail, new RegExp(`^CLERK_PLATFORM_API_KEY reaches the verification workspace ${WORKSPACE}; ${application.id} \\(${application.name}\\) on standard$`)); + assert.equal(standard[1]!.detail, `api.clerk.com accepts the own key of ${application.id}`); + assert.deepEqual([standard[2]!.id, standard[2]!.ok], ['settings', true]); + assert.equal( + standard[2]!.detail, + `${application.id} is on standard; 211 settings match ${STANDARD_FILE}; 1 setting no spec depends on differ from the file (auth_config.reverification is false, and the file says true); Clerk reports 1 setting the file does not list (auth_config.brand_new); 1 spec file declares settings`, + ); + + await (await w.open().apply(mfaGroup, quiet)).release(); + const declared = await settingsCheck(w); + assert.equal(declared.ok, true); + assert.ok(declared.detail.startsWith(`${application.id} is on ${MFA_LABEL}, which ${MFA_SPEC} asked for; 211 settings match ${STANDARD_FILE} with that declaration; `), declared.detail); + assert.match((await w.open().doctorChecks({ live: false }, quiet))[0]!.detail, new RegExp(` on ${MFA_LABEL.replace(/\./g, '\\.')}$`)); + }); + + it('fails when a held application no longer shows its recorded settings, or has none recorded', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const application = w.clerk.live()[0]!; + application.environment['auth_config.test_mode'] = false; + const differing = await settingsCheck(w); + assert.equal(differing.ok, false); + assert.ok(differing.detail.startsWith(`${application.id} is recorded as on standard and shows auth_config.test_mode is false, and those settings expect true`), differing.detail); + assert.equal(differing.fix, '{cli} up returns it to the standard settings'); + application.environment['auth_config.test_mode'] = true; + rmSync(join(instancesDir(w), `${application.name}.state.json`)); + const unknown = await settingsCheck(w); + assert.deepEqual([unknown.ok, unknown.detail.split(';')[0]], [false, `${application.id} has no settings recorded`]); + application.deleted = true; + assert.match((await settingsCheck(w)).detail, new RegExp(`^Clerk no longer serves ${application.id}`)); + }); + + it('fails on the first spec file whose declaration a run would refuse, and names it', async () => { + const w = world(); + specFile(w, STANDARD_SPEC, "test('x', () => {});\n"); + specFile(w, 'specs/explored/b-shared.e2e.ts', 'export const instanceSettings = SHARED;\n'); + specFile(w, 'specs/explored/c-unknown.e2e.ts', `export const instanceSettings = ${JSON.stringify({ config: { auth_email: { nope: true } }, environment: MFA.environment })};\n`); + const malformed = await settingsCheck(w); + assert.equal(malformed.ok, false); + assert.match(malformed.detail, /; specs\/explored\/b-shared\.e2e\.ts: instanceSettings holds something that is not a plain value/); + assert.match(malformed.fix ?? '', /export const instanceSettings: InstanceSettings = \{ config:/); + rmSync(join(w.dir, 'specs/explored/b-shared.e2e.ts')); + assert.match((await settingsCheck(w)).detail, /specs\/explored\/c-unknown\.e2e\.ts declares auth_email\.nope, and the standard file has no value to return it to$/); + }); + + it('says so when instances are held and the credential that down needs is gone', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const later = createInstances({ workspace: w.workspace, env: {}, runner: noOp, progress: quiet, fetch: w.clerk.fetch }); + const checks = await later.doctorChecks({ live: true }, quiet); + assert.equal(checks[0]!.ok, false); + assert.match(checks[0]!.detail, /^app_fake1 \(verify-throwaway-until-\w+-[0-9a-f]{8}\) on standard; no Clerk Platform API credential works here/); + assert.equal(checks.find((c) => c.id === 'settings')?.ok, true, 'the held application is still read'); + assert.equal(w.clerk.live().length, 1); + }); + + it('--live leaves an application this worktree already holds alone', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const checks = await w.open().doctorChecks({ live: true }, quiet); + const live = checks.find((c) => c.id === 'live-instance')!; + assert.deepEqual([live.ok, live.state], [true, 'not-run']); + assert.match(live.detail, /^not run: this worktree already holds app_fake1/); + assert.equal(w.clerk.live().length, 1); + assert.equal(w.clerk.applications.length, 1); + }); + + it('--live creates one application, checks it, deletes it, and leaves the ledger closed', async () => { + const w = world(); + const checks = await w.instances.doctorChecks({ live: true }, quiet); + assert.deepEqual(checks.map((c) => [c.id, c.ok]), [['instances', true], ['clerk-api', true], ['settings', true], ['live-instance', true]]); + assert.match(checks.find((c) => c.id === 'settings')!.detail, /^app_fake1 is on standard; 212 settings match/); + assert.match(checks.find((c) => c.id === 'live-instance')!.detail, /^created app_fake1 \(verify-throwaway-until-\w+-[0-9a-f]{8}\), configured it, compared its environment, and deleted it \(1 application gone from the list\)/); + assert.equal(w.clerk.applications.length, 1); + assert.equal(w.clerk.live().length, 0); + assert.deepEqual(w.workspace.unclosedEntries(), []); + }); + + it('fails the instances check alone when no credential works, with one fix line that says how to supply a key and nothing else', async () => { + for (const live of [false, true]) { + const w = world({ env: {} }); + const checks = await w.instances.doctorChecks({ live }, quiet); + assert.deepEqual(checks.map((c) => [c.id, c.ok]), [['instances', false]]); + const fix = checks[0]!.fix ?? ''; + for (const way of ['set CLERK_PLATFORM_API_KEY to the team key', 'CLERK_PLATFORM_API_KEY_FILE', 'VERIFY_PLATFORM_KEY_REFERENCE', '~/.verify/clerk-platform-key-reference']) assert.ok(fix.includes(way), way); + assert.deepEqual(fix.match(/op:\/\/[^;\s]*/g), [REFERENCE_SHAPE], 'the reference is shown as a shape, never with a vault or an item'); + let printed = ''; + createOutput(false, w.dir, 'bin/control-x', { write: (line: string) => (printed += line) }, { write: () => true }).result({ verb: 'doctor', ok: false, backend: { ios: 'local' }, checks }); + assert.equal(printed.split('\n').filter((line) => line.includes('fix:')).length, 1); + assert.equal(w.clerk.applications.length, 0); + } + }); +}); + +describe('the broker of a run', () => { + + it('seeds a user only while an instance is applied, with that application\'s own key, and ledgers each identity', async () => { + const w = world(); + await w.instances.ensure(NOTHING_NEW, quiet); + const { run, scratch } = w.workspace.newRun(); + const broker = await startBroker(run, w.workspace, scratch as ScratchPath, { clerk: () => w.instances.clerk(), publishableKey: () => w.instances.keys().pk, screens: ['home'], platforms: ['ios'] }); + const post = async (path: string, body: unknown) => { + const response = await fetch(`${broker.url}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${readFileSync(broker.tokenFile, 'utf8')}` }, body: JSON.stringify(body) }); + return { status: response.status, json: (await response.json()) as Record }; + }; + try { + assert.equal((await post('/seedUser', {})).status, 400, 'nothing is applied before the run drives'); + const applied = await w.instances.apply(mfaGroup, quiet); + const seeded = await post('/seedUser', {}); + assert.equal(seeded.status, 200); + assert.deepEqual(Object.keys(seeded.json).sort(), ['email', 'id', 'phone'], 'a seeded user names no instance'); + assert.equal((await post('/reserveEmail', {})).status, 200); + assert.equal((await post('/launch', { platform: 'ios', user: seeded.json, screen: 'home', authMode: null, debugLogs: false, storageScope: null })).status, 200); + await applied.release(); + } finally { + await broker.stop(); + } + const application = w.clerk.live()[0]!; + assert.equal(application.users, 1, 'the user was made with the applied application\'s own key'); + const identities = w.workspace.unclosedEntries().flatMap((entry) => (entry.kind === 'identity' || entry.kind === 'user' ? [entry] : [])); + assert.deepEqual(identities.map((entry) => entry.kind), ['identity', 'user', 'identity'], 'the seed that was refused left no entry'); + }); + +}); + +describe('down with throwaway instances', () => { + function setup(platforms: readonly ('ios' | 'android')[]) { + const w = world(); + const backends = platforms.map((platform) => ({ kind: 'local', platform, availability: () => ({ usable: true, why: 'test' }), release: async () => undefined, reapable: async () => [], describe: (lease: LocalLease) => lease.deviceName }) as unknown as DeviceBackend); + const deps = (): Deps => ({ host: { ...host, platforms, backends } as unknown as HostAdapter, workspace: w.workspace, runner: async () => assert.fail('down runs no commands'), env: w.env, progress: w.say, instances: w.open() }); + for (const platform of platforms) w.workspace.writeLease({ backend: 'local', platform, slot: 1, deviceName: `verify-${platform}-1`, deviceId: `id-${platform}`, claimNonce: `claim-${platform}`, acquiredAt: '2026-10-05T12:00:00Z', installedBuild: null }); + return { w, deps }; + } + + it('deletes the applications and reports them', async () => { + const { w, deps } = setup(['ios']); + await w.instances.ensure(NOTHING_NEW, quiet); + const name = w.clerk.live()[0]!.name; + const before = w.clerk.requests.length; + const dry = await down(deps(), { verb: 'down', stale: false, dryRun: true }); + assert.ok(dry.dryRun); + assert.deepEqual(dry.wouldDelete, [{ kind: 'application', name }]); + assert.equal(w.clerk.requests.length, before, 'a dry run reads the ledger and asks Clerk nothing'); + const result = await down(deps(), { verb: 'down', stale: false, dryRun: false }); + assert.ok(!result.dryRun); + assert.deepEqual(result.deletedApplications, [{ name }]); + assert.equal(w.clerk.live().length, 0); + }); + + it('keeps them while another platform in the worktree still holds a lease', async () => { + const { w, deps } = setup(['ios', 'android']); + await w.instances.ensure(NOTHING_NEW, quiet); + const first = await down(deps(), { verb: 'down', platform: 'ios', stale: false, dryRun: false }); + assert.ok(!first.dryRun); + assert.deepEqual(first.deletedApplications, []); + assert.equal(w.clerk.live().length, 1); + assert.ok(w.lines.some((line) => line.includes('which its android lease still uses'))); + const second = await down(deps(), { verb: 'down', platform: 'android', stale: false, dryRun: false }); + assert.ok(!second.dryRun); + assert.equal(second.deletedApplications.length, 1); + assert.equal(w.clerk.live().length, 0); + }); + + it('deletes the applications even when the device would not release', async () => { + const { w, deps } = setup(['ios']); + await w.instances.ensure(NOTHING_NEW, quiet); + const stuck = deps(); + const failing = { ...stuck, host: { ...stuck.host, backends: stuck.host.backends.map((backend) => ({ ...backend, release: async () => assert.fail('the simulator would not shut down') })) } as unknown as HostAdapter }; + await assert.rejects(down(failing, { verb: 'down', stale: false, dryRun: false }), /would not shut down/); + assert.equal(w.clerk.live().length, 0, 'a lease that failed to release is not a reason to keep its instances'); + }); + + it('still releases the device and reports the failure when the applications cannot be deleted', async () => { + const { w, deps } = setup(['ios']); + await w.instances.ensure(NOTHING_NEW, quiet); + w.clerk.state.refuseDelete = true; + await assert.rejects(down(deps(), { verb: 'down', stale: false, dryRun: false }), /could not be deleted/); + assert.equal(w.workspace.readLease('ios'), null, 'the lease was released first'); + assert.equal(existsSync(instancesDir(w)), true); + rmSync(w.dir, { recursive: true, force: true }); + }); +}); + +describe('the key and child processes', () => { + const credentialVariables = { CLERK_PLATFORM_API_KEY: PLATFORM_KEY, CLERK_PLATFORM_API_KEY_FILE: '/home/x/key', VERIFY_PLATFORM_KEY_REFERENCE: REFERENCE }; + + it('takes the Platform API key and where it is kept out of the environment every child inherits, and keeps them for the instances layer', () => { + const env: NodeJS.ProcessEnv = { ...credentialVariables, PATH: '/usr/bin' }; + const kept = takePlatformKey(env); + assert.deepEqual(env, { PATH: '/usr/bin' }); + assert.deepEqual(kept, { ...credentialVariables, PATH: '/usr/bin' }); + }); + + it('gives a program it starts neither the platform key nor where it is kept', () => { + assert.deepEqual(withoutClerkKeys({ ...credentialVariables, HOME: '/home/x' }), { HOME: '/home/x' }); + }); + + it('redacts a key from the moment it is read, before anything sends it', () => { + new Secret('clerk-platform-key', 'ak_readButNeverUsed000000000000'); + assert.equal(redact('op said ak_readButNeverUsed000000000000'), 'op said '); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/lease-flow.test.ts b/.claude/skills/verify-clerk-expo/test/lease-flow.test.ts new file mode 100644 index 00000000000..5e595d42dad --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/lease-flow.test.ts @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HELD, heldInstances } from '../testing/fake-instances.ts'; +import { describe, it } from 'node:test'; +import { doctor, down, leaseForRun, up, type Deps } from '../src/core/verbs.ts'; +import { openWorkspace } from '../src/core/workspace.ts'; +import { VerifyFailure, type BuildKey, type Command, type DeviceBackend, type HostAdapter, type HostEntry, type LocalLease, type RunContext, type ScratchPath } from '../src/core/types.ts'; + +function setup(buildMs: number, runtime?: HostAdapter['runtime']) { + const dir = mkdtempSync(join(tmpdir(), 'verify-flow-')); + execFileSync('git', ['init', '-q'], { cwd: dir }); + writeFileSync(join(dir, 'app.swift'), 'app'); + const events: string[] = []; + const progress: string[] = []; + let leases = 0; + const backend = { + kind: 'local', + platform: 'ios', + availability: () => ({ usable: true, why: 'test' }), + reapable: async () => [], + check: async () => 'held', + async acquire(request: { waitSeconds: number }): Promise { + events.push(`acquire wait=${request.waitSeconds}`); + leases += 1; + return { backend: 'local', platform: 'ios', slot: leases, deviceName: `verify-ios-${leases}`, deviceId: `UDID-${leases}`, claimNonce: `c${leases}`, acquiredAt: '', installedBuild: null }; + }, + install: async (lease: LocalLease) => (events.push('install'), lease), + release: async () => void events.push('release'), + describe: (lease: LocalLease) => lease.deviceName, + doctorChecks: async () => ({ toolchain: [], device: [] }), + } as unknown as DeviceBackend; + const host = { + repo: 'clerk-ios', + platforms: ['ios'], + backends: [backend], + features: [], + appId: () => 'com.clerk.E2EHost', + buildInputs: () => ['app.swift'], + ...(runtime === undefined ? { entry: () => ({ kind: 'binary' }) } : { runtime }), + async build(platform: 'ios', key: BuildKey, into: ScratchPath) { + events.push('build'); + await new Promise((resolve) => setTimeout(resolve, buildMs)); + mkdirSync(into, { recursive: true }); + writeFileSync(join(into, 'E2EHost.app'), ''); + return { platform, key, appId: 'com.clerk.E2EHost', path: join(into, 'E2EHost.app') as ScratchPath, source: 'local' }; + }, + } as unknown as HostAdapter; + const workspace = openWorkspace({ skillDir: dir, worktree: dir, home: join(dir, 'home') }); + const deps: Deps = { + host, + workspace, + runner: async () => ({ code: 0, stdout: '', stderr: '' }), + env: {}, + progress: (line: string) => void progress.push(line), + instances: heldInstances(), + }; + return { deps, events, progress }; +} + +const runCommand: Extract = { verb: 'run', selection: { all: true }, skip: [], include: [], video: false, waitSeconds: 0 }; + +async function until(holds: () => boolean, what: string): Promise { + const deadline = Date.now() + 15_000; + while (!holds()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +describe('lease flow', () => { + it('builds before it claims a lane', async () => { + const { deps, events } = setup(0); + await up(deps, { verb: 'up', waitSeconds: 0 }); + assert.deepEqual(events, ['build', 'acquire wait=0', 'install']); + }); + + it('doctor creates no directory and no file, in the worktree or in the machine-wide state', async () => { + const { deps } = setup(0); + const report = await doctor(deps, { verb: 'doctor', live: false }); + assert.equal(report.checks.find((c) => c.id === 'build')!.ok, false); + assert.equal(existsSync(deps.workspace.root), false); + assert.equal(existsSync(deps.workspace.home), false); + }); + + it('doctor names the platform in the build fix when the host has more than one', async () => { + const { deps } = setup(0); + const fix = async (host: { readonly platforms?: HostAdapter['platforms'] }, command: Partial>) => (await doctor({ ...deps, host: { ...deps.host, ...host } }, { verb: 'doctor', live: false, ...command })).checks.find((c) => c.id === 'build')!.fix; + assert.equal(await fix({}, {}), '{cli} up'); + assert.equal(await fix({ platforms: ['ios', 'android'] }, {}), '{cli} up --platform ios'); + }); + + it('doctor only warns about a gh that cannot attach, and says what attach then cannot do', async () => { + const { deps } = setup(0); + const gh = (await doctor(deps, { verb: 'doctor', live: false })).checks.find((c) => c.id === 'gh-attach')!; + assert.deepEqual(gh, { + id: 'gh-attach', + ok: true, + state: 'warning', + detail: "this gh has no `gh pr comment --attach`, so `{cli} attach` cannot post a run's video and screenshots from this machine", + fix: 'install a gh build whose `gh pr comment` has --attach', + }); + }); + + it('leases no device when no Platform API credential works', async () => { + const { deps, events } = setup(0); + const noCredential: Deps['instances'] = { ...deps.instances, access: async () => Promise.reject(new VerifyFailure('KEYS_MISSING', 'no Clerk Platform API credential works here', 'set one')) }; + await assert.rejects(up({ ...deps, instances: noCredential }, { verb: 'up', waitSeconds: 0 }), (error: VerifyFailure) => error.code === 'KEYS_MISSING'); + assert.deepEqual(events, [], 'nothing was built or leased'); + }); + + it('keeps the lease and says so when the instances fail after the device was leased', async () => { + const { deps, events } = setup(0); + const failing = { ...deps.instances, ensure: async () => Promise.reject(new VerifyFailure('INSTANCE_MISCONFIGURED', 'the instance does not match its file', 'correct the file')) }; + await assert.rejects(up({ ...deps, instances: failing }, { verb: 'up', waitSeconds: 0 }), (error: VerifyFailure) => error.code === 'INSTANCE_MISCONFIGURED' && error.fix === 'correct the file; the device stays leased until `{cli} down`'); + assert.deepEqual(events, ['build', 'acquire wait=0', 'install'], 'the lease finished and reached the lease file'); + assert.notEqual(deps.workspace.readLease('ios'), null); + }); + + it('reports the instances it brought up', async () => { + const { deps } = setup(0); + assert.deepEqual((await up(deps, { verb: 'up', waitSeconds: 0 })).instances, [HELD]); + }); + + it('lets run join an up that is still building instead of failing DEVICE_BUSY', async () => { + const { deps, events, progress } = setup(400); + const building = up(deps, { verb: 'up', waitSeconds: 0 }); + await until(() => events.includes('build'), 'up to start building'); + progress.length = 0; + const device = await leaseForRun(deps, 'ios', runCommand, { willChange: false }, async (outcome) => outcome.lease.backend === 'local' && outcome.lease.deviceName); + await building; + assert.equal(device, 'verify-ios-1'); + assert.equal(progress.filter((l) => l.startsWith('wait')).length, 1, 'one wait line while up holds the lock'); + assert.match(progress.find((l) => l.startsWith('wait')) ?? '', /is building ios-[0-9a-f]{12} or leasing the device/); + assert.deepEqual(events, ['build', 'acquire wait=0', 'install'], 'run reused the lease up made'); + }); + + it('holds the device lock for the run, so a second run reports DEVICE_BUSY', async () => { + const { deps } = setup(0); + await leaseForRun(deps, 'ios', runCommand, { willChange: false }, async () => { + await assert.rejects(leaseForRun(deps, 'ios', runCommand, { willChange: false }, async () => undefined), (error: VerifyFailure) => { + assert.equal(error.code, 'DEVICE_BUSY'); + assert.match(error.fix, /\{cli\} run --all --wait /, 'the fix names run, the verb that takes --wait'); + return true; + }); + }); + await leaseForRun(deps, 'ios', runCommand, { willChange: false }, async () => undefined); + }); + + it('passes run --wait to the lane claim', async () => { + const { deps, events } = setup(0); + await leaseForRun(deps, 'ios', { ...runCommand, waitSeconds: 300 }, { willChange: false }, async () => undefined); + assert.ok(events.includes('acquire wait=300')); + }); + + it('serves the entry a host runtime returns and ledgers its processes once', async () => { + const devClient: HostEntry = { kind: 'dev-client', launchArguments: ['-EXDevMenuIsOnboardingFinished', 'YES'], openLink: 'exp+app://expo-development-client/?url=http%3A%2F%2F127.0.0.1%3A8082', androidActivity: '.MainActivity' }; + const metro = { what: 'metro' as const, pid: 4242, startedAt: Date.parse('2026-10-03T00:00:00Z') }; + const { deps, progress } = setup(0, async (_lease, say) => (say('metro starting'), { entry: devClient, processes: [metro] })); + await up(deps, { verb: 'up', waitSeconds: 0 }); + assert.ok(progress.includes('metro starting'), 'what a runtime reports goes through the CLI\'s own progress output'); + const entry = await leaseForRun(deps, 'ios', runCommand, { willChange: false }, async (outcome) => outcome.entry); + assert.deepEqual(entry, devClient); + const context = JSON.parse(readFileSync(join(deps.workspace.root, 'context.json'), 'utf8')) as RunContext; + assert.deepEqual(context.targets[0]!.entry, devClient); + const metros = deps.workspace.unclosedEntries().filter((e) => e.kind === 'process' && e.what === 'metro'); + assert.equal(metros.length, 1, 'a reused Metro is ledgered once'); + }); + + it('keeps the binary entry for hosts without a runtime', async () => { + const { deps } = setup(0); + assert.deepEqual(await leaseForRun(deps, 'ios', runCommand, { willChange: false }, async (outcome) => outcome.entry), { kind: 'binary' }); + }); + + it('makes down wait for a run that holds the device, with one wait line, instead of failing DEVICE_BUSY', async () => { + const { deps, progress } = setup(0); + let downFinished = false; + let releaseRun: () => void = () => undefined; + let driving = false; + const running = leaseForRun(deps, 'ios', runCommand, { willChange: false }, () => new Promise((resolve) => ((driving = true), (releaseRun = resolve)))); + await until(() => driving, 'the run to hold the device'); + progress.length = 0; + const downing = down(deps, { verb: 'down', stale: false, dryRun: false }).then((result) => { + downFinished = true; + return result; + }); + await until(() => progress.some((line) => line.startsWith('wait')), 'down to say it is waiting'); + assert.equal(downFinished, false, 'down is still waiting while the run holds the device'); + releaseRun(); + await running; + const result = await downing; + assert.equal(result.dryRun, false); + assert.equal(progress.filter((l) => l.startsWith('wait')).length, 1); + assert.match(progress.find((l) => l.startsWith('wait')) ?? '', /driving the device; down waits for it, with no time limit/); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/ledgers.test.ts b/.claude/skills/verify-clerk-expo/test/ledgers.test.ts new file mode 100644 index 00000000000..b41b7bdcdaf --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/ledgers.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { heldInstances } from '../testing/fake-instances.ts'; +import { describe, it } from 'node:test'; +import { ensureLease } from '../src/core/devices.ts'; +import { newEntryId, openWorkspace } from '../src/core/workspace.ts'; +import type { BuildKey, DeviceBackend, HostAdapter, LedgerEntry, LocalLease, ScratchPath } from '../src/core/types.ts'; + +function worktree(root: string, name: string): string { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + execFileSync('git', ['init', '-q'], { cwd: dir }); + writeFileSync(join(dir, 'app.swift'), name); + return dir; +} + +const application = (name: string): LedgerEntry => ({ id: newEntryId(), kind: 'application', name, workspace: 'org_test' }); + +function fakes(finished: string[]) { + const lease: LocalLease = { backend: 'local', platform: 'ios', slot: 1, deviceName: 'verify-ios-1', deviceId: 'UDID', claimNonce: 'c', acquiredAt: '', installedBuild: null }; + const backend = { + kind: 'local', + platform: 'ios', + availability: () => ({ usable: true, why: 'test' }), + reapable: async () => [], + check: async () => 'held', + acquire: async () => lease, + install: async (held: LocalLease) => held, + describe: () => 'verify-ios-1', + } as unknown as DeviceBackend; + const host = { + repo: 'clerk-ios', + platforms: ['ios'], + backends: [backend], + appId: () => 'com.clerk.E2EHost', + buildInputs: () => ['app.swift'], + async build(platform: 'ios', key: BuildKey, into: ScratchPath) { + mkdirSync(into, { recursive: true }); + writeFileSync(join(into, 'E2EHost.app'), ''); + return { platform, key, appId: 'com.clerk.E2EHost', path: join(into, 'E2EHost.app') as ScratchPath, source: 'local' }; + }, + } as unknown as HostAdapter; + const instances = heldInstances({ finish: async (ledger) => (finished.push(ledger.worktree), []) }); + return { host, instances }; +} + +describe('up finishes ledgers of deleted worktrees', () => { + it('finishes their instances, closes their entries, and leaves live worktrees alone', async () => { + const root = mkdtempSync(join(tmpdir(), 'verify-ledgers-')); + const home = join(root, 'home'); + const live = worktree(root, 'live'); + const gone = worktree(root, 'gone'); + const other = worktree(root, 'other'); + const goneLedger = openWorkspace({ skillDir: gone, worktree: gone, home }); + goneLedger.append({ id: newEntryId(), kind: 'lease-intent', platform: 'ios', backend: 'local', worktree: gone }); + goneLedger.append(application('verify-throwaway-gone')); + const otherLedger = openWorkspace({ skillDir: other, worktree: other, home }); + otherLedger.append(application('verify-throwaway-other')); + rmSync(gone, { recursive: true }); + + const finished: string[] = []; + const { host, instances } = fakes(finished); + const workspace = openWorkspace({ skillDir: live, worktree: live, home }); + const options = { waitSeconds: 0, progress: () => undefined, instances, retryWith: '{cli} up --wait ' }; + await workspace.withAcquireLock('ios', (lock) => ensureLease(lock, workspace, host, options)); + + assert.deepEqual(finished, [gone]); + assert.deepEqual(openWorkspace({ skillDir: gone, worktree: gone, home }).unclosedEntries(), []); + assert.equal(otherLedger.unclosedEntries().length, 1, 'a worktree that still exists keeps its instances'); + + await workspace.withAcquireLock('ios', (lock) => ensureLease(lock, workspace, host, options)); + assert.equal(finished.length, 1, 'a finished ledger is not finished twice'); + }); + + it('leaves the ledger open when its instances cannot be deleted, so the next up retries', async () => { + const root = mkdtempSync(join(tmpdir(), 'verify-ledgers-')); + const home = join(root, 'home'); + const live = worktree(root, 'live'); + const gone = worktree(root, 'gone'); + openWorkspace({ skillDir: gone, worktree: gone, home }).append(application('verify-throwaway-gone')); + rmSync(gone, { recursive: true }); + const { host } = fakes([]); + const failing = heldInstances({ finish: async () => assert.fail('the Platform API is down') }); + const lines: string[] = []; + const workspace = openWorkspace({ skillDir: live, worktree: live, home }); + await workspace.withAcquireLock('ios', (lock) => ensureLease(lock, workspace, host, { waitSeconds: 0, progress: (l) => lines.push(l), instances: failing, retryWith: '{cli} up --wait ' })); + assert.equal(openWorkspace({ skillDir: gone, worktree: gone, home }).unclosedEntries().length, 1); + assert.ok(lines.some((l) => l.includes('left open'))); + }); + + it('reaps a removed worktree whose skill lived at a non-default path, using the path its ledger recorded', async () => { + const root = mkdtempSync(join(tmpdir(), 'verify-ledgers-')); + const home = join(root, 'home'); + const live = worktree(root, 'live'); + const gone = worktree(root, 'gone'); + const skillDir = join(gone, 'tools', 'skills', 'verify-acme'); + const goneLedger = openWorkspace({ skillDir, worktree: gone, home }); + goneLedger.append(application('verify-throwaway-gone')); + const owner = readFileSync(goneLedger.ledgerFile.replace(/\.jsonl$/, '.owner'), 'utf8').trim().split('\n'); + assert.deepEqual(owner, [gone, skillDir], 'the ledger records its worktree and its skill directory'); + rmSync(gone, { recursive: true }); + const finished: string[] = []; + const { host, instances } = fakes(finished); + const workspace = openWorkspace({ skillDir: live, worktree: live, home }); + await workspace.withAcquireLock('ios', (lock) => + ensureLease(lock, workspace, host, { waitSeconds: 0, progress: () => undefined, instances, retryWith: '{cli} up --wait ' }), + ); + assert.deepEqual(finished, [gone]); + assert.deepEqual(openWorkspace({ skillDir, worktree: gone, home }).unclosedEntries(), []); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/lock.test.ts b/.claude/skills/verify-clerk-expo/test/lock.test.ts new file mode 100644 index 00000000000..18aa3aca1f9 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/lock.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; + +const holder = join(import.meta.dirname, '..', 'testing', 'lock-holder.ts'); + +function runHolder(skillDir: string, home: string, log: string, startAt: number, mode: 'hold' | 'crash'): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, [holder, skillDir, home, log, String(startAt), mode], { stdio: 'ignore' }); + child.on('close', (code) => resolve(code)); + }); +} + +describe('acquire lock', () => { + it('lets exactly one of several processes break a stale lock at a time', async () => { + for (let round = 0; round < 4; round += 1) { + const skillDir = mkdtempSync(join(tmpdir(), 'verify-lock-')); + const home = join(skillDir, 'home'); + const log = join(skillDir, 'log'); + writeFileSync(log, ''); + await runHolder(skillDir, home, log, 0, 'crash'); + const startAt = Date.now() + 1500; + const codes = await Promise.all(Array.from({ length: 6 }, () => runHolder(skillDir, home, log, startAt, 'hold'))); + assert.deepEqual(codes, [0, 0, 0, 0, 0, 0]); + const lines = readFileSync(log, 'utf8').trim().split('\n'); + assert.equal(lines.length, 12, `round ${round}: every holder entered once`); + for (let i = 0; i < lines.length; i += 2) { + const [enter, pid] = lines[i]!.split(' '); + assert.equal(enter, 'enter', `round ${round}: two holders overlapped:\n${lines.join('\n')}`); + assert.equal(lines[i + 1], `exit ${pid}`, `round ${round}: two holders overlapped:\n${lines.join('\n')}`); + } + } + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/processes.test.ts b/.claude/skills/verify-clerk-expo/test/processes.test.ts new file mode 100644 index 00000000000..31bb32c18af --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/processes.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { isAlive } from '../src/core/exec.ts'; +import { openProcesses, stopProcesses } from '../src/core/ledgers.ts'; +import { agentDeviceDaemonCheck } from '../src/core/verbs.ts'; +import { newEntryId, openWorkspace } from '../src/core/workspace.ts'; + +const startedAt = (pid: number) => Date.parse(execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' }).trim()); + +function sleeper(): ChildProcess { + return spawn('sleep', ['30'], { stdio: 'ignore' }); +} + +async function exited(child: ChildProcess, ms: number): Promise { + if (child.exitCode !== null || child.signalCode !== null) return true; + return new Promise((resolve) => { + const timer = setTimeout(() => resolve(false), ms); + child.on('exit', () => { + clearTimeout(timer); + resolve(true); + }); + }); +} + +describe('stopProcesses', () => { + it('stops any ledgered process that is still the same process, whatever its command', async () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-procs-')); + const workspace = openWorkspace({ skillDir: dir, worktree: dir, home: join(dir, 'home') }); + const recorder = sleeper(); + const reused = sleeper(); + await new Promise((resolve) => setTimeout(resolve, 200)); + workspace.append({ id: newEntryId(), kind: 'process', what: 'recorder', pid: recorder.pid!, startedAt: new Date(startedAt(recorder.pid!)).toISOString() }); + workspace.append({ id: newEntryId(), kind: 'process', what: 'agent-device', pid: reused.pid!, startedAt: new Date(startedAt(reused.pid!) - 3_600_000).toISOString() }); + const stopped = stopProcesses(workspace, openProcesses(workspace)); + assert.deepEqual(stopped, [`recorder ${recorder.pid}`, `agent-device ${reused.pid} had already exited`]); + assert.equal(await exited(recorder, 2000), true, 'the ledgered recorder was signalled'); + assert.equal(isAlive(reused.pid!), true, 'a pid whose start time does not match is left alone'); + assert.deepEqual(workspace.unclosedEntries(), []); + reused.kill(); + }); +}); + +describe('agent-device daemon doctor check', () => { + for (const folder of ['plain', 'with space']) { + it(`fails only once the daemon's install is removed (${folder} path)`, async () => { + const dir = join(mkdtempSync(join(tmpdir(), 'verify-daemon-')), folder); + execFileSync('mkdir', ['-p', dir]); + const script = join(dir, 'daemon.js'); + writeFileSync(script, 'setInterval(() => {}, 1000);'); + const daemon = spawn(process.execPath, [script], { stdio: 'ignore' }); + await new Promise((resolve) => setTimeout(resolve, 300)); + const lstart = execFileSync('ps', ['-o', 'lstart=', '-p', String(daemon.pid)], { encoding: 'utf8' }).trim(); + const state = join(dir, 'state'); + execFileSync('mkdir', ['-p', state]); + writeFileSync(join(state, 'daemon.json'), JSON.stringify({ pid: daemon.pid, processStartTime: lstart })); + const dirs = [ + { label: 'shared', dir: join(dir, 'none') }, + { label: 'worktree', dir: state }, + ]; + try { + const healthy = agentDeviceDaemonCheck(dirs); + assert.equal(healthy.ok, true, healthy.detail); + assert.match(healthy.detail, /^shared: no daemon running; worktree: pid \d+ from /); + rmSync(script); + const check = agentDeviceDaemonCheck(dirs); + assert.equal(check.ok, false); + assert.match(check.fix ?? '', new RegExp(`kill ${daemon.pid}`)); + } finally { + daemon.kill(); + } + }); + } +}); diff --git a/.claude/skills/verify-clerk-expo/test/run-groups.test.ts b/.claude/skills/verify-clerk-expo/test/run-groups.test.ts new file mode 100644 index 00000000000..63843de7746 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/run-groups.test.ts @@ -0,0 +1,360 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; +import { exitCodeFor } from '../src/core/cli.ts'; +import { assertPublishable, readRecord } from '../src/core/evidence.ts'; +import { createInstances } from '../src/core/instances/instances.ts'; +import { SettingsRefused } from '../src/core/instances/settings.ts'; +import { commentBody } from '../src/core/publish.ts'; +import { runVerb, type Deps } from '../src/core/verbs.ts'; +import { openWorkspace } from '../src/core/workspace.ts'; +import { VerifyFailure, type BuildKey, type Command, type DeviceBackend, type HostAdapter, type InstanceSettings, type LocalLease, type ScratchPath } from '../src/core/types.ts'; +import { PLATFORM_KEY, fakeClerk, type FakeClerkOptions } from '../testing/fake-clerk.ts'; + +const MFA: InstanceSettings = { config: { auth_multi_factor: { required_for_sign_up: true } }, environment: { 'user_settings.sign_up.mfa.required': true } }; +const FORCED_ORG: InstanceSettings = { config: { organization_settings: { force_organization_selection: true } }, environment: { 'organization_settings.force_organization_selection': true } }; +const MFA_LABEL = 'auth_multi_factor.required_for_sign_up=true'; +const ORG_LABEL = 'organization_settings.force_organization_selection=true'; +const AUTH_START = 'specs/golden/auth-start/auth-start.e2e.ts'; +const CHOOSE_ORG = 'specs/golden/session-tasks/choose-organization.e2e.ts'; +const COMPLETE_MFA = 'specs/golden/session-tasks/complete-setup-mfa.e2e.ts'; +const SETUP_MFA = 'specs/golden/session-tasks/setup-mfa.e2e.ts'; +const SIGN_UP = 'specs/golden/sign-up/complete.e2e.ts'; +const GOLDEN: Readonly> = { [AUTH_START]: null, [CHOOSE_ORG]: FORCED_ORG, [COMPLETE_MFA]: MFA, [SETUP_MFA]: MFA, [SIGN_UP]: null }; + +const FAKE_E2E = `#!${process.execPath} +const fs = require('node:fs'); +const path = require('node:path'); +const args = process.argv.slice(2); +const output = args[args.indexOf('--output') + 1]; +const specs = args.slice(1, args.indexOf('--config')); +const plan = JSON.parse(fs.readFileSync('fake-e2e.json', 'utf8')); +fs.appendFileSync('invocations.jsonl', JSON.stringify({ specs, output, args }) + '\\n'); +(async () => { + const context = JSON.parse(fs.readFileSync(process.env.VERIFY_CONTEXT, 'utf8')); + const token = fs.readFileSync(context.broker.tokenFile, 'utf8'); + const results = []; + for (const [index, file] of specs.entries()) { + console.log('fake e2e runs ' + file); + if ((plan.silent || []).includes(file)) continue; + const seeded = await fetch(context.broker.url + '/seedUser', { method: 'POST', headers: { Authorization: 'Bearer ' + token }, body: '{}' }); + const body = await seeded.json(); + const shot = 'ios/' + index + '/attempt-0/screenshots/001-home.png'; + fs.mkdirSync(path.dirname(path.join(output, 'artifacts', shot)), { recursive: true }); + fs.writeFileSync(path.join(output, 'artifacts', shot), path.basename(output)); + results.push({ + id: path.basename(output) + '-' + index + '-0000', kind: 'test', titlePath: ['a test of ' + path.basename(file)], file, platform: 'ios', tags: [], + status: seeded.ok ? 'passed' : 'failed', + attempts: [{ status: seeded.ok ? 'passed' : 'failed', durationMs: 1000, ...(seeded.ok ? {} : { error: { message: body.message } }), artifacts: [{ kind: 'screenshot', path: shot, producer: { kind: 'step', stepId: 's1' } }], steps: [{ id: 's1', api: 'app.screenshot', label: 'home' }] }], + }); + } + if (plan.edit && output.endsWith('/e2e')) fs.appendFileSync(plan.edit, '\\n// edited while the run was in progress\\n'); + if (!(plan.noReport || []).includes(path.basename(output))) { + fs.mkdirSync(output, { recursive: true }); + fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({ schemaVersion: 'report-1', run: { results } })); + } + process.exitCode = plan.exitCode || 0; +})(); +`; + +interface FakeE2EPlan { + readonly silent?: readonly string[]; + readonly noReport?: readonly string[]; + readonly edit?: string; + readonly exitCode?: number; +} + +const specSource = (declared: InstanceSettings | null): string => + `import { test${declared === null ? '' : ', type InstanceSettings'} } from '../../fixtures.ts';\n${declared === null ? '' : `\nexport const instanceSettings: InstanceSettings = ${JSON.stringify(declared)};\n`}\ntest('x', async ({ host }) => {\n await host.seedUser();\n});\n`; + +function world(options: { readonly specs?: Readonly>; readonly plan?: FakeE2EPlan; readonly env?: Record; readonly clerk?: FakeClerkOptions } = {}) { + const dir = mkdtempSync(join(tmpdir(), 'verify-run-')); + execFileSync('git', ['init', '-q'], { cwd: dir }); + writeFileSync(join(dir, 'app.swift'), 'app'); + for (const [path, declared] of Object.entries(options.specs ?? GOLDEN)) { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + writeFileSync(join(dir, path), typeof declared === 'string' ? declared : specSource(declared)); + } + mkdirSync(join(dir, 'node_modules', '.bin'), { recursive: true }); + writeFileSync(join(dir, 'node_modules', '.bin', 'e2e'), FAKE_E2E); + chmodSync(join(dir, 'node_modules', '.bin', 'e2e'), 0o755); + writeFileSync(join(dir, 'fake-e2e.json'), JSON.stringify(options.plan ?? {})); + + const clerk = fakeClerk(options.clerk); + const frontendApi = { answers: null as number | null }; + const request = (async (input: string | URL, init?: RequestInit) => (frontendApi.answers !== null && String(input).endsWith('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/v1/environment') ? new Response('', { status: frontendApi.answers }) : clerk.fetch(input, init))) as typeof fetch; + const lines: string[] = []; + const hooks: ((line: string) => void)[] = []; + const progress = (line: string) => { + lines.push(line); + for (const hook of hooks) hook(line); + }; + let leases = 0; + const backend = { + kind: 'local', + platform: 'ios', + availability: () => ({ usable: true, why: 'test' }), + reapable: async () => [], + check: async () => 'held', + async acquire(): Promise { + leases += 1; + lines.push('device leased'); + return { backend: 'local', platform: 'ios', slot: leases, deviceName: `verify-ios-${leases}`, deviceId: `UDID-${leases}`, claimNonce: `c${leases}`, acquiredAt: '', installedBuild: null }; + }, + install: async (lease: LocalLease) => lease, + release: async () => undefined, + logs: async () => '', + describe: (lease: LocalLease) => lease.deviceName, + } as unknown as DeviceBackend; + const host = { + repo: 'clerk-ios', + platforms: ['ios'], + screens: ['home'], + backends: [backend], + appId: () => 'com.clerk.E2EHost', + entry: () => ({ kind: 'binary' }), + buildInputs: () => ['app.swift'], + async build(platform: 'ios', key: BuildKey, into: ScratchPath) { + mkdirSync(into, { recursive: true }); + writeFileSync(join(into, 'E2EHost.app'), ''); + return { platform, key, appId: 'com.clerk.E2EHost', path: join(into, 'E2EHost.app') as ScratchPath, source: 'local' }; + }, + } as unknown as HostAdapter; + const workspace = openWorkspace({ skillDir: dir, worktree: dir, home: join(dir, 'home') }); + const env = options.env ?? { CLERK_PLATFORM_API_KEY: PLATFORM_KEY }; + const deps = (instanceEnv: Record = env): Deps => ({ + host, + workspace, + runner: async () => ({ code: 0, stdout: '', stderr: '' }), + env: {}, + progress, + instances: createInstances({ workspace, env: instanceEnv, runner: async () => ({ code: 127, stdout: '', stderr: '' }), progress, fetch: request }), + }); + const invocations = () => readFileSync(join(dir, 'invocations.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line) as { specs: string[]; output: string; args: string[] }); + const lastRecord = () => readRecord(workspace.runDir(workspace.runs().at(-1)!)); + const writes = () => clerk.platformRequests().filter((request) => request.method !== 'GET').map((request) => `${request.method} ${request.url.replace('api.clerk.com/v1/platform', '').replace(/\/app_\w+\/instances\/ins_\w+/, '/{app}')}`); + return { dir, clerk, frontendApi, workspace, lines, hooks, deps, invocations, lastRecord, writes }; +} + +const RUN_ALL: Extract = { verb: 'run', selection: { all: true }, skip: [], include: [], video: false, waitSeconds: 0 }; +const statuses = (results: readonly { readonly spec: { readonly path: string }; readonly title: string; readonly status: string }[]) => results.map((result) => `${result.status} ${result.spec.path.split('/').at(-1)}${result.title === 'not run' ? ' (not run)' : ''}`); + +describe('a run whose spec files declare different settings', () => { + it('drives each group on one application, in one e2e invocation each, and records what it did', async () => { + const w = world(); + const result = await runVerb(w.deps(), RUN_ALL); + const { record, dir } = result; + const application = w.clerk.live()[0]!; + + assert.ok(w.lines.includes(`settings 3 groups in this run: standard (2 spec files), ${ORG_LABEL} (1), ${MFA_LABEL} (2)`), w.lines.join('\n')); + assert.deepEqual(w.invocations().map((invocation) => [invocation.output.split('/').at(-1), invocation.specs]), [['e2e', [AUTH_START, SIGN_UP]], ['e2e-2', [CHOOSE_ORG]], ['e2e-3', [COMPLETE_MFA, SETUP_MFA]]]); + assert.ok(w.invocations().every((invocation) => invocation.args.includes('--pass-with-no-tests')), 'a group whose tests are all left out must not fail the run'); + assert.deepEqual(w.writes(), ['POST /applications', 'PATCH /applications/{app}/config', 'PATCH /applications/{app}/config', 'PATCH /applications/{app}/config'], 'one create, and one PATCH for the standard file and for each of the two changes'); + assert.equal(w.clerk.applications.length, 1); + + assert.deepEqual(record.settings, [ + { label: 'standard', askedBy: null, specs: [AUTH_START, SIGN_UP], application: application.id, changed: false, held: true, e2eReport: join(dir, 'e2e', 'report.json') }, + { label: ORG_LABEL, askedBy: CHOOSE_ORG, specs: [CHOOSE_ORG], application: application.id, changed: true, held: true, e2eReport: join(dir, 'e2e-2', 'report.json') }, + { label: MFA_LABEL, askedBy: COMPLETE_MFA, specs: [COMPLETE_MFA, SETUP_MFA], application: application.id, changed: true, held: true, e2eReport: join(dir, 'e2e-3', 'report.json') }, + ]); + assert.equal(record.e2eReport, join(dir, 'e2e', 'report.json'), 'the first group\'s, so a run with one group reads as it always did'); + assert.deepEqual(record.instances, [{ application: application.id }]); + assert.deepEqual(statuses(record.results), ['passed auth-start.e2e.ts', 'passed complete.e2e.ts', 'passed choose-organization.e2e.ts', 'passed complete-setup-mfa.e2e.ts', 'passed setup-mfa.e2e.ts']); + assert.equal(JSON.stringify(record.results).includes('sourceHash'), false); + + assert.equal(application.users, 5, 'every spec seeded its user in the one application, with that application\'s key'); + assert.equal(record.identities.length, 5); + assert.ok(record.identities.every((identity) => identity.userId?.startsWith(`user_${application.id}_`))); + const identities = w.workspace.unclosedEntries().flatMap((entry) => (entry.kind === 'identity' || entry.kind === 'user' ? [entry] : [])); + assert.deepEqual(identities.map((entry) => entry.kind), Array.from({ length: 5 }, () => ['identity', 'user']).flat(), 'each spec reserved one email and made one user'); + + const log = readFileSync(join(dir, 'e2e.log'), 'utf8').split('\n'); + assert.deepEqual(log.filter((line) => line.startsWith('settings ')), [`settings ${ORG_LABEL}: 1 spec file`, `settings ${MFA_LABEL}: 2 spec files`]); + assert.ok(log.indexOf(`fake e2e runs ${SIGN_UP}`) < log.indexOf(`settings ${ORG_LABEL}: 1 spec file`) && log.indexOf(`settings ${ORG_LABEL}: 1 spec file`) < log.indexOf(`fake e2e runs ${CHOOSE_ORG}`), 'one log for the whole run, in the order the groups ran'); + assert.deepEqual(record.screenshots, [{ label: 'home', path: join(dir, 'screenshots', 'home.png') }], 'one screenshot per label across the groups'); + assert.equal(readFileSync(join(dir, 'screenshots', 'home.png'), 'utf8'), 'e2e-3', 'the last group to take a label keeps it'); + + assert.match(result.next, /^\{cli\} attach /); + assert.equal(exitCodeFor(result), 0); + const comment = commentBody(assertPublishable(record, [])); + assert.ok(comment.includes(`Instance settings \`${ORG_LABEL}\` (declared by \`${CHOOSE_ORG}\`): 1 of 1 passed.`)); + assert.ok(comment.includes(`Instance settings \`${MFA_LABEL}\` (declared by \`${COMPLETE_MFA}\`): 2 of 2 passed.`)); + assert.equal(comment.includes('Instance settings `standard`'), false); + + w.lines.length = 0; + await runVerb(w.deps(), RUN_ALL); + assert.ok(w.lines.includes(`settings 3 groups in this run: ${MFA_LABEL} (2 spec files), ${ORG_LABEL} (1), standard (2)`), 'the next run starts with the settings the last one left'); + assert.ok(w.lines.includes(`settings ${MFA_LABEL} already on ${application.id}`)); + assert.equal(w.writes().length, 6, 'two more changes, and no create'); + }); + + it('runs one group in the directory a run always used, with nothing about settings to say', async () => { + const w = world({ specs: { [AUTH_START]: null, [SIGN_UP]: null } }); + const { record, dir } = await runVerb(w.deps(), RUN_ALL); + assert.deepEqual(w.invocations().map((invocation) => invocation.output.split('/').at(-1)), ['e2e']); + assert.equal(w.lines.some((line) => /groups in this run/.test(line)), false); + assert.deepEqual(record.settings.map((group) => [group.label, group.changed, group.e2eReport]), [['standard', false, join(dir, 'e2e', 'report.json')]], 'the application was put on the standard file before the run drove, so the group changed nothing'); + assert.equal(commentBody(assertPublishable(record, [])).includes('Instance settings'), false); + }); + + it('opens the Platform credential before it leases a device when the run will change settings', async () => { + const w = world(); + await w.deps().instances.ensure({ willChange: false }, () => undefined); + await assert.rejects(runVerb(w.deps({}), RUN_ALL), (error: VerifyFailure) => error.code === 'KEYS_MISSING'); + assert.equal(w.workspace.readLease('ios'), null, 'a credential that is gone stops the run before a device is leased, not between two groups'); + assert.equal(w.lines.includes('device leased'), false); + + w.lines.length = 0; + await runVerb(w.deps(), RUN_ALL); + const opened = w.lines.findIndex((line) => line.startsWith('clerk Platform API: ')); + assert.ok(opened >= 0 && opened < w.lines.indexOf('device leased'), w.lines.join('\n')); + }); + +}); + +describe('a group that did not run in full', () => { + const notPublishable = (w: ReturnType) => assert.throws(() => assertPublishable(w.lastRecord(), []), { code: 'EVIDENCE_UNSAFE' }); + + it('fails every file of a group whose e2e wrote no report, seals the run, and exits non-zero', async () => { + const w = world({ plan: { noReport: ['e2e-2'] } }); + await assert.rejects(runVerb(w.deps(), RUN_ALL), (error: VerifyFailure) => error.code === 'E2E_CRASHED' && error.message === `e2e exited 0 before writing a report for ${ORG_LABEL}` && /e2e\.log$/.test(error.fix)); + const record = w.lastRecord(); + assert.equal(record.sealed, true); + assert.deepEqual(statuses(record.results), ['passed auth-start.e2e.ts', 'passed complete.e2e.ts', 'failed choose-organization.e2e.ts (not run)', 'passed complete-setup-mfa.e2e.ts', 'passed setup-mfa.e2e.ts']); + assert.equal(record.results[2]!.error, `e2e exited 0 before writing a report for ${ORG_LABEL}`); + assert.deepEqual(record.settings.map((group) => group.held), [true, true, true]); + notPublishable(w); + assert.ok(w.lines.some((line) => /^evidence .* sealed; 1 group of 3 did not run in full/.test(line))); + }); + + it('fails a selected file that has no result in its group\'s report', async () => { + const w = world({ plan: { silent: [SETUP_MFA] } }); + const result = await runVerb(w.deps(), RUN_ALL); + assert.deepEqual(statuses(result.record.results).slice(3), ['passed complete-setup-mfa.e2e.ts', 'failed setup-mfa.e2e.ts (not run)']); + assert.equal(result.record.results[4]!.error, 'e2e reported no result for this file: it registers no test'); + assert.equal(exitCodeFor(result), 1); + assert.doesNotMatch(result.next, /attach/); + notPublishable(w); + }); + + it('says that e2e exited non-zero when a selected file has no result, and where to read why', async () => { + const w = world({ plan: { silent: [SETUP_MFA], exitCode: 1 } }); + const result = await runVerb(w.deps(), RUN_ALL); + assert.equal(result.record.results[4]!.error, 'e2e exited 1 and reported no result for this file: it failed to load or registers no test; e2e.log in the run directory says which'); + }); + + it('fails the group of a spec file edited after the run was planned, and still runs the others', async () => { + const w = world(); + writeFileSync(join(w.dir, 'fake-e2e.json'), JSON.stringify({ edit: join(w.dir, SETUP_MFA) })); + await assert.rejects(runVerb(w.deps(), RUN_ALL), (error: VerifyFailure) => error.code === 'USAGE' && error.message === `${SETUP_MFA} changed while the run was in progress` && error.fix.startsWith('rerun')); + const record = w.lastRecord(); + assert.deepEqual(statuses(record.results), ['passed auth-start.e2e.ts', 'passed complete.e2e.ts', 'passed choose-organization.e2e.ts', 'failed complete-setup-mfa.e2e.ts (not run)', 'failed setup-mfa.e2e.ts (not run)']); + assert.deepEqual(record.settings[2], { label: MFA_LABEL, askedBy: COMPLETE_MFA, specs: [COMPLETE_MFA, SETUP_MFA], application: null, changed: false, held: false, e2eReport: null }); + assert.equal(w.invocations().length, 2); + assert.equal(w.clerk.live()[0]!.environment['user_settings.sign_up.mfa.required'], false, 'the settings of a plan that no longer matches the file were never applied'); + }); + + it('fails the group of a spec file edited while its settings were being applied, before e2e runs it, and still runs the next', async () => { + const w = world(); + w.hooks.push((line) => { + if (line.startsWith('settings changing ') && line.endsWith(`to ${ORG_LABEL}, which ${CHOOSE_ORG} declares`)) writeFileSync(join(w.dir, CHOOSE_ORG), specSource(MFA)); + }); + await assert.rejects(runVerb(w.deps(), RUN_ALL), (error: VerifyFailure) => error.code === 'USAGE' && error.message === `${CHOOSE_ORG} changed while the run was in progress` && error.fix.startsWith('rerun')); + const record = w.lastRecord(); + assert.deepEqual(w.invocations().map((invocation) => invocation.specs), [[AUTH_START, SIGN_UP], [COMPLETE_MFA, SETUP_MFA]], 'e2e never ran the file under the settings of its old text'); + assert.deepEqual(statuses(record.results), ['passed auth-start.e2e.ts', 'passed complete.e2e.ts', 'failed choose-organization.e2e.ts (not run)', 'passed complete-setup-mfa.e2e.ts', 'passed setup-mfa.e2e.ts']); + assert.deepEqual(record.settings.map((group) => [group.application !== null, group.changed, group.held, group.e2eReport !== null]), [[true, false, true, true], [true, true, false, false], [true, true, true, true]]); + assert.deepEqual(w.workspace.unclosedEntries().filter((entry) => entry.kind === 'application').length, 1); + }); + + it('fails the group of a spec file edited while e2e was running it, though e2e reported a pass, and still runs the next', async () => { + const w = world(); + writeFileSync(join(w.dir, 'fake-e2e.json'), JSON.stringify({ edit: join(w.dir, AUTH_START) })); + await assert.rejects(runVerb(w.deps(), RUN_ALL), (error: VerifyFailure) => error.code === 'USAGE' && error.message === `${AUTH_START} changed while the run was in progress` && error.fix.startsWith('rerun')); + const record = w.lastRecord(); + assert.deepEqual(statuses(record.results), ['failed auth-start.e2e.ts (not run)', 'failed complete.e2e.ts (not run)', 'passed choose-organization.e2e.ts', 'passed complete-setup-mfa.e2e.ts', 'passed setup-mfa.e2e.ts']); + assert.deepEqual(record.settings.map((group) => [group.application !== null, group.held, group.e2eReport !== null]), [[true, true, true], [true, true, true], [true, true, true]]); + assert.equal(w.invocations().length, 3); + notPublishable(w); + }); + + it('keeps in the run directory the text each group was planned from, not what the file holds later', async () => { + const w = world(); + w.hooks.push((line) => { + if (line.startsWith('instances ')) writeFileSync(join(w.dir, CHOOSE_ORG), specSource(MFA)); + }); + await assert.rejects(runVerb(w.deps(), RUN_ALL), (error: VerifyFailure) => error.message === `${CHOOSE_ORG} changed while the run was in progress`); + const run = w.workspace.runDir(w.workspace.runs().at(-1)!); + assert.equal(readFileSync(join(run, CHOOSE_ORG), 'utf8'), specSource(FORCED_ORG)); + assert.equal(readFileSync(join(run, AUTH_START), 'utf8'), specSource(null)); + }); + + it('stops at a failure that comes after the settings were applied, and fails every later group with it', async () => { + const w = world(); + w.hooks.push((line) => { + if (line.startsWith('settings changing ') && line.includes(` to ${ORG_LABEL}, `)) rmSync(join(w.dir, 'node_modules', '.bin', 'e2e')); + }); + await assert.rejects(runVerb(w.deps(), RUN_ALL), (error: VerifyFailure) => error.code === 'NOT_READY' && error.message === 'the pinned e2e is not installed'); + const record = w.lastRecord(); + assert.deepEqual(statuses(record.results), ['passed auth-start.e2e.ts', 'passed complete.e2e.ts', 'failed choose-organization.e2e.ts (not run)', 'failed complete-setup-mfa.e2e.ts (not run)', 'failed setup-mfa.e2e.ts (not run)']); + assert.equal(record.results[4]!.error, 'an earlier group of this run failed, so this one did not run: the pinned e2e is not installed'); + assert.deepEqual(record.settings.map((group) => [group.application !== null, group.changed, group.held, group.e2eReport !== null]), [[true, false, true, true], [true, true, false, false], [false, false, false, false]]); + assert.equal(w.writes().filter((call) => call.startsWith('PATCH')).length, 2, 'the standard file and the one change; the last group\'s settings were never sent'); + }); + + it('fails only its own group when Clerk refuses the declaration, and goes on to the next', async () => { + const w = world(); + w.clerk.state.refusals.push({ path: 'organization_settings.force_organization_selection', value: true, status: 400, code: 'unknown_config_key', param: 'organization_settings.force_organization_selection', message: 'is not a config key' }); + await assert.rejects(runVerb(w.deps(), RUN_ALL), (error: VerifyFailure) => error instanceof SettingsRefused && error.message.startsWith(`${CHOOSE_ORG} declares ${ORG_LABEL}, and Clerk's Platform API refused it`)); + const record = w.lastRecord(); + assert.deepEqual(statuses(record.results), ['passed auth-start.e2e.ts', 'passed complete.e2e.ts', 'failed choose-organization.e2e.ts (not run)', 'passed complete-setup-mfa.e2e.ts', 'passed setup-mfa.e2e.ts']); + assert.match(record.results[2]!.error ?? '', /refused it: organization_settings\.force_organization_selection \(400 unknown_config_key\)/); + assert.deepEqual(record.settings.map((group) => [group.application !== null, group.changed, group.held, group.e2eReport !== null]), [[true, false, true, true], [false, false, false, false], [true, true, true, true]]); + assert.deepEqual(w.invocations().map((invocation) => invocation.specs), [[AUTH_START, SIGN_UP], [COMPLETE_MFA, SETUP_MFA]]); + notPublishable(w); + }); + + it('stops at a failure that is not about a declaration, and fails every later group with it', async () => { + const w = world(); + const deps = w.deps(); + await deps.instances.ensure({ willChange: false }, () => undefined); + w.clerk.state.failConfigure = 1; + await assert.rejects(runVerb(deps, RUN_ALL), (error: VerifyFailure) => !(error instanceof SettingsRefused) && error.code === 'NOT_READY' && /answered 500/.test(error.message)); + const record = w.lastRecord(); + assert.deepEqual(statuses(record.results), ['passed auth-start.e2e.ts', 'passed complete.e2e.ts', 'failed choose-organization.e2e.ts (not run)', 'failed complete-setup-mfa.e2e.ts (not run)', 'failed setup-mfa.e2e.ts (not run)']); + assert.match(record.results[4]!.error ?? '', /^an earlier group of this run failed, so this one did not run: Clerk's Platform API answered 500/); + assert.equal(w.invocations().length, 1, 'the next group would have met the same outage'); + assert.deepEqual(w.writes().filter((call) => call.startsWith('PATCH')).length, 2, 'the standard file, and the one change that failed'); + assert.ok(w.lines.some((line) => /sealed; 2 groups of 3 did not run in full/.test(line))); + }); + + it('fails a group whose settings no longer held when its specs ended, on top of what e2e reported', async () => { + const w = world(); + w.hooks.push((line) => { + if (line === `fake e2e runs ${SETUP_MFA}`) w.clerk.live()[0]!.environment['user_settings.sign_up.mfa.required'] = false; + }); + await assert.rejects(runVerb(w.deps(), RUN_ALL), (error: VerifyFailure) => error.code === 'INSTANCE_MISCONFIGURED' && error.message === `the instance no longer showed ${MFA_LABEL} when its specs ended, so what they saw is unknown`); + const record = w.lastRecord(); + assert.deepEqual(statuses(record.results).slice(3), ['passed complete-setup-mfa.e2e.ts', 'passed setup-mfa.e2e.ts', 'failed complete-setup-mfa.e2e.ts (not run)', 'failed setup-mfa.e2e.ts (not run)']); + assert.deepEqual(record.settings.map((group) => group.held), [true, true, false]); + notPublishable(w); + }); + + it('says the environment could not be read, and what answered, when that is why it cannot tell whether the settings held', async () => { + const w = world(); + w.hooks.push((line) => { + if (line === `fake e2e runs ${SETUP_MFA}`) w.frontendApi.answers = 502; + }); + const application = () => w.clerk.live()[0]!; + await assert.rejects( + runVerb(w.deps(), RUN_ALL), + (error: VerifyFailure) => error.code === 'NOT_READY' && error.message === `the instance's environment could not be read after the specs on ${MFA_LABEL} ended, so what they saw is unknown: the Frontend API of ${application().id} (${application().name}) answered 502`, + ); + const record = w.lastRecord(); + assert.deepEqual(statuses(record.results).slice(3), ['passed complete-setup-mfa.e2e.ts', 'passed setup-mfa.e2e.ts', 'failed complete-setup-mfa.e2e.ts (not run)', 'failed setup-mfa.e2e.ts (not run)']); + assert.deepEqual(record.settings.map((group) => group.held), [true, true, false]); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/settings.test.ts b/.claude/skills/verify-clerk-expo/test/settings.test.ts new file mode 100644 index 00000000000..73519637500 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/settings.test.ts @@ -0,0 +1,325 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { STANDARD, STANDARD_FILE, configFor, configLeaves, declaredIn, expectedEnvironment, planGroups, sameLeaf, settingsOf, standardFile } from '../src/core/instances/settings.ts'; +import { VerifyFailure, type InstanceSettings, type SpecRef } from '../src/core/types.ts'; + +const MFA: InstanceSettings = { config: { auth_multi_factor: { required_for_sign_up: true } }, environment: { 'user_settings.sign_up.mfa.required': true } }; +const FORCED_ORG: InstanceSettings = { config: { organization_settings: { force_organization_selection: true } }, environment: { 'organization_settings.force_organization_selection': true } }; + +const exported = (literal: string, annotation = ': InstanceSettings') => `import { test } from '../../fixtures.ts';\n\nexport const instanceSettings${annotation} = ${literal}\n\ntest('x', async () => {});\n`; +const literalOf = (settings: InstanceSettings) => `${JSON.stringify(settings)};`; +const spec = (path: string): SpecRef => ({ kind: 'golden', path, feature: null }); +const file = (path: string, declared: InstanceSettings | null = null) => ({ spec: spec(path), source: declared === null ? "test('x', async ({ host }) => { await host.launch({ screen: 'home' }); });\n" : exported(literalOf(declared)) }); +const usage = (pattern: RegExp) => (error: VerifyFailure) => error instanceof VerifyFailure && error.code === 'USAGE' && pattern.test(error.message); +const ODD = 'specs/explored/odd.e2e.ts'; +const refuses = (sources: Readonly>, why: RegExp = /./): void => { + for (const [what, source] of Object.entries(sources)) { + assert.throws( + () => declaredIn(source, ODD), + (error: VerifyFailure) => error instanceof VerifyFailure && error.code === 'USAGE' && error.message.startsWith(`${ODD}: `) && why.test(error.message) && error.fix.includes('export const instanceSettings: InstanceSettings = { config:'), + what, + ); + } +}; + +describe('the standard file', () => { + it('pins a standard value for every setting the two golden declarations change', () => { + const pinned = configLeaves(standardFile().config); + assert.equal(Object.keys(pinned).length, 101); + assert.equal(pinned['auth_multi_factor.required_for_sign_up'], false); + assert.equal(pinned['organization_settings.force_organization_selection'], false); + assert.deepEqual(pinned['auth_email.sign_in_strategies'], ['email_code', 'email_link'], 'a list is one leaf'); + }); +}); + +describe('reading a declaration from a spec file', () => { + it('returns null only when the word is nowhere in the file', () => { + assert.equal(declaredIn("import type { InstanceSettings } from '../../fixtures.ts';\ntest('x', () => {});\n", 'specs/a.e2e.ts'), null); + }); + + it('reads identifier and quoted keys, both quote kinds, backticks, numbers, lists, comments, and trailing commas', () => { + const source = exported(`{ + // why this spec needs it + config: { + auth_multi_factor: { required_for_sign_up: true, }, /* inline */ + "auth_password": { 'min_length': 12, max_length: -1, enabled: false, note: null, ratio: 1.5e1 }, + auth_email: { sign_in_strategies: ['email_code', "email_link", \`a\\tb\\u0041\`,], }, + }, + environment: { + 'user_settings.sign_up.mfa.required': true, + }, +};`); + assert.deepEqual(declaredIn(source, 'specs/a.e2e.ts'), { + config: { + auth_multi_factor: { required_for_sign_up: true }, + auth_password: { min_length: 12, max_length: -1, enabled: false, note: null, ratio: 15 }, + auth_email: { sign_in_strategies: ['email_code', 'email_link', 'a\tbA'] }, + }, + environment: { 'user_settings.sign_up.mfa.required': true }, + }); + }); + + it('accepts no annotation, `as const`, and `satisfies`, with or without the semicolon', () => { + const body = JSON.stringify(MFA); + for (const [literal, annotation] of [[`${body}`, ''], [`${body} as const;`, ''], [`${body} satisfies InstanceSettings;`, ''], [`${body} as const satisfies InstanceSettings`, ''], [`${body}; // trailing`, ': InstanceSettings']] as const) { + assert.deepEqual(declaredIn(exported(literal, annotation), 'specs/a.e2e.ts'), MFA, literal); + } + }); + + it('fails closed, with the spec path and the form to write, on anything that is not exactly one plain literal', () => { + const good = literalOf(MFA); + const refused: Readonly> = { + 'a second export': `${exported(good)}export const instanceSettings = ${good}\n`, + 'a named re-export': `const instanceSettings = ${good}\nexport { instanceSettings };\n`, + 'a default export': `const instanceSettings = ${good}\nexport default instanceSettings;\n`, + 'a mention in a comment': `// instanceSettings would go here\ntest('x', () => {});\n`, + 'a mention in a string': `test('needs instanceSettings', () => {});\n`, + 'a second mention beside a good export': `${exported(good)}console.log(instanceSettings);\n`, + 'an indented export': `if (true) {\n export const instanceSettings = ${good}\n}\n`, + 'a variable': exported('shared;'), + 'a shared constant as a value': exported('{ config: MFA_CONFIG, environment: { a: true } };'), + 'a spread': exported("{ ...base, config: { a: { b: true } }, environment: { 'a.b': true } };"), + 'a spread in a list': exported("{ config: { a: { b: [...list] } }, environment: { 'a.b': true } };"), + 'a call': exported('makeSettings({ mfa: true });'), + 'a call as a value': exported("{ config: { a: { b: on() } }, environment: { 'a.b': true } };"), + 'a computed key': exported("{ config: { [key]: { b: true } }, environment: { 'a.b': true } };"), + 'a template with a substitution': exported("{ config: { a: { b: `x${y}` } }, environment: { 'a.b': true } };"), + 'a shorthand property': exported("{ config, environment: { 'a.b': true } };"), + 'a __proto__ key': exported("{ config: { __proto__: { b: true } }, environment: { 'a.b': true } };"), + 'a quoted __proto__ key': exported("{ config: { a: { '__proto__': true } }, environment: { 'a.b': true } };"), + 'a key other than config and environment': exported("{ config: { a: { b: true } }, environment: { 'a.b': true }, extra: 1 };"), + 'no config': exported("{ environment: { 'a.b': true } };"), + 'an empty config': exported("{ config: {}, environment: { 'a.b': true } };"), + 'no environment': exported('{ config: { a: { b: true } } };'), + 'an empty environment': exported('{ config: { a: { b: true } }, environment: {} };'), + 'an environment value that is an object': exported('{ config: { a: { b: true } }, environment: { a: { b: true } } };'), + 'an environment list of objects': exported("{ config: { a: { b: true } }, environment: { 'a.b': [{ c: 1 }] } };"), + 'a list as the whole value': exported('[1, 2];'), + 'something after the literal': exported(`${JSON.stringify(MFA)}.config;`), + 'a literal that never ends': exported("{ config: { a: { b: true } }, environment: { 'a.b': true }"), + 'a generic annotation': exported(good, ': Readonly'), + }; + refuses(refused); + }); + + it('refuses an export line that sits inside a comment, a string, or a template', () => { + const line = `export const instanceSettings = ${literalOf(MFA)}`; + refuses({ 'a block comment': `/*\n${line}\n*/\ntest('x', () => {});\n`, 'a block comment after code': `const a = 1; /* why\n${line}\n*/\n` }, /inside a comment/); + refuses({ 'a string continued over lines': `const text = 'a \\\n${line}\\\n';\n` }, /inside a string/); + refuses({ 'a string its line does not close': `const text = 'a\n${line}\n` }, /a string before its instanceSettings export never ends/); + refuses( + { + 'a template': `const text = \`\n${line}\n\`;\n`, + 'a template after a substitution that holds a brace and a quote': `const text = \`\${{ a: '}' }.a}\n${line}\n\`;\n`, + 'a template nested in a substitution': `const text = \`\${\`\n${line}\n\`}\`;\n`, + 'a substitution': `const text = \`\${\n${line}\n}\`;\n`, + }, + /inside a template/, + ); + }); + + it('refuses a `/` before the export that may start a regular expression, which would hide a quote from the tool', () => { + const line = `export const instanceSettings = ${literalOf(MFA)}`; + refuses({ 'a regular expression that holds a quote, then a template': `const text = /'/.source + \`'\n${line}\n\`;\n`, 'a division': `const half = 10 / 2;\n${line}\n` }, /may start a regular expression/); + }); + + it('reads an export that follows comments, strings, and templates which hold quotes, braces, and comment marks', () => { + const before = [ + '// it\'s a "note" with a ` and a /* that never ends', + '/* it\'s "closed" // here ` */', + "import { test } from '../../fixtures.ts';", + 'const a = \'a // b /* c \\\' d\', b = "it\'s `";', + "const c = `${a ? `${{ b }.b}` : '}'} /* \\` // ${'`'}`;", + ].join('\n'); + assert.deepEqual(declaredIn(`${before}\nexport const instanceSettings = ${literalOf(MFA)}\n`, 'specs/a.e2e.ts'), MFA); + }); + + it('refuses a literal that the next line continues as an expression', () => { + const body = JSON.stringify(MFA); + refuses( + { + 'a call': exported(`${body}\n(shared);`), + 'a member': exported(`${body}\n .config;`), + 'an optional member': exported(`${body}\n ?.config;`), + 'an index': exported(`${body}\n[0];`), + 'an operator': exported(`${body}\n || shared;`), + 'a division': exported(`${body}\n/ 2;`), + 'a tagged template': exported(`${body}\n\`x\`;`), + 'a keyword operator': exported(`${body}\nin shared;`), + 'a call after a comment and a blank line': exported(`${body} // why\n\n/* more */ (shared);`), + 'a call after as const': exported(`${body} as const\n(shared);`), + 'a call after satisfies': exported(`${body} satisfies InstanceSettings\n(shared);`), + }, + /continues the expression/, + ); + for (const next of ['', ';', '\n;(shared)', ';\n(async () => {})();', '\ninterface Other {}', '\ninstances.use();', '\n// a comment\nconst other = 1;', '\n{ const other = 1; }']) { + assert.deepEqual(declaredIn(`export const instanceSettings = ${body}${next}\n`, 'specs/a.e2e.ts'), MFA, JSON.stringify(next)); + } + }); + + it('refuses a number JSON cannot hold and an escape JSON does not have', () => { + const holding = (value: string) => exported(`{ config: { a: { b: ${value} } }, environment: { 'a.b': true } };`); + refuses({ '1e999': holding('1e999'), '-1e999': holding('-1e999') }, /not a finite number/); + refuses( + { + 'a legacy octal escape': holding("'\\1'"), + 'a NUL escape': holding("'\\0'"), + 'a vertical tab': holding("'\\v'"), + 'a letter that only loses its backslash': holding("'\\q'"), + 'a line continuation': holding("'a\\\nb'"), + 'an escaped dollar in a template': holding('`\\${a}`'), + 'an escape in a quoted key': exported("{ config: { 'a\\1': { b: true } }, environment: { 'a.b': true } };"), + }, + /has an escape this tool does not read/, + ); + assert.deepEqual(declaredIn(holding("'\\n\\t\\r\\\\\\'\\\"\\`\\u0041\\u{1F600}\\x41\\b\\f\\/'"), 'specs/a.e2e.ts')?.config, { a: { b: '\n\t\r\\\'"`A\u{1F600}A\b\f/' } }); + }); + + it('refuses a literal nested deeper than 32 levels as a declaration error, however deep it goes', () => { + const lists = (levels: number) => exported(`{ config: { a: ${'['.repeat(levels)}${']'.repeat(levels)} }, environment: { 'a.b': true } };`); + const objects = (levels: number) => exported(`{ config: { a: ${'{ b: '.repeat(levels)}1${' }'.repeat(levels)} }, environment: { 'a.b': true } };`); + assert.ok(declaredIn(lists(30), 'specs/a.e2e.ts'), 'the outer object, config, and 30 lists are 32 levels'); + assert.ok(declaredIn(objects(30), 'specs/a.e2e.ts')); + refuses({ '31 lists': lists(31), '31 objects': objects(31), '50,000 lists': lists(50_000), '50,000 objects': objects(50_000) }, /nests more than 32 levels deep/); + }); +}); + +describe('checking a declaration against the standard file', () => { + it('names the settings by the config leaves they change', () => { + const settings = settingsOf(MFA, 'specs/a.e2e.ts'); + assert.equal(settings.label, 'auth_multi_factor.required_for_sign_up=true'); + assert.equal(settings.askedBy, 'specs/a.e2e.ts'); + assert.notEqual(settings.key, STANDARD.key); + assert.equal(settingsOf({ config: { auth_password: { min_length: 12 }, auth_email: { sign_in_strategies: ['email_code'] } }, environment: { 'user_settings.password_settings.min_length': 12 } }, 'a').label, 'auth_password.min_length=12, auth_email.sign_in_strategies=["email_code"]'); + assert.deepEqual(STANDARD, { key: STANDARD.key, label: 'standard', declared: null, askedBy: null }); + assert.equal(settingsOf(null, 'specs/a.e2e.ts'), STANDARD); + }); + + it('sends the whole standard config with the declaration laid over it', () => { + const body = configFor(settingsOf(MFA, 'a')); + const standard = standardFile().config; + assert.deepEqual(Object.keys(body), Object.keys(standard)); + assert.deepEqual(body.auth_multi_factor, { ...(standard.auth_multi_factor as object), required_for_sign_up: true }); + assert.deepEqual(body.organization_settings, standard.organization_settings, 'so a setting an earlier group changed goes back to standard'); + assert.equal(configFor(STANDARD), standard); + }); + + it('gives two declarations with one effect one key, and different effects different keys', () => { + const alsoStandardLeaf: InstanceSettings = { config: { auth_multi_factor: { required_for_sign_up: true, required_for_sign_in: false } }, environment: MFA.environment }; + assert.equal(settingsOf(alsoStandardLeaf, 'b').key, settingsOf(MFA, 'a').key); + assert.notEqual(settingsOf(FORCED_ORG, 'c').key, settingsOf(MFA, 'a').key); + }); + + it('refuses a config leaf the standard file does not pin, and says what to add', () => { + const unpinned: InstanceSettings = { config: { auth_email: { brand_new_toggle: true } }, environment: MFA.environment }; + assert.throws(() => settingsOf(unpinned, 'specs/a.e2e.ts'), (error: VerifyFailure) => error.code === 'INSTANCE_MISCONFIGURED' && error.message.includes('specs/a.e2e.ts') && error.message.includes('auth_email.brand_new_toggle') && error.fix.startsWith("check the spelling against Clerk's Platform API config; ") && error.fix.includes(`; add the standard value of \`auth_email.brand_new_toggle\` to \`config\` in ${STANDARD_FILE}`) && error.fix.includes('node src/core/manifest.ts --write')); + }); + + it('lists the keys the standard file has beside a config key it does not, before it says to change the file', () => { + const fixOf = (config: InstanceSettings['config']): string => { + try { + settingsOf({ config, environment: MFA.environment }, 'specs/a.e2e.ts'); + } catch (error) { + return (error as VerifyFailure).fix; + } + return assert.fail('the declaration was accepted'); + }; + const add = (leaf: string) => `add the standard value of \`${leaf}\` to \`config\` in ${STANDARD_FILE}`; + assert.ok(fixOf({ auth_multi_factor: { requird_for_sign_up: true } }).startsWith(`check the spelling against Clerk's Platform API config; the standard file has authenticator_app, backup_code, required_for_sign_in, required_for_sign_up under \`auth_multi_factor\`; ${add('auth_multi_factor.requird_for_sign_up')}`)); + assert.ok(fixOf({ auth_multi_factor: { backup_code: { enabld: false } } }).startsWith(`check the spelling against Clerk's Platform API config; the standard file has enabled under \`auth_multi_factor.backup_code\`; ${add('auth_multi_factor.backup_code.enabld')}`)); + const top = Object.keys(standardFile().config); + assert.ok(top.length > 12); + assert.ok(fixOf({ auth_multi_factr: { required_for_sign_up: true } }).startsWith(`check the spelling against Clerk's Platform API config; the standard file has ${top.slice(0, 12).join(', ')}, and ${top.length - 12} more at the top of \`config\`; ${add('auth_multi_factr.required_for_sign_up')}`), 'a misspelt parent lists what stands beside it, and never more than 12'); + assert.ok(fixOf({ auth_multi_factor: { required_for_sign_up: { always: true } } }).startsWith(`check the spelling against Clerk's Platform API config; ${add('auth_multi_factor.required_for_sign_up.always')}`), 'nothing to list under a key that holds a plain value'); + }); + + it('says the standard file has an object where a declaration gives one plain value, and names its keys', () => { + assert.throws( + () => settingsOf({ config: { auth_multi_factor: true }, environment: MFA.environment }, 'specs/a.e2e.ts'), + (error: VerifyFailure) => + error.code === 'INSTANCE_MISCONFIGURED' && + error.message === 'specs/a.e2e.ts declares auth_multi_factor as true, and the standard file has an object there' && + error.fix === 'declare the keys of `auth_multi_factor` to change, each by its own name; the standard file has authenticator_app, backup_code, required_for_sign_in, required_for_sign_up there', + ); + }); + + it('compares the members of a list by what they hold, in any order', () => { + assert.equal(sameLeaf([{ a: 1 }], [{ a: 2 }]), false, 'two lists of objects of one length are not the same list'); + assert.equal(sameLeaf([{ a: 1, b: [2, 3] }, { c: null }], [{ c: null }, { b: [2, 3], a: 1 }]), true); + assert.equal(sameLeaf(['b', 'a'], ['a', 'b']), true); + assert.equal(sameLeaf(['a'], ['a', 'a']), false); + const declared = settingsOf({ config: MFA.config, environment: { 'auth_config.second_factors': ['totp', 'phone_code'] } }, 'a'); + assert.deepEqual(expectedEnvironment(declared)['auth_config.second_factors'], ['phone_code', 'totp'], 'a declared list is expected in the order a live one is read in'); + }); + + it('refuses a declaration that only restates standard values', () => { + assert.throws(() => settingsOf({ config: { auth_multi_factor: { required_for_sign_up: false } }, environment: MFA.environment }, 'specs/a.e2e.ts'), usage(/^specs\/a\.e2e\.ts declares only standard values/)); + }); + + it('refuses an environment leaf the standard file does not list, or one at its standard value', () => { + assert.throws(() => settingsOf({ config: MFA.config, environment: { 'user_settings.sign_up.mfa.requried': true } }, 'specs/a.e2e.ts'), (error: VerifyFailure) => usage(/specs\/a\.e2e\.ts expects the environment leaf user_settings\.sign_up\.mfa\.requried/)(error) && error.fix.endsWith('; a failed change lists the leaves a setting moves') && !error.fix.includes('doctor')); + assert.throws(() => settingsOf({ config: MFA.config, environment: { 'user_settings.sign_up.mfa.required': false } }, 'specs/a.e2e.ts'), usage(/user_settings\.sign_up\.mfa\.required to be false, which is its standard value/)); + const listed = standardFile().environment['auth_config.first_factors'] as readonly string[]; + assert.throws(() => settingsOf({ config: MFA.config, environment: { 'auth_config.first_factors': [...listed].reverse() } }, 'a'), usage(/standard value/), 'a list in another order is the same value'); + assert.ok(settingsOf({ config: MFA.config, environment: { 'auth_config.reverification': false } }, 'a'), 'a leaf under defaults can be declared'); + }); + + it('does not take a name every object inherits for a pinned config leaf or a listed environment leaf', () => { + assert.throws(() => settingsOf({ config: { constructor: true }, environment: MFA.environment }, 'specs/a.e2e.ts'), (error: VerifyFailure) => error.code === 'INSTANCE_MISCONFIGURED' && /^specs\/a\.e2e\.ts declares constructor, and the standard file has no value/.test(error.message)); + assert.throws(() => settingsOf({ config: MFA.config, environment: { toString: true } }, 'specs/a.e2e.ts'), usage(/^specs\/a\.e2e\.ts expects the environment leaf toString, which/)); + }); + + it('expects every leaf of the standard file, with the declared leaves laid over it', () => { + const file = standardFile(); + const expected = expectedEnvironment(settingsOf(MFA, 'a')); + assert.equal(Object.keys(expected).length, Object.keys(file.environment).length + Object.keys(file.defaults).length); + assert.equal(expected['user_settings.sign_up.mfa.required'], true); + assert.equal(expected['auth_config.reverification'], file.defaults['auth_config.reverification']); + assert.equal(expectedEnvironment(STANDARD)['user_settings.sign_up.mfa.required'], false); + }); +}); + +describe('planning a run in groups', () => { + const golden = [file('specs/golden/auth-start/auth-start.e2e.ts'), file('specs/golden/session-tasks/choose-organization.e2e.ts', FORCED_ORG), file('specs/golden/session-tasks/complete-setup-mfa.e2e.ts', MFA), file('specs/golden/session-tasks/setup-mfa.e2e.ts', MFA), file('specs/golden/sign-up/complete.e2e.ts')]; + const mfaKey = settingsOf(MFA, 'x').key; + const orgKey = settingsOf(FORCED_ORG, 'x').key; + const order = (applied: string | null, specs = golden) => planGroups(specs, applied).map((group) => group.settings.label); + + it('puts the group already applied first, so a run changes the instance once less than it has groups', () => { + assert.deepEqual(order(STANDARD.key), ['standard', 'organization_settings.force_organization_selection=true', 'auth_multi_factor.required_for_sign_up=true']); + assert.deepEqual(order(mfaKey), ['auth_multi_factor.required_for_sign_up=true', 'organization_settings.force_organization_selection=true', 'standard']); + assert.deepEqual(order(orgKey), ['organization_settings.force_organization_selection=true', 'auth_multi_factor.required_for_sign_up=true', 'standard']); + }); + + it('with nothing applied, or settings no group has, keeps the order the specs came in and leaves standard for last', () => { + for (const applied of [null, 'ffffffffffff']) assert.deepEqual(order(applied), ['organization_settings.force_organization_selection=true', 'auth_multi_factor.required_for_sign_up=true', 'standard']); + }); + + it('is stable: the same files in the same order plan the same groups, each with its files in order', () => { + const plan = planGroups(golden, STANDARD.key); + assert.deepEqual(plan, planGroups(golden, STANDARD.key)); + assert.deepEqual(plan.map((group) => group.specs.map((s) => s.path.split('/').at(-1))), [['auth-start.e2e.ts', 'complete.e2e.ts'], ['choose-organization.e2e.ts'], ['complete-setup-mfa.e2e.ts', 'setup-mfa.e2e.ts']]); + assert.equal(plan[2]!.settings.askedBy, 'specs/golden/session-tasks/complete-setup-mfa.e2e.ts', 'the first spec of a group is the one that asked'); + assert.equal(plan[0]!.settings, STANDARD); + assert.match(plan[0]!.specs[0]!.sourceHash, /^[0-9a-f]{64}$/); + assert.notEqual(plan[0]!.specs[0]!.sourceHash, plan[1]!.specs[0]!.sourceHash); + }); + + it('makes one group of two files with one config, and expects the leaves of both', () => { + const more: InstanceSettings = { config: MFA.config, environment: { 'auth_config.reverification': false } }; + const [group, ...rest] = planGroups([file('specs/a.e2e.ts', MFA), file('specs/b.e2e.ts', more)], null); + assert.deepEqual(rest, []); + assert.deepEqual(group!.settings.declared?.environment, { 'user_settings.sign_up.mfa.required': true, 'auth_config.reverification': false }); + assert.equal(group!.settings.key, mfaKey); + }); + + it('refuses two files with one config that expect different values of a leaf, naming both', () => { + const a: InstanceSettings = { config: MFA.config, environment: { 'user_settings.password_settings.min_length': 12 } }; + const b: InstanceSettings = { config: MFA.config, environment: { 'user_settings.password_settings.min_length': 14 } }; + assert.throws(() => planGroups([file('specs/a.e2e.ts', a), file('specs/b.e2e.ts', b)], null), usage(/^specs\/a\.e2e\.ts and specs\/b\.e2e\.ts declare the same config and expect different values of user_settings\.password_settings\.min_length/)); + }); + + it('reports a malformed declaration before anything is planned', () => { + assert.throws(() => planGroups([file('specs/a.e2e.ts'), { spec: spec('specs/b.e2e.ts'), source: 'export const instanceSettings = shared;\n' }], null), usage(/^specs\/b\.e2e\.ts: /)); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/something-ran.test.ts b/.claude/skills/verify-clerk-expo/test/something-ran.test.ts new file mode 100644 index 00000000000..35c6cc8e78c --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/something-ran.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { parseE2EReport } from '../src/core/e2e.ts'; +import type { EvidencePath, OptInTag, RunId } from '../src/core/types.ts'; +import { nextStep } from '../src/core/verbs.ts'; + +const run = 'r20261003-000000-abcd' as RunId; +const dir = mkdtempSync(join(tmpdir(), 'verify-ran-')) as EvidencePath; + +function results(rows: readonly { tags?: string[]; status: string; skip?: { cause: string; reason: string } }[], excluded: readonly OptInTag[]) { + const report = { + schemaVersion: 'report-1', + run: { results: rows.map((row, i) => ({ id: `0000000${i}aa`, kind: 'test', titlePath: [`t${i}`], file: 'specs/golden/auth-start/a.e2e.ts', platform: 'ios', tags: row.tags ?? [], status: row.status, skip: row.skip, attempts: [] })) }, + }; + return parseE2EReport(report, [], dir, excluded); +} + +describe('a run in which nothing executed', () => { + it('fails when --grep matched no title', () => { + const grepTypo = results([{ status: 'skipped', skip: { cause: 'filtered', reason: 'title does not match --grep' } }], ['known-bug']); + assert.throws(() => nextStep(run, dir, grepTypo, 'auth-start'), { code: 'NO_SPECS', message: /no test ran for auth-start: filtered: title does not match --grep/ }); + }); + + it('fails when the selection registered no tests', () => { + assert.throws(() => nextStep(run, dir, results([], ['known-bug']), 'specs/explored/empty.e2e.ts'), { code: 'NO_SPECS', message: /registered no tests/ }); + }); + + it('fails a file whose tests are all test.skip', () => { + const allSkipped = results([{ status: 'skipped', skip: { cause: 'explicit', reason: 'test.skip' } }], ['known-bug']); + assert.equal(allSkipped[0]!.status, 'failed', 'an explicitly skipped test is reported as not run, which fails the run'); + assert.doesNotMatch(nextStep(run, dir, allSkipped, 'auth-start'), /attach/); + }); + + it('passes with no attach hint when --skip form-entry left every spec out', () => { + const next = nextStep(run, dir, results([{ tags: ['form-entry'], status: 'skipped', skip: { cause: 'filtered', reason: 'carries an excluded tag' } }], ['form-entry', 'known-bug']), 'sign-up/complete'); + assert.match(next, /^nothing ran: every selected spec was left out/); + assert.doesNotMatch(next, /attach/); + }); + + it('passes with no attach hint when the default known-bug exclusion left every spec out', () => { + const next = nextStep(run, dir, results([{ tags: ['known-bug'], status: 'skipped', skip: { cause: 'filtered', reason: 'carries an excluded tag' } }], ['known-bug']), 'sign-in-email-code'); + assert.doesNotMatch(next, /attach/); + }); + + it('suggests attach after a run with a passing spec', () => { + assert.match(nextStep(run, dir, results([{ status: 'passed' }], ['known-bug']), 'auth-start'), /^\{cli\} attach r20261003-000000-abcd/); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/state.test.ts b/.claude/skills/verify-clerk-expo/test/state.test.ts new file mode 100644 index 00000000000..92784a72652 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/state.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { Secret } from '../src/core/secret.ts'; +import { encodeLaunchArguments, parseVerifyState } from '../src/core/state.ts'; +import type { HostLaunch, LaunchId, PublishableKey, RunId, StorageScope } from '../src/core/types.ts'; + +const HOST_LINE = + 'verify {"environmentLoaded":true,"launchId":"abc123","lastError":null,"orgId":null,"pendingTasks":[],"runId":"r20261002-141210-7c1e","screen":"auth","sessionId":null,"sessionStatus":null,"signInStatus":null,"signUpStatus":null,"signedIn":false,"ticket":"none","userId":null,"v":1}'; + +const launch: HostLaunch = { + verifyPublishableKey: 'pk_test_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk' as PublishableKey, + verifyRunId: 'r20261002-141210-7c1e' as RunId, + verifyStorageScope: '0011aabb' as StorageScope, + verifyLaunchId: 'ffee0011' as LaunchId, + verifyScreen: 'auth', +}; + +describe('parseVerifyState', () => { + it('reads the line the host app renders in its footer', () => { + const state = parseVerifyState(HOST_LINE); + assert.equal(state.screen, 'auth'); + assert.equal(state.launchId, 'abc123'); + assert.equal(state.ticket, 'none'); + assert.deepEqual(state.pendingTasks, []); + }); + + it('rejects a host on another contract version', () => { + assert.throws(() => parseVerifyState(HOST_LINE.replace('"v":1', '"v":2')), { code: 'HOST_CONTRACT_MISMATCH', message: /v2/ }); + }); + + it('rejects text that is not a VerifyState', () => { + assert.throws(() => parseVerifyState('verify not json'), { code: 'HOST_CONTRACT_MISMATCH' }); + assert.throws(() => parseVerifyState(HOST_LINE.replace('"ticket":"none"', '"ticket":"maybe"')), { code: 'HOST_CONTRACT_MISMATCH' }); + }); +}); + +describe('encodeLaunchArguments', () => { + it('encodes iOS launch arguments and Android string extras', () => { + assert.deepEqual(encodeLaunchArguments('ios', launch), [ + '-verifyPublishableKey', launch.verifyPublishableKey, + '-verifyRunId', launch.verifyRunId, + '-verifyStorageScope', '0011aabb', + '-verifyLaunchId', 'ffee0011', + '-verifyScreen', 'auth', + ]); + assert.deepEqual(encodeLaunchArguments('android', launch).slice(0, 3), ['--es', 'verifyPublishableKey', launch.verifyPublishableKey]); + }); + + it('rejects values iOS would parse as a property list', () => { + for (const value of ['(a)', '{a=b}', '', '"quoted"']) { + assert.throws(() => encodeLaunchArguments('ios', { ...launch, verifyScreen: value }), { code: 'USAGE', message: /property list/ }, value); + } + assert.throws(() => encodeLaunchArguments('ios', { ...launch, verifyStorageScope: '{scope}' as StorageScope }), { code: 'USAGE' }); + }); + + it('reads the ticket only through Secret.use', () => { + const args = encodeLaunchArguments('ios', { ...launch, verifySignInTicket: new Secret('ticket', 'tkt_unit_value_123') }); + assert.deepEqual(args.slice(-2), ['-verifySignInTicket', 'tkt_unit_value_123']); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/testing/claim-taker.ts b/.claude/skills/verify-clerk-expo/testing/claim-taker.ts new file mode 100644 index 00000000000..b61506805b8 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/testing/claim-taker.ts @@ -0,0 +1,5 @@ +import { takeSlot } from '../src/core/claims.ts'; + +const [dir, worktree, startAt, from] = process.argv.slice(2) as [string, string, string, string]; +while (Date.now() < Number(startAt)) await new Promise((resolve) => setTimeout(resolve, 1)); +process.stdout.write(takeSlot(dir, 'ios', 1, Number(from), worktree) === null ? 'lost' : 'won'); diff --git a/.claude/skills/verify-clerk-expo/testing/fake-clerk.ts b/.claude/skills/verify-clerk-expo/testing/fake-clerk.ts new file mode 100644 index 00000000000..5d996ef2b81 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/testing/fake-clerk.ts @@ -0,0 +1,233 @@ +import { configLeaves, standardFile } from '../src/core/instances/settings.ts'; +import type { Json } from '../src/core/types.ts'; + +export const WORKSPACE = 'org_3KHungJxbvIscuSvy8oos5MHAli'; +export const PLATFORM_KEY = 'ak_unitTestPlatformKey0123456789'; + +interface FakeApplication { + readonly id: string; + readonly name: string; + readonly instanceId: string; + readonly pk: string; + readonly sk: string; + config: Record; + environment: Record; + users: number; + deleted: boolean; +} + +type JsonObject = { readonly [key: string]: Json }; +const isObject = (value: Json | undefined): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value); + +function merged(base: JsonObject, over: JsonObject): Record { + const out: Record = { ...base }; + for (const [key, value] of Object.entries(over)) { + const under = out[key]; + out[key] = isObject(value) && isObject(under) ? merged(under, value) : value; + } + return out; +} + +const SHOWN: readonly { readonly config: string; readonly leaf: string; readonly as?: (value: Json) => Json }[] = [ + { config: 'organization_settings.force_organization_selection', leaf: 'organization_settings.force_organization_selection' }, + { config: 'auth_multi_factor.required_for_sign_up', leaf: 'user_settings.sign_up.mfa.required' }, + { config: 'session_settings.multi_session_enabled', leaf: 'auth_config.single_session_mode', as: (value) => value !== true }, + { config: 'auth_password.min_length', leaf: 'user_settings.password_settings.min_length' }, +]; + +export interface FakeRefusal { + readonly path: string; + readonly value?: Json; + readonly status: number; + readonly code: string; + readonly param?: string | readonly string[]; + readonly message: string; +} + +export interface FakeClerkOptions { + readonly workspace?: string; + readonly now?: () => number; +} + +function unflatten(leaves: Readonly>): Json { + const root: Record = {}; + for (const [path, value] of Object.entries(leaves)) { + const parts = path.split('.').flatMap((part) => { + const match = /^([^[]+)((?:\[\d+\])*)$/.exec(part); + if (match === null) return [part]; + return [match[1]!, ...[...match[2]!.matchAll(/\[(\d+)\]/g)].map((index) => Number(index[1]))]; + }); + let node = root as Record; + parts.forEach((part, index) => { + if (index === parts.length - 1) node[part] = value; + else node = (node[part] ??= typeof parts[index + 1] === 'number' ? [] : {}) as Record; + }); + } + return root as Json; +} + +const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); + +const nested = (path: string, value: Json): JsonObject => path.split('.').reduceRight((inner, key) => ({ [key]: inner }), value) as JsonObject; + +export function fakeClerk(options: FakeClerkOptions = {}) { + const now = options.now ?? Date.now; + const applications: FakeApplication[] = []; + const requests: { readonly method: string; readonly url: string; readonly authorization: string | null; readonly body: unknown }[] = []; + let counter = 0; + let rateLimited: { left: number; retryAfter: string | null } = { left: 0, retryAfter: null }; + const state = { + listShape: 'array' as 'array' | 'envelope' | 'unreadable', + pageSize: 2, + failConfigure: 0, + loseCreateAnswer: false, + refuseDelete: false, + noDate: false, + refuseInstanceKeys: false, + keyRefusedTimes: 0, + ignoreConfigKey: null as string | null, + refusals: [] as FakeRefusal[], + alsoMoves: {} as Record, + dropFromAfter: null as string | null, + storesInstead: {} as Record, + newApplicationDefaults: {} as Record, + }; + + const json = (status: number, body: unknown, headers: Record = {}): Response => + new Response(JSON.stringify(body), { status, headers: { ...(state.noDate ? {} : { date: new Date(now()).toUTCString() }), ...headers } }); + const error = (status: number, code: string): Response => json(status, { errors: [{ code, message: code }] }); + const live = () => applications.filter((application) => !application.deleted); + + function freshEnvironment(): Record { + const standard = standardFile(); + return { ...standard.defaults, ...standard.environment, 'auth_config.single_session_mode': true, 'user_settings.password_settings.min_length': 15, ...state.newApplicationDefaults }; + } + + const fresh = (): Pick => ({ config: {}, environment: freshEnvironment(), users: 0, deleted: false }); + + function platform(method: string, path: string, authorization: string | null, body: unknown): Response { + if (rateLimited.left > 0) { + rateLimited.left -= 1; + return json(429, { errors: [{ code: 'too_many_requests' }] }, rateLimited.retryAfter === null ? {} : { 'retry-after': rateLimited.retryAfter }); + } + if (authorization === null) return error(401, 'authorization_header_format_invalid'); + if (authorization !== `Bearer ${PLATFORM_KEY}`) return error(401, 'could_not_authenticate_request'); + if (method === 'GET' && path === '/me') return json(200, { object: 'platform_principal', subject: options.workspace ?? WORKSPACE, actor: PLATFORM_KEY, scopes: [] }); + if (method === 'GET' && path.startsWith('/applications')) { + const all = live().map((application) => ({ application_id: application.id, name: application.name, instances: [{ environment_type: 'development', instance_id: application.instanceId, publishable_key: application.pk }] })); + if (state.listShape === 'array') return json(200, all); + if (state.listShape === 'unreadable') return json(200, { applications: all }); + const offset = Number(new URL(`https://x${path}`).searchParams.get('offset') ?? 0); + return json(200, { data: all.slice(offset, offset + state.pageSize), total_count: all.length }); + } + if (method === 'POST' && path === '/applications') { + counter += 1; + const slug = `fake-${counter}`; + const application: FakeApplication = { + id: `app_fake${counter}`, + name: (body as { name: string }).name, + instanceId: `ins_fake${counter}`, + pk: `pk_test_${Buffer.from(`${slug}.clerk.accounts.dev$`).toString('base64url')}`, + sk: `sk_test_fakeSecret${counter}xxxxxxxxxxxxxxxx`, + ...fresh(), + }; + applications.push(application); + if (state.loseCreateAnswer) return error(500, 'internal'); + return json(200, { application_id: application.id, name: application.name, instances: [{ environment_type: 'development', instance_id: application.instanceId, publishable_key: application.pk, secret_key: application.sk }] }); + } + const config = /^\/applications\/([^/]+)\/instances\/([^/?]+)\/config(\?dry_run=true)?$/.exec(path); + if (method === 'PATCH' && config !== null) { + if (state.failConfigure > 0) { + state.failConfigure -= 1; + return error(500, 'internal'); + } + const application = live().find((candidate) => candidate.id === config[1]); + if (application === undefined) return error(404, 'resource_not_found'); + const sent = body as JsonObject; + const leaves = configLeaves(sent); + const refusal = state.refusals.find((candidate) => candidate.path in leaves && (candidate.value === undefined || same(leaves[candidate.path], candidate.value))); + if (refusal !== undefined) { + const named = refusal.param === undefined ? [undefined] : [refusal.param].flat(); + return json(refusal.status, { errors: named.map((param) => ({ code: refusal.code, message: refusal.message, long_message: refusal.message, ...(param === undefined ? {} : { meta: { param_name: param } }) })) }); + } + let after = merged(application.config, sent); + for (const [path, value] of Object.entries(state.storesInstead)) if (path in leaves) after = merged(after, nested(path, value)); + const named = (from: JsonObject): JsonObject => Object.fromEntries(Object.keys(sent).flatMap((key) => (key in from ? [[key, from[key]!]] : []))); + const reordered = (value: Json): Json => (Array.isArray(value) ? [...value].sort().reverse() : typeof value === 'object' && value !== null ? Object.fromEntries(Object.entries(value).map(([key, child]) => [key, reordered(child)])) : value); + const answer = { config_version: '2026-10-05', dry_run: config[3] !== undefined, before: named(application.config), after: reordered(named(after)) }; + if (state.dropFromAfter !== null) { + const [top, ...rest] = state.dropFromAfter.split('.'); + const pruned = structuredClone(answer.after) as Record>; + let node: Record | undefined = pruned[top!]; + for (const part of rest.slice(0, -1)) node = node?.[part] as Record | undefined; + if (node !== undefined) delete node[rest.at(-1)!]; + answer.after = pruned; + } + if (config[3] !== undefined) return json(200, answer); + application.config = after; + const set = configLeaves(after); + for (const shown of SHOWN) { + if (!(shown.config in set) || shown.leaf === state.ignoreConfigKey) continue; + application.environment[shown.leaf] = shown.as === undefined ? set[shown.config]! : shown.as(set[shown.config]!); + } + Object.assign(application.environment, state.alsoMoves); + return json(200, answer); + } + const one = /^\/applications\/([^/?]+)$/.exec(path); + if (method === 'DELETE' && one !== null) { + if (state.refuseDelete) return error(403, 'authorization_missing_scopes'); + const application = applications.find((candidate) => candidate.id === one[1]); + if (application === undefined) return error(404, 'resource_not_found'); + application.deleted = true; + return json(200, { id: application.id, object: 'application', deleted: true }); + } + return error(404, 'resource_not_found'); + } + + const fetchImpl = (async (input: string | URL, init?: RequestInit) => { + const url = new URL(String(input)); + const method = init?.method ?? 'GET'; + const sent = new Headers(init?.headers).get('authorization'); + const body: unknown = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined; + requests.push({ method, url: `${url.host}${url.pathname}${url.search}`, authorization: sent, body }); + const onCom = url.host === 'api.clerk.com'; + if (onCom && url.pathname.startsWith('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/v1/platform/')) { + return platform(method, `${url.pathname.slice('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/v1/platform'.length)}${url.search}`, sent, body); + } + if (onCom || url.host === 'api.clerk.dev') { + const application = live().find((candidate) => sent === `Bearer ${candidate.sk}`); + if (application === undefined || state.refuseInstanceKeys) return error(401, 'clerk_key_invalid'); + if (state.keyRefusedTimes > 0) { + state.keyRefusedTimes -= 1; + return error(401, 'clerk_key_invalid'); + } + if (method === 'GET' && url.pathname === '/v1/users/count') return json(200, { object: 'total_count', total_count: application.users }); + if (method === 'POST' && url.pathname === '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/v1/users') { + if (application.users >= 100) return error(403, 'user_quota_exceeded'); + application.users += 1; + return json(200, { id: `user_${application.id}_${application.users}` }); + } + if (method === 'POST' && url.pathname === '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/v1/sign_in_tokens') return json(200, { token: `ticket_${application.id}_${(counter += 1)}` }); + return json(200, []); + } + const application = applications.find((candidate) => `${Buffer.from(candidate.pk.slice('pk_test_'.length), 'base64url').toString().replace(/\$$/, '')}` === url.host); + if (application === undefined || application.deleted) return error(404, 'resource_not_found'); + return json(200, unflatten(application.environment)); + }) as typeof fetch; + + return { + fetch: fetchImpl, + requests, + state, + applications, + live, + platformRequests: () => requests.filter((request) => request.url.startsWith('api.clerk.com/v1/platform/')), + rateLimit: (times: number, retryAfter: string | null = null) => (rateLimited = { left: times, retryAfter }), + plant(name: string): FakeApplication { + counter += 1; + const application: FakeApplication = { id: `app_planted${counter}`, name, instanceId: `ins_planted${counter}`, pk: `pk_test_${Buffer.from(`planted-${counter}.clerk.accounts.dev$`).toString('base64url')}`, sk: `sk_test_planted${counter}xxxxxxxxxxxxxxxxxxx`, ...fresh() }; + applications.push(application); + return application; + }, + }; +} diff --git a/.claude/skills/verify-clerk-expo/testing/fake-instances.ts b/.claude/skills/verify-clerk-expo/testing/fake-instances.ts new file mode 100644 index 00000000000..77d65934348 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/testing/fake-instances.ts @@ -0,0 +1,25 @@ +import type { Instances } from '../src/core/instances/instances.ts'; +import type { ApplicationView, InstanceView } from '../src/core/types.ts'; +import type { Workspace } from '../src/core/workspace.ts'; + +export const HELD: InstanceView = { id: 'app_held', name: 'verify-throwaway-held', created: false, settings: 'standard' }; + +export interface FakeHeld { + readonly finish?: (ledger: Workspace, options: { readonly keepApplications: boolean }) => Promise; +} + +export function heldInstances(fake: FakeHeld = {}): Instances { + const notDriving = (): never => { + throw new Error('this test drives no run, and something asked for the applied instance'); + }; + return { + access: async () => 'a test', + recordedKey: () => null, + ensure: async () => [HELD], + apply: async () => notDriving(), + keys: notDriving, + clerk: notDriving, + finish: async (ledger, options) => (await fake.finish?.(ledger, options)) ?? [], + doctorChecks: async () => [], + }; +} diff --git a/.claude/skills/verify-clerk-expo/testing/lock-holder.ts b/.claude/skills/verify-clerk-expo/testing/lock-holder.ts new file mode 100644 index 00000000000..3aeaf524bef --- /dev/null +++ b/.claude/skills/verify-clerk-expo/testing/lock-holder.ts @@ -0,0 +1,12 @@ +import { appendFileSync } from 'node:fs'; +import { openWorkspace } from '../src/core/workspace.ts'; + +const [skillDir, home, log, startAt, mode] = process.argv.slice(2) as [string, string, string, string, string]; +const workspace = openWorkspace({ skillDir, worktree: skillDir, home }); +while (Date.now() < Number(startAt)) await new Promise((resolve) => setTimeout(resolve, 1)); +await workspace.withAcquireLock('ios', async () => { + if (mode === 'crash') process.kill(process.pid, 'SIGKILL'); + appendFileSync(log, `enter ${process.pid}\n`); + await new Promise((resolve) => setTimeout(resolve, 100)); + appendFileSync(log, `exit ${process.pid}\n`); +}); From 41a354ae74be28bdbb916787b575295f4144ed5e Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:41:01 -0400 Subject: [PATCH 04/27] test(expo): add the Expo host, the fixture build, the Metro freshness check, and the golden specs src/host.ts builds the expo-native fixture as a Debug dev client, starts the watch build and one Metro per lane, and waits until Metro serves current JS before specs start. specs/golden holds ten spec files for six features. Co-Authored-By: Claude Opus 5.5 --- .../custom-flow-sign-in/complete.e2e.ts | 15 + .../custom-flow-sign-in/request-code.e2e.ts | 13 + .../custom-flow-sign-up/complete.e2e.ts | 16 + .../custom-flow-sign-up/request-code.e2e.ts | 13 + .../golden/native-auth-view/complete.e2e.ts | 22 + .../golden/native-auth-view/opens.e2e.ts | 26 + .../native-auth-view/request-code.e2e.ts | 21 + .../sign-out-from-native.e2e.ts | 16 + .../token-cache-persistence/relaunch.e2e.ts | 26 + .../user-button-and-profile/profile.e2e.ts | 25 + .../skills/verify-clerk-expo/specs/native.ts | 27 + .../skills/verify-clerk-expo/src/fixture.ts | 176 ++++++ .../skills/verify-clerk-expo/src/freshness.ts | 370 +++++++++++ .claude/skills/verify-clerk-expo/src/host.ts | 578 ++++++++++++++++++ .../verify-clerk-expo/test/freshness.test.ts | 450 ++++++++++++++ .../verify-clerk-expo/test/host.test.ts | 207 +++++++ 16 files changed, 2001 insertions(+) create mode 100644 .claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-in/complete.e2e.ts create mode 100644 .claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-in/request-code.e2e.ts create mode 100644 .claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-up/complete.e2e.ts create mode 100644 .claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-up/request-code.e2e.ts create mode 100644 .claude/skills/verify-clerk-expo/specs/golden/native-auth-view/complete.e2e.ts create mode 100644 .claude/skills/verify-clerk-expo/specs/golden/native-auth-view/opens.e2e.ts create mode 100644 .claude/skills/verify-clerk-expo/specs/golden/native-auth-view/request-code.e2e.ts create mode 100644 .claude/skills/verify-clerk-expo/specs/golden/native-js-sync/sign-out-from-native.e2e.ts create mode 100644 .claude/skills/verify-clerk-expo/specs/golden/token-cache-persistence/relaunch.e2e.ts create mode 100644 .claude/skills/verify-clerk-expo/specs/golden/user-button-and-profile/profile.e2e.ts create mode 100644 .claude/skills/verify-clerk-expo/specs/native.ts create mode 100644 .claude/skills/verify-clerk-expo/src/fixture.ts create mode 100644 .claude/skills/verify-clerk-expo/src/freshness.ts create mode 100644 .claude/skills/verify-clerk-expo/src/host.ts create mode 100644 .claude/skills/verify-clerk-expo/test/freshness.test.ts create mode 100644 .claude/skills/verify-clerk-expo/test/host.test.ts diff --git a/.claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-in/complete.e2e.ts b/.claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-in/complete.e2e.ts new file mode 100644 index 00000000000..1c2e55913ac --- /dev/null +++ b/.claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-in/complete.e2e.ts @@ -0,0 +1,15 @@ +import { test, expect, CLERK_TEST_CODE } from '../../fixtures.ts'; + +test('useSignIn completes with the email code', { tags: ['form-entry'] }, async ({ host, screen }) => { + const user = await host.seedUser(); + await host.launch({ screen: 'customSignIn' }); + await host.fill(screen.getByTestId('verify.customSignIn.emailAddress'), user.email); + await host.tap(screen.getByTestId('verify.customSignIn.sendCode')); + await expect(screen.getByTestId('verify.customSignIn.code')).toBeVisible({ timeout: 20_000 }); + await host.screenshot('custom-code'); + await host.fill(screen.getByTestId('verify.customSignIn.code'), CLERK_TEST_CODE); + await host.tap(screen.getByTestId('verify.customSignIn.verifyCode')); + const state = await host.waitForState(s => s.signedIn && s.sessionStatus === 'active', 30_000); + expect(state.userId).toBe(user.id); + await host.screenshot('custom-signed-in'); +}); diff --git a/.claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-in/request-code.e2e.ts b/.claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-in/request-code.e2e.ts new file mode 100644 index 00000000000..cb385d5bbef --- /dev/null +++ b/.claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-in/request-code.e2e.ts @@ -0,0 +1,13 @@ +import { test, expect } from '../../fixtures.ts'; + +test('useSignIn sends an email code to an existing test user', async ({ host, screen }) => { + const user = await host.seedUser(); + const launched = await host.launch({ screen: 'customSignIn' }); + expect(launched.signedIn).toBe(false); + await host.fill(screen.getByTestId('verify.customSignIn.emailAddress'), user.email); + await host.tap(screen.getByTestId('verify.customSignIn.sendCode')); + await expect(screen.getByTestId('verify.customSignIn.code')).toBeVisible({ timeout: 20_000 }); + const state = await host.waitForState(s => s.signInStatus === 'needs_first_factor', 15_000); + expect(state.signedIn).toBe(false); + await host.screenshot('custom-code'); +}); diff --git a/.claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-up/complete.e2e.ts b/.claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-up/complete.e2e.ts new file mode 100644 index 00000000000..b5514bec3ca --- /dev/null +++ b/.claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-up/complete.e2e.ts @@ -0,0 +1,16 @@ +import { test, expect, CLERK_TEST_CODE } from '../../fixtures.ts'; + +test('useSignUp completes with the email code', { tags: ['form-entry'] }, async ({ host, screen }) => { + const email = await host.newEmail(); + const launched = await host.launch({ screen: 'customSignUp' }); + await host.fill(screen.getByTestId('verify.customSignUp.emailAddress'), email); + await host.fill(screen.getByTestId('verify.customSignUp.password'), `Verify-${launched.runId}-Pw1!`); + await host.tap(screen.getByTestId('verify.customSignUp.sendCode')); + await expect(screen.getByTestId('verify.customSignUp.code')).toBeVisible({ timeout: 20_000 }); + await host.screenshot('custom-signup-code'); + await host.fill(screen.getByTestId('verify.customSignUp.code'), CLERK_TEST_CODE); + await host.tap(screen.getByTestId('verify.customSignUp.verifyCode')); + const state = await host.waitForState(s => s.signedIn && s.sessionStatus === 'active', 30_000); + expect(state.userId).not.toBeNull(); + await host.screenshot('custom-signed-up'); +}); diff --git a/.claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-up/request-code.e2e.ts b/.claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-up/request-code.e2e.ts new file mode 100644 index 00000000000..54792d6fb11 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/specs/golden/custom-flow-sign-up/request-code.e2e.ts @@ -0,0 +1,13 @@ +import { test, expect } from '../../fixtures.ts'; + +test('useSignUp sends an email code to a new test address', { tags: ['form-entry'] }, async ({ host, screen }) => { + const email = await host.newEmail(); + const launched = await host.launch({ screen: 'customSignUp' }); + await host.fill(screen.getByTestId('verify.customSignUp.emailAddress'), email); + await host.fill(screen.getByTestId('verify.customSignUp.password'), `Verify-${launched.runId}-Pw1!`); + await host.tap(screen.getByTestId('verify.customSignUp.sendCode')); + await expect(screen.getByTestId('verify.customSignUp.code')).toBeVisible({ timeout: 20_000 }); + const state = await host.waitForState(s => s.signUpStatus === 'missing_requirements', 15_000); + expect(state.signedIn).toBe(false); + await host.screenshot('custom-signup-code'); +}); diff --git a/.claude/skills/verify-clerk-expo/specs/golden/native-auth-view/complete.e2e.ts b/.claude/skills/verify-clerk-expo/specs/golden/native-auth-view/complete.e2e.ts new file mode 100644 index 00000000000..14564f99671 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/specs/golden/native-auth-view/complete.e2e.ts @@ -0,0 +1,22 @@ +import { test, expect, CLERK_TEST_CODE } from '../../fixtures.ts'; + +test( + 'signs in through AuthView with the email code', + { tags: ['form-entry'], platforms: ['ios'] }, + async ({ host, screen }) => { + const user = await host.seedUser(); + await host.launch({ screen: 'auth', authMode: 'signIn' }); + await expect(screen.getByTestId('clerk.auth.start.identifier')).toBeVisible({ timeout: 30_000 }); + await host.fill(screen.getByTestId('clerk.auth.start.identifier'), user.email); + await host.tap(screen.getByTestId('clerk.auth.start.continue')); + await host.waitForState(s => s.signInStatus === 'needs_first_factor', 20_000); + await host.tap(screen.getByText('Use another method')); + await host.tap(screen.getByTestId('clerk.auth.signIn.alternativeMethod.email_code')); + await expect(screen.getByTestId('clerk.auth.signIn.code')).toBeVisible({ timeout: 20_000 }); + await host.screenshot('code-screen'); + await host.fill(screen.getByTestId('clerk.auth.signIn.code'), CLERK_TEST_CODE); + const state = await host.waitForState(s => s.signedIn && s.sessionStatus === 'active', 30_000); + expect(state.userId).toBe(user.id); + await host.screenshot('signed-in'); + }, +); diff --git a/.claude/skills/verify-clerk-expo/specs/golden/native-auth-view/opens.e2e.ts b/.claude/skills/verify-clerk-expo/specs/golden/native-auth-view/opens.e2e.ts new file mode 100644 index 00000000000..429dc7fadb7 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/specs/golden/native-auth-view/opens.e2e.ts @@ -0,0 +1,26 @@ +import { test, expect } from '../../fixtures.ts'; +import { nativeAuth } from '../../native.ts'; + +test('nativeAuth shows the native AuthView start screen without a tap', async ({ host, screen, platform }) => { + const state = await host.launch({ screen: 'nativeAuth' }); + expect(state.screen).toBe('nativeAuth'); + expect(state.signedIn).toBe(false); + await expect(nativeAuth(screen, platform).identifier).toBeVisible({ timeout: 30_000 }); + const loaded = await host.waitForState(s => s.extra?.authViewLoaded === true, 20_000); + expect(loaded.lastError).toBeNull(); + await host.screenshot('native-auth'); +}); + +test( + 'dismissing AuthView returns to the JS home screen', + { tags: ['known-bug'], platforms: ['ios'] }, + async ({ host, screen }) => { + await host.launch({ screen: 'nativeAuth' }); + await expect(screen.getByTestId('clerk.auth.start.identifier')).toBeVisible({ timeout: 30_000 }); + await host.tap(screen.getByTestId('clerk.dismissButton')); + const state = await host.waitForState(s => s.screen === 'home', 15_000); + expect(state.signedIn).toBe(false); + await expect(screen.getByTestId('open-auth-view-button')).toBeVisible(); + await host.screenshot('dismissed-home'); + }, +); diff --git a/.claude/skills/verify-clerk-expo/specs/golden/native-auth-view/request-code.e2e.ts b/.claude/skills/verify-clerk-expo/specs/golden/native-auth-view/request-code.e2e.ts new file mode 100644 index 00000000000..8dfc877b7d5 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/specs/golden/native-auth-view/request-code.e2e.ts @@ -0,0 +1,21 @@ +import { test, expect } from '../../fixtures.ts'; + +test( + 'an existing test user reaches the email code screen in AuthView', + { platforms: ['ios'] }, + async ({ host, screen }) => { + const user = await host.seedUser(); + await host.launch({ screen: 'auth', authMode: 'signIn' }); + await expect(screen.getByTestId('clerk.auth.start.identifier')).toBeVisible({ timeout: 30_000 }); + await host.fill(screen.getByTestId('clerk.auth.start.identifier'), user.email); + await host.tap(screen.getByTestId('clerk.auth.start.continue')); + await host.waitForState(s => s.signInStatus === 'needs_first_factor', 20_000); + await host.tap(screen.getByText('Use another method')); + await host.tap(screen.getByTestId('clerk.auth.signIn.alternativeMethod.email_code')); + await expect(screen.getByTestId('clerk.auth.signIn.code')).toBeVisible({ timeout: 20_000 }); + const state = await host.state(); + expect(state.signedIn).toBe(false); + expect(state.signInStatus).toBe('needs_first_factor'); + await host.screenshot('code-screen'); + }, +); diff --git a/.claude/skills/verify-clerk-expo/specs/golden/native-js-sync/sign-out-from-native.e2e.ts b/.claude/skills/verify-clerk-expo/specs/golden/native-js-sync/sign-out-from-native.e2e.ts new file mode 100644 index 00000000000..4e1c50e9291 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/specs/golden/native-js-sync/sign-out-from-native.e2e.ts @@ -0,0 +1,16 @@ +import { test, expect } from '../../fixtures.ts'; +import { nativeProfile } from '../../native.ts'; + +test('signing out in the native UserProfileView signs out the JS hooks', async ({ host, screen }) => { + const user = await host.seedUser(); + const state = await host.launch({ signedInAs: user, screen: 'userProfile' }); + expect(state.userId).toBe(user.id); + const profile = nativeProfile(screen); + await expect(profile.signOut).toBeVisible({ timeout: 30_000 }); + await host.screenshot('before-sign-out'); + await host.tap(profile.signOut); + const signedOut = await host.waitForState(s => !s.signedIn, 20_000); + expect(signedOut.userId).toBeNull(); + expect(signedOut.sessionId).toBeNull(); + await host.screenshot('signed-out'); +}); diff --git a/.claude/skills/verify-clerk-expo/specs/golden/token-cache-persistence/relaunch.e2e.ts b/.claude/skills/verify-clerk-expo/specs/golden/token-cache-persistence/relaunch.e2e.ts new file mode 100644 index 00000000000..69819c3ba48 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/specs/golden/token-cache-persistence/relaunch.e2e.ts @@ -0,0 +1,26 @@ +import { test, expect } from '../../fixtures.ts'; + +test('the token cache keeps the session across a relaunch, and a new scope starts signed out', async ({ + host, + screen, +}) => { + const user = await host.seedUser(); + const seeded = await host.launch({ signedInAs: user, screen: 'tokenCache' }); + expect(seeded.userId).toBe(user.id); + + await host.launch({ screen: 'tokenCache', keepStorage: true }); + const restored = await host.waitForState(s => s.signedIn, 20_000); + expect(restored.userId).toBe(user.id); + expect(restored.ticket).toBe('none'); + await expect(screen.getByTestId('verify.tokenCache.clientToken')).toHaveText('stored client token: present', { + timeout: 15_000, + }); + await host.screenshot('token-cache'); + + await host.launch({ screen: 'tokenCache' }); + await expect(screen.getByTestId('verify.tokenCache.clientToken')).toHaveText('stored client token: absent', { + timeout: 15_000, + }); + const fresh = await host.state(); + expect(fresh.signedIn).toBe(false); +}); diff --git a/.claude/skills/verify-clerk-expo/specs/golden/user-button-and-profile/profile.e2e.ts b/.claude/skills/verify-clerk-expo/specs/golden/user-button-and-profile/profile.e2e.ts new file mode 100644 index 00000000000..844e347e7b7 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/specs/golden/user-button-and-profile/profile.e2e.ts @@ -0,0 +1,25 @@ +import { test, expect } from '../../fixtures.ts'; +import { nativeProfile, nativeUserButton } from '../../native.ts'; + +test('the native UserProfileView shows the seeded user', async ({ host, screen }) => { + const user = await host.seedUser(); + const state = await host.launch({ signedInAs: user, screen: 'userProfile' }); + expect(state.userId).toBe(user.id); + expect(state.sessionStatus).toBe('active'); + const profile = nativeProfile(screen); + await expect(profile.manageAccount).toBeVisible({ timeout: 30_000 }); + await host.tap(profile.manageAccount); + await expect(screen.getByText(user.email)).toBeVisible({ timeout: 20_000 }); + await host.screenshot('profile'); +}); + +test('the native UserButton opens the profile', async ({ host, screen, platform }) => { + const user = await host.seedUser(); + const state = await host.launch({ signedInAs: user, screen: 'userButton' }); + expect(state.userId).toBe(user.id); + const button = nativeUserButton(screen, platform); + await expect(button).toBeVisible({ timeout: 30_000 }); + await host.tap(button); + await expect(nativeProfile(screen).manageAccount).toBeVisible({ timeout: 20_000 }); + await host.screenshot('user-button-profile'); +}); diff --git a/.claude/skills/verify-clerk-expo/specs/native.ts b/.claude/skills/verify-clerk-expo/specs/native.ts new file mode 100644 index 00000000000..3e7de53182e --- /dev/null +++ b/.claude/skills/verify-clerk-expo/specs/native.ts @@ -0,0 +1,27 @@ +import type { Locator } from 'e2e'; + +interface Screen { + getByTestId(id: string): Locator; + getByText(text: string | RegExp): Locator; + getByLabel(text: string): Locator; +} + +export function nativeAuth(screen: Screen, platform: string) { + const ios = platform === 'ios'; + return { + identifier: ios + ? screen.getByTestId('clerk.auth.start.identifier') + : screen.getByText('Enter your email or username'), + }; +} + +export function nativeUserButton(screen: Screen, platform: string): Locator { + return platform === 'ios' ? screen.getByTestId('clerk.userButton.profile') : screen.getByLabel('Open user profile'); +} + +export function nativeProfile(screen: Screen) { + return { + manageAccount: screen.getByText('Manage account'), + signOut: screen.getByText('Sign out'), + }; +} diff --git a/.claude/skills/verify-clerk-expo/src/fixture.ts b/.claude/skills/verify-clerk-expo/src/fixture.ts new file mode 100644 index 00000000000..022a7dad51e --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/fixture.ts @@ -0,0 +1,176 @@ +import { spawn } from 'node:child_process'; +import { cpSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { VerifyFailure, type Platform } from './core/types.ts'; +import { resolveJavaHome, sdkRoot } from './platform/android/sdk.ts'; + +export const APP_ID = 'com.clerk.exponativebuildfixture'; +export const IOS_PRODUCT = 'ClerkExpoNativeBuildFixture'; +export const WORKTREE = fileURLToPath(new URL('../../../../', import.meta.url)); +export const FIXTURE = join(WORKTREE, 'integration', 'templates', 'expo-native'); + +const EXPO_PACKAGES = [ + 'expo-auth-session', + 'expo-constants', + 'expo-crypto', + 'expo-dev-client', + 'expo-secure-store', + 'expo-web-browser', +]; + +const SHARED_NATIVE_INPUTS = [ + 'packages/expo/app.plugin.js', + 'packages/expo/src/specs', + 'packages/expo/expo-module.config.json', + 'packages/expo/react-native.config.js', + 'packages/expo/package.json', + 'packages/expo-google-signin/app.plugin.js', + 'packages/expo-google-signin/expo-module.config.json', + 'packages/expo-google-signin/package.json', + 'packages/expo-biometrics/expo-module.config.json', + 'packages/expo-biometrics/package.json', + 'integration/templates/expo-native/app.json', + 'integration/templates/expo-native/app.config.js', + 'integration/templates/expo-native/package.sdk-57.json', + 'integration/templates/expo-native/pnpm-workspace.yaml', + 'integration/templates/expo-native/modules', +] as const; + +const PLATFORM_NATIVE_INPUTS: Readonly> = { + ios: ['packages/expo/ios', 'packages/expo-google-signin/ios', 'packages/expo-biometrics/ios'], + android: ['packages/expo/android', 'packages/expo-google-signin/android', 'packages/expo-biometrics/android'], +}; + +export function nativeInputs(platform: Platform): readonly string[] { + return [...PLATFORM_NATIVE_INPUTS[platform], ...SHARED_NATIVE_INPUTS]; +} + +export function artifact(platform: Platform): string { + return platform === 'ios' + ? join(FIXTURE, 'ios', 'build', 'Build', 'Products', 'Debug-iphonesimulator', `${IOS_PRODUCT}.app`) + : join(FIXTURE, 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk'); +} + +function step( + command: string, + args: readonly string[], + cwd: string, + env: Readonly> = {}, +): Promise<{ code: number; tail: string }> { + return new Promise(resolve => { + const child = spawn(command, [...args], { + cwd, + env: { ...process.env, LANG: 'en_US.UTF-8', CI: '1', ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const lines: string[] = []; + const collect = (chunk: Buffer) => { + for (const line of chunk.toString().split('\n')) { + if (line.trim().length === 0) continue; + lines.push(line); + if (lines.length > 40) lines.shift(); + } + }; + child.stdout.on('data', collect); + child.stderr.on('data', collect); + child.on('error', () => resolve({ code: 127, tail: `${command} could not start` })); + child.on('close', code => resolve({ code: code ?? 1, tail: lines.slice(-15).join('\n') })); + }); +} + +export async function mustStep( + what: string, + command: string, + args: readonly string[], + cwd: string, + env?: Readonly>, +): Promise { + const result = await step(command, args, cwd, env); + if (result.code !== 0) + throw new VerifyFailure( + 'BUILD_FAILED', + `${what} exited ${result.code}:\n${result.tail}`, + 'fix the error above, then rerun {cli} up', + ); +} + +export interface FixtureBuild { + readonly platform: Platform; + readonly buildPackages: boolean; + readonly progress: (line: string) => void; +} + +async function generateNativeProject(build: FixtureBuild): Promise { + const { platform, progress } = build; + cpSync(join(FIXTURE, 'package.sdk-57.json'), join(FIXTURE, 'package.json')); + await mustStep( + 'pnpm add the workspace packages', + 'pnpm', + [ + 'add', + 'link:../../../packages/expo', + 'link:../../../packages/expo-google-signin', + 'link:../../../packages/expo-biometrics', + ], + FIXTURE, + ); + await mustStep('expo install', 'pnpm', ['expo', 'install', ...EXPO_PACKAGES], FIXTURE); + progress(`build expo prebuild --clean --platform ${platform}`); + await mustStep('expo prebuild', 'pnpm', ['expo', 'prebuild', '--clean', '--platform', platform], FIXTURE); +} + +export async function buildFixture(build: FixtureBuild): Promise { + const { platform, progress } = build; + if (!existsSync(join(WORKTREE, 'node_modules'))) { + throw new VerifyFailure('NOT_READY', 'the monorepo has no node_modules', `cd ${WORKTREE} && pnpm install`); + } + if (build.buildPackages) { + progress('build turbo build @clerk/expo, @clerk/expo-biometrics, @clerk/expo-google-signin'); + await mustStep( + 'turbo build', + 'pnpm', + [ + 'turbo', + 'build', + '--filter=@clerk/expo...', + '--filter=@clerk/expo-biometrics...', + '--filter=@clerk/expo-google-signin...', + ], + WORKTREE, + ); + } + await generateNativeProject(build); + if (platform === 'ios') { + progress('build xcodebuild Debug (dev client)'); + await mustStep( + 'xcodebuild', + 'xcodebuild', + [ + 'build', + '-quiet', + '-workspace', + `ios/${IOS_PRODUCT}.xcworkspace`, + '-scheme', + IOS_PRODUCT, + '-configuration', + 'Debug', + '-sdk', + 'iphonesimulator', + '-derivedDataPath', + join(FIXTURE, 'ios', 'build'), + 'CODE_SIGN_IDENTITY=-', + ], + FIXTURE, + ); + } else { + const java = resolveJavaHome(); + if (!java.ok) throw new VerifyFailure('NOT_READY', java.detail, java.fix); + progress('build gradlew assembleDebug (dev client)'); + await mustStep('gradlew assembleDebug', './gradlew', ['assembleDebug', '-q'], join(FIXTURE, 'android'), { + JAVA_HOME: java.home, + ANDROID_HOME: sdkRoot(), + }); + } + return artifact(platform); +} diff --git a/.claude/skills/verify-clerk-expo/src/freshness.ts b/.claude/skills/verify-clerk-expo/src/freshness.ts new file mode 100644 index 00000000000..7830309c3e6 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/freshness.ts @@ -0,0 +1,370 @@ +import { createHash } from 'node:crypto'; +import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +interface SourceFile { + readonly rel: string; + readonly mtime: number; +} + +const BUILT_EXTENSION = /\.(ts|tsx|js|jsx)$/; + +const inTestsDir = (rel: string) => rel.split('/').includes('__tests__'); + +export function isTsdownSource(rel: string): boolean { + return BUILT_EXTENSION.test(rel) && !/\.test\.(ts|tsx)$/.test(rel) && !inTestsDir(rel); +} + +export function isPackageSource(rel: string): boolean { + return ( + BUILT_EXTENSION.test(rel) && + !/\.(test|spec)\.(ts|tsx|js|jsx)$/.test(rel) && + !inTestsDir(rel) && + !rel.endsWith('.d.ts') + ); +} + +export function isBundledOutput(rel: string): boolean { + return /\.(js|cjs|mjs)$/.test(rel); +} + +export function listFiles(root: string, prefix: string, keep: (rel: string) => boolean): readonly SourceFile[] { + const walk = (dir: string, relDir: string): SourceFile[] => { + if (!existsSync(dir)) return []; + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const rel = relDir === '' ? entry.name : `${relDir}/${entry.name}`; + const path = join(dir, entry.name); + if (entry.isDirectory()) return entry.name === 'node_modules' ? [] : walk(path, rel); + return entry.isFile() && keep(rel) ? [{ rel: `${prefix}${rel}`, mtime: statSync(path).mtimeMs }] : []; + }); + }; + return walk(root, ''); +} + +export const newest = (files: readonly SourceFile[]): number => files.reduce((max, f) => Math.max(max, f.mtime), 0); + +interface WorkspacePackage { + readonly name: string; + readonly dir: string; +} + +export function workspaceDependencies(worktree: string, packageDir: string): readonly WorkspacePackage[] { + const packagesRoot = realpathSync(join(worktree, 'packages')); + const found = new Map(); + const visit = (dir: string) => { + const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as Record< + string, + Record | undefined + >; + const names = Object.keys({ + ...manifest.dependencies, + ...manifest.peerDependencies, + ...manifest.optionalDependencies, + }); + for (const name of names) { + const link = join(dir, 'node_modules', name); + if (found.has(name) || !existsSync(link)) continue; + const real = realpathSync(link); + if (relative(packagesRoot, real).startsWith('..') || !existsSync(join(real, 'package.json'))) continue; + found.set(name, { name, dir: real }); + visit(real); + } + }; + visit(packageDir); + return [...found.values()]; +} + +function isExpoModule(pkg: WorkspacePackage): boolean { + return existsSync(join(pkg.dir, 'expo-module.config.json')); +} + +const builtTo = (pkg: WorkspacePackage) => listFiles(join(pkg.dir, 'dist'), '', isBundledOutput); +const hasBuild = (pkg: WorkspacePackage) => existsSync(join(pkg.dir, 'src')) && existsSync(join(pkg.dir, 'dist')); + +interface BuiltRecord { + readonly sources: string; + readonly dist: string; +} +export type BuiltRecords = Readonly>; + +function contentDigest(files: readonly SourceFile[]): string { + const hash = createHash('sha256'); + for (const file of [...files].sort((a, b) => a.rel.localeCompare(b.rel))) { + hash.update(`${file.rel}\0`).update(readFileSync(file.rel)).update('\0'); + } + return hash.digest('hex'); +} + +function outputSignature(files: readonly SourceFile[]): string { + return [...files] + .sort((a, b) => a.rel.localeCompare(b.rel)) + .map(f => `${f.rel}:${f.mtime}`) + .join('|'); +} + +interface Judged { + readonly stale: boolean; + readonly record: BuiltRecord | null; +} + +function judgeBuilt( + pkg: WorkspacePackage, + sourcePackages: readonly WorkspacePackage[], + previous: BuiltRecord | undefined, +): Judged { + const sources = sourcePackages.flatMap(p => listFiles(join(p.dir, 'src'), `${p.dir}/src/`, isPackageSource)); + const dist = builtTo(pkg); + const signature = outputSignature(dist); + if (!isStale(sources, dist)) return { stale: false, record: { sources: contentDigest(sources), dist: signature } }; + if (previous === undefined || previous.dist !== signature) return { stale: true, record: null }; + const unchanged = previous.sources === contentDigest(sources); + return { stale: !unchanged, record: unchanged ? previous : null }; +} + +interface ScopeCheck { + readonly stale: readonly WorkspacePackage[]; + readonly records: BuiltRecords; +} + +function check(worktree: string, expoDir: string, records: BuiltRecords, inScope: boolean): ScopeCheck { + const next: Record = { ...records }; + const stale: WorkspacePackage[] = []; + for (const pkg of workspaceDependencies(worktree, expoDir)) { + if (isExpoModule(pkg) !== inScope || !hasBuild(pkg)) continue; + const sourcePackages = inScope ? [pkg] : [pkg, ...workspaceDependencies(worktree, pkg.dir).filter(hasBuild)]; + const judged = judgeBuilt(pkg, sourcePackages, records[pkg.name]); + if (judged.stale) { + stale.push(pkg); + } else if (judged.record !== null) { + next[pkg.name] = judged.record; + } + } + return { stale, records: next }; +} + +export function staleInScope(worktree: string, expoDir: string, records: BuiltRecords = {}): ScopeCheck { + return check(worktree, expoDir, records, true); +} + +export function staleOutOfScope(worktree: string, expoDir: string, records: BuiltRecords = {}): ScopeCheck { + return check(worktree, expoDir, records, false); +} + +export function isStale(srcFiles: readonly SourceFile[], distFiles: readonly SourceFile[]): boolean { + return srcFiles.length > 0 && distFiles.length > 0 && newest(srcFiles) > newest(distFiles); +} + +interface OutputEntry { + readonly mtime: number; + readonly size: number; + readonly hash: string; + readonly since: number; +} +export type Fingerprint = Readonly>; + +export function fingerprint(root: string, rels: readonly string[], previous: Fingerprint | null): Fingerprint { + const out: Record = {}; + for (const rel of rels) { + const path = join(root, rel); + let stat; + try { + stat = statSync(path); + } catch { + continue; + } + const before = previous?.[rel]; + if (before !== undefined && before.mtime === stat.mtimeMs && before.size === stat.size) { + out[rel] = before; + continue; + } + let content: Buffer; + try { + content = readFileSync(path); + } catch { + continue; + } + const hash = createHash('sha256').update(content).digest('hex'); + out[rel] = { + mtime: stat.mtimeMs, + size: stat.size, + hash, + since: before !== undefined && before.hash === hash ? before.since : stat.mtimeMs, + }; + } + return out; +} + +export function changedFiles(before: Fingerprint, after: Fingerprint): readonly string[] { + const rels = new Set([...Object.keys(before), ...Object.keys(after)]); + return [...rels].filter(rel => before[rel]?.hash !== after[rel]?.hash).sort(); +} + +const sameContent = (a: Fingerprint, b: Fingerprint): boolean => changedFiles(a, b).length === 0; + +function inBundle(body: string, rels: readonly string[]): readonly string[] { + return rels.filter(rel => body.includes(`${rel}"`)); +} + +function bundledDigest(current: Fingerprint, body: string): string { + const hash = createHash('sha256'); + for (const rel of inBundle(body, Object.keys(current)).slice().sort()) hash.update(`${rel}:${current[rel]!.hash}\n`); + return hash.digest('hex'); +} + +const STALE = 'stale'; + +export interface GateMemory { + readonly metroPid: number; + readonly spawn: Fingerprint | null; + readonly seen: Readonly>; + readonly outputs: Fingerprint; +} + +export interface BundleView { + readonly revId: string; + readonly lastModified: number; + readonly body: string; +} + +type Verdict = 'fresh' | 'stale' | 'restart'; + +export function judge( + memory: GateMemory, + current: Fingerprint, + bundle: BundleView, +): { readonly verdict: Verdict; readonly memory: GateMemory } { + const digest = bundledDigest(current, bundle.body); + const known = memory.seen[bundle.revId]; + if (known !== undefined) return { verdict: known === digest ? 'fresh' : 'stale', memory }; + let verdict: Verdict; + if (Object.keys(memory.seen).length === 0) { + verdict = memory.spawn !== null && bundledDigest(memory.spawn, bundle.body) === digest ? 'fresh' : 'restart'; + } else { + const lastContent = Math.max(0, ...inBundle(bundle.body, Object.keys(current)).map(rel => current[rel]!.since)); + verdict = bundle.lastModified >= Math.floor(lastContent / 1000) * 1000 ? 'fresh' : 'stale'; + } + if (verdict === 'restart') return { verdict, memory }; + return { + verdict, + memory: { ...memory, seen: { ...memory.seen, [bundle.revId]: verdict === 'fresh' ? digest : STALE } }, + }; +} + +export type Fetched = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly transient: boolean; readonly message: string }; + +export interface GateIO { + list(): readonly string[]; + fingerprint(rels: readonly string[], previous: Fingerprint | null): Fingerprint; + fetch(): Promise>; + touch(rels: readonly string[]): void; + restart(): Promise; + now(): number; + sleep(ms: number): Promise; + progress(line: string): void; +} + +interface GateOptions { + readonly timeoutMs: number; + readonly nudgeAfterMs: number; + readonly maxRestarts: number; + readonly settledFailureMs: number; +} + +type GateResult = + | { readonly ok: true; readonly memory: GateMemory } + | { readonly ok: false; readonly kind: 'timeout' | 'bundle-error'; readonly message: string }; + +const complete = (rels: readonly string[], fp: Fingerprint) => rels.length === Object.keys(fp).length; + +export async function confirmServed(io: GateIO, start: GateMemory, options: GateOptions): Promise { + let memory = start; + let current = memory.outputs; + let candidate: { readonly revId: string; readonly outputs: Fingerprint } | null = null; + let failure: { + readonly message: string; + readonly outputs: Fingerprint; + readonly rels: string; + readonly since: number; + } | null = null; + let lastError = ''; + let staleSince: number | null = null; + let restarts = 0; + let delay = 500; + const deadline = io.now() + options.timeoutMs; + while (io.now() < deadline) { + const rels = io.list(); + const before = io.fingerprint(rels, current); + const bundle = await io.fetch(); + const after = io.fingerprint(io.list(), before); + current = after; + const settled = sameContent(before, after) && complete(rels, after) && complete(io.list(), after); + if (!bundle.ok) { + candidate = null; + lastError = bundle.message; + const key = rels.join('\n'); + if (!bundle.transient && settled) { + if ( + failure !== null && + failure.message === bundle.message && + failure.rels === key && + sameContent(failure.outputs, after) + ) { + if (io.now() - failure.since >= options.settledFailureMs) + return { ok: false, kind: 'bundle-error', message: bundle.message }; + } else { + failure = { message: bundle.message, outputs: after, rels: key, since: io.now() }; + } + } else { + failure = null; + } + await io.sleep(delay); + delay = Math.min(delay * 2, 8_000); + continue; + } + failure = null; + delay = 500; + if (!settled) { + candidate = null; + await io.sleep(500); + continue; + } + const judged = judge(memory, after, bundle.value); + memory = judged.memory; + if (judged.verdict === 'restart') { + if (restarts >= options.maxRestarts) + return { ok: false, kind: 'timeout', message: 'Metro kept serving a bundle that predates the current dist' }; + restarts += 1; + io.progress( + 'metro restarting Metro, because dist changed after it started and its first bundle cannot be dated', + ); + const spawn = io.fingerprint(io.list(), after); + const pid = await io.restart(); + memory = { metroPid: pid, spawn, seen: {}, outputs: memory.outputs }; + candidate = null; + continue; + } + if (judged.verdict === 'stale') { + candidate = null; + staleSince ??= io.now(); + if (io.now() - staleSince >= options.nudgeAfterMs) { + const missed = inBundle(bundle.value.body, changedFiles(memory.outputs, after)); + io.progress( + `metro still serving an older bundle; touching ${missed.length} changed file(s) so Metro's watcher sees them`, + ); + io.touch(missed); + staleSince = io.now(); + } + await io.sleep(500); + continue; + } + staleSince = null; + if (candidate !== null && candidate.revId === bundle.value.revId && sameContent(candidate.outputs, after)) { + return { ok: true, memory: { ...memory, outputs: after } }; + } + candidate = { revId: bundle.value.revId, outputs: after }; + await io.sleep(500); + } + return { ok: false, kind: 'timeout', message: lastError }; +} diff --git a/.claude/skills/verify-clerk-expo/src/host.ts b/.claude/skills/verify-clerk-expo/src/host.ts new file mode 100644 index 00000000000..9604bac4b3e --- /dev/null +++ b/.claude/skills/verify-clerk-expo/src/host.ts @@ -0,0 +1,578 @@ +import { spawn } from 'node:child_process'; +import { + appendFileSync, + cpSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { createRequire } from 'node:module'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isRunning, run, sleep } from './core/exec.ts'; +import { + LOCAL_POOL, + VerifyFailure, + type HostAdapter, + type HostEntry, + type Platform, + type RuntimeProcess, + type ScratchPath, +} from './core/types.ts'; +import { takeSlotLock } from './core/workspace.ts'; +import { APP_ID, FIXTURE, IOS_PRODUCT, WORKTREE, buildFixture, mustStep, nativeInputs } from './fixture.ts'; +import { + confirmServed, + fingerprint, + isBundledOutput, + isTsdownSource, + listFiles, + newest, + staleInScope, + staleOutOfScope, + workspaceDependencies, + type BuiltRecords, + type BundleView, + type Fetched, + type Fingerprint, + type GateMemory, +} from './freshness.ts'; +import { localAndroidBackend } from './platform/android/local.ts'; +import { sdkTool } from './platform/android/sdk.ts'; +import { localIosBackend } from './platform/ios/local.ts'; + +const ANDROID_ACTIVITY = '.MainActivity'; +const DEV_CLIENT_SCHEME = 'exp+clerk-expo-native-build-fixture'; +const ANDROID_DEV_MENU_PREFS = ``; + +const EXPO_PACKAGE = join(WORKTREE, 'packages', 'expo'); +const RUNTIME_DIR = fileURLToPath(new URL('../.verify/runtime/', import.meta.url)); + +const FIXTURE_LOCK_MINUTES = 45; + +const SCREENS = [ + 'home', + 'auth', + 'nativeAuth', + 'userButton', + 'userProfile', + 'customSignIn', + 'customSignUp', + 'tokenCache', +] as const; + +export function metroPort(lease: { readonly platform: Platform; readonly slot: number }): number { + return 8081 + (lease.platform === 'ios' ? 0 : 4) + lease.slot; +} + +export function devClientEntry(platform: Platform, port: number): HostEntry { + const url = `http://localhost:${port}`; + return platform === 'ios' + ? { + kind: 'dev-client', + launchArguments: [ + '--initialUrl', + url, + '-EXDevMenuShowsAtLaunch', + 'NO', + '-EXDevMenuIsOnboardingFinished', + 'YES', + '-EXDevMenuShowFloatingActionButton', + 'NO', + ], + openLink: null, + androidActivity: null, + } + : { + kind: 'dev-client', + launchArguments: [], + openLink: `${DEV_CLIENT_SCHEME}://expo-development-client/?url=${encodeURIComponent(url)}`, + androidActivity: ANDROID_ACTIVITY, + }; +} + +async function withFixtureLock(progress: (line: string) => void, fn: () => Promise): Promise { + mkdirSync(RUNTIME_DIR, { recursive: true }); + const release = await takeSlotLock( + join(RUNTIME_DIR, 'fixture-lock'), + FIXTURE_LOCK_MINUTES * 60_000, + () => + new VerifyFailure( + 'NOT_READY', + `another verify process in this worktree has been building the expo-native fixture for ${FIXTURE_LOCK_MINUTES} minutes`, + '{cli} down, then {cli} up', + ), + owner => + progress(`build waiting for pid ${owner.pid}, which is building the expo-native fixture in this worktree`), + ); + try { + return await fn(); + } finally { + release(); + } +} + +const pidFile = (name: string) => join(RUNTIME_DIR, `${name}.json`); +const runtimeLog = (name: string) => join(RUNTIME_DIR, `${name}.log`); + +function readRuntime(name: string): RuntimeProcess | null { + if (!existsSync(pidFile(name))) return null; + const ref = JSON.parse(readFileSync(pidFile(name), 'utf8')) as RuntimeProcess; + return isRunning(ref) ? ref : null; +} + +const allRuntimeNames = (): readonly string[] => [ + 'watch', + ...(['ios', 'android'] as const).flatMap(platform => + Array.from({ length: LOCAL_POOL[platform] }, (_, i) => `metro-${metroPort({ platform, slot: i + 1 })}`), + ), +]; + +function stopRuntime(names: readonly string[] = allRuntimeNames()): void { + for (const name of names) { + const ref = readRuntime(name); + if (ref !== null) process.kill(ref.pid, 'SIGTERM'); + rmSync(pidFile(name), { force: true }); + } +} + +function startDetached( + name: string, + owner: Pick, + args: readonly string[], + cwd: string, +): RuntimeProcess { + mkdirSync(RUNTIME_DIR, { recursive: true }); + const log = openSync(runtimeLog(name), 'a'); + appendFileSync(log, `\n==== ${name} started ${new Date().toISOString()}\n`); + const { CI: _ci, ...env } = process.env; + const child = spawn(process.execPath, [...args], { + cwd, + detached: true, + stdio: ['ignore', log, log], + env: { ...env, LANG: 'en_US.UTF-8', EXPO_NO_TELEMETRY: '1', EXPO_OFFLINE: '1' }, + }); + child.unref(); + const ref: RuntimeProcess = { ...owner, pid: child.pid ?? 0, startedAt: Date.now() }; + writeFileSync(pidFile(name), JSON.stringify(ref)); + return ref; +} + +async function waitFor( + what: string, + ready: () => Promise, + timeoutMs: number, + logName: string, + fix?: string, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await ready()) return; + await sleep(500); + } + throw new VerifyFailure( + 'NOT_READY', + `${what} was not ready within ${timeoutMs / 1000}s`, + fix ?? `read ${runtimeLog(logName)}`, + ); +} + +interface Started { + readonly names: string[]; +} + +export async function withCleanup( + stop: (names: readonly string[]) => void, + fn: (started: Started) => Promise, + progress: (line: string) => void, +): Promise { + const started: Started = { names: [] }; + try { + return await fn(started); + } catch (error) { + if (started.names.length > 0) { + progress(`stop ${started.names.join(', ')} (started by this call, which failed)`); + stop(started.names); + } + throw error; + } +} + +async function ensureWatch(started: Started, progress: (line: string) => void): Promise { + const running = readRuntime('watch'); + if (running !== null) return running; + const tsdown = createRequire(join(EXPO_PACKAGE, 'package.json')).resolve('tsdown/package.json'); + const offset = existsSync(runtimeLog('watch')) ? readFileSync(runtimeLog('watch')).length : 0; + const ref = startDetached( + 'watch', + { what: 'watch' }, + [join(tsdown, '..', 'dist', 'run.mjs'), '--watch'], + EXPO_PACKAGE, + ); + started.names.push('watch'); + progress(`watch packages/expo tsdown --watch (pid ${ref.pid})`); + await waitFor( + 'the @clerk/expo watch build', + async () => /Build complete|built in|Rebuilt/i.test(readFileSync(runtimeLog('watch')).subarray(offset).toString()), + 180_000, + 'watch', + ); + return ref; +} + +const expoOutputs = () => listFiles(join(EXPO_PACKAGE, 'dist'), 'packages/expo/dist/', isBundledOutput); + +async function waitForWatchToCatchUp(): Promise { + let previous = ''; + await waitFor( + 'the @clerk/expo watch build to pick up the latest edit', + async () => { + const outputs = expoOutputs(); + const signature = outputs.map(f => `${f.rel}:${f.mtime}`).join('|'); + const settled = signature === previous; + previous = signature; + return ( + settled && newest(outputs) >= newest(listFiles(join(EXPO_PACKAGE, 'src'), 'packages/expo/src/', isTsdownSource)) + ); + }, + 60_000, + 'watch', + ); +} + +const recordsFile = () => join(RUNTIME_DIR, 'built-dependencies.json'); + +function readRecords(): BuiltRecords { + return existsSync(recordsFile()) ? (JSON.parse(readFileSync(recordsFile(), 'utf8')) as BuiltRecords) : {}; +} + +function writeRecords(records: BuiltRecords): void { + mkdirSync(RUNTIME_DIR, { recursive: true }); + writeFileSync(recordsFile(), JSON.stringify(records)); +} + +function refuseOutOfScope(): void { + const { stale, records } = staleOutOfScope(WORKTREE, EXPO_PACKAGE, readRecords()); + writeRecords(records); + if (stale.length === 0) return; + const names = stale.map(pkg => pkg.name); + throw new VerifyFailure( + 'NOT_READY', + `${names.join(', ')} ${stale.length === 1 ? 'has' : 'have'} source newer than ${stale.length === 1 ? 'its dist' : 'their dist'}, with content that dist was not built from. This skill rebuilds only @clerk/expo and its Expo-module siblings; it verifies other workspace dependencies after you build them, and it will not launch on their stale dist`, + `{cli} down, then pnpm turbo build --force ${names.map(n => `--filter=${n}`).join(' ')}, then rerun`, + ); +} + +async function rebuildStaleSiblings(progress: (line: string) => void): Promise { + const { stale, records } = staleInScope(WORKTREE, EXPO_PACKAGE, readRecords()); + writeRecords(records); + if (stale.length === 0) return; + const names = stale.map(pkg => pkg.name); + progress( + `build ${names.join(', ')} src changed since dist was built; stopping this worktree's Metro and watch build, then pnpm turbo build --force ${names.map(n => `--filter=${n}`).join(' ')}`, + ); + stopRuntime(); + await mustStep( + `turbo build ${names.join(' ')}`, + 'pnpm', + ['turbo', 'build', '--force', ...names.map(n => `--filter=${n}`)], + WORKTREE, + ); + writeRecords(staleInScope(WORKTREE, EXPO_PACKAGE, readRecords()).records); +} + +function servedOutputs(): readonly string[] { + const roots = [{ name: '@clerk/expo', dir: EXPO_PACKAGE }, ...workspaceDependencies(WORKTREE, EXPO_PACKAGE)]; + return roots.flatMap(pkg => { + const prefix = `${relative(WORKTREE, pkg.dir)}/dist/`; + return listFiles(join(pkg.dir, 'dist'), prefix, isBundledOutput).map(f => f.rel); + }); +} + +async function metroAnswers(port: number): Promise { + try { + const response = await fetch(`http://127.0.0.1:${port}/status`, { signal: AbortSignal.timeout(2000) }); + return (await response.text()).includes('packager-status:running'); + } catch { + return false; + } +} + +interface Metro { + readonly ref: RuntimeProcess; + readonly spawnOutputs: Fingerprint | null; + readonly started: Started; +} + +const metroCli = () => join(FIXTURE, 'node_modules', 'expo', 'bin', 'cli'); + +const startMetro = (port: number, platform: Platform): RuntimeProcess => + startDetached( + `metro-${port}`, + { what: 'metro', platform }, + [metroCli(), 'start', '--port', String(port), '--dev-client'], + FIXTURE, + ); + +async function ensureMetro( + started: Started, + port: number, + platform: Platform, + progress: (line: string) => void, +): Promise { + const name = `metro-${port}`; + const running = readRuntime(name); + if (running !== null) { + await waitFor( + `Metro pid ${running.pid} on port ${port} to answer`, + () => metroAnswers(port), + 120_000, + name, + `{cli} down, then retry; read ${runtimeLog(name)}`, + ); + return { ref: running, spawnOutputs: null, started }; + } + if (await metroAnswers(port)) { + throw new VerifyFailure( + 'NOT_READY', + `port ${port} already serves a Metro that this worktree did not start`, + `stop the process listening on ${port} (lsof -nP -iTCP:${port} -sTCP:LISTEN)`, + ); + } + if (!existsSync(metroCli())) + throw new VerifyFailure('NOT_READY', 'the expo-native fixture has no node_modules', '{cli} up'); + const spawnOutputs = fingerprint(WORKTREE, servedOutputs(), null); + const ref = startMetro(port, platform); + started.names.push(name); + progress(`metro :${port} expo start (pid ${ref.pid})`); + await waitFor(`Metro on port ${port}`, () => metroAnswers(port), 120_000, name); + return { ref, spawnOutputs, started }; +} + +async function fetchManifest(port: number, platform: Platform): Promise> { + try { + const response = await fetch(`http://127.0.0.1:${port}/`, { + headers: { 'expo-platform': platform, accept: 'application/expo+json,application/json' }, + signal: AbortSignal.timeout(30_000), + }); + const text = await response.text(); + const url = response.ok ? (JSON.parse(text) as { launchAsset?: { url?: string } }).launchAsset?.url : undefined; + return url === undefined + ? { ok: false, transient: true, message: `manifest ${response.status}: ${text.slice(0, 200)}` } + : { ok: true, value: url }; + } catch (error) { + return { ok: false, transient: true, message: (error as Error).message }; + } +} + +async function fetchBundle(url: string): Promise> { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(300_000) }); + const body = await response.text(); + if (response.status === 200) + return { + ok: true, + value: { + revId: response.headers.get('x-metro-delta-id') ?? '', + lastModified: Date.parse(response.headers.get('last-modified') ?? '') || 0, + body, + }, + }; + let message = body.slice(0, 300); + try { + const parsed = JSON.parse(body) as { type?: string; message?: string }; + message = `${parsed.type ?? 'error'}: ${(parsed.message ?? '').split('\n')[0]}`; + } catch {} + return { ok: false, transient: response.status !== 500, message: `bundle ${response.status}: ${message}` }; + } catch (error) { + return { ok: false, transient: true, message: (error as Error).message }; + } +} + +async function ensureServed( + metro: Metro, + port: number, + platform: Platform, + progress: (line: string) => void, +): Promise { + const name = `metro-${port}`; + const stateFile = join(RUNTIME_DIR, `served-${port}-${platform}.json`); + const saved = existsSync(stateFile) ? (JSON.parse(readFileSync(stateFile, 'utf8')) as Partial) : null; + let current = metro.ref; + const memory: GateMemory = + saved !== null && saved.metroPid === current.pid && saved.seen !== undefined && saved.outputs !== undefined + ? (saved as GateMemory) + : { metroPid: current.pid, spawn: metro.spawnOutputs, seen: {}, outputs: metro.spawnOutputs ?? {} }; + const pending = + Object.keys(memory.seen).length === 0 + ? [] + : servedOutputs().filter( + rel => fingerprint(WORKTREE, [rel], memory.outputs)[rel]?.hash !== memory.outputs[rel]?.hash, + ); + progress( + Object.keys(memory.seen).length === 0 + ? `metro :${port} bundling ${platform} once so the first launch does not wait on Metro` + : pending.length > 0 + ? `metro :${port} ${pending.length} served file(s) changed since the last launch; waiting until Metro's ${platform} bundle has the new code` + : `metro :${port} no served file changed since the last launch`, + ); + const fix = `retry {cli} up --platform ${platform}; if it fails again, read ${runtimeLog(name)}`; + const url = await (async () => { + let last = ''; + let delay = 500; + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const manifest = await fetchManifest(port, platform); + if (manifest.ok) return manifest.value; + last = manifest.message; + await sleep(delay); + delay = Math.min(delay * 2, 8_000); + } + throw new VerifyFailure( + 'NOT_READY', + `Metro on port ${port} returned no ${platform} launch asset within 120s (${last})`, + fix, + ); + })(); + const result = await confirmServed( + { + list: servedOutputs, + fingerprint: (rels, previous) => fingerprint(WORKTREE, rels, previous), + fetch: () => fetchBundle(url), + touch: rels => { + const now = new Date(); + for (const rel of rels) utimesSync(join(WORKTREE, rel), now, now); + }, + restart: async () => { + if (isRunning(current)) process.kill(current.pid, 'SIGTERM'); + await waitFor(`Metro pid ${current.pid} to exit`, async () => !(await metroAnswers(port)), 30_000, name); + current = startMetro(port, platform); + metro.started.names.includes(name) || metro.started.names.push(name); + progress(`metro :${port} expo start (pid ${current.pid})`); + await waitFor(`Metro on port ${port}`, () => metroAnswers(port), 120_000, name); + return current.pid; + }, + now: Date.now, + sleep, + progress: line => progress(line.replace(/^metro {3}/, `metro :${port} `)), + }, + memory, + { timeoutMs: 300_000, nudgeAfterMs: 5_000, maxRestarts: 2, settledFailureMs: 3_000 }, + ); + if (!result.ok) { + if (result.kind === 'bundle-error') { + throw new VerifyFailure( + 'NOT_READY', + `Metro on port ${port} could not bundle ${platform}: ${result.message}`, + `fix the bundling error, then rerun; read ${runtimeLog(name)}`, + ); + } + throw new VerifyFailure( + 'NOT_READY', + `Metro on port ${port} did not serve the latest ${platform} bundle within 300s${result.message === '' ? '' : ` (${result.message})`}`, + fix, + ); + } + writeFileSync(stateFile, JSON.stringify(result.memory)); + return current; +} + +function keepBuild(platform: Platform, built: string, into: string): string { + mkdirSync(into, { recursive: true }); + const path = join(into, platform === 'ios' ? `${IOS_PRODUCT}.app` : 'app-debug.apk'); + rmSync(path, { recursive: true, force: true }); + cpSync(built, path, { recursive: true, verbatimSymlinks: true }); + return path; +} + +export const host: HostAdapter<(typeof SCREENS)[number]> = { + repo: 'clerk-expo', + cli: '.claude/skills/verify-clerk-expo/bin/control-clerk-expo', + platforms: ['ios', 'android'], + screens: SCREENS, + githubRepo: 'clerk/javascript', + appId: () => APP_ID, + buildInputs: nativeInputs, + async build(platform, key, into, progress) { + if (process.platform !== 'darwin') + throw new VerifyFailure( + 'UNSUPPORTED', + `the expo-native fixture is built locally on macOS only, and this machine runs ${process.platform}`, + 'run {cli} up on a Mac with Xcode and Android Studio', + ); + const path = await withFixtureLock(progress, async () => { + const watching = readRuntime('watch') !== null; + if (watching) + progress('build the running watch build keeps packages/expo/dist current, so turbo build is skipped'); + else stopRuntime(); + const built = await buildFixture({ + platform, + buildPackages: !watching, + progress, + }); + return keepBuild(platform, built, into); + }); + return { platform, key, appId: APP_ID, path: path as ScratchPath, source: 'local' }; + }, + async runtime(lease, progress) { + const port = metroPort(lease); + return withCleanup( + stopRuntime, + async started => { + refuseOutOfScope(); + await rebuildStaleSiblings(progress); + const watch = await ensureWatch(started, progress); + await waitForWatchToCatchUp(); + const metro = await ensureServed( + await ensureMetro(started, port, lease.platform, progress), + port, + lease.platform, + progress, + ); + if (lease.platform === 'android') { + const adb = sdkTool('adb'); + const reverse = await run(adb, ['-s', lease.deviceId, 'reverse', `tcp:${port}`, `tcp:${port}`]); + if (reverse.code !== 0) + throw new VerifyFailure( + 'NOT_READY', + `adb reverse tcp:${port} failed: ${reverse.stderr.trim()}`, + '{cli} down --platform android, then {cli} up --platform android', + ); + await run(adb, ['-s', lease.deviceId, 'shell', 'am', 'force-stop', APP_ID]); + const prefs = await run( + adb, + [ + '-s', + lease.deviceId, + 'shell', + `run-as ${APP_ID} sh -c 'mkdir -p shared_prefs && cat > shared_prefs/expo.modules.devmenu.sharedpreferences.xml'`, + ], + { input: ANDROID_DEV_MENU_PREFS }, + ); + if (prefs.code !== 0) + throw new VerifyFailure( + 'NOT_READY', + `could not turn off the dev menu onboarding: ${prefs.stderr.trim()}`, + '{cli} down --platform android, then {cli} up --platform android', + ); + } + return { entry: devClientEntry(lease.platform, port), processes: [watch, metro] }; + }, + progress, + ); + }, + logPredicates: { ios: `process == "${IOS_PRODUCT}" AND senderImagePath CONTAINS "${IOS_PRODUCT}"` }, + features: [ + 'native-auth-view', + 'user-button-and-profile', + 'custom-flow-sign-in', + 'custom-flow-sign-up', + 'token-cache-persistence', + 'native-js-sync', + ], + backends: [localIosBackend(), localAndroidBackend()], +}; diff --git a/.claude/skills/verify-clerk-expo/test/freshness.test.ts b/.claude/skills/verify-clerk-expo/test/freshness.test.ts new file mode 100644 index 00000000000..b084330672e --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/freshness.test.ts @@ -0,0 +1,450 @@ +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it } from 'node:test'; +import { + changedFiles, + confirmServed, + fingerprint, + isBundledOutput, + isPackageSource, + isStale, + isTsdownSource, + listFiles, + newest, + staleInScope, + staleOutOfScope, + workspaceDependencies, + judge, + type BundleView, + type Fetched, + type Fingerprint, + type GateIO, + type GateMemory, +} from '../src/freshness.ts'; + +const scratch = () => mkdtempSync(join(tmpdir(), 'verify-fresh-')); + +describe('which sources the builds read', () => { + it('counts only the files tsdown builds for @clerk/expo', () => { + assert.equal(isTsdownSource('hooks/useAuth.ts'), true); + assert.equal(isTsdownSource('native/AuthView.tsx'), true); + assert.equal(isTsdownSource('hooks/useAuth.test.ts'), false); + assert.equal(isTsdownSource('hooks/__tests__/useAuth.ts'), false); + assert.equal(isTsdownSource('hooks/.useAuth.ts.swp'), false); + }); + + it('ignores tests, specs, and declarations in a sibling package', () => { + assert.equal(isPackageSource('index.ts'), true); + assert.equal(isPackageSource('index.spec.ts'), false); + assert.equal(isPackageSource('types.d.ts'), false); + assert.equal(isPackageSource('__tests__/x.ts'), false); + }); + + it('ignores an edited test file when deciding whether dist caught up', () => { + const root = scratch(); + mkdirSync(join(root, 'src', 'hooks'), { recursive: true }); + mkdirSync(join(root, 'dist', 'hooks'), { recursive: true }); + writeFileSync(join(root, 'src', 'hooks', 'useAuth.ts'), ''); + writeFileSync(join(root, 'dist', 'hooks', 'useAuth.js'), ''); + writeFileSync(join(root, 'src', 'hooks', 'useAuth.test.ts'), ''); + utimesSync(join(root, 'src', 'hooks', 'useAuth.ts'), 100, 100); + utimesSync(join(root, 'dist', 'hooks', 'useAuth.js'), 200, 200); + utimesSync(join(root, 'src', 'hooks', 'useAuth.test.ts'), 300, 300); + const src = newest(listFiles(join(root, 'src'), '', isTsdownSource)); + const dist = newest(listFiles(join(root, 'dist'), '', rel => rel.endsWith('.js'))); + assert.ok(dist >= src); + }); + + it('treats .cjs and .mjs chunks as bundled output, and not maps or declarations', () => { + for (const rel of ['index.js', 'internal.cjs', 'hooks-ByQmbuum.cjs', 'index.mjs']) + assert.equal(isBundledOutput(rel), true, rel); + for (const rel of ['index.js.map', 'index.d.ts', 'errors.d.cts']) assert.equal(isBundledOutput(rel), false, rel); + }); + + it('flags a sibling whose source is newer than its dist', () => { + assert.equal(isStale([{ rel: 'a.ts', mtime: 300 }], [{ rel: 'a.js', mtime: 200 }]), true); + assert.equal(isStale([{ rel: 'a.ts', mtime: 100 }], [{ rel: 'a.js', mtime: 200 }]), false); + assert.equal(isStale([{ rel: 'a.ts', mtime: 300 }], []), false); + }); +}); + +describe('workspaceDependencies', () => { + it('follows runtime dependencies into the workspace, transitively, and skips dev dependencies', () => { + const root = scratch(); + const pkg = (dir: string, json: object) => { + mkdirSync(join(root, 'packages', dir, 'node_modules', '@clerk'), { recursive: true }); + writeFileSync(join(root, 'packages', dir, 'package.json'), JSON.stringify(json)); + }; + pkg('expo', { + name: '@clerk/expo', + dependencies: { '@clerk/react': '*' }, + peerDependencies: { '@clerk/expo-passkeys': '*' }, + devDependencies: { '@clerk/testing': '*' }, + }); + pkg('react', { name: '@clerk/react', dependencies: { '@clerk/shared': '*' } }); + pkg('shared', { name: '@clerk/shared' }); + pkg('expo-passkeys', { name: '@clerk/expo-passkeys' }); + pkg('testing', { name: '@clerk/testing' }); + const link = (from: string, name: string, to: string) => + symlinkSync(join(root, 'packages', to), join(root, 'packages', from, 'node_modules', '@clerk', name)); + link('expo', 'react', 'react'); + link('expo', 'expo-passkeys', 'expo-passkeys'); + link('expo', 'testing', 'testing'); + link('react', 'shared', 'shared'); + const names = workspaceDependencies(root, join(root, 'packages', 'expo')) + .map(p => p.name) + .sort(); + assert.deepEqual(names, ['@clerk/expo-passkeys', '@clerk/react', '@clerk/shared']); + }); +}); + +describe('judge', () => { + const rel = 'packages/expo/dist/hooks/useAuth.js'; + const body = `var x;__d(function(){},12,[],"../../../${rel}");`; + const fp = (hash: string, since: number): Fingerprint => ({ [rel]: { mtime: since, size: 1, hash, since } }); + const fresh: GateMemory = { metroPid: 1, spawn: null, seen: {}, outputs: {} }; + + it('trusts the first revision of a Metro it started when dist has not changed since', () => { + const memory = { ...fresh, spawn: fp('a', 1_000) }; + assert.equal(judge(memory, fp('a', 1_000), { revId: 'r1', lastModified: 2_000, body }).verdict, 'fresh'); + }); + + it('restarts a Metro whose first bundle cannot be dated against the current dist', () => { + assert.equal( + judge({ ...fresh, spawn: fp('a', 1_000) }, fp('b', 3_000), { revId: 'r1', lastModified: 4_000, body }).verdict, + 'restart', + ); + assert.equal(judge(fresh, fp('a', 1_000), { revId: 'r1', lastModified: 4_000, body }).verdict, 'restart'); + }); + + it('does not confirm a revision it already saw paired with older content', () => { + const afterEdit1 = judge({ ...fresh, spawn: fp('a', 1_000) }, fp('a', 1_000), { + revId: 'r1', + lastModified: 2_000, + body, + }); + assert.equal(afterEdit1.verdict, 'fresh'); + const lagging = judge(afterEdit1.memory, fp('b', 5_400), { revId: 'r1', lastModified: 2_000, body }); + assert.equal(lagging.verdict, 'stale'); + assert.equal(judge(lagging.memory, fp('b', 5_400), { revId: 'r2', lastModified: 6_000, body }).verdict, 'fresh'); + }); + + it('rejects a revision first seen now but dated before the current content', () => { + const memory = { ...fresh, seen: { r1: 'x' } }; + const early = judge(memory, fp('c', 9_400), { revId: 'r2', lastModified: 8_000, body }); + assert.equal(early.verdict, 'stale'); + assert.equal(judge(early.memory, fp('c', 9_400), { revId: 'r2', lastModified: 8_000, body }).verdict, 'stale'); + }); + + it('keeps confirming the same revision after a rewrite with identical content', () => { + const first = judge({ ...fresh, spawn: fp('a', 1_000) }, fp('a', 1_000), { + revId: 'r1', + lastModified: 2_000, + body, + }); + const rewritten = { [rel]: { mtime: 7_000, size: 1, hash: 'a', since: 1_000 } }; + assert.equal(judge(first.memory, rewritten, { revId: 'r1', lastModified: 2_000, body }).verdict, 'fresh'); + }); + + it('ignores a changed file the bundle does not include', () => { + const web = 'packages/expo/dist/web/index.js'; + const first = judge({ ...fresh, spawn: fp('a', 1_000) }, fp('a', 1_000), { + revId: 'r1', + lastModified: 2_000, + body, + }); + const withWeb = { ...fp('a', 1_000), [web]: { mtime: 8_000, size: 1, hash: 'w', since: 8_000 } }; + assert.equal(judge(first.memory, withWeb, { revId: 'r1', lastModified: 2_000, body }).verdict, 'fresh'); + }); +}); + +describe('fingerprint', () => { + it('keeps the time content first appeared across an identical rewrite', () => { + const root = scratch(); + const rel = 'a.js'; + writeFileSync(join(root, rel), 'one'); + utimesSync(join(root, rel), 10, 10); + const first = fingerprint(root, [rel], null); + writeFileSync(join(root, rel), 'one'); + utimesSync(join(root, rel), 20, 20); + const second = fingerprint(root, [rel], first); + assert.equal(second[rel]!.since, first[rel]!.since); + assert.deepEqual(changedFiles(first, second), []); + writeFileSync(join(root, rel), 'two'); + utimesSync(join(root, rel), 30, 30); + assert.equal(fingerprint(root, [rel], second)[rel]!.since, 30_000); + }); +}); + +describe('confirmServed', () => { + const rel = 'packages/expo/dist/hooks/useAuth.js'; + const body = `__d(function(){},1,[],"../../../${rel}");`; + type Step = { readonly files: Record; readonly response: Fetched }; + const harness = (steps: Step[], after: Record[] = []) => { + let clock = 0; + let call = 0; + let files: Record = steps[0]!.files; + const touched: string[][] = []; + let restarts = 0; + const io: GateIO = { + list: () => Object.keys(files), + fingerprint: (rels, previous) => + Object.fromEntries( + rels + .filter(r => files[r] !== undefined) + .map(r => { + const hash = files[r]!; + const before = previous?.[r]; + return [ + r, + { + mtime: clock, + size: hash.length, + hash, + since: before !== undefined && before.hash === hash ? before.since : clock, + }, + ]; + }), + ), + fetch: async () => { + const step = steps[Math.min(call, steps.length - 1)]!; + files = after[call] ?? step.files; + call += 1; + return step.response; + }, + touch: rels => touched.push([...rels]), + restart: async () => { + restarts += 1; + return 2; + }, + now: () => clock, + sleep: async ms => { + clock += ms; + }, + progress: () => undefined, + }; + return { io, touched: () => touched, restarts: () => restarts, calls: () => call }; + }; + const ok = (revId: string, lastModified: number): Fetched => ({ + ok: true, + value: { revId, lastModified, body }, + }); + const options = { timeoutMs: 20_000, nudgeAfterMs: 5_000, maxRestarts: 2, settledFailureMs: 3_000 }; + + it('confirms after two matching reads of a fresh revision', async () => { + const h = harness([{ files: { [rel]: 'a' }, response: ok('r1', 0) }]); + const result = await confirmServed( + h.io, + { metroPid: 1, spawn: { [rel]: { mtime: 0, size: 1, hash: 'a', since: 0 } }, seen: {}, outputs: {} }, + options, + ); + assert.equal(result.ok, true); + assert.equal(h.calls(), 2); + }); + + it('waits out a 500 while tsdown empties and refills dist, then confirms', async () => { + const error: Fetched = { ok: false, transient: false, message: 'bundle 500: UnableToResolveError' }; + const h = harness( + [ + { files: { [rel]: 'a' }, response: error }, + { files: {}, response: error }, + { files: { [rel]: 'b' }, response: ok('r2', 60_000) }, + ], + [{}, { [rel]: 'b' }], + ); + const memory: GateMemory = { + metroPid: 1, + spawn: null, + seen: { r1: 'x' }, + outputs: { [rel]: { mtime: 0, size: 1, hash: 'a', since: 0 } }, + }; + const result = await confirmServed(h.io, memory, options); + assert.equal(result.ok, true, JSON.stringify(result)); + }); + + it('fails a 500 only once it has persisted over settled outputs for the settle window', async () => { + const error: Fetched = { ok: false, transient: false, message: 'bundle 500: SyntaxError' }; + const h = harness([{ files: { [rel]: 'a' }, response: error }]); + const result = await confirmServed(h.io, { metroPid: 1, spawn: null, seen: { r0: 'x' }, outputs: {} }, options); + assert.deepEqual(result, { ok: false, kind: 'bundle-error', message: 'bundle 500: SyntaxError' }); + assert.ok(h.io.now() >= 3_000, `failed after ${h.io.now()}ms`); + assert.ok(h.calls() >= 3); + }); + + it('confirms when a 500 clears within the settle window', async () => { + const error: Fetched = { ok: false, transient: false, message: 'bundle 500: UnableToResolveError' }; + const h = harness([ + { files: { [rel]: 'a' }, response: error }, + { files: { [rel]: 'a' }, response: error }, + { files: { [rel]: 'a' }, response: ok('r1', 0) }, + ]); + const memory: GateMemory = { + metroPid: 1, + spawn: { [rel]: { mtime: 0, size: 1, hash: 'a', since: 0 } }, + seen: {}, + outputs: {}, + }; + assert.equal((await confirmServed(h.io, memory, options)).ok, true); + }); + + it('does not confirm a lagging revision after a second edit, and touches the changed file', async () => { + const memory: GateMemory = { + metroPid: 1, + spawn: null, + seen: { r1: 'digest-of-edit-1' }, + outputs: { [rel]: { mtime: 0, size: 1, hash: 'edit-1', since: 0 } }, + }; + const h = harness([{ files: { [rel]: 'edit-2' }, response: ok('r1', 0) }]); + const result = await confirmServed(h.io, memory, options); + assert.equal(result.ok, false); + assert.ok(h.touched().length > 0); + assert.deepEqual(h.touched()[0], [rel]); + }); + + it('restarts a Metro with no history, at most maxRestarts times', async () => { + const h = harness([{ files: { [rel]: 'a' }, response: ok('r1', 0) }]); + const result = await confirmServed(h.io, { metroPid: 1, spawn: null, seen: {}, outputs: {} }, options); + assert.equal(h.restarts(), 1); + assert.equal(result.ok, true); + }); +}); + +describe('scope', () => { + const workspace = () => { + const root = scratch(); + const pkg = (dir: string, json: object, files: Record, expoModule = false) => { + const base = join(root, 'packages', dir); + mkdirSync(join(base, 'node_modules', '@clerk'), { recursive: true }); + writeFileSync(join(base, 'package.json'), JSON.stringify(json)); + if (expoModule) writeFileSync(join(base, 'expo-module.config.json'), '{}'); + for (const [rel, time] of Object.entries(files)) { + mkdirSync(join(base, rel, '..'), { recursive: true }); + writeFileSync(join(base, rel), rel); + utimesSync(join(base, rel), time, time); + } + }; + pkg( + 'expo', + { + name: '@clerk/expo', + dependencies: { '@clerk/clerk-js': '*', '@clerk/shared': '*', '@clerk/expo-passkeys': '*' }, + }, + { 'src/index.ts': 100, 'dist/index.js': 200 }, + ); + pkg( + 'clerk-js', + { name: '@clerk/clerk-js', dependencies: { '@clerk/shared': '*' } }, + { 'src/index.ts': 100, 'dist/clerk.js': 200 }, + ); + pkg('shared', { name: '@clerk/shared' }, { 'src/index.ts': 100, 'dist/index.cjs': 200 }); + pkg( + 'expo-passkeys', + { name: '@clerk/expo-passkeys', dependencies: { '@clerk/shared': '*' } }, + { 'src/index.ts': 100, 'dist/index.js': 200 }, + true, + ); + const link = (from: string, name: string) => + symlinkSync(join(root, 'packages', name), join(root, 'packages', from, 'node_modules', '@clerk', name)); + link('expo', 'clerk-js'); + link('expo', 'shared'); + link('expo', 'expo-passkeys'); + link('clerk-js', 'shared'); + link('expo-passkeys', 'shared'); + return { + root, + expo: join(root, 'packages', 'expo'), + touch: (rel: string, time: number) => utimesSync(join(root, 'packages', rel), time, time), + }; + }; + + it('finds nothing stale on a fresh build', () => { + const w = workspace(); + assert.deepEqual(staleOutOfScope(w.root, w.expo).stale, []); + assert.deepEqual(staleInScope(w.root, w.expo).stale, []); + }); + + it('refuses an out-of-scope dependency edit, including packages that bundle it', () => { + const w = workspace(); + w.touch('shared/src/index.ts', 300); + assert.deepEqual( + staleOutOfScope(w.root, w.expo) + .stale.map(p => p.name) + .sort(), + ['@clerk/clerk-js', '@clerk/shared'], + ); + assert.deepEqual(staleInScope(w.root, w.expo).stale, []); + }); + + it('still refuses @clerk/clerk-js when only @clerk/shared was rebuilt', () => { + const w = workspace(); + w.touch('shared/src/index.ts', 300); + w.touch('shared/dist/index.cjs', 400); + assert.deepEqual( + staleOutOfScope(w.root, w.expo).stale.map(p => p.name), + ['@clerk/clerk-js'], + ); + }); + + it('clears a content-neutral touch once it has seen the package built', () => { + const w = workspace(); + const { records } = staleOutOfScope(w.root, w.expo); + w.touch('shared/src/index.ts', 300); + const after = staleOutOfScope(w.root, w.expo, records); + assert.deepEqual(after.stale, []); + assert.deepEqual(after.records, records); + }); + + it('still refuses a real edit that a record has seen built from other content', () => { + const w = workspace(); + const { records } = staleOutOfScope(w.root, w.expo); + writeFileSync(join(w.root, 'packages', 'shared', 'src', 'index.ts'), 'changed'); + w.touch('shared/src/index.ts', 300); + assert.deepEqual( + staleOutOfScope(w.root, w.expo, records) + .stale.map(p => p.name) + .sort(), + ['@clerk/clerk-js', '@clerk/shared'], + ); + }); + + it('accepts a revert of an unbuilt edit, because dist was built from the restored content', () => { + const w = workspace(); + const { records } = staleOutOfScope(w.root, w.expo); + const file = join(w.root, 'packages', 'shared', 'src', 'index.ts'); + writeFileSync(file, 'edited'); + w.touch('shared/src/index.ts', 300); + const refused = staleOutOfScope(w.root, w.expo, records); + assert.ok(refused.stale.length > 0); + writeFileSync(file, 'src/index.ts'); + w.touch('shared/src/index.ts', 310); + assert.deepEqual(staleOutOfScope(w.root, w.expo, refused.records).stale, []); + }); + + it('refuses a touch it has no record for, until the build rewrites dist', () => { + const w = workspace(); + w.touch('shared/src/index.ts', 300); + assert.ok(staleOutOfScope(w.root, w.expo).stale.length > 0); + w.touch('shared/dist/index.cjs', 400); + w.touch('clerk-js/dist/clerk.js', 400); + assert.deepEqual(staleOutOfScope(w.root, w.expo).stale, []); + }); + + it('does not rebuild an in-scope sibling again after a content-neutral touch', () => { + const w = workspace(); + const { records } = staleInScope(w.root, w.expo); + w.touch('expo-passkeys/src/index.ts', 300); + assert.deepEqual(staleInScope(w.root, w.expo, records).stale, []); + }); + + it('rebuilds an in-scope Expo module sibling instead of refusing it', () => { + const w = workspace(); + w.touch('expo-passkeys/src/index.ts', 300); + assert.deepEqual( + staleInScope(w.root, w.expo).stale.map(p => p.name), + ['@clerk/expo-passkeys'], + ); + assert.deepEqual(staleOutOfScope(w.root, w.expo).stale, []); + }); +}); diff --git a/.claude/skills/verify-clerk-expo/test/host.test.ts b/.claude/skills/verify-clerk-expo/test/host.test.ts new file mode 100644 index 00000000000..03cd98d771d --- /dev/null +++ b/.claude/skills/verify-clerk-expo/test/host.test.ts @@ -0,0 +1,207 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { existsSync, mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { nativeInputs } from '../src/fixture.ts'; +import { devClientEntry, host, metroPort, withCleanup } from '../src/host.ts'; +import { selectBackend } from '../src/core/devices.ts'; +import { resolveSpecs } from '../src/core/e2e.ts'; +import { STANDARD, declaredIn, planGroups } from '../src/core/instances/settings.ts'; +import { encodeLaunchArguments } from '../src/core/state.ts'; +import { VerifyFailure, type LaunchId, type PublishableKey, type RunId, type StorageScope } from '../src/core/types.ts'; +import { localAndroidBackend } from '../src/platform/android/local.ts'; +import { localIosBackend } from '../src/platform/ios/local.ts'; + +const WORKTREE = join(import.meta.dirname, '..', '..', '..', '..'); + +describe('nativeInputs', () => { + it('rebuilds the dev client for the native code of its own platform and for the fixture config, and not for the other platform', () => { + for (const input of [ + 'packages/expo/ios', + 'packages/expo/app.plugin.js', + 'packages/expo/src/specs', + 'integration/templates/expo-native/app.json', + 'integration/templates/expo-native/modules', + ]) { + assert.ok(nativeInputs('ios').includes(input), input); + } + assert.ok(nativeInputs('android').includes('packages/expo/android')); + assert.deepEqual( + nativeInputs('ios').filter(input => input.endsWith('/android')), + [], + ); + assert.deepEqual( + nativeInputs('android').filter(input => input.endsWith('/ios')), + [], + ); + assert.deepEqual( + [...nativeInputs('ios'), ...nativeInputs('android')].filter( + input => input.startsWith('packages/expo/src/') && input !== 'packages/expo/src/specs', + ), + [], + ); + }); + + it('names only paths this repository has, so a moved directory cannot stop triggering a rebuild unseen', () => { + for (const input of new Set([...nativeInputs('ios'), ...nativeInputs('android')])) + assert.ok(existsSync(join(WORKTREE, input)), input); + }); +}); + +describe('the clerk-expo host', () => { + it('runs each platform on a device of this machine only', () => { + assert.deepEqual( + host.backends.map(b => `${b.platform} ${b.kind}`), + ['ios local', 'android local'], + ); + }); + + it('says what a machine without the device would need', () => { + const empty = mkdtempSync(join(tmpdir(), 'verify-expo-host-')); + const elsewhere = { + ...host, + backends: [ + localIosBackend({ os: 'linux' }), + localAndroidBackend({ machine: { os: 'linux', arch: 'x64', home: empty, env: {}, kvm: join(empty, 'kvm') } }), + ], + }; + assert.throws( + () => selectBackend(elsewhere, 'ios'), + (error: VerifyFailure) => + error.code === 'UNSUPPORTED' && + /^no ios backend runs on this machine \(local: the iOS simulator needs macOS and this machine runs linux/.test( + error.message, + ) && + error.fix === 'run on a Mac with Xcode', + ); + assert.throws( + () => selectBackend(elsewhere, 'android'), + (error: VerifyFailure) => + error.code === 'UNSUPPORTED' && + /^no android backend runs on this machine \(local: there is no .*kvm/.test(error.message), + ); + }); + + it('asks for the screens the fixture routes, and no other', () => { + const fixture = readFileSync( + join(WORKTREE, 'integration', 'templates', 'expo-native', 'verify', 'launch.ts'), + 'utf8', + ); + const routed = /const verifyScreens = \[([^\]]*)\]/.exec(fixture)?.[1] ?? ''; + assert.deepEqual( + [...host.screens], + [...routed.matchAll(/'([^']+)'/g)].map(match => match[1]), + ); + }); +}); + +describe('the golden specs of this repository', () => { + const skill = join(import.meta.dirname, '..'); + const golden = resolveSpecs(skill, { all: true }).map(spec => ({ + spec, + source: readFileSync(join(skill, spec.path), 'utf8'), + })); + + it('name no instance and declare no settings, so a run is one group on the standard settings', () => { + assert.ok(golden.length > 0); + for (const { spec, source } of golden) { + assert.equal(declaredIn(source, spec.path), null, spec.path); + } + const plan = planGroups(golden, STANDARD.key); + assert.equal(plan.length, 1); + assert.equal(plan[0]!.settings, STANDARD); + assert.deepEqual(plan[0]!.specs.map(spec => spec.path).sort(), golden.map(({ spec }) => spec.path).sort()); + }); +}); + +describe('metroPort', () => { + it('gives every lane on the Mac its own port', () => { + const ports = [ + ...[1, 2, 3, 4].map(slot => metroPort({ platform: 'ios', slot })), + ...[1, 2].map(slot => metroPort({ platform: 'android', slot })), + ]; + assert.deepEqual(ports, [8082, 8083, 8084, 8085, 8086, 8087]); + }); +}); + +describe('devClientEntry', () => { + it('opens the iOS dev client on the lane Metro with the dev menu out of the way', () => { + const entry = devClientEntry('ios', 8083); + assert.equal(entry.kind, 'dev-client'); + if (entry.kind !== 'dev-client') return; + assert.deepEqual(entry.launchArguments.slice(0, 2), ['--initialUrl', 'http://localhost:8083']); + assert.ok(entry.launchArguments.includes('-EXDevMenuIsOnboardingFinished')); + assert.equal(entry.openLink, null); + }); + + it('opens the Android dev client through the exp+ link to the lane Metro', () => { + const entry = devClientEntry('android', 8086); + assert.equal(entry.kind, 'dev-client'); + if (entry.kind !== 'dev-client') return; + assert.equal( + entry.openLink, + 'exp+clerk-expo-native-build-fixture://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8086', + ); + assert.deepEqual(entry.launchArguments, []); + }); + + it('keeps the iOS dev-client arguments compatible with the verify launch arguments', () => { + const verify = encodeLaunchArguments('ios', { + verifyPublishableKey: 'pk_test_x' as PublishableKey, + verifyRunId: 'r20261003-000000-abcd' as RunId, + verifyStorageScope: 'aa' as StorageScope, + verifyLaunchId: 'bb' as LaunchId, + }); + const entry = devClientEntry('ios', 8082); + if (entry.kind !== 'dev-client') return assert.fail('not a dev client'); + const keys = new Set([...entry.launchArguments, ...verify].filter(arg => arg.startsWith('-'))); + assert.equal( + keys.size, + entry.launchArguments.filter(a => a.startsWith('-')).length + verify.filter(a => a.startsWith('-')).length, + ); + }); +}); + +describe('withCleanup', () => { + it('stops exactly what the failed call started, then rethrows', async () => { + const stopped: string[][] = []; + await assert.rejects( + withCleanup( + names => stopped.push([...names]), + async started => { + started.names.push('watch', 'metro-8082'); + throw new Error('warm-up failed'); + }, + () => undefined, + ), + /warm-up failed/, + ); + assert.deepEqual(stopped, [['watch', 'metro-8082']]); + }); + + it('stops nothing when the call succeeds or started nothing', async () => { + const stopped: string[][] = []; + assert.equal( + await withCleanup( + names => stopped.push([...names]), + async started => { + started.names.push('watch'); + return 7; + }, + () => undefined, + ), + 7, + ); + await assert.rejects( + withCleanup( + names => stopped.push([...names]), + async () => { + throw new Error('refused'); + }, + () => undefined, + ), + ); + assert.deepEqual(stopped, []); + }); +}); From ae77d627bc1cda84e4b1656ba5d35521fbba2c01 Mon Sep 17 00:00:00 2001 From: Mike Pitre <12040919+mikepitre@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:41:01 -0400 Subject: [PATCH 05/27] docs(expo): add the verify-clerk-expo skill docs and point agents at them Co-Authored-By: Claude Opus 5.5 --- .claude/skills/README.md | 18 +- .claude/skills/verify-clerk-expo/SKILL.md | 220 ++++++++++++++++++ .../verify-clerk-expo/features/README.md | 72 ++++++ .../features/custom-flow-sign-in.md | 29 +++ .../features/custom-flow-sign-up.md | 29 +++ .../features/native-auth-view.md | 37 +++ .../features/native-js-sync.md | 26 +++ .../features/token-cache-persistence.md | 27 +++ .../features/user-button-and-profile.md | 31 +++ .../verify-clerk-expo/references/devices.md | 14 ++ .../verify-clerk-expo/references/freshness.md | 39 ++++ .../verify-clerk-expo/references/instances.md | 78 +++++++ AGENTS.md | 5 + packages/expo/AGENTS.md | 11 + 14 files changed, 629 insertions(+), 7 deletions(-) create mode 100644 .claude/skills/verify-clerk-expo/SKILL.md create mode 100644 .claude/skills/verify-clerk-expo/features/README.md create mode 100644 .claude/skills/verify-clerk-expo/features/custom-flow-sign-in.md create mode 100644 .claude/skills/verify-clerk-expo/features/custom-flow-sign-up.md create mode 100644 .claude/skills/verify-clerk-expo/features/native-auth-view.md create mode 100644 .claude/skills/verify-clerk-expo/features/native-js-sync.md create mode 100644 .claude/skills/verify-clerk-expo/features/token-cache-persistence.md create mode 100644 .claude/skills/verify-clerk-expo/features/user-button-and-profile.md create mode 100644 .claude/skills/verify-clerk-expo/references/devices.md create mode 100644 .claude/skills/verify-clerk-expo/references/freshness.md create mode 100644 .claude/skills/verify-clerk-expo/references/instances.md diff --git a/.claude/skills/README.md b/.claude/skills/README.md index 0031ac9bc9c..a6ddf536741 100644 --- a/.claude/skills/README.md +++ b/.claude/skills/README.md @@ -23,9 +23,12 @@ Edits to a `SKILL.md` take effect immediately, including in already-running sess ## Scope -Skills are Claude Code specific. Cursor does not read this directory; it uses `.cursor/rules/` and -`AGENTS.md`. When a repo rule changes, update `AGENTS.md` first, then mirror the change here and in -`.cursor/rules/` where relevant. +Skills here are Claude Code specific, except `verify-clerk-expo`, the agent-neutral skill that drives +`@clerk/expo` on a simulator or emulator. Its files live here, and `.cursor/skills/verify-clerk-expo` +is a symlink to this directory so Cursor reads the same skill. Edit it here, not through the +symlink. For the other skills, Cursor uses `.cursor/rules/` and `AGENTS.md`. When a repo rule +changes, update `AGENTS.md` first, then mirror the change here and in `.cursor/rules/` where +relevant. ## Maintaining a skill @@ -41,7 +44,8 @@ Skills are Claude Code specific. Cursor does not read this directory; it uses `. ## Skills in this repo -| Skill | Use it for | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `clerk-monorepo` | Day-to-day work in the monorepo: setup, build/test loops, the package map, changesets, commits, PRs, breaking-change checks. | -| `mosaic` | Mosaic flow UI: authoring machines, controllers, and views, and migrating a legacy component into the split (with parity verification). | +| Skill | Use it for | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `clerk-monorepo` | Day-to-day work in the monorepo: setup, build/test loops, the package map, changesets, commits, PRs, breaking-change checks. | +| `mosaic` | Mosaic flow UI: authoring machines, controllers, and views, and migrating a legacy component into the split (with parity verification). | +| `verify-clerk-expo` | Proving a `@clerk/expo` change on an iOS simulator or Android emulator, with video and app state as evidence. | diff --git a/.claude/skills/verify-clerk-expo/SKILL.md b/.claude/skills/verify-clerk-expo/SKILL.md new file mode 100644 index 00000000000..fbe369857a5 --- /dev/null +++ b/.claude/skills/verify-clerk-expo/SKILL.md @@ -0,0 +1,220 @@ +--- +name: verify-clerk-expo +description: Drive @clerk/expo in the expo-native fixture app (native AuthView, UserButton, UserProfileView, custom useSignIn and useSignUp flows, token cache) on an iOS simulator or Android emulator against a real Clerk development instance that the session creates and deletes, and capture video, screenshots, and app state as evidence. The device is a simulator or emulator on this Mac. Use it to prove any change to packages/expo or the fixture works before calling it done, to reproduce a UI bug, or to run the golden regression specs. +--- + +# verify-clerk-expo + +`.claude/skills/verify-clerk-expo/bin/control-clerk-expo` is a control CLI over [e2e](https://github.com/tester-army/e2e) 0.15.2 and `@e2e-dev/mobile` 0.9.0. It builds the `expo-native` fixture in `integration/templates/expo-native`, leases a simulator or emulator, creates one Clerk application for the worktree, seeds `+clerk_test` users, runs specs, and keeps the evidence. The device is local, the fixture is a Debug dev client, and Metro serves your working tree to it. The skill needs a Mac: on any other machine `doctor`, `up`, and `run` fail with `UNSUPPORTED`. + +No change to `@clerk/expo` UI or auth behavior is done until a `run` on the real fixture shows the changed behavior, on each platform the change touches. + +Run every command from the repo root. In the prose below, `doctor`, `up`, `run`, `screen`, `attach`, and `down` are verbs of that CLI. Paths that begin `specs/`, `features/`, `references/`, `src/`, `test/`, or `.verify/` are inside `.claude/skills/verify-clerk-expo/`. Every verb but `attach` takes `--platform ios|android`, and iOS is the default. Every verb takes `--json` and then prints one `{ "ok": ... }` object. Exit codes are 0 for success, 1 for a failing spec, 2 for a usage error, and 3 for a failed precondition. Every error prints a `fix` line. + +CI runs none of these specs. The `Verify Skill Tests` job in `.github/workflows/ci.yml` runs the skill's unit tests and `tsc`. The device tests that gate a pull request are `integration/tests/expo-native/*.e2e.ts`, which `.github/workflows/expo-native-build.yml` runs against a Release build of the same fixture with the same `e2e` engine. This skill is the development loop, and a regression test that must run on every pull request belongs in `integration/tests/expo-native/`. + +The fixture links `@clerk/expo`, `@clerk/expo-biometrics`, and `@clerk/expo-google-signin` from the workspace. It does not install `@clerk/expo-passkeys`, so the skill cannot verify passkeys. + +## Launch + +Set up each machine once. + +1. Install Node 24. For iOS, install Xcode with an iOS simulator runtime. For Android, install Android Studio with the SDK, the emulator, and Java 21. The CLI looks for the SDK in `ANDROID_HOME`, `ANDROID_SDK_ROOT`, and `~/Library/Android/sdk`. +2. Create the iOS template simulator, which the CLI clones to make each simulator it drives, for example with `xcrun simctl clone "iPhone Air" "Clerk Verify Template iOS"`. If this Mac sends HTTPS through a debugging proxy, boot the template once, install and trust the proxy's CA in it, and shut it down. Android needs no template: the first `up --platform android` writes the `Clerk_Verify_Pixel` AVD. +3. Give the machine the team's Clerk Platform API key. Set `CLERK_PLATFORM_API_KEY`, or set `CLERK_PLATFORM_API_KEY_FILE` to a file that only you can read (mode 0600). To keep the key in 1Password instead, install the 1Password CLI, turn on its desktop app integration, and put the key's secret reference in `VERIFY_PLATFORM_KEY_REFERENCE` or as the one line of `~/.verify/clerk-platform-key-reference`. The reference has the shape `op:////credential`, and the team's private setup note has the real one. Never put the key or the reference in a file inside a repository. + +Then, in each worktree: + +```console +$ pnpm install # once per worktree +$ npm ci --prefix .claude/skills/verify-clerk-expo # once per worktree +$ .claude/skills/verify-clerk-expo/bin/control-clerk-expo doctor --platform ios +$ .claude/skills/verify-clerk-expo/bin/control-clerk-expo up --platform ios +instance creating verify-throwaway-until-- in org_3KHungJxbvIscuSvy8oos5MHAli +build local building... +build turbo build @clerk/expo, @clerk/expo-biometrics, @clerk/expo-google-signin +instance up in 0.8s on standard, 212 settings match src/core/instances/base.json +build expo prebuild --clean --platform ios +build xcodebuild Debug (dev client) +build local built in 123s +device verify-ios-2 cloning Clerk Verify Template iOS +install on verify-ios-2 +watch packages/expo tsdown --watch (pid ) +metro :8083 expo start (pid ) +metro :8083 bundling ios once so the first launch does not wait on Metro +device verify-ios-2 local leased by this worktree installed +``` + +The skill is outside the pnpm workspace, so `npm ci` installs its pinned `e2e` and `agent-device` from the skill's own lockfile. The sample is the first `up` in a worktree, without its `instances` and `clerk` lines. The lane is ready when `up` prints the `device` line that ends in `installed `, which is its last line. `run` does the same steps itself, so `up` only starts the slow part early. Teardown is `down` (see [Cleanup](#cleanup)). + +`up` does four things: + +- It builds the dev client when no build matches the native inputs: `turbo build` for the three packages, `expo prebuild --clean`, then `xcodebuild` or `gradlew assembleDebug`. The build overwrites the fixture's generated `package.json`, `ios/`, and `android/`. A change to anything else reuses the build and prints `build local reused`. +- It creates this worktree's Clerk application through Clerk's Platform API, in the team's verification workspace, and puts its development instance on the standard settings in `src/core/instances/base.json`. The application holds only the users that this worktree's runs create, and `down` deletes it. [Test instances](references/instances.md) has the credential lookup, the application's lifetime, and its limits. +- It leases a lane and installs the build. An iOS lane is a clone of the template named `verify-ios-`. An Android lane boots `Clerk_Verify_Pixel` read-only as `emulator-5560` or `emulator-5562`. +- It starts `tsdown --watch` in `packages/expo` (the watch build) and `expo start` on the lane's Metro port. + +`up` is idempotent. It keeps a lease that this worktree already holds. A failed `up` stops the Metro and the watch build that it started. + +A JS change reaches the app with no build. Before the specs start, `run` waits until the watch build has caught up and Metro serves the current code, and it fails with `NOT_READY` and the path of the Metro log when Metro never does. [How a change reaches the app](references/freshness.md) has the native inputs, the checks, the ports, and the logs. It also says what to do after a change to another workspace package, such as `@clerk/clerk-js` or `@clerk/shared`. While Metro runs, never run `pnpm --filter @clerk/expo build` or a build of a package that `@clerk/expo` depends on, because the build deletes the `dist` that Metro serves. + +A worktree can hold one lane of each platform. The two lanes share the watch build and the application, and each has its own Metro. Start their runs one after the other, for two reasons. A `run` that finds an edited sibling package stops every Metro of the worktree while it rebuilds the package, including the Metro that a run on the other platform is using. And while both platforms ran specs on the shared application at the same time, a ticket sign-in failed with `resource_not_found` in two of four tries, which never happened with one run at a time. + +A Mac has four iOS lanes and two Android lanes, shared by every worktree on it. When all are taken, `up` and `run` fail with `POOL_FULL`, and `--wait ` on either verb waits for a lane. Never drive a simulator or emulator that the CLI did not create, the template, a physical device, or a lane that another worktree holds. [Local devices](references/devices.md) says how to find a lane's UDID or serial. + +With the key in 1Password, a command that needs it prints `wait reading the team key from 1Password; approve the request in the 1Password app within 60s`, and the 1Password app asks the person at the Mac to approve. An agent cannot approve the request, so tell the person before the first command. + +## Doctor + +```console +$ .claude/skills/verify-clerk-expo/bin/control-clerk-expo doctor --platform ios +$ .claude/skills/verify-clerk-expo/bin/control-clerk-expo doctor --platform android +``` + +Run it first, and again whenever anything looks off. Without `--live` it only reads. It creates no file, no device, and no Clerk application. Each line starts with `ok`, `warn`, `skip`, or `FAIL`, then has the id of the check and what the check found. `skip` marks a check that did not run, and its text starts with `not run:`. A failing check also prints a `fix:` line with the command to run, and `doctor` exits 3. A warning does not change the exit code. + +| Checks | Pass when | +| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node`, `e2e-pins` | Node is 24.x, and the installed `e2e` and `@e2e-dev/mobile` are the versions that `package.json` pins. `e2e-pins` fails until `npm ci` has run in this worktree | +| `xcode`, `template`, `proxy-trust`, `lane-ports` | iOS: `xcodebuild` runs. `Clerk Verify Template iOS` exists and is shut down. It trusts a custom CA if macOS has a system HTTPS proxy. Every booted `verify-ios-` simulator has a live claim | +| `jdk`, `template`, `lane-ports` | Android: a Java 21 is found. The SDK's emulator and adb run. Ports 5560 to 5562 hold only lanes that the CLI booted | +| `instances`, `clerk-api`, `settings` | A Platform API credential reaches the verification workspace. Clerk's Backend API accepts the secret key of the application this worktree holds. That application shows the settings recorded for it, and every declaration under `specs/` is well formed | +| `build` | A build of the fixture matches the current tree | +| `gh-attach` | `gh pr comment` has `--attach`. A `gh` without it prints `warn` and not `FAIL`, because only `attach` needs it | +| `stale-claims`, `agent-device-daemon` | No lane is claimed by a worktree that no longer exists, and no agent-device daemon runs from an install that was deleted | +| `feature-map`, `core-drift` | Every feature in `src/host.ts` has a feature file and a golden spec, and `src/core/` matches `src/core/MANIFEST` | + +After the once-per-machine setup and before the first `up`, `build` is the one failing check, and its fix is the `up` command for that platform. A machine with no Platform API credential fails `instances`, and the fix line says how to supply one. `doctor --live` also proves that the credential can do the work. It creates one application, configures it, compares it with the standard file, and deletes it, and reports that in a `live-instance` line. When this worktree already holds an application, `doctor --live` creates nothing, and the `live-instance` line is a `skip`. + +## Drive + +Input reaches the app only through specs. A spec is a TypeScript file that uses the `host` fixture from `specs/fixtures.ts` and e2e's `screen` locators. + +```console +$ .claude/skills/verify-clerk-expo/bin/control-clerk-expo run native-auth-view --platform ios # one feature: every spec in specs/golden/native-auth-view/ +$ .claude/skills/verify-clerk-expo/bin/control-clerk-expo run custom-flow-sign-in/request-code # one golden spec, on iOS +$ .claude/skills/verify-clerk-expo/bin/control-clerk-expo run specs/explored/.e2e.ts # a spec you wrote +$ .claude/skills/verify-clerk-expo/bin/control-clerk-expo run native-auth-view native-js-sync # several targets in one run, with one video +$ .claude/skills/verify-clerk-expo/bin/control-clerk-expo run --all --skip form-entry --platform android # every golden spec except the ones that type a code or a password +$ .claude/skills/verify-clerk-expo/bin/control-clerk-expo screen --platform ios # the UI tree that is on screen now; --png adds a screenshot +``` + +`run` also takes `--grep `, `--no-video`, and `--wait `. The wait covers a free lane and another `run` in this worktree that holds the device. A golden spec tagged `known-bug` reproduces an open bug, and `run` leaves it out unless you pass `--include known-bug`. Today that is the AuthView dismiss test in `native-auth-view/opens`. A spec limited to one platform with `test(title, { platforms: ['ios'] }, fn)` reports as skipped on the other. + +For a JS change, edit the source and `run` the spec, with no `up` in between. For a change to a native input, the same `run` rebuilds the dev client first. + +### Sign in with the form or with a ticket + +A change that touches sign-in or sign-up gets a spec that drives the real form with a `+clerk_test` identity and the test code. Any other change reaches a signed-in state with a sign-in ticket, `host.launch({ signedInAs: user })`, which is the intended shortcut and not a fallback. A runtime that cannot type codes into the app skips the typing specs with `--skip form-entry` on `run` and says so in the PR. + +Tag every spec that types a code or a password `form-entry`. Four golden specs carry the tag: `custom-flow-sign-in/complete`, `custom-flow-sign-up/request-code`, `custom-flow-sign-up/complete`, and `native-auth-view/complete`. `run` reports a skipped one as `skipped by --skip form-entry`. Never report it as verified through a ticket launch. With the flag, `custom-flow-sign-in/request-code` and `native-auth-view/request-code` still run and prove their flow up to the code screen. Sign-up has no spec that runs without typing a password, so a run with the flag leaves sign-up unproven. + +Type only test identities: `+clerk_test` emails and the code `424242`, which specs import as `CLERK_TEST_CODE`. Never type a real person's address or password. The repository is public, and a run's video can land on a PR. [The feature map index](features/README.md) lists the test identities and the identifier for each step of the forms. + +### Write a spec + +```ts +import { test, expect } from '../../fixtures.ts'; +import { nativeProfile } from '../../native.ts'; + +test('the native UserProfileView shows the seeded user', async ({ host, screen }) => { + const user = await host.seedUser(); + const state = await host.launch({ signedInAs: user, screen: 'userProfile' }); + expect(state.userId).toBe(user.id); + expect(state.sessionStatus).toBe('active'); + await host.tap(nativeProfile(screen).manageAccount); + await expect(screen.getByText(user.email)).toBeVisible({ timeout: 20_000 }); + await host.screenshot('profile'); +}); +``` + +- `host.seedUser({ phone? })` creates a `+clerk_test` user in the worktree's application. `host.newEmail()` reserves an address for a sign-up through the form. +- `host.launch({ signedInAs?, screen?, authMode?, debugLogs?, keepStorage? })` relaunches the fixture and returns the first ready `VerifyState` of that launch. Screens are `home`, `auth` (AuthView with no close button), `nativeAuth` (AuthView with a close button), `userButton`, `userProfile`, `customSignIn`, `customSignUp`, and `tokenCache`. Auth modes are `signIn`, `signUp`, and `signInOrUp`. A launch starts with fresh storage unless `keepStorage` is true, so a launch with no `signedInAs` is signed out. A new screen goes in `verify/launch.ts` and `verify/VerifyHost.tsx` of the fixture, and in `SCREENS` in `src/host.ts`. +- `host.state()` reads the state footer, and `host.waitForState(predicate, timeoutMs?)` polls it. +- `host.tap(locator)` taps the middle of a node, and `host.fill(locator, text)` taps it and types. Use them inside the native views. agent-device reports a SwiftUI view inside the React Native host as covered by another element and refuses a plain `locator.tap()` or `locator.fill()` there. +- `host.screenshot(label)` writes `screenshots/