diff --git a/.claude/launch.json b/.claude/launch.json index ffa1c6e..3c95a9c 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -2,13 +2,10 @@ "version": "0.0.1", "configurations": [ { - "name": "start", - "runtimeExecutable": "npm", - "runtimeArgs": [ - "run", - "start" - ], - "port": 3000 + "name": "expo-web", + "runtimeExecutable": "npx", + "runtimeArgs": ["expo", "start", "--web", "--port", "5173"], + "port": 5173 } ] } diff --git a/README.md b/README.md index 5994ddf..426dab5 100644 --- a/README.md +++ b/README.md @@ -67,20 +67,34 @@ Tokens in `src/ui/tokens.ts` are transcribed from the web's `frontend/src/index. npm run lint && npm run typecheck && npm test ``` -Two integration suites are opt-in because they need a live server. They drive -the real client modules against it, and the onboarding one writes real rows -(fresh accounts and team keys per run): +The integration suites are opt-in because they need a live server. They drive +the real client modules against it — the same code the screens call — and several +of them write real rows (fresh accounts and team keys per run): ```bash -SOFTTRACK_LIVE_URL=http://localhost:8000 npm test +SOFTTRACK_LIVE_URL=http://localhost:8000 npm run test:live ``` -The live suites between them register a handful of real accounts per run, and -registration is throttled per IP and charged even on success — so a few full runs -in a row will eventually 429. The counter lives in the API process, so +`test:live` rather than `npm test`, because these suites **cannot run in +parallel**. Registration is throttled per IP and charged even on success, and +several suites register: run concurrently they exhaust the budget immediately, +and the 429 that comes back is an `AxiosError` whose circular `req`/`res` pair +crashes the jest worker trying to serialize it, so the failure surfaces as +`Test suite failed to run` rather than as the throttle it is. `--runInBand` +spreads them out. A few full runs in a row will still eventually 429; the +counter lives in the API process, so `docker compose restart backend && docker compose up -d` clears it without touching the database. +They also share one instance, so they pick their fixtures deliberately rather +than by position — see `src/api/__tests__/support/team.ts`. `GET /teams` promises +no order, and the team-administration and onboarding suites create teams the API +offers no way to delete, so an instance these have run against accumulates empty +teams permanently. Taking `teams[0]` eventually lands on one of those. + +Production is covered separately by `live-production.test.ts`, which stays +read-only apart from a single scratch issue it deletes in the same run. + Note `experiments.typedRoutes` is a dev-time aid: the route union is written by `expo start`, not by `expo export`, so outside the dev server every path typechecks permissively. Paths built at runtime go through `href()` in diff --git a/jest.config.js b/jest.config.js index 9c3fdb0..5642356 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,5 +7,6 @@ module.exports = { resolver: 'react-native-worklets/jest/resolver.js', // The generated client is orval's output, not ours -- it is covered by the // api-contract check instead. - testPathIgnorePatterns: ['/node_modules/', '/src/api/generated/'], + // `support/` holds helpers for the live suites, not suites of their own. + testPathIgnorePatterns: ['/node_modules/', '/src/api/generated/', '/__tests__/support/'], }; diff --git a/package-lock.json b/package-lock.json index 4860f5d..cb1525e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", + "react-native-web": "^0.21.2", "react-native-worklets": "0.10.1" }, "devDependencies": { @@ -6500,6 +6501,15 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6514,6 +6524,15 @@ "node": ">= 8" } }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -9095,6 +9114,36 @@ "bser": "2.1.1" } }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "license": "MIT" + }, + "node_modules/fbjs/node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, "node_modules/fetch-nodeshim": { "version": "0.4.10", "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", @@ -9873,6 +9922,12 @@ "node": ">=10.17.0" } }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -9980,6 +10035,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/inline-style-prefixer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", + "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", + "license": "MIT", + "dependencies": { + "css-in-js-utils": "^3.1.0" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -13450,6 +13514,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-forge": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", @@ -13565,7 +13671,6 @@ "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" @@ -14487,6 +14592,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -14952,6 +15063,38 @@ "react-native": "*" } }, + "node_modules/react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", + "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^7.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { + "version": "0.74.89", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz", + "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==", + "license": "MIT" + }, + "node_modules/react-native-web/node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/react-native-worklets": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.1.tgz", @@ -15660,6 +15803,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -16233,6 +16382,12 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, + "node_modules/styleq": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", + "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -16761,6 +16916,32 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", diff --git a/package.json b/package.json index df5300e..1934373 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "lint": "expo lint", "typecheck": "tsc --noEmit", "test": "jest", + "test:live": "jest --runInBand", "sync:openapi": "./scripts/sync-openapi.sh", "generate:api": "orval --config orval.config.ts" }, @@ -44,6 +45,7 @@ "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.0", "react-native-svg": "15.15.4", + "react-native-web": "^0.21.2", "react-native-worklets": "0.10.1" }, "devDependencies": { diff --git a/src/api/__tests__/live-attachments.test.ts b/src/api/__tests__/live-attachments.test.ts index 54ef82b..fbf135e 100644 --- a/src/api/__tests__/live-attachments.test.ts +++ b/src/api/__tests__/live-attachments.test.ts @@ -13,10 +13,10 @@ import { createIssueTeamsTeamIdIssuesPost, deleteIssueIssuesIssueIdDelete, } from '@/api/generated/endpoints/issues/issues'; -import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; import type { AttachmentRead, TeamRead } from '@/api/generated/models'; import { setInstanceUrl } from '@/api/instance'; import { persistToken, getAccessToken } from '@/auth/session'; +import { seededTeam } from '@/api/__tests__/support/team'; /** * Attachments against a real instance. @@ -59,7 +59,7 @@ describeLive('attachments against a live instance', () => { password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', }); await persistToken(token.access_token); - team = (await listMyTeamsTeamsGet())[0]; + team = await seededTeam(); const issue = await createIssueTeamsTeamIdIssuesPost(team.id, { title: `Attachments ${RUN}`, }); diff --git a/src/api/__tests__/live-board.test.ts b/src/api/__tests__/live-board.test.ts index bfcca18..a9217d6 100644 --- a/src/api/__tests__/live-board.test.ts +++ b/src/api/__tests__/live-board.test.ts @@ -8,11 +8,11 @@ import { updateIssueIssuesIssueIdPatch, } from '@/api/generated/endpoints/issues/issues'; import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; -import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; import type { StatusRead, TeamRead } from '@/api/generated/models'; import { toQueryParams } from '@/board/filters'; import { setInstanceUrl } from '@/api/instance'; import { persistToken } from '@/auth/session'; +import { seededTeam } from '@/api/__tests__/support/team'; /** * The board's data path against a real instance: that the six filters narrow @@ -36,7 +36,7 @@ describeLive('the board against a live instance', () => { }); await persistToken(token.access_token); - team = (await listMyTeamsTeamsGet())[0]; + team = await seededTeam(); statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); }); diff --git a/src/api/__tests__/live-comments.test.ts b/src/api/__tests__/live-comments.test.ts index 1589376..90160e1 100644 --- a/src/api/__tests__/live-comments.test.ts +++ b/src/api/__tests__/live-comments.test.ts @@ -16,14 +16,12 @@ import { getWatchStateIssuesIssueIdWatchGet, setWatchStateIssuesIssueIdWatchPut, } from '@/api/generated/endpoints/notifications/notifications'; -import { - listMyTeamsTeamsGet, - listTeamMembersTeamsTeamIdMembersGet, -} from '@/api/generated/endpoints/teams/teams'; +import { listTeamMembersTeamsTeamIdMembersGet } from '@/api/generated/endpoints/teams/teams'; import type { TeamRead } from '@/api/generated/models'; import { setInstanceUrl } from '@/api/instance'; import { persistToken } from '@/auth/session'; import { toggleTaskAtIndex } from '@/markdown/tasks'; +import { seededTeam } from '@/api/__tests__/support/team'; /** * Comments and markdown against a real instance. @@ -48,7 +46,7 @@ describeLive('comments against a live instance', () => { password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', }); await persistToken(token.access_token); - team = (await listMyTeamsTeamsGet())[0]; + team = await seededTeam(); }); afterAll(async () => { diff --git a/src/api/__tests__/live-cycles.test.ts b/src/api/__tests__/live-cycles.test.ts index bb34535..d045068 100644 --- a/src/api/__tests__/live-cycles.test.ts +++ b/src/api/__tests__/live-cycles.test.ts @@ -17,10 +17,10 @@ import { listIssuesTeamsTeamIdIssuesGet, } from '@/api/generated/endpoints/issues/issues'; import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; -import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; import type { TeamRead } from '@/api/generated/models'; import { setInstanceUrl } from '@/api/instance'; import { persistToken } from '@/auth/session'; +import { seededTeam } from '@/api/__tests__/support/team'; /** * Cycles against a real instance. @@ -52,7 +52,7 @@ describeLive('cycles against a live instance', () => { password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', }); await persistToken(token.access_token); - team = (await listMyTeamsTeamsGet())[0]; + team = await seededTeam(); }); afterAll(async () => { diff --git a/src/api/__tests__/live-issue-detail.test.ts b/src/api/__tests__/live-issue-detail.test.ts index b5a3a83..79eb8d7 100644 --- a/src/api/__tests__/live-issue-detail.test.ts +++ b/src/api/__tests__/live-issue-detail.test.ts @@ -18,10 +18,10 @@ import { setWatchStateIssuesIssueIdWatchPut, } from '@/api/generated/endpoints/notifications/notifications'; import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; -import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; import type { StatusRead, TeamRead } from '@/api/generated/models'; import { setInstanceUrl } from '@/api/instance'; import { persistToken } from '@/auth/session'; +import { seededTeam } from '@/api/__tests__/support/team'; /** * The issue detail sections against a real instance: sub-issues, links and @@ -53,7 +53,7 @@ describeLive('issue detail against a live instance', () => { password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', }); await persistToken(token.access_token); - team = (await listMyTeamsTeamsGet())[0]; + team = await seededTeam(); statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); }); diff --git a/src/api/__tests__/live-issues.test.ts b/src/api/__tests__/live-issues.test.ts index 52834ed..5904a69 100644 --- a/src/api/__tests__/live-issues.test.ts +++ b/src/api/__tests__/live-issues.test.ts @@ -12,11 +12,11 @@ import { import { createLabelTeamsTeamIdLabelsPost } from '@/api/generated/endpoints/labels/labels'; import { createProjectTeamsTeamIdProjectsPost } from '@/api/generated/endpoints/projects/projects'; import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; -import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; import type { StatusRead, TeamRead } from '@/api/generated/models'; import { setInstanceUrl } from '@/api/instance'; import { persistToken } from '@/auth/session'; import { ESTIMATE_SCALE } from '@/issues/use-issue-properties'; +import { seededTeam } from '@/api/__tests__/support/team'; /** * Creating and editing issues against a real instance. @@ -45,7 +45,7 @@ describeLive('issues against a live instance', () => { password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', }); await persistToken(token.access_token); - team = (await listMyTeamsTeamsGet())[0]; + team = await seededTeam(); statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); }); diff --git a/src/api/__tests__/live-notifications.test.ts b/src/api/__tests__/live-notifications.test.ts index f3d1ef2..465a234 100644 --- a/src/api/__tests__/live-notifications.test.ts +++ b/src/api/__tests__/live-notifications.test.ts @@ -21,10 +21,10 @@ import { acceptInviteInvitesTokenAcceptPost, createInviteTeamsTeamIdInvitesPost, } from '@/api/generated/endpoints/invites/invites'; -import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; import type { TeamRead } from '@/api/generated/models'; import { setInstanceUrl } from '@/api/instance'; import { persistToken } from '@/auth/session'; +import { seededTeam } from '@/api/__tests__/support/team'; /** * The inbox against a real instance. @@ -56,7 +56,7 @@ describeLive('notifications against a live instance', () => { }); demoToken = demo.access_token; await persistToken(demoToken); - team = (await listMyTeamsTeamsGet())[0]; + team = await seededTeam(); const other = await registerAuthRegisterPost({ email: OTHER_EMAIL, diff --git a/src/api/__tests__/live-production.test.ts b/src/api/__tests__/live-production.test.ts new file mode 100644 index 0000000..e7098cc --- /dev/null +++ b/src/api/__tests__/live-production.test.ts @@ -0,0 +1,216 @@ +/** + * @jest-environment node + */ +import { loginAuthLoginPost, meAuthMeGet } from '@/api/generated/endpoints/auth/auth'; +import { createCommentIssuesIssueIdCommentsPost, listCommentsIssuesIssueIdCommentsGet } from '@/api/generated/endpoints/comments/comments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + getEstimateSummaryTeamsTeamIdEstimatesGet, + getIssueIssuesIssueIdGet, + listIssuesTeamsTeamIdIssuesGet, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import { getNotificationSettingsNotificationsSettingsGet, unreadCountNotificationsUnreadCountGet } from '@/api/generated/endpoints/notifications/notifications'; +import { teamCumulativeFlowTeamsTeamIdCumulativeFlowGet, teamVelocityTeamsTeamIdVelocityGet } from '@/api/generated/endpoints/reports/reports'; +import { searchSearchGet } from '@/api/generated/endpoints/search/search'; +import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import { listViewsTeamsTeamIdViewsGet } from '@/api/generated/endpoints/views/views'; +import type { TeamRead } from '@/api/generated/models'; +import { normalizeInstanceUrl, probeInstance, setInstanceUrl } from '@/api/instance'; +import { resolveColor } from '@/ui/color'; +import { light } from '@/ui/tokens'; +import { persistToken } from '@/auth/session'; +import { toQueryParams, NO_FILTERS } from '@/board/filters'; +import { toggleTaskAtIndex } from '@/markdown/tasks'; + +/** + * The app's own client modules against the production instance. + * + * Runs the real mutator, instance store, session and generated client -- the + * same code the screens call -- so what is verified here is the client, not a + * hand-written request. + * + * SOFTTRACK_PROD_URL=https://softback.quantadev.io \ + * SOFTTRACK_PROD_EMAIL=... SOFTTRACK_PROD_PASSWORD=... npm test + * + * Deliberately constrained, because this is somebody's real instance: + * everything is read-only except one issue that is created and then deleted in + * the same run. It never registers an account, never creates a team (the API + * has no way to delete one), never changes a password and never signs anyone + * out. Those paths are covered against the local stack instead. + */ +const URL_ = process.env.SOFTTRACK_PROD_URL; +const EMAIL = process.env.SOFTTRACK_PROD_EMAIL; +const PASSWORD = process.env.SOFTTRACK_PROD_PASSWORD; +const ready = Boolean(URL_ && EMAIL && PASSWORD); +const describeProd = ready ? describe : describe.skip; + +describeProd('production instance', () => { + let teams: TeamRead[]; + let scratchTeam: TeamRead; + const created: number[] = []; + + beforeAll(async () => { + const base = normalizeInstanceUrl(URL_!); + expect(base).not.toBeNull(); + await setInstanceUrl(base!); + + const token = await loginAuthLoginPost({ username: EMAIL!, password: PASSWORD! }); + await persistToken(token.access_token); + teams = await listMyTeamsTeamsGet(); + // Prefer a team that is empty, so a scratch issue is least intrusive. + scratchTeam = teams.find((t) => t.key === 'MB') ?? teams[0]; + }); + + afterAll(async () => { + for (const id of created) { + await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + } + }); + + it('probes as a SoftTrack instance over TLS', async () => { + const result = await probeInstance(normalizeInstanceUrl(URL_!)!); + expect(result.ok).toBe(true); + if (result.ok) { + // The login screen reads these to decide what to offer. + expect(typeof result.config.open_registration).toBe('boolean'); + expect(result.config.demo_credentials).toBe(false); + } + }); + + it('signs in and identifies the account', async () => { + const me = await meAuthMeGet(); + expect(me.email).toBe(EMAIL); + expect(me.is_active).toBe(true); + expect(me.avatar_color).toMatch(/^#[0-9a-f]{6}$/i); + }); + + it('lists teams with the keys the board routes on', async () => { + expect(teams.length).toBeGreaterThan(0); + for (const team of teams) { + // Uppercase keys are what keep /ENG from colliding with /board. + expect(team.key).toBe(team.key.toUpperCase()); + expect(team.key.length).toBeGreaterThanOrEqual(2); + } + }); + + it('draws columns from each team’s own statuses', async () => { + for (const team of teams) { + const statuses = await listStatusesTeamsTeamIdStatusesGet(team.id); + expect(statuses.length).toBeGreaterThan(0); + for (const status of statuses) { + // Not asserted to be hex: a user-created status on this instance is + // stored as `var(--color-status-progress)`, which a browser resolves + // and React Native does not. What matters is that it resolves to + // something paintable rather than to an invisible dot. + const painted = resolveColor(status.color, light); + expect(painted).toMatch(/^(#[0-9a-f]{3,8}|rgba?\(|[a-z]+$)/i); + expect(['backlog', 'unstarted', 'started', 'done', 'cancelled']).toContain( + status.category, + ); + } + // Rendered in this order, left to right. + const positions = statuses.map((s) => s.position); + expect([...positions].sort((a, b) => a - b)).toEqual(positions); + } + }); + + it('returns board data and a matching point rollup', async () => { + for (const team of teams) { + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { limit: 200 }); + const statusIds = new Set( + (await listStatusesTeamsTeamIdStatusesGet(team.id)).map((s) => s.id), + ); + for (const issue of page.items) { + expect(issue.identifier).toContain(team.key); + expect(statusIds.has(issue.status.id)).toBe(true); + expect(typeof issue.blocked_by_count).toBe('number'); + } + + const estimates = await getEstimateSummaryTeamsTeamIdEstimatesGet(team.id); + for (const key of Object.keys(estimates.by_status)) { + expect(Number.isInteger(Number(key))).toBe(true); + } + } + }); + + it('narrows server-side with the filters the board sends', async () => { + const team = teams[0]; + const params = toQueryParams({ ...NO_FILTERS, assignee: 'unassigned' }); + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { ...params, limit: 200 }); + for (const issue of page.items) expect(issue.assignee ?? null).toBeNull(); + }); + + it('answers search, reports, views and notification settings', async () => { + const team = teams[0]; + + const hits = await searchSearchGet({ q: 'the', limit: 5 }); + expect(Array.isArray(hits.items)).toBe(true); + + const velocity = await teamVelocityTeamsTeamIdVelocityGet(team.id); + expect(Array.isArray(velocity.cycles)).toBe(true); + const flow = await teamCumulativeFlowTeamsTeamIdCumulativeFlowGet(team.id); + expect(Array.isArray(flow.days)).toBe(true); + + const views = await listViewsTeamsTeamIdViewsGet(team.id); + expect(Array.isArray(views.items)).toBe(true); + + const settings = await getNotificationSettingsNotificationsSettingsGet(); + expect(typeof settings.email_delivery_configured).toBe('boolean'); + const unread = await unreadCountNotificationsUnreadCountGet(); + expect(typeof unread.unread).toBe('number'); + }); + + it('creates, edits, comments on and deletes one scratch issue', async () => { + const statuses = await listStatusesTeamsTeamIdStatusesGet(scratchTeam.id); + const stamp = Date.now().toString(36).slice(-5); + + const issue = await createIssueTeamsTeamIdIssuesPost(scratchTeam.id, { + title: `Mobile client smoke test ${stamp}`, + description: '- [ ] verify round trip', + priority: 'low', + estimate: 2, + }); + created.push(issue.id); + + // Indistinguishable from a web-created issue. + expect(issue.identifier).toBe(`${scratchTeam.key}-${issue.number}`); + expect(issue.priority).toBe('low'); + expect(issue.estimate).toBe(2); + + // A move, the board's core mutation. + const target = statuses.find((s) => s.id !== issue.status.id)!; + const moved = await updateIssueIssuesIssueIdPatch(issue.id, { status_id: target.id }); + expect(moved.status.id).toBe(target.id); + + // Markdown survives byte for byte, including a ticked checkbox written + // back as a one-character edit. + const ticked = toggleTaskAtIndex(issue.description ?? '', 0)!; + const updated = await updateIssueIssuesIssueIdPatch(issue.id, { description: ticked }); + expect(updated.description).toBe('- [x] verify round trip'); + + const body = '## Smoke\n\n`code` and **bold**.'; + const comment = await createCommentIssuesIssueIdCommentsPost(issue.id, { body }); + expect(comment.body).toBe(body); + const comments = await listCommentsIssuesIssueIdCommentsGet(issue.id, { limit: 10 }); + expect(comments.items.map((c) => c.body)).toContain(body); + + // And cleans up after itself. + await deleteIssueIssuesIssueIdDelete(issue.id); + created.pop(); + await expect(getIssueIssuesIssueIdGet(issue.id)).rejects.toMatchObject({ + response: { status: 404 }, + }); + }); + + it('leaves the instance as it was found', async () => { + // Nothing from this run should remain in any team. + for (const team of teams) { + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { limit: 200 }); + const residue = page.items.filter((i) => i.title.includes('Mobile client smoke test')); + expect(residue).toEqual([]); + } + }); +}); diff --git a/src/api/__tests__/live-reports.test.ts b/src/api/__tests__/live-reports.test.ts index bf2932f..3fa076f 100644 --- a/src/api/__tests__/live-reports.test.ts +++ b/src/api/__tests__/live-reports.test.ts @@ -9,11 +9,11 @@ import { teamCumulativeFlowTeamsTeamIdCumulativeFlowGet, teamVelocityTeamsTeamIdVelocityGet, } from '@/api/generated/endpoints/reports/reports'; -import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; import type { TeamRead } from '@/api/generated/models'; import { setInstanceUrl } from '@/api/instance'; import { persistToken } from '@/auth/session'; import { makeScale } from '@/reports/chart'; +import { seededTeam } from '@/api/__tests__/support/team'; /** * The four reports against a real instance. @@ -37,7 +37,7 @@ describeLive('reports against a live instance', () => { password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', }); await persistToken(token.access_token); - team = (await listMyTeamsTeamsGet())[0]; + team = await seededTeam(); }); it('returns a burndown with an ideal line to draw against', async () => { diff --git a/src/api/__tests__/live-search.test.ts b/src/api/__tests__/live-search.test.ts index de7613b..2128771 100644 --- a/src/api/__tests__/live-search.test.ts +++ b/src/api/__tests__/live-search.test.ts @@ -8,10 +8,10 @@ import { deleteIssueIssuesIssueIdDelete, } from '@/api/generated/endpoints/issues/issues'; import { searchSearchGet } from '@/api/generated/endpoints/search/search'; -import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; import type { TeamRead } from '@/api/generated/models'; import { setInstanceUrl } from '@/api/instance'; import { persistToken } from '@/auth/session'; +import { seededTeam } from '@/api/__tests__/support/team'; /** * Search against a real instance. @@ -37,7 +37,7 @@ describeLive('search against a live instance', () => { password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', }); await persistToken(token.access_token); - team = (await listMyTeamsTeamsGet())[0]; + team = await seededTeam(); const inTitle = await createIssueTeamsTeamIdIssuesPost(team.id, { title: `Title match ${NEEDLE}`, diff --git a/src/api/__tests__/live-views.test.ts b/src/api/__tests__/live-views.test.ts index ddb21f6..15eebd9 100644 --- a/src/api/__tests__/live-views.test.ts +++ b/src/api/__tests__/live-views.test.ts @@ -2,7 +2,6 @@ * @jest-environment node */ import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; -import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; import { createViewTeamsTeamIdViewsPost, deleteViewViewsViewIdDelete, @@ -16,6 +15,7 @@ import { setInstanceUrl } from '@/api/instance'; import { persistToken } from '@/auth/session'; import { NO_FILTERS, sameFilters } from '@/board/filters'; import { fromViewFilters, toViewFilters } from '@/views/saved-views'; +import { seededTeam } from '@/api/__tests__/support/team'; /** * Saved views against a real instance. @@ -40,7 +40,7 @@ describeLive('saved views against a live instance', () => { password: process.env.SOFTTRACK_LIVE_PASSWORD ?? 'password123', }); await persistToken(token.access_token); - team = (await listMyTeamsTeamsGet())[0]; + team = await seededTeam(); }); afterAll(async () => { diff --git a/src/api/__tests__/support/team.ts b/src/api/__tests__/support/team.ts new file mode 100644 index 0000000..68feea6 --- /dev/null +++ b/src/api/__tests__/support/team.ts @@ -0,0 +1,28 @@ +import { listIssuesTeamsTeamIdIssuesGet } from '@/api/generated/endpoints/issues/issues'; +import { listMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; +import type { TeamRead } from '@/api/generated/models'; + +/** + * The team the live suites should work against. + * + * `GET /teams` promises no particular order, and the suites that exercise team + * administration and onboarding create teams the API offers no way to delete -- + * so an instance these tests have run against accumulates empty teams for good. + * Taking `teams[0]` eventually picks one of those and fails on an empty board, + * which says nothing about the code under test. + * + * Prefer the seeded team by key, then any team that actually has issues. + */ +export async function seededTeam(preferredKey = 'ENG'): Promise { + const teams = await listMyTeamsTeamsGet(); + if (teams.length === 0) throw new Error('the instance has no teams to test against'); + + const preferred = teams.find((team) => team.key === preferredKey); + if (preferred) return preferred; + + for (const team of teams) { + const page = await listIssuesTeamsTeamIdIssuesGet(team.id, { limit: 1 }); + if (page.items.length > 0) return team; + } + return teams[0]; +} diff --git a/src/board/__tests__/column-width.test.ts b/src/board/__tests__/column-width.test.ts new file mode 100644 index 0000000..1e789e2 --- /dev/null +++ b/src/board/__tests__/column-width.test.ts @@ -0,0 +1,43 @@ +import { columnWidth, COLUMN_GAP } from '@/board/column-layout'; +import { RAIL_WIDTH } from '@/ui/tab-bar'; + +/** + * Column sizing from the width the board actually gets. + * + * These exist because running the app turned up a board whose columns were two + * pixels wide: the width came from an async measurement that resolves to zero, + * and every column -- and so every card -- collapsed to nothing while the DOM + * still held all the content. A zero must never reach `columnWidth`. + */ +describe('columnWidth against the real viewports', () => { + it('leaves the neighbours peeking on a phone', () => { + // 390 phone, no rail. The peek is what says the strip scrolls sideways. + expect(columnWidth('compact', 390)).toBe(320); + }); + + it('fits exactly two beside the rail on a foldable', () => { + // 700 window less the 76 rail is the pane the columns actually get. + const pane = 700 - RAIL_WIDTH; + const width = columnWidth('medium', pane); + expect(width).toBe(306); + expect(width * 2 + COLUMN_GAP).toBe(pane); + }); + + it('fits exactly four beside the rail on a tablet', () => { + const pane = 1280 - RAIL_WIDTH; + const width = columnWidth('expanded', pane); + expect(width).toBe(292); + expect(width * 4 + COLUMN_GAP * 3).toBe(pane); + }); + + it('never returns something a card can disappear into', () => { + // The regression itself: a zero or negative available width used to make + // a column two pixels wide, borders only. + for (const size of ['compact', 'medium', 'expanded'] as const) { + expect(columnWidth(size, 0)).toBeLessThanOrEqual(0); + } + // Which is why the caller substitutes the window width rather than + // passing a measurement through unchecked. + expect(columnWidth('compact', 390)).toBeGreaterThan(100); + }); +}); diff --git a/src/board/board-screen.tsx b/src/board/board-screen.tsx index 08fbe15..4560c06 100644 --- a/src/board/board-screen.tsx +++ b/src/board/board-screen.tsx @@ -25,7 +25,7 @@ import { TeamSwitcher } from '@/team/team-switcher'; import { href } from '@/ui/href'; import { Icon } from '@/ui/icon'; import { useIsMultiPane } from '@/ui/layout'; -import { AppText, Button, Loading, TeamBadge } from '@/ui/primitives'; +import { AppText, Button, Dot, Loading, TeamBadge } from '@/ui/primitives'; import { Sheet } from '@/ui/sheet'; import { useTokens } from '@/ui/theme'; @@ -303,14 +303,7 @@ export function BoardScreen() { moving?.status.id === status.id ? t.line.navActive : 'transparent', }} > - + {status.name} diff --git a/src/board/filter-sheet.tsx b/src/board/filter-sheet.tsx index aa8f5e2..375b037 100644 --- a/src/board/filter-sheet.tsx +++ b/src/board/filter-sheet.tsx @@ -11,7 +11,7 @@ import type { BoardFilters } from '@/board/filters'; import { NO_FILTERS } from '@/board/filters'; import { PRIORITY_LABEL, PRIORITY_ORDER, priorityColor } from '@/issues/issue-meta'; import { Icon } from '@/ui/icon'; -import { AppText, Button } from '@/ui/primitives'; +import { AppText, Button, Dot } from '@/ui/primitives'; import { Sheet } from '@/ui/sheet'; import { useTokens } from '@/ui/theme'; @@ -48,16 +48,7 @@ function Group({ title, options }: { title: string; options: Option[] }) { backgroundColor: option.selected ? t.line.navActive : 'transparent', }} > - {option.color ? ( - - ) : null} + {option.color ? : null} {option.label} diff --git a/src/board/issue-card.tsx b/src/board/issue-card.tsx index 27071f2..f396d7e 100644 --- a/src/board/issue-card.tsx +++ b/src/board/issue-card.tsx @@ -3,6 +3,7 @@ import { StyleSheet, Text, View } from 'react-native'; import type { IssueRead } from '@/api/generated/models'; import { priorityColor } from '@/issues/issue-meta'; import { Avatar, AppText } from '@/ui/primitives'; +import { resolveColor } from '@/ui/color'; import { useTokens } from '@/ui/theme'; /** @@ -110,7 +111,7 @@ export function IssueCard({ issue }: { issue: IssueRead }) { borderRadius: t.radius.pill, backgroundColor: t.line.well, borderWidth: StyleSheet.hairlineWidth, - borderColor: label.color, + borderColor: resolveColor(label.color, t), }} > {label.name} diff --git a/src/board/issue-list.tsx b/src/board/issue-list.tsx index 66bda84..36c39a6 100644 --- a/src/board/issue-list.tsx +++ b/src/board/issue-list.tsx @@ -2,7 +2,7 @@ import { FlatList, Pressable, StyleSheet, View } from 'react-native'; import type { IssueRead, StatusRead } from '@/api/generated/models'; import { priorityColor } from '@/issues/issue-meta'; -import { Avatar, AppText } from '@/ui/primitives'; +import { Avatar, AppText, Dot } from '@/ui/primitives'; import { useTokens } from '@/ui/theme'; /** @@ -103,9 +103,6 @@ export function IssueList({ function StatusDot({ status }: { status: StatusRead }) { return ( - + ); } diff --git a/src/board/kanban-board.tsx b/src/board/kanban-board.tsx index 5ad5f6f..1dd79b6 100644 --- a/src/board/kanban-board.tsx +++ b/src/board/kanban-board.tsx @@ -1,5 +1,5 @@ import { useCallback, useRef, useState } from 'react'; -import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { Pressable, ScrollView, StyleSheet, useWindowDimensions, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, @@ -20,10 +20,11 @@ import { edgeScrollStep, type ColumnBounds, } from '@/board/column-layout'; +import { RAIL_WIDTH } from '@/ui/tab-bar'; import { COLLAPSED_CATEGORIES } from '@/issues/issue-meta'; import { Icon } from '@/ui/icon'; import { useSizeClass } from '@/ui/layout'; -import { AppText } from '@/ui/primitives'; +import { AppText, Dot } from '@/ui/primitives'; import { useTokens } from '@/ui/theme'; /** How long a press has to be held before the card lifts. */ @@ -58,9 +59,7 @@ function ColumnHeader({ paddingVertical: 10, }} > - + {status.name} @@ -117,8 +116,23 @@ export function KanbanBoard({ const pointerY = useSharedValue(0); const dragging = useSharedValue(false); - const [boardWidth, setBoardWidth] = useState(0); + const [measuredWidth, setMeasuredWidth] = useState(0); const [boardOriginX, setBoardOriginX] = useState(0); + + /** + * The width the columns are sized from. + * + * A tab screen can be laid out while it is still detached, which fires + * onLayout once at zero and never again -- and a zero width collapses every + * column, and so every card, to nothing. So the window is the source, less + * the nav rail where there is one, and a real measurement refines it if one + * ever arrives. + */ + const { width: windowWidth } = useWindowDimensions(); + const boardWidth = + measuredWidth > 0 + ? measuredWidth + : windowWidth - (sizeClass === 'compact' ? 0 : RAIL_WIDTH); const [lifted, setLifted] = useState(null); const [collapsed, setCollapsed] = useState>(() => Object.fromEntries( @@ -133,7 +147,7 @@ export function KanbanBoard({ const bounds = useRef([]); const boardRef = useRef(null); - const width = boardWidth > 0 ? columnWidth(sizeClass, boardWidth) : 0; + const width = columnWidth(sizeClass, boardWidth); const scrollHandler = useAnimatedScrollHandler((event) => { scrollX.set(event.contentOffset.x); @@ -207,13 +221,16 @@ export function KanbanBoard({ { - // Screen-space origin, so the gesture's absoluteX can be converted into - // the content coordinates the columns were measured in. - boardRef.current?.measureInWindow((x, _y, w) => { - setBoardOriginX(x); - setBoardWidth(w); - }); + onLayout={(event) => { + // Width comes from the layout event, which carries it synchronously. + // It used to come from measureInWindow, which is asynchronous and + // resolves to zero on web. + setMeasuredWidth(event.nativeEvent.layout.width); + + // The screen-space origin is only needed to convert a drag's absoluteX + // into content coordinates, so it may arrive late or not at all; zero + // is correct whenever the board starts at the left edge. + boardRef.current?.measureInWindow((x) => setBoardOriginX(x)); }} > - + {columnIssues.length} ) : ( diff --git a/src/issues/detail/issue-picker.tsx b/src/issues/detail/issue-picker.tsx index 124b71b..4abd8b0 100644 --- a/src/issues/detail/issue-picker.tsx +++ b/src/issues/detail/issue-picker.tsx @@ -3,7 +3,7 @@ import { Pressable, View } from 'react-native'; import { useListIssuesTeamsTeamIdIssuesGet } from '@/api/generated/endpoints/issues/issues'; import type { IssueRead } from '@/api/generated/models'; -import { AppText, Field, Loading } from '@/ui/primitives'; +import { AppText, Dot, Field, Loading } from '@/ui/primitives'; import { Sheet } from '@/ui/sheet'; import { useTokens } from '@/ui/theme'; @@ -84,14 +84,7 @@ export function IssuePicker({ borderRadius: t.radius.control, }} > - + {issue.identifier} diff --git a/src/issues/detail/sections.tsx b/src/issues/detail/sections.tsx index 1bc6e30..409bd9c 100644 --- a/src/issues/detail/sections.tsx +++ b/src/issues/detail/sections.tsx @@ -20,7 +20,7 @@ import { errorDetail } from '@/api/errors'; import { IssuePicker } from '@/issues/detail/issue-picker'; import { Icon } from '@/ui/icon'; import { href } from '@/ui/href'; -import { Alert, AppText, Button, Card, Field } from '@/ui/primitives'; +import { Alert, AppText, Button, Card, Dot, Field } from '@/ui/primitives'; import { Sheet } from '@/ui/sheet'; import { useTokens } from '@/ui/theme'; @@ -67,9 +67,7 @@ function LinkedRow({ paddingHorizontal: 12, }} > - + {issue.identifier} diff --git a/src/issues/property-picker.tsx b/src/issues/property-picker.tsx index 28556f4..a20a6cf 100644 --- a/src/issues/property-picker.tsx +++ b/src/issues/property-picker.tsx @@ -2,7 +2,7 @@ import { useState, type ReactNode } from 'react'; import { Pressable, View } from 'react-native'; import { Icon } from '@/ui/icon'; -import { AppText, Button, Field } from '@/ui/primitives'; +import { AppText, Button, Dot, Field } from '@/ui/primitives'; import { Sheet } from '@/ui/sheet'; import { useTokens } from '@/ui/theme'; @@ -52,9 +52,7 @@ export function PropertyRow({ {label} - {color ? ( - - ) : null} + {color ? : null} - {color ? ( - - ) : null} + {color ? : null} {label} diff --git a/src/markdown/markdown.tsx b/src/markdown/markdown.tsx index 4a22576..628adb2 100644 --- a/src/markdown/markdown.tsx +++ b/src/markdown/markdown.tsx @@ -132,6 +132,10 @@ function TaskList({ key={index} accessibilityRole="checkbox" accessibilityState={{ checked: item.checked === true }} + // react-native-web does not map accessibilityState.checked onto + // aria-checked, so a screen reader on the web build would hear a + // checkbox with no state. Native reads the former, web the latter. + aria-checked={item.checked === true} accessibilityLabel={item.text} disabled={!isTask || !onToggleTask} onPress={() => onToggleTask?.(taskIndex)} diff --git a/src/offline/__tests__/live-queue.test.ts b/src/offline/__tests__/live-queue.test.ts new file mode 100644 index 0000000..d673cae --- /dev/null +++ b/src/offline/__tests__/live-queue.test.ts @@ -0,0 +1,198 @@ +/** + * @jest-environment node + */ +import { MutationCache, QueryClient, onlineManager } from '@tanstack/react-query'; + +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { listCommentsIssuesIssueIdCommentsGet } from '@/api/generated/endpoints/comments/comments'; +import { + createIssueTeamsTeamIdIssuesPost, + deleteIssueIssuesIssueIdDelete, + getIssueIssuesIssueIdGet, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; +import type { IssueRead, StatusRead } from '@/api/generated/models'; +import { setInstanceUrl } from '@/api/instance'; +import { persistToken } from '@/auth/session'; +import { QUEUED, MoveConflict, registerQueuedMutations } from '@/offline/queue'; + +/** + * The offline queue against a running instance. + * + * The unit tests cover the conflict rule in isolation; what they cannot show is + * that React Query actually pauses these mutations when `onlineManager` goes + * down and replays them on the way back up. That round trip is the whole + * feature, so it is worth exercising against a real server. + * + * Every test works on an issue it created itself. The seeded issues are shared + * with every other live suite, and these assertions turn on an issue *not* + * moving while the network is down -- which another suite moving it in parallel + * would break for reasons that have nothing to do with the queue. + * + * SOFTTRACK_URL=http://localhost:8000 SOFTTRACK_EMAIL=... SOFTTRACK_PASSWORD=... npm test + */ +const URL_ = process.env.SOFTTRACK_URL; +const EMAIL = process.env.SOFTTRACK_EMAIL; +const PASSWORD = process.env.SOFTTRACK_PASSWORD; +const describeLive = URL_ && EMAIL && PASSWORD ? describe : describe.skip; + +const TEAM_ID = 1; + +describeLive('the offline queue, live', () => { + let client: QueryClient; + let statuses: StatusRead[]; + const scratch: number[] = []; + + beforeAll(async () => { + await setInstanceUrl(URL_!); + const token = await loginAuthLoginPost({ username: EMAIL!, password: PASSWORD! }); + await persistToken(token.access_token); + statuses = await listStatusesTeamsTeamIdStatusesGet(TEAM_ID); + }); + + beforeEach(() => { + client = new QueryClient({ + mutationCache: new MutationCache(), + // Settled mutations otherwise sit in the cache behind a five-minute + // garbage-collection timer, which keeps the test runner alive. + defaultOptions: { mutations: { gcTime: 0 } }, + }); + registerQueuedMutations(client); + // Without mounting, the client never subscribes to `onlineManager` and a + // paused mutation is never resumed -- which is also true in the app, and is + // what `QueryClientProvider` does for it. + client.mount(); + onlineManager.setOnline(true); + }); + + afterEach(() => { + onlineManager.setOnline(true); + client.unmount(); + client.getMutationCache().clear(); + }); + + afterAll(async () => { + for (const id of scratch) await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + }); + + /** An issue nothing else is touching. */ + async function scratchIssue(title: string): Promise { + const issue = await createIssueTeamsTeamIdIssuesPost(TEAM_ID, { title }); + scratch.push(issue.id); + return issue; + } + + function queue(key: readonly unknown[]) { + return client.getMutationCache().build(client, { mutationKey: [...key] }); + } + + it('holds a move while there is no network and sends it on reconnect', async () => { + const issue = await scratchIssue('Offline queue: a move that waits'); + const target = statuses.find((s) => s.id !== issue.status.id)!; + + onlineManager.setOnline(false); + const mutation = queue(QUEUED.moveIssue); + const settled = mutation.execute({ + issueId: issue.id, + status: target, + seenAt: issue.updated_at, + identifier: issue.identifier, + }); + + // Paused rather than failed, and genuinely not sent. + await new Promise((r) => setTimeout(r, 150)); + expect(mutation.state.isPaused).toBe(true); + expect((await getIssueIssuesIssueIdGet(issue.id)).status.id).toBe(issue.status.id); + + onlineManager.setOnline(true); + await settled; + + expect(mutation.state.isPaused).toBe(false); + expect((await getIssueIssuesIssueIdGet(issue.id)).status.id).toBe(target.id); + }, 30_000); + + it('refuses a queued move when somebody else moved it first', async () => { + const issue = await scratchIssue('Offline queue: a move somebody overtook'); + const mine = statuses.find((s) => s.id !== issue.status.id)!; + const theirs = statuses.find((s) => s.id !== issue.status.id && s.id !== mine.id)!; + + onlineManager.setOnline(false); + const mutation = queue(QUEUED.moveIssue); + const settled = mutation.execute({ + issueId: issue.id, + status: mine, + seenAt: issue.updated_at, + identifier: issue.identifier, + }); + await new Promise((r) => setTimeout(r, 100)); + expect(mutation.state.isPaused).toBe(true); + + // Somebody else, on the web, moves it somewhere else entirely -- while this + // client is still offline. A direct call, because their browser is not + // subject to this client's `onlineManager`. + await updateIssueIssuesIssueIdPatch(issue.id, { status_id: theirs.id }); + + onlineManager.setOnline(true); + await expect(settled).rejects.toBeInstanceOf(MoveConflict); + // Their move stands; the queued one is dropped rather than applied on top. + expect((await getIssueIssuesIssueIdGet(issue.id)).status.id).toBe(theirs.id); + }, 30_000); + + it('applies a queued move when somebody else made the same one', async () => { + const issue = await scratchIssue('Offline queue: a move somebody agreed with'); + const target = statuses.find((s) => s.id !== issue.status.id)!; + + onlineManager.setOnline(false); + const mutation = queue(QUEUED.moveIssue); + const settled = mutation.execute({ + issueId: issue.id, + status: target, + seenAt: issue.updated_at, + identifier: issue.identifier, + }); + await new Promise((r) => setTimeout(r, 100)); + + await updateIssueIssuesIssueIdPatch(issue.id, { status_id: target.id }); + + onlineManager.setOnline(true); + // Landing where it was already headed is not a conflict. + await expect(settled).resolves.toBeDefined(); + expect((await getIssueIssuesIssueIdGet(issue.id)).status.id).toBe(target.id); + }, 30_000); + + it('holds a comment and an issue creation, then sends both', async () => { + const issue = await scratchIssue('Offline queue: something to say'); + const body = 'Written in a tunnel, sent on the way out.'; + + onlineManager.setOnline(false); + const comment = queue(QUEUED.addComment); + const create = queue(QUEUED.createIssue); + const commentDone = comment.execute({ + issueId: issue.id, + body, + identifier: issue.identifier, + }); + const createDone = create.execute({ + teamId: TEAM_ID, + title: 'Offline queue: captured while away', + }); + + await new Promise((r) => setTimeout(r, 150)); + expect(comment.state.isPaused).toBe(true); + expect(create.state.isPaused).toBe(true); + expect((await listCommentsIssuesIssueIdCommentsGet(issue.id, { limit: 50 })).items).toEqual([]); + + onlineManager.setOnline(true); + await Promise.all([commentDone, createDone]); + + const comments = await listCommentsIssuesIssueIdCommentsGet(issue.id, { limit: 50 }); + expect(comments.items.map((c) => c.body)).toContain(body); + + const created = create.state.data as IssueRead; + scratch.push(created.id); + expect((await getIssueIssuesIssueIdGet(created.id)).title).toBe( + 'Offline queue: captured while away', + ); + }, 30_000); +}); diff --git a/src/search/search-screen.tsx b/src/search/search-screen.tsx index 5cc8aa2..b07cb06 100644 --- a/src/search/search-screen.tsx +++ b/src/search/search-screen.tsx @@ -8,7 +8,7 @@ import type { SearchHit } from '@/api/generated/models'; import { useTeams } from '@/team/team-context'; import { href } from '@/ui/href'; import { useIsMultiPane } from '@/ui/layout'; -import { AppText, Card, Field, Loading, TeamBadge } from '@/ui/primitives'; +import { AppText, Card, Dot, Field, Loading, TeamBadge } from '@/ui/primitives'; import { Sheet } from '@/ui/sheet'; import { useDebouncedValue } from '@/ui/use-debounced-value'; import { useTokens } from '@/ui/theme'; @@ -157,14 +157,7 @@ export function SearchScreen() { {item.identifier} - + {matchedInLabel(item)} diff --git a/src/storage/secure.ts b/src/storage/secure.ts index a14445b..bd89669 100644 --- a/src/storage/secure.ts +++ b/src/storage/secure.ts @@ -1,3 +1,4 @@ +import { Platform } from 'react-native'; import * as SecureStore from 'expo-secure-store'; /** @@ -7,10 +8,21 @@ import * as SecureStore from 'expo-secure-store'; * recover the session, which is the same outcome as having no session. Callers * treat `null` that way, so swallowing is still correct -- but a *write* failure * would silently sign the user out on next launch, so it is allowed to throw. + * + * SecureStore has no web implementation, so on the web target the token falls + * back to localStorage. That is not the Keychain, but the web build exists for + * development and review against a real instance, not for shipping secrets to a + * phone -- and it is the same store the web app itself keeps its token in. */ +const isWeb = Platform.OS === 'web'; + +function webStorage(): Storage | null { + return typeof window !== 'undefined' ? window.localStorage : null; +} export async function readSecret(key: string): Promise { try { + if (isWeb) return webStorage()?.getItem(key) ?? null; return await SecureStore.getItemAsync(key); } catch { return null; @@ -18,11 +30,19 @@ export async function readSecret(key: string): Promise { } export async function writeSecret(key: string, value: string): Promise { + if (isWeb) { + webStorage()?.setItem(key, value); + return; + } await SecureStore.setItemAsync(key, value); } export async function deleteSecret(key: string): Promise { try { + if (isWeb) { + webStorage()?.removeItem(key); + return; + } await SecureStore.deleteItemAsync(key); } catch { // Already gone, or the store is unavailable; either way there is nothing diff --git a/src/team/admin/statuses-panel.tsx b/src/team/admin/statuses-panel.tsx index 8297ab6..e3ddd6a 100644 --- a/src/team/admin/statuses-panel.tsx +++ b/src/team/admin/statuses-panel.tsx @@ -12,7 +12,7 @@ import { import { StatusCategory, type StatusRead, type TeamRead } from '@/api/generated/models'; import { errorDetail } from '@/api/errors'; import { Icon } from '@/ui/icon'; -import { Alert, AppText, Button, Card, Field } from '@/ui/primitives'; +import { Alert, AppText, Button, Card, Dot, Field } from '@/ui/primitives'; import { Sheet } from '@/ui/sheet'; import { useTokens } from '@/ui/theme'; @@ -136,9 +136,7 @@ export function StatusesPanel({ key={status.id} style={{ flexDirection: 'row', alignItems: 'center', gap: 10, padding: 12 }} > - + {status.name} diff --git a/src/team/team-context.tsx b/src/team/team-context.tsx index f8d5062..c61b164 100644 --- a/src/team/team-context.tsx +++ b/src/team/team-context.tsx @@ -9,6 +9,7 @@ import { import { useListMyTeamsTeamsGet } from '@/api/generated/endpoints/teams/teams'; import type { TeamRead } from '@/api/generated/models'; +import { useAccessToken } from '@/auth/session'; import { ACTIVE_TEAM_KEY } from '@/storage/keys'; import { readPref, writePref } from '@/storage/prefs'; @@ -44,8 +45,21 @@ export function TeamProvider({ children: ReactNode; }) { const [storedKey, setStoredKey] = useState(initialTeamKey); - const teamsQuery = useListMyTeamsTeamsGet({ query: { staleTime: 30_000 } }); - const teams = useMemo(() => teamsQuery.data ?? [], [teamsQuery.data]); + // Gated on a session existing: this provider mounts outside the auth guard, + // and an ungated query fires GET /teams before anyone has signed in -- on + // web that relative request even "succeeds" against the dev server and hands + // back HTML where an array belongs. + const token = useAccessToken(); + const teamsQuery = useListMyTeamsTeamsGet({ + query: { staleTime: 30_000, enabled: Boolean(token) }, + }); + // Never trust the shape blindly: the cache is persisted for a week, and this + // provider sits above the error boundary -- a poisoned entry restored from + // storage must degrade to "no teams", not brick the whole app at boot. + const teams = useMemo( + () => (Array.isArray(teamsQuery.data) ? teamsQuery.data : []), + [teamsQuery.data], + ); const setTeamKey = useCallback((key: string) => { setStoredKey(key); diff --git a/src/ui/__tests__/color.test.ts b/src/ui/__tests__/color.test.ts new file mode 100644 index 0000000..020df62 --- /dev/null +++ b/src/ui/__tests__/color.test.ts @@ -0,0 +1,45 @@ +import { resolveColor } from '@/ui/color'; +import { dark, light } from '@/ui/tokens'; + +describe('resolveColor', () => { + it('passes through what React Native can already paint', () => { + expect(resolveColor('#6342db', light)).toBe('#6342db'); + expect(resolveColor('#FFF', light)).toBe('#FFF'); + expect(resolveColor('rgba(0,0,0,0.5)', light)).toBe('rgba(0,0,0,0.5)'); + expect(resolveColor('tomato', light)).toBe('tomato'); + }); + + it('resolves the CSS variable production actually stores', () => { + // A user-created status on the production instance carries exactly this, + // and React Native renders it as nothing. + expect(resolveColor('var(--color-status-progress)', light)).toBe(light.status.progress); + }); + + it('resolves every family the web can emit', () => { + expect(resolveColor('var(--color-status-done)', light)).toBe(light.status.done); + expect(resolveColor('var(--color-priority-urgent)', light)).toBe(light.priority.urgent); + expect(resolveColor('var(--color-accent-sky)', light)).toBe(light.accent.sky); + expect(resolveColor('var(--color-brand-600)', light)).toBe(light.brand[600]); + expect(resolveColor('var(--color-neutral-400)', light)).toBe(light.neutral[400]); + }); + + it('resolves against the active theme, not a fixed palette', () => { + // The neutral ramp inverts in dark, so the same variable is a different + // colour -- which is the whole reason this takes tokens. + expect(resolveColor('var(--color-neutral-900)', light)).toBe(light.neutral[900]); + expect(resolveColor('var(--color-neutral-900)', dark)).toBe(dark.neutral[900]); + expect(light.neutral[900]).not.toBe(dark.neutral[900]); + }); + + it('honours a fallback inside the variable syntax by ignoring it safely', () => { + expect(resolveColor('var(--color-status-done, #fff)', light)).toBe(light.status.done); + }); + + it('falls back visibly rather than to nothing', () => { + // An unknown variable, junk, or a missing value must still paint something. + for (const input of ['var(--color-unknown-thing)', 'var(--nonsense)', '', null, undefined, '#nothex']) { + expect(resolveColor(input as string, light)).toBe(light.neutral[400]); + } + expect(resolveColor(null, light, light.brand[600])).toBe(light.brand[600]); + }); +}); diff --git a/src/ui/color.ts b/src/ui/color.ts new file mode 100644 index 0000000..5ad90cb --- /dev/null +++ b/src/ui/color.ts @@ -0,0 +1,88 @@ +import type { Tokens } from '@/ui/tokens'; + +/** + * Turning a colour the server stored into one React Native can paint. + * + * Statuses, labels and projects carry a colour chosen through the web app, and + * the web is happy to store a CSS custom property -- production has a + * user-created status whose colour is the literal string + * `var(--color-status-progress)`. A browser resolves that; React Native does + * not, and hands back an invisible or black swatch instead. + * + * So every server-provided colour is resolved here rather than passed straight + * to a style. Known variables map onto the same token the web would have + * resolved them to, which is what keeps the two clients showing the same + * colour; anything unrecognisable falls back to a visible neutral rather than + * to nothing. + */ +const VAR_PATTERN = /^var\(\s*(--[a-z0-9-]+)\s*(?:,[^)]*)?\)$/i; +const HEX_PATTERN = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i; + +function fromVariable(name: string, t: Tokens): string | null { + const key = name.replace(/^--color-/, ''); + + const status: Record = { + 'status-backlog': t.status.backlog, + 'status-todo': t.status.todo, + 'status-progress': t.status.progress, + 'status-review': t.status.review, + 'status-done': t.status.done, + 'status-cancelled': t.status.cancelled, + }; + if (status[key]) return status[key]; + + const priority: Record = { + 'priority-urgent': t.priority.urgent, + 'priority-high': t.priority.high, + 'priority-medium': t.priority.medium, + 'priority-low': t.priority.low, + 'priority-none': t.priority.none, + }; + if (priority[key]) return priority[key]; + + const accent: Record = { + 'accent-sky': t.accent.sky, + 'accent-pink': t.accent.pink, + 'accent-mint': t.accent.mint, + 'accent-amber': t.accent.amber, + }; + if (accent[key]) return accent[key]; + + const ramp = /^(brand|neutral|danger)-(\d{2,3})$/.exec(key); + if (ramp) { + const [, family, step] = ramp; + const shades = + family === 'brand' ? t.brand : family === 'neutral' ? t.neutral : t.danger; + const value = (shades as Record)[step]; + if (value) return value; + } + + return null; +} + +/** + * A paintable colour for `raw`, or the fallback when there is nothing usable. + * + * `rgb()`/`rgba()` are passed through because React Native understands them; + * only CSS variables need translating. + */ +export function resolveColor( + raw: string | null | undefined, + t: Tokens, + fallback?: string, +): string { + const missing = fallback ?? t.neutral[400]; + if (!raw) return missing; + + const value = raw.trim(); + if (HEX_PATTERN.test(value)) return value; + if (/^rgba?\(/i.test(value)) return value; + + const variable = VAR_PATTERN.exec(value); + if (variable) return fromVariable(variable[1], t) ?? missing; + + // A bare CSS colour keyword is still something React Native can paint. + if (/^[a-z]+$/i.test(value)) return value; + + return missing; +} diff --git a/src/ui/primitives.tsx b/src/ui/primitives.tsx index b62b688..f4d85b9 100644 --- a/src/ui/primitives.tsx +++ b/src/ui/primitives.tsx @@ -12,6 +12,7 @@ import { } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; +import { resolveColor } from '@/ui/color'; import { useTokens } from '@/ui/theme'; import type { Tokens } from '@/ui/tokens'; @@ -325,6 +326,7 @@ export function TeamBadge({ teamKey, size = 44 }: { teamKey: string; size?: numb /** Circular initials on the server-supplied `avatar_color`. */ export function Avatar({ name, color, size = 40 }: { name: string; color: string; size?: number }) { + const t = useTokens(); const initials = name .split(/\s+/) .filter(Boolean) @@ -338,7 +340,7 @@ export function Avatar({ name, color, size = 40 }: { name: string; color: string width: size, height: size, borderRadius: 999, - backgroundColor: color, + backgroundColor: resolveColor(color, t, t.brand[600]), alignItems: 'center', justifyContent: 'center', }} @@ -360,7 +362,8 @@ export function Avatar({ name, color, size = 40 }: { name: string; color: string */ export function Chip({ label, color }: { label: string; color: string }) { const t = useTokens(); - const rgb = color.startsWith('#') ? hexToRgb(color) : null; + const resolved = resolveColor(color, t); + const rgb = resolved.startsWith('#') ? hexToRgb(resolved) : null; return ( - + {label} @@ -394,3 +397,33 @@ export function RoleChip({ role }: { role: string }) { /> ); } + +/** + * The coloured dot that stands for a status, label or project. + * + * Colours arrive from the server and are not always paintable -- the web will + * happily store a CSS custom property -- so every one goes through + * `resolveColor` on the way to a style. + */ +export function Dot({ + color, + size = 8, + label, +}: { + color: string | null | undefined; + size?: number; + label?: string; +}) { + const t = useTokens(); + return ( + + ); +} diff --git a/src/ui/theme-control.tsx b/src/ui/theme-control.tsx index 8f7e991..a08fd8a 100644 --- a/src/ui/theme-control.tsx +++ b/src/ui/theme-control.tsx @@ -60,7 +60,9 @@ export function ThemePreferenceControl() { key={option.value} onPress={() => setPreference(option.value)} accessibilityRole="button" + accessibilityLabel={option.label} accessibilityState={active ? { selected: true } : {}} + aria-selected={active} style={{ flex: 1, paddingVertical: 8,