后端前端移动开发【免费下载链接】SparkyFitnessSparkyFitness: Built for Families. Powered by AI. Track food, fitness, water, and health — together.项目地址https://gitcode.com/gh_mirrors/sp/SparkyFitness点击查看免费下载本文是 SparkyFitness 仓库Built for Families, Powered by AI 的全栈健康应用包含 SparkyFitnessServer、SparkyFitnessFrontend 与 SparkyFitnessMobile 三端的测试方法论指南源自 agent-docs/testing-patterns.md。它回答了开发中反复出现的问题新增一个功能或修复一个 bug 时究竟该写哪种测试、写到哪个目录、用哪个框架、mock 哪一层读完本文你将掌握一套可直接落地的按层测试模式——用 Vitest supertest 钉死 HTTP 契约用 Vitest 单测业务编排用假客户端断言 SQL 形状用真实数据库验证 RLS 行级权限再用 Jestts-jest / jest-expo覆盖前端 React Query 与移动端 API 层。Overview各层该测什么、在哪测整个仓库的测试策略可以浓缩为一张分层对照表。核心原则是在哪一层引入复杂度就在哪一层写测试HTTP 契约在 Route 层验证、业务编排在 Service 层验证、SQL 形状在 Repository 层验证、行级权限只能在真实数据库上验证。层框架测什么怎么测位置Route端点Vitest supertest请求/响应、状态码、校验错误mock 掉 service/repository 与鉴权中间件只测 HTTP 契约SparkyFitnessServer/tests/domainRoutes.test.tsService业务逻辑Vitest逻辑、编排、错误处理mock 掉 repository测工作流SparkyFitnessServer/tests/domainService.test.tsRepository数据库VitestSQL 查询形状、行映射mock 掉poolManager的getClient用假客户端断言 SQL 与参数SparkyFitnessServer/tests/domainRepository.test.tsRLS Policy权限Vitest 真实测试库行过滤、权限继承、授权代理delegation以 app 角色通过getClient()连接播种 delegate 授权执行查询以存活 DB 探针为门禁SparkyFitnessServer/tests/rlsPermissionMatrix.integration.test.tsReact Query前端Jest testing-libraryhook 状态、缓存失效、错误处理用jest.mockmock API 模块测查询生命周期SparkyFitnessFrontend/src/tests/hooks/*.test.tsx扁平存放ComponentUIJest testing-library渲染、用户交互、表单提交mock API hooks测用户流程SparkyFitnessFrontend/src/tests/components/Integration移动端Jestjest-expoAPI 调用、状态更新、权限mockapiFetch配合真实 auth contextSparkyFitnessMobile/__tests__/services/按层组织有一个关键环境事实必须牢记服务端测试套件是 Vitest而前端ts-jest/jsdom与移动端jest-expo套件是 Jest。因此在两处前端代码里要使用jest.mock/jest.fn而不是vi.*系列 API混用会导致测试无法运行。另外绝大多数 Repository 测试都 mock 掉了poolManager整套测试里唯一的真实数据库 RLS 往返测试是rlsPermissionMatrix.integration.test.ts。文档中的示例代码是示意性的——动手之前请打开对应的真实测试文件获取确切的测试脚手架harness。服务端测试Route、Service、Repository 与 RLSRoute 测试钉死端点 HTTP 契约测什么HTTP 请求/响应、状态码、错误处理、请求体校验。真实的 Route 测试不会启动整个服务器也不会触碰数据库。它们会构造一个一次性的express()应用只挂载被测路由再用vi.mock(...)mock 掉 repository/service 层以及鉴权/权限中间件从而把测试隔离在纯 HTTP 契约层面。真实参考SparkyFitnessServer/tests/medicationRoutes.test.ts。// SparkyFitnessServer/tests/medicationRoutes.test.ts (shape) import { vi, describe, it, expect, beforeEach } from vitest; import request from supertest; import express from express; import medicationRepository from ../models/medicationRepository.js; import medicationRoutes from ../routes/v2/medicationRoutes.js; vi.mock(../models/medicationRepository.js); // Guards are stubbed to pass-through so the test exercises the handler, not auth: vi.mock(../middleware/checkPermissionMiddleware.js, () ({ default: vi.fn(() (_req, _res, next) next()), })); vi.mock(../middleware/onBehalfOfMiddleware.js, () ({ default: (_req, _res, next) next(), })); const app express(); app.use(express.json()); app.use((req, _res, next) { (req as any).userId user-1; next(); }); app.use(/api/v2/medications, medicationRoutes); describe(Medication Routes, () { beforeEach(() vi.clearAllMocks()); it(POST creates a medication entry, async () { vi.mocked(medicationRepository.create).mockResolvedValue({ id: med-123 }); const response await request(app) .post(/api/v2/medications) .send({ name: Insulin, dosage: 10mg }); expect(response.status).toBe(201); expect(response.body.id).toBe(med-123); }); it(rejects an invalid body with 400, async () { const response await request(app) .post(/api/v2/medications) .send({ dosage: }); // fails the Zod route schema expect(response.status).toBe(400); }); });打开真实文件可以看到这个形状被严格执行且更复杂medicationRoutes.test.ts开头 mock 了多达 8 个数据访问模块medicationRepository、medicationPenRepository、injectionRepository、titrationRepository、medicationEntryRepository、medicationDisplayPreferenceRepository、glp1Service、permissionUtils.canAccessUserData甚至用vi.mock(../db/poolManager.js)提供返回假{ query, release }的getClient来支撑是否为补充剂的子类型查询并用executedSql数组记录每次执行的 SQL 供断言。关键模式只在本地express()应用上挂载被测路由绝不 importSparkyFitnessServer.ts避免启动整个服务与真实中间件链用vi.mockmock 掉 repository/service 层与鉴权/权限中间件——不碰真实 DB、不碰真实 tokenv2 路由挂载在/api/v2/...前缀下真实中间件中 auth 是userId形式的 cookie这里被 stub 掉了。真实测试正是通过.set(Cookie, [userIdtestUser])注入身份同时覆盖成功路径与 Zod 校验400错误路径。真实测试还验证了查询参数透传例如GET /api/v2/medications?glp1Onlytrue会以expect.objectContaining({ glp1Only: true })断言listMedications(testUser, ...)收到的第二参。Service 测试隔离业务编排与错误处理测什么逻辑、计算、错误处理、跨模块编排。// SparkyFitnessServer/tests/medicationService.test.ts import { describe, it, expect, vi, beforeEach } from vitest; import medicationService from ../services/medicationService.js; import medicationRepository from ../models/medicationRepository.js; // Mock the repository vi.mock(../models/medicationRepository.js); describe(Medication Service, () { beforeEach(() { vi.clearAllMocks(); }); it(should calculate next dose time correctly, async () { const result medicationService.calculateNextDoseTime({ scheduleType: daily, lastDoseTime: new Date(2026-07-08T08:00:00Z), dosageIntervalHours: 12, }); expect(result).toEqual(new Date(2026-07-08T20:00:00Z)); }); it(should validate dosage before creating entry, async () { vi.spyOn(medicationRepository, create).mockResolvedValue({ id: med-123, dosage: 10mg, }); const result await medicationService.logMedication( user-1, { medicationId: med-1, dosage: 10mg, takenAt: new Date() } ); expect(medicationRepository.create).toHaveBeenCalled(); expect(result.dosage).toBe(10mg); }); it(should reject invalid dosage, async () { const invalidDosage { medicationId: med-1, dosage: invalid-format, takenAt: new Date(), }; await expect( medicationService.logMedication(user-1, invalidDosage) ).rejects.toThrow(Invalid dosage format); }); it(should handle repository errors gracefully, async () { vi.spyOn(medicationRepository, create).mockRejectedValue( new Error(Database error) ); await expect( medicationService.logMedication(user-1, { medicationId: med-1, dosage: 10mg, takenAt: new Date(), }) ).rejects.toThrow(Failed to log medication); }); });关键模式mock 掉 repository 层绝不调用真实 DB测试纯逻辑、计算、校验分支用.rejects.toThrow()覆盖错误路径验证 repository 是否以正确参数被调用toHaveBeenCalledWith。从仓库现状看Service 测试是服务端测试体量最大的部分覆盖了热量计算calorieCalculationService.test.ts、TDEE 自适应adaptiveTdeeService.test.ts、酒精周统计alcoholWeekService.test.ts等计算密集型领域同一模式在数百个Service.test.ts文件中反复出现。Repository 测试断言 SQL 形状与行映射仓库里明确区分了两类数据库相关测试1. 普通 Repository 测试domainRepository.test.ts——最常见形态。它们vi.mock(../db/poolManager.js)用假{ query: vi.fn() }客户端断言 SQL 字符串与参数以及行到对象的映射。不运行真实数据库。参考measurementRepository.test.ts。以 medicationRepository.test.ts 为例其脚手架是vi.mock(../db/poolManager, () ({ getClient: vi.fn(), })); beforeEach(() { mockClient { query: vi.fn(), release: vi.fn() }; // ts-expect-error mocked in the module mock above getClient.mockResolvedValue(mockClient); mockClient.query.mockClear(); });随后用mockClient.query.mock.calls[0]直接解构出[sql, values]并断言二者const [sql, values] mockClient.query.mock.calls[0]; expect(sql).toContain(UPDATE medication_schedules); expect(sql).toContain(schedule_type_id $3); expect(sql).toContain(WHERE id $1 AND user_id $2); expect(values).toEqual([scheduleId, userId, daily, 09:00, null, null]);这种断言 SQL 片段 断言参数数组的方式能精准捕获占位符顺序错乱、NULL 透传丢失、WHERE条件漏掉user_id这类经典回归。真实测试中还覆盖了空 patch 时不发UPDATE而改发SELECT的分支。2. RLS 集成测试rlsPermissionMatrix.integration.test.ts——全仓库唯一真实数据库测试。它通过getClient()以非 superuser 的 app 角色连接播种用户与family_access授权断言行过滤以存活 DB 探针为门禁数据库不可达时自动 skip且不启动服务器。它在文件头部即解释了自己存在的原因Row-Level Security 是在 Postgres 内部强制的用 mock 的 pool 无法测试其余套件 mock 掉db/poolManager.js恰恰绕过了 RLS。RLS 集成测试的骨架注意真实的family_access列名以及必须加引号的保留字user表// SparkyFitnessServer/tests/rlsPermissionMatrix.integration.test.ts (shape) import { describe, it, expect, beforeEach, afterEach } from vitest; import medicationRepository from ../models/medicationRepository.js; import { getClient, getSystemClient } from ../db/poolManager.js; describe(Medication Repository (with RLS), () { let userId: string; let otherUserId: string; let medicationId: string; beforeEach(async () { const systemClient getSystemClient(); // Create test users (user is a reserved word — must be quoted) const users await systemClient.query( INSERT INTO user (id, email) VALUES ($1, $2), ($3, $4) RETURNING id, [user-1, user1test.com, user-2, user2test.com] ); userId users.rows[0].id; otherUserId users.rows[1].id; // Create medication for user 1 const meds await systemClient.query( INSERT INTO medications (id, user_id, name) VALUES ($1, $2, $3) RETURNING id, [med-123, userId, Insulin] ); medicationId meds.rows[0].id; systemClient.release(); }); it(should return only user\s medications (RLS enforced), async () { // Query as user 1 const client getClient(userId, userId); const result await medicationRepository.findByUserId(userId, client); client.release(); expect(result).toHaveLength(1); expect(result[0].name).toBe(Insulin); }); it(should NOT return other user\s medications (RLS enforced), async () { // Query as user 2 requesting user 1s medications const client getClient(otherUserId, otherUserId); const result await medicationRepository.findByUserId(userId, client); client.release(); // RLS policy should filter this — result should be empty expect(result).toHaveLength(0); }); it(should allow family delegate to read with permission, async () { const systemClient getSystemClient(); // Grant family access. Columns are owner_user_id / family_user_id, and the // permissions are a JSONB boolean map — not a permission_type string. await systemClient.query( INSERT INTO family_access (owner_user_id, family_user_id, access_permissions, is_active) VALUES ($1, $2, $3, TRUE), [userId, otherUserId, { can_manage_medications: true }] ); systemClient.release(); // Query as user 2 with delegation const client getClient(userId, otherUserId); // active context user 1, authenticated user 2 const result await medicationRepository.findByUserId(userId, client); client.release(); // Delegate with can_manage_medications should see the owners medications expect(result).toHaveLength(1); }); afterEach(async () { const systemClient getSystemClient(); await systemClient.query(DELETE FROM medications WHERE id $1, [medicationId]); await systemClient.query(DELETE FROM family_access WHERE owner_user_id $1, [userId]); await systemClient.query(DELETE FROM user WHERE id IN ($1, $2), [userId, otherUserId]); systemClient.release(); }); });关键模式用getClient(userId, authenticatedUserId)设置 RLS 上下文两个参数分别是数据归属人与实际认证用户二者不同即触发 delegation 语义测试 owner 专属访问测试带权限的家庭授权代理验证 RLS 正确过滤user 2 不该看到 user 1 的数据getSystemClient()只用于 setup/teardown。getClient的底层行为可以从 db/poolManager.ts 的源码得到印证它从 app 角色连接池_getRawAppPool().connect()取出连接后立即执行SELECT public.set_app_context($1, $2)设置 RLS 上下文——第一个参数是被访问数据的 user id第二个是实际认证用户缺省时回退到 AsyncLocalStorage 中的authenticatedUserId再回退到 userId 本身上下文设置失败时通过client.release(true)销毁连接而不是归还防止脏上下文泄漏给下一个调用方。真实的rlsPermissionMatrix.integration.test.ts1484 行远比示例宏大它用describe.runIf(RUN)结合存活 DB 探针2 秒超时的独立 pg.Client 连接探测SELECT 1做门禁SKIP_RLS_MATRIX1环境变量可强制跳过Part A 断言每张启用 RLS 的表都被归入正确的权限域、其策略引用了预期的 helper能抓出checkin 表被错误接到 diary 策略这类分类错误Part B 逐个断言各 RLS helper 对每种权限的 allow/deny 行为抓出删除死键变体等逻辑回归。前端测试JestReact Query Hook 与组件React Query Hook 测试测什么hook 状态、缓存失效、错误处理。前端测试使用Jestts-jest/jsdom所以要使用jest.mock/jest.mocked——不是vi.*。Hook 测试是 src/tests/hooks/ 下的扁平文件扩展名必须是.tsxJSX 在.ts文件里无法被 ts-jest 编译。真实参考useOnboarding.test.tsx。// SparkyFitnessFrontend/src/tests/hooks/useMedications.test.tsx import { renderHook, waitFor } from testing-library/react; import { QueryClientProvider, QueryClient } from tanstack/react-query; import { useMedications } from /hooks/useMedications; import * as medicationsApi from /api/Medications/medications; // Mock the API module (jest globals are ambient — no import needed) jest.mock(/api/Medications/medications); const createTestQueryClient () new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); describe(useMedications hook, () { beforeEach(() { jest.clearAllMocks(); }); it(should fetch medications on mount, async () { jest.mocked(medicationsApi.fetchMedications).mockResolvedValue([ { id: med-1, name: Insulin }, { id: med-2, name: Aspirin }, ]); const wrapper ({ children }: any) ( QueryClientProvider client{createTestQueryClient()} {children} /QueryClientProvider ); const { result } renderHook(() useMedications(), { wrapper }); // Initially loading expect(result.current.isLoading).toBe(true); // Wait for data await waitFor(() { expect(result.current.isLoading).toBe(false); }); expect(result.current.data).toHaveLength(2); expect(result.current.data[0].name).toBe(Insulin); }); it(should handle fetch errors, async () { jest.mocked(medicationsApi.fetchMedications).mockRejectedValue(new Error(API Error)); const wrapper ({ children }: any) ( QueryClientProvider client{createTestQueryClient()} {children} /QueryClientProvider ); const { result } renderHook(() useMedications(), { wrapper }); await waitFor(() { expect(result.current.isError).toBe(true); }); expect(result.current.error?.message).toBe(API Error); }); });关键模式测试内用QueryClientProvider包裹 hookmock 掉 API 调用覆盖 loading/success/error 三态异步更新用waitFor等待。真实文件 useOnboarding.test.tsx 展示了更完整的实战写法除了jest.mock(/api/Onboarding/onboarding)还 mock 了react-i18next的useTranslation返回t回调直接回退 fallback 文本避免 i18n 初始化干扰并把 API 函数逐一转型为jest.MockedFunction后赋值给mockedGetOnboardingStatus等命名变量——这种写法让每个用例的断言意图更清晰也方便在beforeEach里统一jest.clearAllMocks()。组件测试测什么渲染、用户交互、表单提交。// SparkyFitnessFrontend/src/tests/components/MedicationForm.test.tsx import { render, screen, fireEvent, waitFor } from testing-library/react; import userEvent from testing-library/user-event; import MedicationForm from /pages/Medications/MedicationForm; import * as medicationsApi from /api/Medications/medications; import { QueryClientProvider, QueryClient } from tanstack/react-query; jest.mock(/api/Medications/medications); describe(MedicationForm component, () { beforeEach(() { jest.clearAllMocks(); }); it(should render form fields, () { const queryClient new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( QueryClientProvider client{queryClient} MedicationForm / /QueryClientProvider ); expect(screen.getByLabelText(/medication name/i)).toBeInTheDocument(); expect(screen.getByLabelText(/dosage/i)).toBeInTheDocument(); expect(screen.getByRole(button, { name: /save/i })).toBeInTheDocument(); }); it(should submit form with valid data, async () { jest.mocked(medicationsApi.createMedication).mockResolvedValue({ id: med-123, name: Insulin }); const user userEvent.setup(); const queryClient new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( QueryClientProvider client{queryClient} MedicationForm / /QueryClientProvider ); await user.type(screen.getByLabelText(/medication name/i), Insulin); await user.type(screen.getByLabelText(/dosage/i), 10mg); await user.click(screen.getByRole(button, { name: /save/i })); await waitFor(() { expect(medicationsApi.createMedication).toHaveBeenCalledWith( expect.objectContaining({ name: Insulin, dosage: 10mg }) ); }); }); it(should show validation error for empty name, async () { const user userEvent.setup(); const queryClient new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( QueryClientProvider client{queryClient} MedicationForm / /QueryClientProvider ); await user.click(screen.getByRole(button, { name: /save/i })); await waitFor(() { expect(screen.getByText(/medication name is required/i)).toBeInTheDocument(); }); }); });关键模式组件用 provider 包裹渲染QueryClientProvider等用userEvent模拟贴近真实的用户交互相比fireEvent更接近浏览器行为同时覆盖成功与校验失败两条路径验证 API 以正确数据被调用expect.objectContaining只关心关键字段。组件测试统一存放在 src/tests/components/ 下与页面/组件源码目录一一对应。移动端测试Jest / jest-expoHook 与 API测什么API 调用、状态更新、健康数据同步。移动端测试使用Jestjest-expo与相对路径导入/别名虽已配置但在src/中未被使用。HTTP 辅助函数的导出名是apiFetch不存在apiClient导出——这是新手最容易踩的坑。测试按层组织在__tests__/services/、__tests__/hooks/等目录下。示例使用真实的mealsApi。// SparkyFitnessMobile/__tests__/services/mealsApi.test.ts import { fetchMeals } from ../../src/services/api/mealsApi; import * as apiClient from ../../src/services/api/apiClient; jest.mock(../../src/services/api/apiClient); describe(meals API, () { beforeEach(() { jest.clearAllMocks(); }); it(fetches meals via apiFetch, async () { jest.mocked(apiClient.apiFetch).mockResolvedValue([{ id: meal-1 }]); const result await fetchMeals(); expect(apiClient.apiFetch).toHaveBeenCalledWith( expect.objectContaining({ url: expect.stringContaining(/meals) }) ); expect(result).toHaveLength(1); }); it(propagates auth errors, async () { jest.mocked(apiClient.apiFetch).mockRejectedValue({ status: 401 }); await expect(fetchMeals()).rejects.toMatchObject({ status: 401 }); }); });关键模式mock 掉apiClient模块并断言apiFetch——它接收一个 options 对象而不是位置参数使用相对路径导入与jest.mock/jest.mocked——这是 Jest 项目不是 Vitest同时覆盖成功与错误响应。从仓库结构看移动端测试体量很大tests/services/ 下有约 90 个服务测试文件tests/hooks/ 下有 100 余个 hook 测试tests/components/ 下还有 98 个组件测试文件覆盖了水合记录、营养、健康数据同步等核心业务域。决策速查什么时候用哪种测试场景使用避免测试 HTTP 契约状态码、响应形状Route 测试Vitest supertestService 测试不测 HTTP测试业务逻辑Service 测试mock repoRoute 测试与 HTTP 耦合过重测试 SQL 查询形状Repository 测试mockpoolManagerService 测试层级不对测试 RLS 行过滤rlsPermissionMatrix.integration.test.ts真实 DBService/Route 测试无法验证 RLS测试 React hook 状态Hook 测试renderHook组件测试setup 过重测试组件渲染与交互组件测试render userEventHook 测试不测 UI测试家庭访问权限RLS 集成测试getClient 带 delegationRoute 测试无法验证 RLS 过滤这张表的取舍逻辑很简单每一层只验证自己职责范围内的行为并把更底层的能力视为已由下层测试保证的黑盒。测试 HTTP 契约时不在乎业务细节mock service测业务时不在乎 SQLmock repo测 SQL 形状时不在乎 RLS 语义mock client而 RLS 语义本身交给唯一的真实数据库测试去兜底。前端同理hook 测试通过renderHook验证查询状态机组件测试用render userEvent验证交互流二者互不越界。运行测试三端命令速览服务端Vitestcd SparkyFitnessServer pnpm test # 运行全部测试vitest run pnpm exec vitest run tests/medication*.test.ts # 只跑特定测试文件 pnpm run test:coverage # 覆盖率报告服务端 package.json 中的相关脚本还包括test:watchvitest监听模式、test:civitest run --coverage --reporterverbose以及test:migrationstsx tests/migrate.script.ts。pnpm test在本地数据库可达时会把 rlsPermissionMatrix.integration.test.ts 一并纳入运行凭据来自../.envCI 的 migration-check 任务也会运行它从而在 PR 合并前拦截 RLS 回归。前端Jestcd SparkyFitnessFrontend pnpm test # 运行全部测试jest pnpm test -- useMedications # 按测试名/路径过滤移动端Jest / jest-expocd SparkyFitnessMobile pnpm test:run -- --watchmanfalse --runInBand # 运行全部测试 pnpm exec jest --watchmanfalse __tests__/services # 只跑某一层目录--watchmanfalse在无 watchman 的环境下避免文件监听报错--runInBand单进程串行执行以规避 RN/Jest 环境的资源竞争。移动端 package.json 还提供test:coveragejest --coverage与test:cijest --ci --coverage --maxWorkers2等脚本。从模式到实践给新功能/新 Bug 的落地建议结合 testing-patterns.md 与仓库现状编写测试时建议按如下顺序自查先确定变更发生的层新增/修改了路由 → Route 测试动了业务编排或计算 → Service 测试改了 SQL 或行映射 → Repository 测试动了 RLS 策略或授权语义 → RLS 集成测试动了前端 hook → Hook 测试动了组件交互 → 组件测试动了移动端 API/hook → 移动端按层测试。严格遵守框架边界服务端只用 Vitest 的vi.*前端SparkyFitnessFrontend与移动端SparkyFitnessMobile只用 Jest 的jest.*混用会导致 mock 失效或直接报错。不要把真实依赖拖进测试Route 测试不要 import SparkyFitnessServer.ts 启动整机Service 测试不要连库Repository 测试不要连真实 pool除了 RLS 集成测试。涉及家庭授权delegation时优先 RLS 集成测试getClient(userId, authenticatedUserId)两个参数不一致即触发代理语义只有真实数据库能验证被授权人可见、无授权不可见。异步断言一律用waitFor/.rejects.toThrow()避免裸断言时序竞态。这套按层测试体系的价值在于它把测试写在哪一层从个人偏好问题变成了有明确依据的工程决策——每一层的 mock 边界恰好对应着该层对下一层的抽象边界从而让整套测试既可以飞快运行绝大多数用例不碰数据库又能对最敏感的权限语义保留真实数据库回归网。赞分享后端前端移动开发【免费下载链接】SparkyFitnessSparkyFitness: Built for Families. Powered by AI. Track food, fitness, water, and health — together.项目地址https://gitcode.com/gh_mirrors/sp/SparkyFitness点击查看免费下载相关推荐如何快速让 GitHub Desktop 对接 GitLab、Bitbucket 与 Azure DevOps三大平台集成完全指南如何快速让 GitHub Desktop 对接 GitLab、Bitbucket 与 Azure DevOps三大平台集成完全指南 GitHub Deskto桌面应用版本控制开发工具10 分钟把 Win11 调回 Win10 的样子ExplorerPatcher 界面定制完整攻略10 分钟把 Win11 调回 Win10 的样子ExplorerPatcher 界面定制完整攻略 Win11 的界面争议从发布那天就没停过任务栏锁死在中间桌面应用系统编程STF 测试指南基于 Karma 与 Protractor 的前端单元测试和端到端测试实战STF 测试指南基于 Karma 与 Protractor 的前端单元测试和端到端测试实战 本文以 Smartphone Test FarmSTF仓库的测试后端前端上一篇Calibre格式转换单本书、整库、传设备三步走下一篇流程图变成一条链接发出去Mermaid Live Editor 3 分钟跑起来创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考