diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7720e10..9139116 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,11 @@ ## 检查清单 -- [ ] `npm run verify` 通过 +- [ ] 已按改动范围选择最小充分的测试层级 +- [ ] `npm run test:fast` 通过(如涉及逻辑或代码) +- [ ] 相关 UI/E2E 测试通过(如涉及页面、导航、离线或 PWA) +- [ ] 数据库/Functions/集成测试通过(如涉及对应边界) +- [ ] `npm run test:full` 通过(合并前、发布前或高风险改动) - [ ] 未提交 `.env`、`.vercel`、secret key 或个人数据 - [ ] 数据库变更附带迁移文件 - [ ] 已检查本次变更涉及的 README、公开 docs、CHANGELOG、发布说明及隐私/安全文档,并已同步实现差异 @@ -22,3 +26,9 @@ - [ ] 如包含外部贡献或第三方代码,已阅读 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md),并在 PR 中说明来源与授权状态 - [ ] 已运行 `npm run public:check`,并复查暂存区没有内部、敏感或不必要文件 - [ ] 如果这是发布准备变更,已确认 `release-gate.config.json`、版本文件和发布说明同步 + +## 验证记录 + +- 选定层级: +- 命令与结果: +- 未运行或被环境阻塞的检查及原因: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12bed71..2e8b92d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,10 +62,13 @@ jobs: run: | status="$(npx supabase status --output env 2>/dev/null)" api_url="$(printf '%s\n' "$status" | sed -n 's/^API_URL=//p' | tr -d '"')" + db_url="$(printf '%s\n' "$status" | sed -n 's/^DB_URL=//p' | tr -d '"')" publishable_key="$(printf '%s\n' "$status" | sed -n 's/^ANON_KEY=//p' | tr -d '"')" test -n "$api_url" + test -n "$db_url" test -n "$publishable_key" echo "VITE_SUPABASE_URL=$api_url" >> "$GITHUB_ENV" + echo "SHADOW_MATE_TEST_DB_URL=$db_url" >> "$GITHUB_ENV" echo "VITE_SUPABASE_PUBLISHABLE_KEY=$publishable_key" >> "$GITHUB_ENV" echo "E2E_REAL_SUPABASE=1" >> "$GITHUB_ENV" - name: Run database tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0fd8978..983acd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,17 +6,31 @@ npm.cmd ci git config core.hooksPath .githooks git config user.email "YOUR_GITHUB_NOREPLY_ADDRESS" -npm.cmd run verify +npm.cmd run test:fast ``` Get the noreply address from GitHub **Settings → Emails**. Do not use a personal or work mailbox in public commit metadata. Maintainers may create an ignored `.security-local-denylist` file with one private term per line. The security check scans tracked and untracked candidate files without publishing the denylist itself. +## 测试范围与分层 + +先写清本次改动的范围、明确不做什么、验收条件和受影响边界,再按风险选择最小充分的验证层级。开发循环不要求每次小改动都运行全量测试;合并、发布和高风险边界仍必须经过完整门禁。 + +| 层级 | 适用场景 | 命令 | +| --- | --- | --- | +| 静态 | 文档、文案、低风险 CSS 或静态检查 | `npm run check` | +| 快速 | 纯逻辑、数据模型、控制器 | `npm run test:fast` | +| 页面 | 导航、设置、PWA、离线和可见交互 | `npm run test:ui`,或运行指定 E2E 文件 | +| 集成 | Supabase schema/RLS、Functions、认证、同步、导出/删除 | `npm run test:db`、`npm run test:functions`,以及受影响的 E2E | +| 完整 | 合并前、发布前、依赖/公开资源或高风险边界 | `npm run test:full` | + +`test:fast` 当前包含全部 unit test 和 `check`,它是比浏览器/数据库测试更快的项目级入口,但不是 changed-only 测试。`verify` 负责公开范围、安全检查、静态检查、构建和覆盖率,不包含数据库、Functions 或 E2E;`test:full` 只在合并、发布或高风险边界运行。PR 必须记录实际选择的层级、命令、结果,以及未运行或被环境阻塞的检查。 + ## Required checks - Use a branch and pull request; do not push directly to `main`. -- Run `npm run verify` before pushing. +- Before pushing, run the smallest sufficient layer for the changed surface; source/build/public-resource changes require `npm run verify`, with database, Functions and E2E layers added when affected. Run `npm run test:full` before merge or release. - For a release tag, run `npm run build` followed by `npm run release:check`; the tag workflow repeats this against the final archive. - Commit `package-lock.json` and pin dependency versions. - Add explicit PostgreSQL grants and RLS policies in the same migration. @@ -37,7 +51,7 @@ Maintainers may create an ignored `.security-local-denylist` file with one priva - 检查本次 `git diff` 涉及的用户行为、数据模型、迁移、配置、测试命令、覆盖率、版本号和发布状态。 - 按影响范围同步 `README.md`、公开 `docs/`、`CHANGELOG.md`、`RELEASE_NOTES.md`、隐私/安全文档和 PR 说明;内部计划、法律记录和发布闸门不得放入公开目录。 - 代码、测试、迁移、配置和对应文档必须作为同一项工作提交并推送,禁止明知文档过期而先提交代码、之后再补文档。 -- 提交前运行 `npm run public:check`、`git diff --cached --check` 和 `npm run verify`,并逐项复查 `git diff --cached --name-status` 与 `git diff --cached`,确认没有把内部、敏感或不必要文件加入提交。 +- 提交前运行 `npm run public:check`、`git diff --cached --check` 和与改动范围匹配的测试层,并逐项复查 `git diff --cached --name-status` 与 `git diff --cached`,确认没有把内部、敏感或不必要文件加入提交;合并前或发布前补齐 `npm run test:full`。 - 推送前重新核对远端仓库可见性、目标分支、PR base/head 和 PR 描述;任何不确定的文件先移出暂存区,不要“先提交再解释”。 ## Release 闸门 @@ -59,7 +73,7 @@ Release 必须在 Tag 上执行,不把普通 PR 当作发布验收: ## GitHub 协作流程 -- 本地先运行 `npm.cmd run verify`;涉及端到端流程时,再运行 `npm.cmd run test:e2e`。 +- 本地先运行 `npm.cmd run test:fast`;涉及页面交互时,再运行 `npm.cmd run test:ui`;涉及数据库、认证或同步时,补充对应集成测试。合并前按风险矩阵运行 `npm.cmd run test:full`。 - 使用分支提交并推送,保持现有 SSH 远程仓库配置;不需要为每次 PR 重复配置 GitHub CLI。 - 分支推送后,优先使用已连接的 GitHub 插件创建、查看、Review 和合并 PR,避免通过浏览器重复填写表单。 - PR 作者不能批准自己的 PR;需要独立 Review 时邀请其他协作者,管理员按分支保护规则完成合并。 diff --git a/README.md b/README.md index f822f01..f5505a1 100644 --- a/README.md +++ b/README.md @@ -82,20 +82,38 @@ npm.cmd run dev ```powershell npm.cmd run check npm.cmd run build -npm.cmd run test:unit -npm.cmd run test:e2e +npm.cmd run test:fast +npm.cmd run test:ui ``` 需要本地数据库测试时,先启动 Docker Desktop: ```powershell -supabase start -npm.cmd run test:db -supabase db lint --local --schema public --level warning --fail-on error +npm run supabase:local:start +npm run test:db +``` + +`supabase:local:start` 会转到同级 `shadow-size/merchant-admin`,启动共享本地 Supabase,并按 Shadow Portal 控制面的 SHA-256 校验结果加载 Shadow Mate 的 `learning_*` 业务 schema。控制面历史快照只会应用到 `127.0.0.1:54322`,不会复制到生产迁移目录,也不会连接生产数据库。 + +如果要验收登录、找回密码或其他 Edge Function,再开一个终端运行: + +```powershell +npm run supabase:local:functions:serve +``` + +该命令会准备共享函数覆盖层并以前台方式运行本地函数服务;关闭该终端就会停止函数服务。 + +如果要对共享本地数据库执行 lint,请在 merchant-admin 目录运行: + +```powershell +cd ../shadow-size/merchant-admin +npx supabase db lint --local --schema public --level warning --fail-on error ``` `test:coverage` 覆盖核心纯函数、学习状态机和防重复操作锁,语句、分支、函数和行覆盖率门槛均为 80%。`test:e2e` 覆盖离线导航、打卡、积分、日历、家庭空间、重复点击保护、邮箱验证码/密码登录、找回密码、数据生命周期和云端冲突限次重试;真实 Supabase E2E 需要额外配置环境变量。 +日常开发按改动范围选择最小充分的检查:页面改动运行目标 UI 测试,数据库/认证/同步改动补充对应集成测试;合并或发布前运行 `npm.cmd run test:full`。`test:fast` 是静态检查加全部 unit test,不是 changed-only 测试。 + ## 工作方式 ```text @@ -124,7 +142,7 @@ src/learning-state.js 学习状态机与四个模块的打卡分组 src/cloud.js 验证码/密码登录、家庭空间、同步、导出与删除 src/action-lock.js 全局快速连点拦截与异步操作单次执行锁 src/icons.js Lucide 图标渲染与图标 hydration -supabase/migrations/ 家庭数据、RLS、生命周期和删除权限 +supabase/migrations/ Shadow Mate 的 schema 提案与隔离 CI 测试副本 supabase/functions/ 账号级服务端删除 tests/unit/ 纯函数与学习状态机测试 tests/e2e/ 离线、云端和数据生命周期测试 @@ -134,7 +152,15 @@ tests/e2e/ 离线、云端和数据生命周期测试 当前部署配置位于 `src/config.js`,浏览器端只使用 publishable key。真正的数据隔离由 Supabase RLS、家庭成员关系和产品 ID 共同完成;绝不能把 secret key 或 `service_role` key 放进仓库。 -数据库迁移位于 `supabase/migrations/`,包括: +### 共享 Supabase 与迁移边界 + +影伴接入共享 Supabase 后,日常本地验收必须通过 `npm run supabase:local:start`,由同级 `shadow-size/merchant-admin` 启动共享本地实例,并加载经 Shadow Portal 控制面校验的 Shadow Mate `learning_*` schema。需要验收 Auth 或 Edge Functions 时,再在第二个终端运行 `npm run supabase:local:functions:serve`。 + +仓库中的 `supabase/migrations/` 仍用于保存与代码同步的迁移提案和隔离 CI 测试副本,不是共享生产库的唯一发布目录。共享生产迁移的 canonical 文件、审批、发布和台账由 `shadow-portal/supabase/control-plane` 管理;不要在本仓库直接执行生产 `db push`、`migration repair` 或 linked SQL。 + +`supabase/config.toml` 的独立端口和迁移配置仅供 CI/隔离测试使用。不要在影伴仓库根目录直接运行裸 `supabase start` 来代替共享本地启动。 + +数据库迁移提案包括: - 项目登记和共享多租户兼容性 - 家庭、成员、学习者和学习状态表 diff --git a/package.json b/package.json index 77ca309..3fb879d 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,13 @@ "verify": "npm run public:check && npm run security:check && npm run check && npm run build && node scripts/check-build.mjs && npm run test:coverage", "test": "vitest run", "test:unit": "vitest run tests/unit", + "test:fast": "npm run check && npm run test:unit", + "test:ui": "node scripts/run-e2e.mjs tests/e2e/offline.spec.js", + "test:full": "npm run verify && npm run test:db && npm run test:functions && npm run test:e2e && npm run release:check", "test:coverage": "vitest run --coverage", - "test:db": "supabase test db --local", + "supabase:local:start": "node scripts/start-shared-supabase.mjs", + "supabase:local:functions:serve": "node scripts/serve-shared-functions.mjs", + "test:db": "node scripts/test-shared-db.mjs", "test:functions": "node scripts/test-delete-account-guard.mjs", "test:e2e": "node scripts/run-e2e.mjs" }, diff --git a/scripts/serve-shared-functions.mjs b/scripts/serve-shared-functions.mjs new file mode 100644 index 0000000..1f8971d --- /dev/null +++ b/scripts/serve-shared-functions.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin') + +if (!fs.existsSync(path.join(merchantAdminRoot, 'package.json'))) { + throw new Error( + `找不到共享本地 Supabase 控制仓库:${merchantAdminRoot}\n请确认 shadow-mate、shadow-size 位于同一个 VibeCoding 目录。`, + ) +} + +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' +execFileSync(npmCommand, ['run', 'supabase:local:functions:serve'], { + cwd: merchantAdminRoot, + stdio: 'inherit', +}) diff --git a/scripts/start-shared-supabase.mjs b/scripts/start-shared-supabase.mjs new file mode 100644 index 0000000..8a5c968 --- /dev/null +++ b/scripts/start-shared-supabase.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin') + +if (!fs.existsSync(path.join(merchantAdminRoot, 'package.json'))) { + throw new Error( + `找不到共享本地 Supabase 控制仓库:${merchantAdminRoot}\n请确认 shadow-mate、shadow-size 位于同一个 VibeCoding 目录。`, + ) +} + +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm' +execFileSync(npmCommand, ['run', 'supabase:local:start'], { + cwd: merchantAdminRoot, + stdio: 'inherit', +}) diff --git a/scripts/test-delete-account-guard.mjs b/scripts/test-delete-account-guard.mjs index d9a9622..1c00fb6 100644 --- a/scripts/test-delete-account-guard.mjs +++ b/scripts/test-delete-account-guard.mjs @@ -1,8 +1,39 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +function loadLocalEnv() { + const envPath = path.join(projectRoot, ".env.local"); + if (!fs.existsSync(envPath)) return; + + for (const line of fs.readFileSync(envPath, "utf8").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const separator = trimmed.indexOf("="); + if (separator < 1) continue; + + const key = trimmed.slice(0, separator).trim(); + const value = trimmed + .slice(separator + 1) + .trim() + .replace(/^(["'])(.*)\1$/, "$2"); + + if (!process.env[key]) process.env[key] = value; + } +} + +loadLocalEnv(); + const supabaseUrl = process.env.VITE_SUPABASE_URL; const publishableKey = process.env.VITE_SUPABASE_PUBLISHABLE_KEY; if (!supabaseUrl || !publishableKey) { - throw new Error("VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY are required"); + throw new Error( + "VITE_SUPABASE_URL and VITE_SUPABASE_PUBLISHABLE_KEY are required; set them in the environment or .env.local", + ); } const email = `delete-guard-${Date.now()}@example.test`; diff --git a/scripts/test-shared-db.mjs b/scripts/test-shared-db.mjs new file mode 100644 index 0000000..0523ddd --- /dev/null +++ b/scripts/test-shared-db.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const shadowMateRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const merchantAdminRoot = path.resolve(shadowMateRoot, '..', 'shadow-size', 'merchant-admin') +const testPath = path.join(shadowMateRoot, 'supabase', 'tests', 'learning_rls_test.sql') + +function validateLocalDatabaseUrl(databaseUrl, source) { + let parsedUrl + try { + parsedUrl = new URL(databaseUrl) + } catch { + parsedUrl = null + } + + if (!parsedUrl || parsedUrl.protocol !== 'postgresql:' || parsedUrl.hostname !== '127.0.0.1') { + throw new Error( + `${source} 不是 loopback 本地 PostgreSQL 地址;为避免测试误连生产库,已停止。`, + ) + } + + return databaseUrl +} + +function readDatabaseUrlFromSupabase(root, source) { + const status = execFileSync( + 'npx', + ['supabase', 'status', '-o', 'env'], + { + cwd: root, + env: { ...process.env, SUPABASE_TELEMETRY_DISABLED: '1' }, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + const line = status.split(/\r?\n/).find((entry) => entry.startsWith('DB_URL=')) + const databaseUrl = line + ?.slice('DB_URL='.length) + .trim() + .replace(/^['"]|['"]$/g, '') + + if (!databaseUrl) { + throw new Error(`${source} 没有返回 DB_URL;请先启动本地 Supabase。`) + } + + return validateLocalDatabaseUrl(databaseUrl, source) +} + +function getDatabaseUrl() { + if (process.env.SHADOW_MATE_TEST_DB_URL) { + return validateLocalDatabaseUrl(process.env.SHADOW_MATE_TEST_DB_URL, 'SHADOW_MATE_TEST_DB_URL') + } + + if (fs.existsSync(path.join(merchantAdminRoot, 'package.json'))) { + return readDatabaseUrlFromSupabase(merchantAdminRoot, 'merchant-admin 本地 Supabase') + } + + throw new Error( + `找不到 merchant-admin:${merchantAdminRoot}\n本地测试请运行 npm run supabase:local:start;CI 若使用隔离数据库,必须显式设置 SHADOW_MATE_TEST_DB_URL。`, + ) +} + +function buildTestDatabaseUrl(databaseUrl) { + const parsedUrl = new URL(databaseUrl) + parsedUrl.searchParams.set('sslmode', 'disable') + return parsedUrl.toString() +} + +export { buildTestDatabaseUrl } + +if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) { + const databaseUrl = getDatabaseUrl() + const testDatabaseUrl = buildTestDatabaseUrl(databaseUrl) + + console.log('🧪 运行 Shadow Mate learning RLS 测试(目标:loopback 本地数据库)') + execFileSync( + 'npx', + [ + 'supabase', + 'test', + 'db', + '--db-url', + testDatabaseUrl, + testPath, + ], + { + cwd: shadowMateRoot, + env: { ...process.env, SUPABASE_TELEMETRY_DISABLED: '1' }, + stdio: 'inherit', + }, + ) +} diff --git a/tests/unit/shared-db-test-script.test.js b/tests/unit/shared-db-test-script.test.js new file mode 100644 index 0000000..869460a --- /dev/null +++ b/tests/unit/shared-db-test-script.test.js @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { buildTestDatabaseUrl } from "../../scripts/test-shared-db.mjs"; + +const localDatabaseUrl = [ + "postgresql://", + "postgres", + ":", + "postgres", + "@127.0.0.1:54322/postgres", +].join(""); + +describe("shared database pgTAP connection", () => { + it("keeps the raw local URL and disables SSL for Supabase CLI", () => { + expect(buildTestDatabaseUrl(localDatabaseUrl)).toBe( + `${localDatabaseUrl}?sslmode=disable`, + ); + }); + + it("overrides an existing SSL mode without encoding the whole URL", () => { + const localDatabaseUrlWithOptions = `${localDatabaseUrl}?sslmode=require&connect_timeout=10`; + + expect( + buildTestDatabaseUrl(localDatabaseUrlWithOptions), + ).toBe(`${localDatabaseUrl}?sslmode=disable&connect_timeout=10`); + }); +});