@stjoseph/prumo 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,8 @@ Do not mock `EntityManager` or any concrete class from a library.
15
15
 
16
16
  **In a client, replace the network through the injected transport**: pass a fake `fetch` to `createClient`.
17
17
  Do not add a request-mocking library. Test a component that shows an error by rendering it and asserting
18
- **where** the error appears.
18
+ **where** the error appears. Build every test's `QueryClient` with `createTestQueryClient()` from
19
+ `@test/support/query-client`, never with `new QueryClient()`: the defaults a test needs live there, once.
19
20
 
20
21
  Measure coverage and report it. Do not block a merge on a coverage threshold.
21
22
 
@@ -59,9 +60,18 @@ written in a mobile test is a mistake an assistant will make.
59
60
  is **asynchronous** and its matchers need no setup. `expo-secure-store` needs the native Keychain or Keystore,
60
61
  which Jest does not have, so a test builds the auth client with an in-memory token store, which is why a form
61
62
  asks only for the auth methods it calls. And `jest-expo` leaves the app manifest empty, so `expo-linking` cannot
62
- know the app's scheme: a test that reaches `Linking.createURL` mocks it with the scheme it expects. A test rendering a `useMutation` builds
63
- its `QueryClient` with `mutations: { gcTime: Infinity }`: once unmounted, the mutation otherwise starts TanStack
64
- Query's five-minute garbage-collection timer, and Jest force-exits the worker still waiting on it.
63
+ know the app's scheme: a test that reaches `Linking.createURL` mocks it with the scheme it expects. And a query
64
+ or mutation left without an observer once a test unmounts starts TanStack Query's five-minute garbage-collection
65
+ timer, which Jest's worker waits on until it is force-exited. `createTestQueryClient()` sets `gcTime: Infinity`
66
+ for both, so no timer starts, and `retry: false` for queries, so a failure shows at once: the two defaults TanStack
67
+ Query's testing guide gives. Vitest does not wait on the timer, and `web` uses the same helper so that one rule
68
+ holds in both.
69
+
70
+ **`web` turns route code splitting off under Vitest**, in `vite.config.ts`. Split, a route's component is a module
71
+ Vite transforms the first time a test renders it, inside the one second a `find*` query waits, and on a loaded CI
72
+ runner the first page of a test file missed it. The wait is not the problem and is not raised: with the transform
73
+ gone the same load passes, and past that load a longer wait fails the same tests anyway. **What it costs:** the
74
+ tests never load a route's lazy chunk; the build and its preview do.
65
75
 
66
76
  A coverage threshold turns a proxy into a target. Whoever is below writes tests to raise the number rather
67
77
  than to check anything; whoever is above stops thinking. With no database mocks a test costs more to write,
package/README.md CHANGED
@@ -60,7 +60,9 @@ committed, so the first commit is yours and the diff shows everything Prumo wrot
60
60
  writes into the current directory instead, which must be empty, and names the project after it. If
61
61
  anything fails or is interrupted, Prumo removes what it wrote, so the same command can run again.
62
62
  A project cannot be named `api`, `web`, `mobile` or `site`: those are the names its apps take inside
63
- a workspace.
63
+ a workspace. Nor can it take the name of a package that pnpm's compatibility database extends, such as `ws`:
64
+ pnpm applies the extension to the project's own `package.json` and installs what it adds. Prumo names
65
+ the packages and suggests another name.
64
66
 
65
67
  Every question also has a flag, which is how CI runs it without a terminal. Outside an interactive
66
68
  terminal every question that applies needs its flag, the layout of a single type included: a missing
