From ee875f8eb4e0a148efdc5cade8c02681dd7c537d Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Sat, 12 Sep 2026 00:01:58 +0500 Subject: [PATCH 1/5] Fix a board whose columns rendered two pixels wide Found by running the app rather than by any test. The board sized its columns from a width obtained with measureInWindow, which is asynchronous and never resolves on web -- and react-navigation lays a tab screen out while it is still detached, so the one onLayout that does fire reports zero. A zero width made every column, and so every card, zero pixels wide: the whole board was in the DOM and none of it was visible. Width now comes from the window less the nav rail, which is always known, and a real measurement refines it if one arrives. Verified live at all three size classes: 320px columns on a 390 phone (neighbours peeking), exactly two 306px columns beside the rail at 700, exactly four 292px at 1280. Also from running it: SecureStore has no web implementation, so signing in on the web target threw and the token was never stored. It falls back to localStorage there -- the same store the web app keeps its own token in. The web build is for development and review against a real instance, which is precisely what it could not do. The teams query fired before anyone had signed in, because TeamProvider mounts outside the auth guard. It is gated on a session now, and the team list is treated as untrusted on the way out of a week-long persisted cache: a poisoned entry degraded to a crash at boot above the error boundary. react-native-web does not map accessibilityState.checked onto aria-checked, so a task list on the web build announced checkboxes with no state. Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 11 +- package-lock.json | 183 ++++++++++++++++++++++- package.json | 1 + src/board/__tests__/column-width.test.ts | 43 ++++++ src/board/kanban-board.tsx | 39 +++-- src/markdown/markdown.tsx | 4 + src/storage/secure.ts | 20 +++ src/team/team-context.tsx | 18 ++- 8 files changed, 299 insertions(+), 20 deletions(-) create mode 100644 src/board/__tests__/column-width.test.ts 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/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..883218d 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,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/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/kanban-board.tsx b/src/board/kanban-board.tsx index 5ad5f6f..08d75ac 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,6 +20,7 @@ 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'; @@ -117,8 +118,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 +149,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 +223,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)); }} > onToggleTask?.(taskIndex)} 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/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); From 19efa623fce18b0d9a35cdb9e36947a4615fba60 Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Sat, 12 Sep 2026 00:08:03 +0500 Subject: [PATCH 2/5] Label the theme options so they can be operated and announced The three appearance options carried no accessible label. Every other control in the app has one, and without it a screen reader announces three unnamed buttons -- and nothing automated can drive them either, which is how this was found. Also sets aria-selected, since react-native-web does not map accessibilityState.selected onto it, the same gap the task checkboxes had. Co-Authored-By: Claude Opus 5 --- src/ui/theme-control.tsx | 2 ++ 1 file changed, 2 insertions(+) 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, From 3ffa4b04037268ba5361fce9cb1ae2901314e26c Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Sat, 12 Sep 2026 00:17:07 +0500 Subject: [PATCH 3/5] Paint status colours the web stores as CSS variables A status created through the web UI stores its colour as `var(--color-status-progress)` rather than a hex value -- the production instance has one, "In QA". A browser resolves that against the stylesheet; React Native hands it to the native layer, which cannot, so the dot renders as nothing. Seeded statuses carry hex and hid this. `resolveColor` maps the variable back onto the active theme's tokens, which also makes these dots follow light and dark rather than freezing at whatever the web's light palette happened to be. Anything already paintable passes through untouched, and an unrecognised value falls back to a visible grey instead of disappearing. Routed through a `Dot` primitive so every status and label dot resolves the same way, along with `Avatar` and `Chip`. Co-Authored-By: Claude Opus 5 --- src/api/__tests__/live-production.test.ts | 216 ++++++++++++++++++++++ src/board/board-screen.tsx | 11 +- src/board/filter-sheet.tsx | 13 +- src/board/issue-card.tsx | 3 +- src/board/issue-list.tsx | 7 +- src/board/kanban-board.tsx | 15 +- src/issues/detail/issue-picker.tsx | 11 +- src/issues/detail/sections.tsx | 6 +- src/issues/property-picker.tsx | 10 +- src/search/search-screen.tsx | 11 +- src/team/admin/statuses-panel.tsx | 6 +- src/ui/__tests__/color.test.ts | 45 +++++ src/ui/color.ts | 88 +++++++++ src/ui/primitives.tsx | 39 +++- 14 files changed, 407 insertions(+), 74 deletions(-) create mode 100644 src/api/__tests__/live-production.test.ts create mode 100644 src/ui/__tests__/color.test.ts create mode 100644 src/ui/color.ts 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/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 08d75ac..1dd79b6 100644 --- a/src/board/kanban-board.tsx +++ b/src/board/kanban-board.tsx @@ -24,7 +24,7 @@ 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. */ @@ -59,9 +59,7 @@ function ColumnHeader({ paddingVertical: 10, }} > - + {status.name} @@ -283,14 +281,7 @@ export function KanbanBoard({ } style={{ flex: 1, alignItems: 'center', paddingVertical: 12, gap: 8 }} > - + {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/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/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/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 ( + + ); +} From 54372e057b5adf4de83c69c1807d567f53762dc4 Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Sat, 12 Sep 2026 00:45:07 +0500 Subject: [PATCH 4/5] Cover the offline queue's pause and replay against a real server The existing tests cover the conflict rule in isolation, which leaves the part that actually carries the feature untested: that React Query pauses these mutations when the network goes away and replays them when it returns. Three round trips against a running instance -- a move, a comment and an issue creation -- asserting the request is genuinely withheld while offline rather than merely failing, and that a move somebody else has overtaken is refused instead of applied on top of their work. The client is mounted, because an unmounted one never subscribes to `onlineManager` and so never resumes anything; and mutation `gcTime` is zero, because a settled mutation otherwise sits behind a five-minute collection timer that keeps the runner alive after the last assertion. Co-Authored-By: Claude Opus 5 --- src/offline/__tests__/live-queue.test.ts | 169 +++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/offline/__tests__/live-queue.test.ts diff --git a/src/offline/__tests__/live-queue.test.ts b/src/offline/__tests__/live-queue.test.ts new file mode 100644 index 0000000..20699df --- /dev/null +++ b/src/offline/__tests__/live-queue.test.ts @@ -0,0 +1,169 @@ +/** + * @jest-environment node + */ +import { MutationCache, QueryClient, onlineManager } from '@tanstack/react-query'; + +import { loginAuthLoginPost } from '@/api/generated/endpoints/auth/auth'; +import { + deleteIssueIssuesIssueIdDelete, + getIssueIssuesIssueIdGet, + listIssuesTeamsTeamIdIssuesGet, + updateIssueIssuesIssueIdPatch, +} from '@/api/generated/endpoints/issues/issues'; +import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; +import { listCommentsIssuesIssueIdCommentsGet } from '@/api/generated/endpoints/comments/comments'; +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. + * + * 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; + +describeLive('the offline queue, live', () => { + let client: QueryClient; + let teamId: number; + const scratch: number[] = []; + + beforeAll(async () => { + await setInstanceUrl(URL_!); + const token = await loginAuthLoginPost({ username: EMAIL!, password: PASSWORD! }); + await persistToken(token.access_token); + teamId = 1; + }); + + 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(() => { + client.unmount(); + client.getMutationCache().clear(); + }); + + afterAll(async () => { + onlineManager.setOnline(true); + for (const id of scratch) await deleteIssueIssuesIssueIdDelete(id).catch(() => undefined); + }); + + it('holds a move while there is no network and sends it on reconnect', async () => { + const statuses = await listStatusesTeamsTeamIdStatusesGet(teamId); + const page = await listIssuesTeamsTeamIdIssuesGet(teamId, { limit: 1 }); + const issue = page.items[0]; + const target = statuses.find((s) => s.id !== issue.status.id)!; + const before = issue.status; + + onlineManager.setOnline(false); + + const mutation = client + .getMutationCache() + .build(client, { mutationKey: QUEUED.moveIssue }); + const settled = mutation.execute({ + issueId: issue.id, + status: target, + seenAt: issue.updated_at, + identifier: issue.identifier, + }); + + // Paused, not failed, and not sent. + await new Promise((r) => setTimeout(r, 150)); + expect(mutation.state.isPaused).toBe(true); + expect((await getIssueIssuesIssueIdGet(issue.id)).status.id).toBe(before.id); + + onlineManager.setOnline(true); + await settled; + + expect(mutation.state.isPaused).toBe(false); + expect((await getIssueIssuesIssueIdGet(issue.id)).status.id).toBe(target.id); + + // Put it back where it was found. + const restore = client.getMutationCache().build(client, { mutationKey: QUEUED.moveIssue }); + const now = await getIssueIssuesIssueIdGet(issue.id); + await restore.execute({ + issueId: issue.id, + status: before, + seenAt: now.updated_at, + identifier: issue.identifier, + }); + }, 30_000); + + it('refuses a queued move when somebody else moved it first', async () => { + const statuses = await listStatusesTeamsTeamIdStatusesGet(teamId); + const page = await listIssuesTeamsTeamIdIssuesGet(teamId, { limit: 1 }); + const issue = page.items[0]; + const mine = statuses.find((s) => s.id !== issue.status.id)!; + const theirs = statuses.find((s) => s.id !== issue.status.id && s.id !== mine.id)!; + const before = issue.status; + + onlineManager.setOnline(false); + const mutation = client.getMutationCache().build(client, { mutationKey: 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); + + await updateIssueIssuesIssueIdPatch(issue.id, { status_id: before.id }); + }, 30_000); + + it('holds a comment and an issue creation, then sends both', async () => { + const page = await listIssuesTeamsTeamIdIssuesGet(teamId, { limit: 1 }); + const issue = page.items[0]; + const body = `Queued while offline ${Date.now().toString(36).slice(-5)}`; + + onlineManager.setOnline(false); + const comment = client.getMutationCache().build(client, { mutationKey: QUEUED.addComment }); + const create = client.getMutationCache().build(client, { mutationKey: QUEUED.createIssue }); + const commentDone = comment.execute({ issueId: issue.id, body, identifier: issue.identifier }); + const createDone = create.execute({ teamId, title: `Queued offline ${body.slice(-5)}` }); + + await new Promise((r) => setTimeout(r, 150)); + expect(comment.state.isPaused).toBe(true); + expect(create.state.isPaused).toBe(true); + + 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 { id: number }; + scratch.push(created.id); + expect((await getIssueIssuesIssueIdGet(created.id)).id).toBe(created.id); + }, 30_000); +}); From 62102821a9fa15a390cba8b5a91974f09624d336 Mon Sep 17 00:00:00 2001 From: Waleed Khalid Date: Sat, 12 Sep 2026 01:35:42 +0500 Subject: [PATCH 5/5] Keep the live suites from tripping over each other Running them all against one instance surfaced two things that look like product failures and are not. They pick their fixture team by position, and `GET /teams` promises no order. 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 for good -- twenty of them on the local stack by now. `teams[0]` eventually lands on one, and the board suite fails with an empty page. `seededTeam()` asks for the seeded key instead, falling back to any team that actually has issues. They also cannot run in parallel. Several register accounts, registration is throttled per IP and charged even on success, and concurrent suites exhaust the budget at once. The 429 that comes back is an AxiosError whose circular req/res pair crashes the jest worker trying to serialize it, so it surfaces as `Test suite failed to run` with no mention of throttling -- which is what made this worth a note in the README rather than a shrug. `npm run test:live` runs them in band. Co-Authored-By: Claude Opus 5 --- README.md | 28 +++-- jest.config.js | 3 +- package.json | 1 + src/api/__tests__/live-attachments.test.ts | 4 +- src/api/__tests__/live-board.test.ts | 4 +- src/api/__tests__/live-comments.test.ts | 8 +- src/api/__tests__/live-cycles.test.ts | 4 +- src/api/__tests__/live-issue-detail.test.ts | 4 +- src/api/__tests__/live-issues.test.ts | 4 +- src/api/__tests__/live-notifications.test.ts | 4 +- src/api/__tests__/live-reports.test.ts | 4 +- src/api/__tests__/live-search.test.ts | 4 +- src/api/__tests__/live-views.test.ts | 4 +- src/api/__tests__/support/team.ts | 28 +++++ src/offline/__tests__/live-queue.test.ts | 109 ++++++++++++------- 15 files changed, 142 insertions(+), 71 deletions(-) create mode 100644 src/api/__tests__/support/team.ts 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.json b/package.json index 883218d..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" }, 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-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/offline/__tests__/live-queue.test.ts b/src/offline/__tests__/live-queue.test.ts index 20699df..d673cae 100644 --- a/src/offline/__tests__/live-queue.test.ts +++ b/src/offline/__tests__/live-queue.test.ts @@ -4,14 +4,15 @@ 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, - listIssuesTeamsTeamIdIssuesGet, updateIssueIssuesIssueIdPatch, } from '@/api/generated/endpoints/issues/issues'; import { listStatusesTeamsTeamIdStatusesGet } from '@/api/generated/endpoints/statuses/statuses'; -import { listCommentsIssuesIssueIdCommentsGet } from '@/api/generated/endpoints/comments/comments'; +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'; @@ -24,6 +25,11 @@ import { QUEUED, MoveConflict, registerQueuedMutations } from '@/offline/queue'; * 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; @@ -31,16 +37,18 @@ 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 teamId: number; + let statuses: StatusRead[]; const scratch: number[] = []; beforeAll(async () => { await setInstanceUrl(URL_!); const token = await loginAuthLoginPost({ username: EMAIL!, password: PASSWORD! }); await persistToken(token.access_token); - teamId = 1; + statuses = await listStatusesTeamsTeamIdStatusesGet(TEAM_ID); }); beforeEach(() => { @@ -59,27 +67,32 @@ describeLive('the offline queue, live', () => { }); afterEach(() => { + onlineManager.setOnline(true); client.unmount(); client.getMutationCache().clear(); }); afterAll(async () => { - onlineManager.setOnline(true); 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 statuses = await listStatusesTeamsTeamIdStatusesGet(teamId); - const page = await listIssuesTeamsTeamIdIssuesGet(teamId, { limit: 1 }); - const issue = page.items[0]; + const issue = await scratchIssue('Offline queue: a move that waits'); const target = statuses.find((s) => s.id !== issue.status.id)!; - const before = issue.status; onlineManager.setOnline(false); - - const mutation = client - .getMutationCache() - .build(client, { mutationKey: QUEUED.moveIssue }); + const mutation = queue(QUEUED.moveIssue); const settled = mutation.execute({ issueId: issue.id, status: target, @@ -87,38 +100,25 @@ describeLive('the offline queue, live', () => { identifier: issue.identifier, }); - // Paused, not failed, and not sent. + // 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(before.id); + 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); - - // Put it back where it was found. - const restore = client.getMutationCache().build(client, { mutationKey: QUEUED.moveIssue }); - const now = await getIssueIssuesIssueIdGet(issue.id); - await restore.execute({ - issueId: issue.id, - status: before, - seenAt: now.updated_at, - identifier: issue.identifier, - }); }, 30_000); it('refuses a queued move when somebody else moved it first', async () => { - const statuses = await listStatusesTeamsTeamIdStatusesGet(teamId); - const page = await listIssuesTeamsTeamIdIssuesGet(teamId, { limit: 1 }); - const issue = page.items[0]; + 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)!; - const before = issue.status; onlineManager.setOnline(false); - const mutation = client.getMutationCache().build(client, { mutationKey: QUEUED.moveIssue }); + const mutation = queue(QUEUED.moveIssue); const settled = mutation.execute({ issueId: issue.id, status: mine, @@ -137,24 +137,51 @@ describeLive('the offline queue, live', () => { 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)!; - await updateIssueIssuesIssueIdPatch(issue.id, { status_id: before.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 page = await listIssuesTeamsTeamIdIssuesGet(teamId, { limit: 1 }); - const issue = page.items[0]; - const body = `Queued while offline ${Date.now().toString(36).slice(-5)}`; + 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 = client.getMutationCache().build(client, { mutationKey: QUEUED.addComment }); - const create = client.getMutationCache().build(client, { mutationKey: QUEUED.createIssue }); - const commentDone = comment.execute({ issueId: issue.id, body, identifier: issue.identifier }); - const createDone = create.execute({ teamId, title: `Queued offline ${body.slice(-5)}` }); + 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]); @@ -162,8 +189,10 @@ describeLive('the offline queue, live', () => { const comments = await listCommentsIssuesIssueIdCommentsGet(issue.id, { limit: 50 }); expect(comments.items.map((c) => c.body)).toContain(body); - const created = create.state.data as { id: number }; + const created = create.state.data as IssueRead; scratch.push(created.id); - expect((await getIssueIssuesIssueIdGet(created.id)).id).toBe(created.id); + expect((await getIssueIssuesIssueIdGet(created.id)).title).toBe( + 'Offline queue: captured while away', + ); }, 30_000); });