@@ -138,6 +140,7 @@ Once written, those documents belong to the team. Prumo does not come back and r
138
140
  | [`docs/structure.md`](https://github.com/stjosephworks/prumo/blob/main/docs/structure.md) | The `.prumo/` layout, and the rule deciding which areas a project receives |
139
141
  | [`docs/document-template.md`](https://github.com/stjosephworks/prumo/blob/main/docs/document-template.md) | The shape every convention document fills |
140
142
  | [`docs/maintaining-templates.md`](https://github.com/stjosephworks/prumo/blob/main/docs/maintaining-templates.md) | How a template is changed, and how the change is proved |
143
+ | [`docs/upgrading.md`](https://github.com/stjosephworks/prumo/blob/main/docs/upgrading.md) | What each release changed in the files it generates, and how an existing project carries it over |
141
144
 
142
145
  ## Releasing
143
146
 
@@ -145,6 +148,14 @@ Once written, those documents belong to the team. Prumo does not come back and r
145
148
  registry does not have yet, the `publish` job sends it to npm through npm's trusted publishing, with provenance and
146
149
  without a token in the repository. Any other push to `main` publishes nothing.
147
150
 
151
+ ## Upgrading a project
152
+
153
+ Prumo does not update a project it generated, and there is no `prumo upgrade`. For each release that changed
154
+ the generated files, [`docs/upgrading.md`](https://github.com/stjosephworks/prumo/blob/main/docs/upgrading.md)
155
+ lists what to copy or change and why, and gives one method: generate a reference project with the same answers
156
+ at the old and the new version, and diff the files listed. A file your team has edited is merged by hand, never
157
+ overwritten.
158
+
148
159
  ## Status
149
160
 
150
161
  Prumo is in beta: every template runs and every generated project is verified in CI, and what a generated project looks like can still change before 1.0.
package/dist/names.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readdirSync, statSync } from 'node:fs';
2
2
  import { basename, resolve } from 'node:path';
3
+ import { EXTENDED_BY_PNPM } from "./pnpm-extended.js";
3
4
  const PROJECT_NAME = /^[a-z][a-z0-9-]*$/;
4
5
  // npm refuses a longer package name.
5
6
  const MAX_LENGTH = 214;
@@ -15,7 +16,18 @@ export function validateProjectName(name) {
15
16
  if (RESERVED.includes(name)) {
16
17
  return 'That is the name an app takes inside a workspace; choose another.';
17
18
  }
18
- return undefined;
19
+ return extendedByPnpm(name);
20
+ }
21
+ function extendedByPnpm(name) {
22
+ const added = Object.hasOwn(EXTENDED_BY_PNPM, name) ? EXTENDED_BY_PNPM[name] : undefined;
23
+ if (added === undefined) {
24
+ return undefined;
25
+ }
26
+ const shown = added.length > 3
27
+ ? `${added.slice(0, 3).join(', ')} and ${added.length - 3} more`
28
+ : added.join(', ');
29
+ return (`pnpm extends the npm package "${name}" with ${shown}, and would install them into a project of that name. ` +
30
+ `Choose another, such as "${name}-app".`);
19
31
  }
20
32
  // Why a directory cannot receive a project, or undefined when it can: it does not exist yet, or it is empty.
21
33
  export function targetProblem(target) {
@@ -0,0 +1,159 @@
1
+ // pnpm applies a compatibility database, @yarnpkg/extensions, to every manifest it reads: the registry's packages and
2
+ // the project's own package.json alike. An entry matches by name and version range, and the template version 0.0.1
3
+ // falls inside ranges like `ws@<7.2.1`. A project carrying one of these names therefore gains the dependencies the
4
+ // entry adds, installed into its root, and a package among them with a build script stops `pnpm install` with
5
+ // ERR_PNPM_IGNORED_BUILDS. Each name maps to the packages it would add.
6
+ //
7
+ // Derived from @yarnpkg/extensions, pinned in devDependencies, by test/names.spec.ts, which fails when this list and
8
+ // the database part ways. Only names a project can take are kept, and only entries that add a package: one that sets
9
+ // nothing but peerDependenciesMeta changes no install.
10
+ export const EXTENDED_BY_PNPM = {
11
+ 'apollo-upload-client': ['graphql'],
12
+ 'auto-relay': ['reflect-metadata'],
13
+ 'babel-plugin-graphql-tag': ['graphql'],
14
+ 'babel-plugin-remove-graphql-queries': ['@babel/runtime', '@babel/types', 'gatsby-core-utils'],
15
+ 'babel-plugin-transform-typescript-metadata': ['@babel/core', '@babel/traverse'],
16
+ 'babel-preset-gatsby-package': ['@babel/runtime'],
17
+ 'bin-links': ['mkdirp-infer-owner'],
18
+ 'cacheable-lookup': ['@types/keyv'],
19
+ 'clipanion-v3-codemod': ['jscodeshift'],
20
+ 'connect-mongo': ['express-session'],
21
+ consolidate: [
22
+ 'arc-templates',
23
+ 'atpl',
24
+ 'babel-core',
25
+ 'bracket-template',
26
+ 'coffee-script',
27
+ 'dot',
28
+ 'dust',
29
+ 'dustjs-helpers',
30
+ 'dustjs-linkedin',
31
+ 'eco',
32
+ 'ect',
33
+ 'ejs',
34
+ 'haml-coffee',
35
+ 'hamlet',
36
+ 'hamljs',
37
+ 'handlebars',
38
+ 'hogan.js',
39
+ 'htmling',
40
+ 'jade',
41
+ 'jazz',
42
+ 'jqtpl',
43
+ 'just',
44
+ 'liquid-node',
45
+ 'liquor',
46
+ 'lodash',
47
+ 'marko',
48
+ 'mote',
49
+ 'mustache',
50
+ 'nunjucks',
51
+ 'plates',
52
+ 'pug',
53
+ 'qejs',
54
+ 'ractive',
55
+ 'razor-tmpl',
56
+ 'react',
57
+ 'react-dom',
58
+ 'slm',
59
+ 'squirrelly',
60
+ 'swig',
61
+ 'swig-templates',
62
+ 'teacup',
63
+ 'templayed',
64
+ 'then-jade',
65
+ 'then-pug',
66
+ 'tinyliquid',
67
+ 'toffee',
68
+ 'twig',
69
+ 'twing',
70
+ 'underscore',
71
+ 'vash',
72
+ 'velocityjs',
73
+ 'walrus',
74
+ 'whiskers',
75
+ ],
76
+ 'cordova-ios': ['underscore'],
77
+ 'cordova-lib': ['underscore'],
78
+ 'create-gatsby': ['@babel/runtime'],
79
+ 'elm-webpack-loader': ['temp'],
80
+ 'eslint-import-resolver-vite': ['debug', 'resolve'],
81
+ fdir: ['picomatch'],
82
+ 'fork-ts-checker-webpack-plugin': ['eslint', 'typescript', 'vue-template-compiler', 'webpack'],
83
+ 'gatsby-admin': ['@babel/runtime'],
84
+ 'gatsby-cli': ['@babel/runtime'],
85
+ 'gatsby-core-utils': ['@babel/runtime', 'got'],
86
+ 'gatsby-design-tokens': ['@babel/runtime'],
87
+ 'gatsby-legacy-polyfills': ['@babel/runtime'],
88
+ 'gatsby-plugin-benchmark-reporting': ['@babel/runtime'],
89
+ 'gatsby-plugin-favicon': ['webpack'],
90
+ 'gatsby-plugin-gatsby-cloud': ['gatsby-core-utils', 'webpack'],
91
+ 'gatsby-plugin-graphql-config': ['@babel/runtime'],
92
+ 'gatsby-plugin-i18n': ['ramda'],
93
+ 'gatsby-plugin-image': ['@babel/runtime'],
94
+ 'gatsby-plugin-mdx': ['@babel/runtime', 'mkdirp'],
95
+ 'gatsby-plugin-netlify-cms': ['@babel/runtime'],
96
+ 'gatsby-plugin-no-sourcemaps': ['@babel/runtime'],
97
+ 'gatsby-plugin-page-creator': ['@babel/runtime', 'fs-extra'],
98
+ 'gatsby-plugin-preact': ['@babel/runtime'],
99
+ 'gatsby-plugin-preload-fonts': ['@babel/runtime'],
100
+ 'gatsby-plugin-schema-snapshot': ['@babel/runtime'],
101
+ 'gatsby-plugin-sharp': ['debug'],
102
+ 'gatsby-plugin-styletron': ['@babel/runtime'],
103
+ 'gatsby-plugin-subfont': ['@babel/runtime'],
104
+ 'gatsby-plugin-utils': ['@babel/runtime', 'fastq', 'graphql'],
105
+ 'gatsby-react-router-scroll': ['prop-types'],
106
+ 'gatsby-recipes': ['@babel/runtime'],
107
+ 'gatsby-remark-prismjs': ['lodash'],
108
+ 'gatsby-source-apiserver': ['babel-polyfill'],
109
+ 'gatsby-source-shopify': ['@babel/runtime'],
110
+ 'gatsby-source-wikipedia': ['@babel/runtime'],
111
+ 'gatsby-transformer-screenshot': ['@babel/runtime'],
112
+ 'gatsby-worker': ['@babel/runtime'],
113
+ 'git-node-fs': ['js-git'],
114
+ got: ['@types/keyv', '@types/responselike'],
115
+ 'http-link-dataloader': ['graphql'],
116
+ 'ink-select-input': ['react'],
117
+ 'jest-vue-preprocessor': ['@babel/core', '@babel/template', 'pug'],
118
+ 'jss-plugin-rule-value-function': ['tiny-warning'],
119
+ mqtt: ['duplexify'],
120
+ 'query-ast': ['lodash'],
121
+ 'rc-animate': ['react', 'react-dom'],
122
+ 'react-bootstrap-table2-paginator': ['classnames'],
123
+ 'react-color': ['react'],
124
+ 'react-dev-utils': ['typescript', 'webpack'],
125
+ 'react-draggable': ['react', 'react-dom'],
126
+ 'react-github-btn': ['react'],
127
+ 'react-instantsearch-core': ['algoliasearch'],
128
+ 'react-instantsearch-dom': ['react-fast-compare'],
129
+ 'react-live': ['react', 'react-dom'],
130
+ 'react-portal': ['react-dom'],
131
+ 'react-rnd': ['react', 'react-dom'],
132
+ 'react-scripts': ['eslint', 'react'],
133
+ reactcss: ['react'],
134
+ rebass: ['react'],
135
+ 'redux-persist': ['react'],
136
+ 'redux-thunk': ['redux'],
137
+ 'rollup-plugin-polyfill-node': ['rollup'],
138
+ 'scss-parser': ['lodash'],
139
+ skypack: ['tar'],
140
+ snowpack: ['magic-string'],
141
+ terser: ['acorn'],
142
+ testcafe: ['@babel/plugin-transform-for-of', '@babel/runtime'],
143
+ 'testcafe-legacy-api': ['read-file-relative', 'testcafe-hammerhead'],
144
+ 'typescript-language-server': ['vscode-jsonrpc', 'vscode-languageserver-protocol'],
145
+ unified: ['@types/unist'],
146
+ 'unplugin-vue2-script-setup': ['@vue/composition-api', '@vue/runtime-dom'],
147
+ 'vite-plugin-vuetify': ['vue'],
148
+ 'volar-service-typescript': ['typescript'],
149
+ 'volar-service-typescript-twoslash-queries': ['typescript'],
150
+ 'vue-cli-plugin-vuetify': ['null-loader', 'semver'],
151
+ 'vue-i18n': ['vue'],
152
+ 'vue-loader': ['@vue/compiler-sfc', 'webpack'],
153
+ 'vue-router': ['vue'],
154
+ 'vue-template-babel-compiler': ['vue-template-compiler'],
155
+ 'webpack-plugin-vuetify': ['vue'],
156
+ 'winston-transport': ['logform'],
157
+ ws: ['bufferutil', 'utf-8-validate'],
158
+ xo: ['webpack'],
159
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stjoseph/prumo",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Scaffolds a TypeScript project and ships it the conventions it follows, in a .prumo/ folder any AI assistant reads.",
5
5
  "keywords": [
6
6
  "scaffolding",
@@ -48,6 +48,9 @@
48
48
  "devDependencies": {
49
49
  "@biomejs/biome": "2.5.13",
50
50
  "@types/node": "24.13.4",
51
+ "@types/semver": "7.8.0",
52
+ "@yarnpkg/extensions": "2.0.7",
53
+ "semver": "7.8.5",
51
54
  "typescript": "6.0.3",
52
55
  "vitest": "4.1.11"
53
56
  },
@@ -1,6 +1,7 @@
1
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
2
  import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
3
  import { memoryTokenStore } from '@test/support/memory-token-store'
4
+ import { createTestQueryClient } from '@test/support/query-client'
4
5
  import { renderRouter, screen } from 'expo-router/testing-library'
5
6
  import type { ReactNode } from 'react'
6
7
  import { Text } from 'react-native'
@@ -23,7 +24,7 @@ async function renderAt(getSession: () => Response | Promise<Response>) {
23
24
  tokens: memoryTokenStore(TOKENS),
24
25
  })
25
26
  const clients = { auth, api: createClient({ baseUrl: API_URL, fetch: auth.fetch }) }
26
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
27
+ const queryClient = createTestQueryClient()
27
28
 
28
29
  function Providers({ children }: { children: ReactNode }) {
29
30
  return (
@@ -1,6 +1,7 @@
1
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
2
  import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
3
  import { memoryTokenStore } from '@test/support/memory-token-store'
4
+ import { createTestQueryClient } from '@test/support/query-client'
4
5
  import { userEvent } from '@testing-library/react-native'
5
6
  import { renderRouter, screen } from 'expo-router/testing-library'
6
7
  import type { ReactNode } from 'react'
@@ -18,7 +19,7 @@ async function renderAt(initialUrl: string, routes: Parameters<typeof fakeTransp
18
19
  const transport = fakeTransport(routes)
19
20
  const auth = createAuthClient({ baseUrl: API_URL, fetch: transport, tokens: memoryTokenStore() })
20
21
  const clients = { auth, api: createClient({ baseUrl: API_URL, fetch: auth.fetch }) }
21
- const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
22
+ const queryClient = createTestQueryClient()
22
23
 
23
24
  function Providers({ children }: { children: ReactNode }) {
24
25
  return (
@@ -1,6 +1,7 @@
1
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
2
  import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
3
  import { memoryTokenStore } from '@test/support/memory-token-store'
4
+ import { createTestQueryClient } from '@test/support/query-client'
4
5
  import { render, screen, userEvent } from '@testing-library/react-native'
5
6
  import { createAuthClient } from '@/api-contract'
6
7
  import { SignInForm } from '@/features/auth/sign-in-form'
@@ -23,7 +24,7 @@ describe('SignInForm', () => {
23
24
  const user = userEvent.setup()
24
25
 
25
26
  await render(
26
- <QueryClientProvider client={new QueryClient()}>
27
+ <QueryClientProvider client={createTestQueryClient()}>
27
28
  <SignInForm auth={auth} onSignedIn={onSignedIn} />
28
29
  </QueryClientProvider>,
29
30
  )
@@ -1,6 +1,7 @@
1
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
2
  import { API_URL, fakeTransport } from '@test/support/fake-transport'
3
3
  import { memoryTokenStore } from '@test/support/memory-token-store'
4
+ import { createTestQueryClient } from '@test/support/query-client'
4
5
  import { render, screen, userEvent, waitFor } from '@testing-library/react-native'
5
6
  import { createAuthClient } from '@/api-contract'
6
7
  import { SignOutButton } from '@/features/auth/sign-out-button'
@@ -10,7 +11,7 @@ import { clearUserData } from '@/features/storage/storage'
10
11
  jest.mock('@/features/storage/storage', () => ({ clearUserData: jest.fn() }))
11
12
 
12
13
  async function signOutWith(signOut: () => Response | Promise<Response>) {
13
- const queryClient = new QueryClient()
14
+ const queryClient = createTestQueryClient()
14
15
  const onSignedOut = jest.fn()
15
16
  const auth = createAuthClient({
16
17
  baseUrl: API_URL,
@@ -1,6 +1,7 @@
1
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
2
  import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
3
  import { memoryTokenStore } from '@test/support/memory-token-store'
4
+ import { createTestQueryClient } from '@test/support/query-client'
4
5
  import { render, screen, userEvent, waitFor } from '@testing-library/react-native'
5
6
  import * as WebBrowser from 'expo-web-browser'
6
7
  import { createAuthClient } from '@/api-contract'
@@ -28,7 +29,7 @@ async function renderButtons(exchange: () => Response) {
28
29
  })
29
30
 
30
31
  await render(
31
- <QueryClientProvider client={new QueryClient()}>
32
+ <QueryClientProvider client={createTestQueryClient()}>
32
33
  <SocialButtons auth={auth} onSignedIn={onSignedIn} />
33
34
  </QueryClientProvider>,
34
35
  )
@@ -1,5 +1,6 @@
1
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
2
  import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
+ import { createTestQueryClient } from '@test/support/query-client'
3
4
  import { render, screen, userEvent, within } from '@testing-library/react-native'
4
5
  import { createClient, type Profile } from '@/api-contract'
5
6
  import { ProfileForm } from '@/features/profile/profile-form'
@@ -36,9 +37,7 @@ describe('ProfileForm', () => {
36
37
  }),
37
38
  })
38
39
 
39
- // A mutation left without an observer starts a five-minute garbage-collection timer, and Jest's worker would wait
40
- // it out. Infinity schedules none, as TanStack Query's testing guide advises.
41
- const queryClient = new QueryClient({ defaultOptions: { mutations: { gcTime: Infinity } } })
40
+ const queryClient = createTestQueryClient()
42
41
 
43
42
  await render(
44
43
  <QueryClientProvider client={queryClient}>
@@ -0,0 +1,14 @@
1
+ import { QueryClient } from '@tanstack/react-query'
2
+
3
+ // Every test builds its QueryClient here, with the defaults TanStack Query's testing guide gives. A failed query
4
+ // fails at once instead of retrying three times with backoff. And gcTime Infinity schedules no garbage-collection
5
+ // timer: once a test unmounts, a query or mutation left without an observer would otherwise start a real five-minute
6
+ // setTimeout, and Jest's worker waits on it until it is force-exited.
7
+ export function createTestQueryClient(): QueryClient {
8
+ return new QueryClient({
9
+ defaultOptions: {
10
+ queries: { retry: false, gcTime: Infinity },
11
+ mutations: { gcTime: Infinity },
12
+ },
13
+ })
14
+ }
@@ -1,5 +1,6 @@
1
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
2
  import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
+ import { createTestQueryClient } from '@test/support/query-client'
3
4
  import { renderApp } from '@test/support/render-app'
4
5
  import { render, screen } from '@testing-library/react'
5
6
  import userEvent from '@testing-library/user-event'
@@ -26,7 +27,7 @@ function renderForm(routes: Parameters<typeof fakeTransport>[0]) {
26
27
  const auth = createAuthClient({ baseUrl: API_URL, fetch: fakeTransport(routes) })
27
28
 
28
29
  render(
29
- <QueryClientProvider client={new QueryClient()}>
30
+ <QueryClientProvider client={createTestQueryClient()}>
30
31
  <ConsentForm auth={auth} requestId={REQUEST} leave={leave} />
31
32
  </QueryClientProvider>,
32
33
  )
@@ -1,5 +1,6 @@
1
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1
+ import { QueryClientProvider } from '@tanstack/react-query'
2
2
  import { API_URL, fakeTransport, json } from '@test/support/fake-transport'
3
+ import { createTestQueryClient } from '@test/support/query-client'
3
4
  import { render, screen } from '@testing-library/react'
4
5
  import userEvent from '@testing-library/user-event'
5
6
  import { describe, expect, it } from 'vitest'
@@ -40,7 +41,7 @@ describe('ProfileForm', () => {
40
41
  })
41
42
 
42
43
  render(
43
- <QueryClientProvider client={new QueryClient()}>
44
+ <QueryClientProvider client={createTestQueryClient()}>
44
45
  <ProfileForm api={api} profile={profile} />
45
46
  </QueryClientProvider>,
46
47
  )
@@ -0,0 +1,14 @@
1
+ import { QueryClient } from '@tanstack/react-query'
2
+
3
+ // Every test builds its QueryClient here, with the defaults TanStack Query's testing guide gives. A failed query
4
+ // fails at once instead of retrying three times with backoff. And gcTime Infinity schedules no garbage-collection
5
+ // timer: once a test unmounts, a query or mutation left without an observer would otherwise start a real five-minute
6
+ // setTimeout, and Jest's worker waits on it until it is force-exited.
7
+ export function createTestQueryClient(): QueryClient {
8
+ return new QueryClient({
9
+ defaultOptions: {
10
+ queries: { retry: false, gcTime: Infinity },
11
+ mutations: { gcTime: Infinity },
12
+ },
13
+ })
14
+ }
@@ -1,6 +1,6 @@
1
- import { QueryClient } from '@tanstack/react-query'
2
1
  import { createMemoryHistory } from '@tanstack/react-router'
3
2
  import { API_URL } from '@test/support/fake-transport'
3
+ import { createTestQueryClient } from '@test/support/query-client'
4
4
  import { render } from '@testing-library/react'
5
5
  import { createAuthClient, createClient, type Transport } from '@/api-contract'
6
6
  import { App } from '@/app'
@@ -8,7 +8,7 @@ import { App } from '@/app'
8
8
  export function renderApp(path: string, transport: Transport) {
9
9
  const auth = createAuthClient({ baseUrl: API_URL, fetch: transport })
10
10
  const context = {
11
- queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }),
11
+ queryClient: createTestQueryClient(),
12
12
  api: createClient({ baseUrl: API_URL, fetch: auth.fetch }),
13
13
  auth,
14
14
  }
@@ -3,8 +3,15 @@ import { tanstackRouter } from '@tanstack/router-plugin/vite'
3
3
  import react from '@vitejs/plugin-react'
4
4
  import { defineConfig } from 'vitest/config'
5
5
 
6
- export default defineConfig({
7
- plugins: [tanstackRouter({ target: 'react', autoCodeSplitting: true }), react(), tailwindcss()],
6
+ // Under Vitest (mode `test`) every route is imported with the route tree. Split, each route's component is a
7
+ // virtual module Vite transforms the first time a test renders it, inside the one second Testing Library waits for
8
+ // the page, and on a loaded machine that transform alone outlasted the wait.
9
+ export default defineConfig(({ mode }) => ({
10
+ plugins: [
11
+ tanstackRouter({ target: 'react', autoCodeSplitting: mode !== 'test' }),
12
+ react(),
13
+ tailwindcss(),
14
+ ],
8
15
  resolve: {
9
16
  alias: { '@': `${import.meta.dirname}/src`, '@test': `${import.meta.dirname}/test` },
10
17
  },
@@ -15,4 +22,4 @@ export default defineConfig({
15
22
  setupFiles: ['./test/support/setup.ts'],
16
23
  coverage: { provider: 'v8', include: ['src/**'] },
17
24
  },
18
- })
25
+ }))