@_linked/cli 1.12.0 → 1.14.0
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.
- package/CHANGELOG.md +44 -0
- package/README.md +81 -38
- package/defaults/app-react-native/README.md +62 -22
- package/defaults/app-react-native/apps/mobile/App.tsx +6 -3
- package/defaults/app-react-native/apps/mobile/__tests__/env.test.ts +39 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/apiEnv.ts +49 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/globalSetup.ts +156 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/globalTeardown.ts +20 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/nodeFetchEnvironment.js +13 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/personOverview.test.tsx +51 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/restoreNodeFetch.ts +8 -0
- package/defaults/app-react-native/apps/mobile/__tests__/{linkedDefaults.test.tsx → nativeDefaults.test.tsx} +5 -7
- package/defaults/app-react-native/apps/mobile/__tests__/storage.test.ts +24 -0
- package/defaults/app-react-native/apps/mobile/__tests__/stripJsonImportAttributes.test.ts +45 -0
- package/defaults/app-react-native/apps/mobile/app.config.ts +21 -0
- package/defaults/app-react-native/apps/mobile/babel/stripJsonImportAttributes.js +41 -0
- package/defaults/app-react-native/apps/mobile/babel.config.js +13 -0
- package/defaults/app-react-native/apps/mobile/jest.integration.config.js +16 -0
- package/defaults/app-react-native/apps/mobile/package.json +11 -3
- package/defaults/app-react-native/apps/mobile/src/components/PersonOverview.tsx +123 -0
- package/defaults/app-react-native/apps/mobile/src/components/PersonOverviewContext.tsx +12 -0
- package/defaults/app-react-native/apps/mobile/src/components/PersonPreview.tsx +135 -0
- package/defaults/app-react-native/apps/mobile/src/shell/apiPort.json +1 -0
- package/defaults/app-react-native/apps/mobile/src/shell/env.ts +30 -0
- package/defaults/app-react-native/apps/mobile/src/shell/storage.ts +9 -0
- package/defaults/app-react-native/docker-compose.yml +22 -0
- package/defaults/app-react-native/eslint.config.js +47 -0
- package/defaults/app-react-native/gitignore.template +5 -0
- package/defaults/app-react-native/package.json +14 -4
- package/defaults/app-react-native/packages/app-shapes/package.json +20 -3
- package/defaults/app-react-native/packages/app-shapes/tsconfig-esm.json +12 -0
- package/defaults/app-react-native/scripts/check-react.mjs +55 -0
- package/defaults/app-react-native/services/api/README.md +50 -2
- package/defaults/app-react-native/services/api/env.example.template +11 -0
- package/defaults/app-react-native/services/api/linked.backend.datasets.json +15 -0
- package/defaults/app-react-native/services/api/linked.backend.storage.ts +35 -0
- package/defaults/app-react-native/services/api/package.json +27 -1
- package/defaults/app-react-native/services/api/scripts/wait-for-fuseki.mjs +26 -0
- package/defaults/app-react-native/services/api/src/backend.ts +9 -0
- package/defaults/app-react-native/services/api/src/fileStoreEnv.ts +14 -0
- package/defaults/app-react-native/services/api/test/hasS3Env.test.mjs +28 -0
- package/defaults/app-react-native/services/api/test/waitForFuseki.test.mjs +27 -0
- package/defaults/app-react-native/services/api/tsconfig.json +15 -0
- package/lib/esm/cli-methods.d.ts +11 -1
- package/lib/esm/cli-methods.d.ts.map +1 -1
- package/lib/esm/cli-methods.js +122 -58
- package/lib/esm/cli-methods.js.map +1 -1
- package/lib/esm/cli.js +10 -6
- package/lib/esm/cli.js.map +1 -1
- package/lib/esm/commands/start.d.ts +33 -0
- package/lib/esm/commands/start.d.ts.map +1 -1
- package/lib/esm/commands/start.js +96 -54
- package/lib/esm/commands/start.js.map +1 -1
- package/lib/esm/interfaces.d.ts +7 -0
- package/lib/esm/interfaces.d.ts.map +1 -1
- package/lib/esm/utils/esmSpecifiers.d.ts +9 -0
- package/lib/esm/utils/esmSpecifiers.d.ts.map +1 -0
- package/lib/esm/utils/esmSpecifiers.js +104 -0
- package/lib/esm/utils/esmSpecifiers.js.map +1 -0
- package/lib/esm/utils/shippedDotfiles.d.ts.map +1 -1
- package/lib/esm/utils/shippedDotfiles.js +3 -1
- package/lib/esm/utils/shippedDotfiles.js.map +1 -1
- package/lib/esm/vite-config.d.ts +42 -0
- package/lib/esm/vite-config.d.ts.map +1 -1
- package/lib/esm/vite-config.js +185 -38
- package/lib/esm/vite-config.js.map +1 -1
- package/package.json +1 -1
- package/defaults/app-react-native/apps/mobile/src/shell/linkedDefaults.tsx +0 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#81](https://github.com/linked-cm/cli/pull/81) [`d1c0729`](https://github.com/linked-cm/cli/commit/d1c07296a9470ed3c995ce83a9ade22c0632395c) Thanks [@flyon](https://github.com/flyon)! - `create-app --template react-native` now scaffolds the Linked backend next to the app.
|
|
8
|
+
|
|
9
|
+
- `services/api`: an API-only Linked backend (`linked start --api-only`) on `@_linked/server`, with Fuseki through `linked.backend.datasets.json`, a local file store (or `S3FileStore` when the S3 variables are set), a Fuseki reachability check and `node --test` unit tests.
|
|
10
|
+
- A root `docker-compose.yml` for Fuseki (`secoresearch/fuseki:5.5.0`, host port `FUSEKI_PORT`), root scripts `fuseki:up`, `fuseki:down`, `api`, `check:react`, `lint`, `typecheck`, `test` and `test:integration`, a root ESLint flat config (with a rule that keeps `apps/mobile` on `@_linked/server` subpath imports) and a "Before every PR" checklist in the README (the template ships no CI workflow).
|
|
11
|
+
- Pins `@_linked/server` 2.3.0, `@_linked/server-utils` 1.2.0 and `@_linked/cli` 1.13.0.
|
|
12
|
+
- `apps/mobile/babel.config.js` registers a Babel plugin that strips JSON import attributes (`import('x.json', { with: { type: 'json' } })`), so Metro bundles Linked ontology packages.
|
|
13
|
+
- `resolveApiUrl` ignores a non-string `extra.apiUrl`: the dev-client manifest delivers `null` as `{}`, which crashed the app with "LincdServerProxy requires a root URL".
|
|
14
|
+
- `apps/mobile` imports `@_linked/react/native` (1.5.0) instead of its own render defaults, resolves the API URL in `app.config.ts` and `src/shell/env.ts`, and sends queries to the API through `BackendAPIStore`. The root `overrides` entry is gone.
|
|
15
|
+
- An example add/edit/delete screen (`PersonOverview`, `PersonPreview`), ported from the web app-template to React Native with `@_linked/schema`'s `Person`, with a Jest integration test against the running API and Fuseki.
|
|
16
|
+
- The shapes package sets `"linked": {"extensionlessImports": true}`, so `linked build` emits Node-loadable `lib/esm`.
|
|
17
|
+
- With `linked.extensionlessImports`, `linked build` fails when `lib/esm` was not emitted (no `tsconfig-esm.json`), finishes "with warnings" listing every relative specifier it could not resolve, and handles `'.'`, `'..'` and `'./dir/'`. `linked build` now exits non-zero when the build fails.
|
|
18
|
+
- README: documents `linked start --api-only` / `server.apiOnly`, the `extensionlessImports` flag, and the react-native template as it is now.
|
|
19
|
+
- Integration tests only reset a Fuseki that is this repo's Compose `fuseki` on localhost (`docker compose port fuseki 3030`) with a `-test` dataset. They read `services/api/.env` (the shell wins), pass every `FUSEKI_*` variable to the API explicitly, blank the S3 variables (removing `AWS_REGION`, which `@_linked/s3` rejects when empty), and kill the API's process group on timeout, early exit, SIGINT, SIGTERM and exit. `INTEGRATION_API_BIN` and `INTEGRATION_API_TIMEOUT_MS` exist for testing that cleanup.
|
|
20
|
+
- Load order is enforced by imports: `storage.ts` imports `env.ts`, and the example linked components import `storage.ts`. The default API port is shared from `apps/mobile/src/shell/apiPort.json`.
|
|
21
|
+
- `wait-for-fuseki.mjs` honours `WAIT_FOR_FUSEKI_TIMEOUT_MS`. New tests: `storage.test.ts` (load order) and `waitForFuseki.test.mjs`. Local uploads go to `services/api/data/uploads/`, which is gitignored.
|
|
22
|
+
- `linked call` (a direct `callBackendMethod`) now prints the error and exits 1 when the method is unmatched or fails, instead of an unhandled rejection.
|
|
23
|
+
- The Fuseki dataset names (`<prefix>-dev`, `<prefix>-test`) are stamped from the app prefix.
|
|
24
|
+
- Shipped dotfiles are restored at any depth, and `env.example.template` becomes `.env.example`.
|
|
25
|
+
|
|
26
|
+
## 1.13.0
|
|
27
|
+
|
|
28
|
+
### Minor Changes
|
|
29
|
+
|
|
30
|
+
- [#79](https://github.com/linked-cm/cli/pull/79) [`fd27a8d`](https://github.com/linked-cm/cli/commit/fd27a8d827b591c2d6db6bd2a7db1aa1de94b71d) Thanks [@flyon](https://github.com/flyon)! - Add `linked start --api-only` for a Linked backend without a web frontend.
|
|
31
|
+
|
|
32
|
+
- No `vite.config.*` is required: without one, Vite runs with the `createViteConfig()` defaults inline, so TypeScript, decorators and extensionless imports in source-only shape packages still work.
|
|
33
|
+
- No `src/App.tsx` or `src/routes.tsx` is loaded. The CLI sets `server.apiOnly` on the LinkedServer config instead of the page rendering hooks, so `/call/...` and `/api/...` routes are served and page requests get a 404 (needs a `@_linked/server` release that honours `server.apiOnly`).
|
|
34
|
+
- The mode is explicit and never inferred from missing files. `server.apiOnly: true` in `linked.config.js` turns it on too.
|
|
35
|
+
- `LinkedServerConfig` gains the `apiOnly` field.
|
|
36
|
+
|
|
37
|
+
Add `"linked": {"extensionlessImports": true}` to a package's `package.json` for packages whose source uses extensionless relative imports (for example, shapes shared with React Native, where Metro does not map `.js` specifiers to `.ts` source).
|
|
38
|
+
|
|
39
|
+
- `linked build` skips the "Checking imports" step for that package and logs that it did.
|
|
40
|
+
- After compiling ESM, it appends `.js` (or `/index.js` for a directory) to extensionless `./` and `../` specifiers of static imports, exports and `import()` calls in `lib/esm/**/*.js`, so the output loads under Node. Specifiers it cannot resolve are left unchanged with a warning. `.d.ts` files are not rewritten.
|
|
41
|
+
- Packages without the field build exactly as before.
|
|
42
|
+
|
|
43
|
+
Fix linked-package discovery in `linked start` for apps inside an npm/yarn workspaces monorepo: dependencies are now resolved the way Node resolves them, walking up parent `node_modules` directories to the workspace root. A linked package hoisted to the root `node_modules` is found, so the app no longer falls into standalone mode (which dropped the `development` export condition).
|
|
44
|
+
|
|
45
|
+
In workspace mode, `linked start` now bundles only the discovered source workspaces through Vite SSR (`ssr.noExternal`). Published framework packages installed in `node_modules` (such as `@_linked/core`) stay external and load through Node. Before, every `@_linked/*` package was force-bundled, so Vite ran its own `@_linked/core` while a store loaded by core's `loadStores` through a native `import()` (for example `@_linked/fuseki/shapes/FusekiStore`) pulled in a second, Node-loaded core. That split the shape registry ("Cannot resolve an rdf:type for shape"). An installed package that depends (directly or transitively, through `dependencies` or `peerDependencies`) on a discovered workspace is bundled too. For example, when `@_linked/core` is itself a source workspace, a published `@_linked/fuseki` goes through Vite and uses the same core. Only the dependency closure of the app and its workspaces is scanned.
|
|
46
|
+
|
|
3
47
|
## 1.12.0
|
|
4
48
|
|
|
5
49
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -62,6 +62,7 @@ linked setup-publish --scope community # use NPM_AUTH_TOKEN_CM instead of N
|
|
|
62
62
|
|
|
63
63
|
```bash
|
|
64
64
|
linked start # run the dev server (app)
|
|
65
|
+
linked start --api-only # run only the backend API (no page rendering, no vite.config needed)
|
|
65
66
|
linked dev # file-watch rebuild (package)
|
|
66
67
|
linked yarn <args> # safe-yarn: run yarn at root while preserving nested yarn.lock files
|
|
67
68
|
```
|
|
@@ -88,6 +89,45 @@ The CLI recognizes two flags in `package.json`:
|
|
|
88
89
|
|
|
89
90
|
The legacy `lincd: true` / `lincdApp: true` flags are no longer read. Migrate to `linkedPackage` / `linkedApp`.
|
|
90
91
|
|
|
92
|
+
### `"linked": {"extensionlessImports": true}`
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{
|
|
96
|
+
"linkedPackage": true,
|
|
97
|
+
"type": "module",
|
|
98
|
+
"linked": {"extensionlessImports": true}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
By default `linked build` rejects relative imports without an extension (`./shapes/Example`), because Node's ESM
|
|
103
|
+
loader cannot resolve them. Set this flag when the package source must use extensionless imports, for example a
|
|
104
|
+
shapes package consumed as TypeScript source by Metro (which does not map `.js` specifiers to `.ts` files).
|
|
105
|
+
With the flag, `linked build`:
|
|
106
|
+
|
|
107
|
+
- skips the import check;
|
|
108
|
+
- after compiling ESM, rewrites every relative specifier in `lib/esm` (static imports and exports, `export * as`,
|
|
109
|
+
side-effect imports and `import()` with a literal) to `./x.js` or `./dir/index.js`, so Node can load the output;
|
|
110
|
+
- fails if `lib/esm` was not emitted;
|
|
111
|
+
- finishes "with warnings" and lists each specifier it could not resolve (left unchanged).
|
|
112
|
+
|
|
113
|
+
Requirements: `"linkedPackage": true`, `"type": "module"` and a `tsconfig-esm.json` in the package.
|
|
114
|
+
|
|
115
|
+
## API-only backend
|
|
116
|
+
|
|
117
|
+
`linked start --api-only`, or `server.apiOnly: true` in `linked.config.js`, serves only the backend API
|
|
118
|
+
(`/call/...`, `/api/...` and provider routes). There is no server-side page rendering, page requests get a 404,
|
|
119
|
+
and no `vite.config.*` is required. Use it for a backend whose frontend lives elsewhere, such as a React Native
|
|
120
|
+
app:
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
// linked.config.js
|
|
124
|
+
export default {
|
|
125
|
+
server: {apiOnly: true},
|
|
126
|
+
};
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
API-only mode is never inferred: a web app without a `vite.config.*` and without the flag still fails at startup.
|
|
130
|
+
|
|
91
131
|
## Development
|
|
92
132
|
|
|
93
133
|
```bash
|
|
@@ -110,68 +150,71 @@ Templates live in `defaults/`:
|
|
|
110
150
|
|
|
111
151
|
`linked create-app <name>` copies `defaults/app-with-backend/` to the new app's folder, substitutes `${name}` / `${hyphen_name}` / `${app_prefix}` / `${app_domain}` placeholders in selected files, and copies `linked.backend.datasets.example.json` → `linked.backend.datasets.json` so first boot works zero-config.
|
|
112
152
|
|
|
113
|
-
Storage configuration follows the two-layer pattern from [backlog 016](https://github.com/create-
|
|
114
|
-
|
|
115
|
-
| File | Side | Role | Git |
|
|
116
|
-
|---|---|---|---|
|
|
117
|
-
| `linked.backend.storage.ts` | backend | Shape→alias routing. Uses `parseDatasetsConfig` + `loadStores` from `@_linked/core`; calls `LinkedStorage.setDefaultDataset(...)` / `setDatasetForShapes(...)`. | committed |
|
|
118
|
-
| `linked.backend.datasets.json` | backend | Alias → `{ store, config }`. `store` is an npm import path; `config` is the store class's constructor arg. `${VAR:-default}` placeholders resolved at boot. | **gitignored** |
|
|
119
|
-
| `linked.backend.datasets.example.json` | backend | Template / seed for the gitignored file. Has placeholders matching env vars. | committed |
|
|
120
|
-
| `src/linked.frontend.storage.ts` | frontend | Same shape→alias model; imports store classes explicitly (webpack-bundle-safe), constructs per alias. | committed |
|
|
121
|
-
| `src/linked.frontend.datasets.json` | frontend | Frontend alias → `{ store, config }`. Public values only — never put secrets here, it ships in the browser bundle. | committed |
|
|
122
|
-
|
|
123
|
-
Conventions:
|
|
124
|
-
- Aliases on the two sides are **independent**. The framework re-routes by shape on each side; matching alias names between FE and BE is convention, not a framework requirement.
|
|
125
|
-
- The backend dispatcher is registry-free (dynamic `await import(entry.store)`). The frontend hardcodes each `new StoreClass(config)` because webpack can't bundle dynamic imports of arbitrary npm specifiers.
|
|
126
|
-
- Each store class accepts a single config-object constructor argument — `new FusekiStore({ endpoint, credentials? })`, `new BackendAPIStore({ name?, id? })`.
|
|
127
|
-
|
|
128
|
-
### `linked create-app --template react-native`
|
|
153
|
+
Storage configuration follows the two-layer pattern from [backlog 016](https://github.com/c### `linked create-app --template react-native`
|
|
129
154
|
|
|
130
155
|
`linked create-app <name> --template react-native` (with the usual `--app-name`, `--app-prefix`, `--app-domain`,
|
|
131
156
|
`--skip-install`) copies `defaults/app-react-native/` instead of cloning the web template. The result is an npm
|
|
132
|
-
workspaces monorepo for Expo SDK 57 / React Native 0.86 / React 19.2:
|
|
157
|
+
workspaces monorepo for Expo SDK 57 / React Native 0.86 / React 19.2 with an API-only Linked backend on Fuseki:
|
|
133
158
|
|
|
134
159
|
```
|
|
135
160
|
<name>/
|
|
136
|
-
package.json
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
161
|
+
package.json enumerated workspaces; scripts fuseki:up/down, api, check:react, lint, typecheck,
|
|
162
|
+
test, test:integration
|
|
163
|
+
docker-compose.yml local Fuseki (secoresearch/fuseki, host port FUSEKI_PORT, default 3030)
|
|
164
|
+
eslint.config.js flat config; keeps apps/mobile on @_linked/server subpath imports
|
|
165
|
+
apps/mobile/ Expo app (ios/ and android/ are generated, not committed)
|
|
166
|
+
App.tsx imports ./src/shell/env, @_linked/react/native, ./src/shell/storage
|
|
167
|
+
babel.config.js registers babel/stripJsonImportAttributes.js
|
|
168
|
+
src/shell/ env.ts (API URL), storage.ts (BackendAPIStore)
|
|
169
|
+
src/components/ PersonOverview / PersonPreview: example add/edit/delete screen
|
|
170
|
+
__tests__/ unit tests; __tests__/integration/ runs against the API and Fuseki
|
|
171
|
+
packages/<prefix>-shapes/ Linked shapes package: TypeScript source, extensionless imports,
|
|
172
|
+
"linked": {"extensionlessImports": true} so `linked build` emits Node-loadable lib/esm
|
|
173
|
+
services/api/ API-only backend (`linked start --api-only`) on @_linked/server, Fuseki through
|
|
174
|
+
linked.backend.datasets.json, node --test unit tests
|
|
144
175
|
```
|
|
145
176
|
|
|
146
177
|
What the scaffold does:
|
|
147
178
|
|
|
148
179
|
- Refuses, before writing anything, a prefix that does not match `^[a-z][a-z0-9-]*$`, and a target folder that
|
|
149
180
|
exists and is not empty.
|
|
150
|
-
-
|
|
181
|
+
- Restores shipped dotfiles at any depth (`gitignore.template` becomes `.gitignore`, `env.example.template`
|
|
182
|
+
becomes `.env.example`). npm strips real dotfiles like `.gitignore` from the CLI tarball.
|
|
151
183
|
- Renames `packages/app-shapes` to `packages/<prefix>-shapes`, and replaces the literal `app-shapes` token in
|
|
152
184
|
every text file.
|
|
153
185
|
- Sets the root `name` to `<hyphen-name>-monorepo`, and `app.json` `expo.name`, `expo.slug` and
|
|
154
|
-
`expo.ios.bundleIdentifier`. The bundle ID is the reversed domain plus the prefix, e.g.
|
|
155
|
-
|
|
186
|
+
`expo.ios.bundleIdentifier`. The bundle ID is the reversed domain plus the prefix, e.g. `com.example.demo`.
|
|
187
|
+
- Stamps the Fuseki dataset names `<prefix>-dev` and `<prefix>-test` from the prefix.
|
|
156
188
|
- Never runs the `${…}` placeholder substitution, so template literals in the sources are left alone.
|
|
157
189
|
- Installs with `npm install` (never Yarn) unless `--skip-install` is given. If the install fails, the files
|
|
158
190
|
stay and the command exits non-zero; run `npm install` in the folder to retry.
|
|
159
191
|
|
|
160
|
-
|
|
161
|
-
them from `app.json`, and `apps/mobile/.gitignore` ignores them.
|
|
192
|
+
The generated app:
|
|
162
193
|
|
|
163
|
-
|
|
194
|
+
- **Render defaults** come from `@_linked/react/native`, which replaces `@_linked/react`'s `<svg>` loader and
|
|
195
|
+
error elements (they crash on React Native).
|
|
196
|
+
- **Person example.** `PersonOverview` and `PersonPreview` list, add, edit and delete `@_linked/schema`'s
|
|
197
|
+
`Person` through `BackendAPIStore` → API → Fuseki. A Jest integration test covers it.
|
|
198
|
+
- **Babel plugin.** Metro rejects `import('x.json', { with: { type: 'json' } })`, which Linked ontology packages
|
|
199
|
+
use; `apps/mobile/babel/stripJsonImportAttributes.js` drops that options argument.
|
|
200
|
+
- **Before every PR.** The template ships no CI workflow. Its README has a checklist to run locally and paste
|
|
201
|
+
into the PR:
|
|
164
202
|
|
|
165
203
|
```bash
|
|
166
|
-
npm
|
|
167
|
-
npm
|
|
168
|
-
|
|
169
|
-
|
|
204
|
+
npm run check:react
|
|
205
|
+
npm run lint
|
|
206
|
+
npm run typecheck
|
|
207
|
+
npm test
|
|
208
|
+
npm run fuseki:up && npm run test:integration
|
|
209
|
+
cd apps/mobile && npx expo export --platform ios --output-dir /tmp/export
|
|
170
210
|
```
|
|
171
211
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
212
|
+
To run it: `npm run fuseki:up`, `npm run api`, then `cd apps/mobile && npx expo run:ios`.
|
|
213
|
+
|
|
214
|
+
Tests for this template in this repo: `yarn test:unit` covers the scaffold offline, using the fixture in
|
|
215
|
+
`tests/fixtures/app-react-native-min` and the real template. `npm run test:template` runs the built CLI with
|
|
216
|
+
install, then checks the generated files, `check:react`, lint, typecheck, `npm test`, the shapes build and a
|
|
217
|
+
Metro iOS bundle. It builds nothing itself, needs network and takes several minutes.
|
|
175
218
|
|
|
176
219
|
## Repository
|
|
177
220
|
|
|
@@ -1,42 +1,67 @@
|
|
|
1
1
|
# Linked React Native monorepo
|
|
2
2
|
|
|
3
3
|
An npm-workspaces monorepo for a [Linked](https://github.com/linked-cm) app on Expo SDK 57 / React Native 0.86 /
|
|
4
|
-
React 19.2.
|
|
4
|
+
React 19.2, with an API-only Linked backend on Fuseki.
|
|
5
5
|
|
|
6
6
|
```
|
|
7
7
|
apps/mobile/ Expo app (continuous native generation: ios/ is generated, not committed)
|
|
8
|
-
src/shell/
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
src/shell/ env (API URL) and storage (BackendAPIStore) wiring
|
|
9
|
+
src/components/ PersonOverview / PersonPreview: example add/edit/delete screen, safe to replace
|
|
10
|
+
__tests__/ unit tests; __tests__/integration/ runs against a real API and Fuseki
|
|
11
|
+
packages/app-shapes/ Linked shapes package, consumed as TypeScript source; `linked build` for Node
|
|
12
|
+
services/api/ Linked backend (`linked start --api-only`), see services/api/README.md
|
|
13
|
+
docker-compose.yml local Fuseki
|
|
12
14
|
```
|
|
13
15
|
|
|
14
16
|
## Run
|
|
15
17
|
|
|
18
|
+
Requires Node `^22.13 || >=24` and Docker. iOS builds need macOS 26.2+ and Xcode 26.4+ with the iOS platform
|
|
19
|
+
component installed.
|
|
20
|
+
|
|
16
21
|
```bash
|
|
17
22
|
npm install
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
cp services/api/.env.example services/api/.env # once
|
|
24
|
+
npm run fuseki:up # Fuseki on :3030 (Docker Compose)
|
|
25
|
+
npm run api # API-only backend on :4000
|
|
26
|
+
cd apps/mobile && npx expo run:ios # generates ios/, builds a dev client, starts Metro
|
|
20
27
|
```
|
|
21
28
|
|
|
22
29
|
`expo-dev-client` is a dependency of `apps/mobile`, so `expo run:ios` produces a custom dev client (not Expo Go),
|
|
23
|
-
which is what native modules need.
|
|
30
|
+
which is what native modules need. The app finds the API on the dev machine's host (see "API URL" below).
|
|
24
31
|
|
|
25
|
-
|
|
26
|
-
|
|
32
|
+
If port 3030 is taken, see "Port 3030 taken" in `services/api/README.md`: set `FUSEKI_PORT` in a root `.env`
|
|
33
|
+
and `FUSEKI_BASE_URL` in `services/api/.env`.
|
|
27
34
|
|
|
28
|
-
##
|
|
35
|
+
## Checks
|
|
29
36
|
|
|
30
37
|
| Command | What it proves |
|
|
31
38
|
|---|---|
|
|
32
|
-
| `npm
|
|
33
|
-
| `npm
|
|
39
|
+
| `npm run check:react` | `apps/mobile`, `@_linked/react` and `react-native` resolve one React 19.2.3 |
|
|
40
|
+
| `npm run typecheck` | `tsc --noEmit` in every workspace |
|
|
41
|
+
| `npm run lint` | ESLint over every workspace, including the `@_linked/server` subpath rule for `apps/mobile` |
|
|
42
|
+
| `npm test` | app Jest (shapes, render defaults, API URL) and the API's `node --test` unit tests |
|
|
43
|
+
| `npm run fuseki:up && npm run test:integration` | the example screen lists, adds and deletes through `BackendAPIStore` → API → Fuseki (starts its own API on :4100 with the in-memory `app-test` dataset) |
|
|
44
|
+
| `npm run build -w packages/app-shapes` | `linked build` emits `lib/esm` with `.js` specifiers, loadable by Node |
|
|
34
45
|
| `cd apps/mobile && npx expo export --platform ios --output-dir /tmp/export` | Metro resolves `@_linked/*` (including subpaths) and the workspace packages |
|
|
35
46
|
|
|
47
|
+
## Before every PR
|
|
48
|
+
|
|
49
|
+
There is no CI workflow. Run every check locally before opening or updating a PR, and paste the results into the
|
|
50
|
+
PR description:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm run check:react
|
|
54
|
+
npm run lint
|
|
55
|
+
npm run typecheck
|
|
56
|
+
npm test
|
|
57
|
+
npm run fuseki:up && npm run test:integration
|
|
58
|
+
cd apps/mobile && npx expo export --platform ios --output-dir /tmp/export
|
|
59
|
+
```
|
|
60
|
+
|
|
36
61
|
## React Native specifics
|
|
37
62
|
|
|
38
|
-
- **One React.**
|
|
39
|
-
|
|
63
|
+
- **One React.** `npm ls react` lists extra React copies nested under `@_linked/server` and
|
|
64
|
+
`@_linked/server-utils`; the app never loads them, which `npm run check:react` verifies. Do not use
|
|
40
65
|
`--legacy-peer-deps`: it would hide a duplicate React.
|
|
41
66
|
- **Workspaces are enumerated**, never globbed, so foreign repositories checked out under `packages/` are not
|
|
42
67
|
adopted. `.gitignore` uses `packages/*` plus a negation per workspace package (`packages/` cannot be negated).
|
|
@@ -46,11 +71,26 @@ installed.
|
|
|
46
71
|
subpaths.
|
|
47
72
|
- **Jest** maps `@_linked/*` to its ESM build with a scoped `moduleNameMapper`. A global
|
|
48
73
|
`customExportConditions` would break `@babel/runtime`. Add every workspace package to
|
|
49
|
-
`transformIgnorePatterns`.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
74
|
+
`transformIgnorePatterns`. The integration config restores Node's `fetch`, which jest-expo replaces with a
|
|
75
|
+
stub that never sends a request.
|
|
76
|
+
- **Render defaults.** `@_linked/react`'s root loader and error elements are `<svg>`, which crash on React
|
|
77
|
+
Native. Importing `@_linked/react/native` installs React Native defaults; `__tests__/nativeDefaults.test.tsx`
|
|
78
|
+
guards it.
|
|
79
|
+
- **API URL.** `app.config.ts` passes `EXPO_PUBLIC_API_URL` (if set) and the API port (`defaultApiPort` in
|
|
80
|
+
`src/shell/apiPort.json`, shared with `env.ts`) to `src/shell/env.ts`,
|
|
81
|
+
which falls back to the dev machine's host from Expo's `hostUri`, then `localhost`, and sets
|
|
82
|
+
`process.env.SITE_ROOT` and `DATA_ROOT` for `BackendAPIStore`. The app identity (name, slug, bundle id) stays in
|
|
83
|
+
`app.json`.
|
|
84
|
+
- **Load order is enforced by imports:** `App.tsx` imports `@_linked/react/native` first, and every module that
|
|
85
|
+
needs the API store imports `src/shell/storage`, which imports `src/shell/env`.
|
|
86
|
+
- **`@_linked/server` by subpath only** in `apps/mobile` (for example
|
|
87
|
+
`@_linked/server/shapes/quadstores/BackendAPIStore`): the root barrel pulls express, webpack and react-dom.
|
|
88
|
+
ESLint `no-restricted-imports` enforces it.
|
|
53
89
|
- **Shapes package imports are extensionless** (`./shapes/Example`, not `./shapes/Example.js`): Metro does not
|
|
54
|
-
map `.js` specifiers to `.ts` source.
|
|
55
|
-
|
|
56
|
-
|
|
90
|
+
map `.js` specifiers to `.ts` source. `"linked": {"extensionlessImports": true}` in its `package.json` makes
|
|
91
|
+
`linked build` skip the import check and add `.js` to the emitted `lib/esm` specifiers. The app, Jest and the
|
|
92
|
+
API in development consume `src/` through the `react-native` and `development` export conditions.
|
|
93
|
+
- **Decorators** work with the stock `babel-preset-expo`. The one custom Babel plugin,
|
|
94
|
+
`apps/mobile/babel/stripJsonImportAttributes.js`, strips `with { type: 'json' }` from dynamic `import()`, which
|
|
95
|
+
Metro rejects and Linked ontology packages use; `__tests__/stripJsonImportAttributes.test.ts` covers it. After
|
|
96
|
+
editing `babel.config.js` or the plugin, restart with `npx expo start --clear`.
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
//
|
|
2
|
-
import
|
|
1
|
+
// @_linked/react/native first: it installs the React Native render defaults before any component renders. Modules
|
|
2
|
+
// that need the API store import ./src/shell/storage themselves, which imports ./src/shell/env.
|
|
3
|
+
import '@_linked/react/native';
|
|
4
|
+
import { Example } from 'app-shapes';
|
|
3
5
|
|
|
4
6
|
import { StyleSheet, Text, View } from 'react-native';
|
|
5
7
|
import { getShapeClass } from '@_linked/core/utils/ShapeClass';
|
|
6
|
-
import {
|
|
8
|
+
import { PersonOverview } from './src/components/PersonOverview';
|
|
7
9
|
|
|
8
10
|
export default function App() {
|
|
9
11
|
const registered = getShapeClass(Example.shape.id) === Example;
|
|
@@ -12,6 +14,7 @@ export default function App() {
|
|
|
12
14
|
{/* @linkedShape adds static packageName at runtime; it is not on the class type. */}
|
|
13
15
|
<Text>{(Example as any).packageName}</Text>
|
|
14
16
|
<Text>{registered ? 'shapes registered' : 'shapes not registered'}</Text>
|
|
17
|
+
<PersonOverview />
|
|
15
18
|
</View>
|
|
16
19
|
);
|
|
17
20
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { apiUrl, resolveApiUrl } from '../src/shell/env';
|
|
2
|
+
|
|
3
|
+
// Explicit, so the module-level resolution does not depend on jest-expo's expo-constants mock.
|
|
4
|
+
jest.mock('expo-constants', () => ({
|
|
5
|
+
__esModule: true,
|
|
6
|
+
default: { expoConfig: { extra: { apiUrl: null, apiPort: 4000 }, hostUri: '192.168.1.5:8081' } },
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe('resolveApiUrl', () => {
|
|
10
|
+
test('explicit url wins', () => {
|
|
11
|
+
expect(
|
|
12
|
+
resolveApiUrl({ extra: { apiUrl: 'http://api.test:9000', apiPort: 4000 }, hostUri: '192.168.1.5:8081' }),
|
|
13
|
+
).toBe('http://api.test:9000');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('derives from hostUri', () => {
|
|
17
|
+
expect(resolveApiUrl({ extra: { apiUrl: null, apiPort: 4000 }, hostUri: '192.168.1.5:8081' })).toBe(
|
|
18
|
+
'http://192.168.1.5:4000',
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('ignores a non-string apiUrl (the dev-client manifest turns null into {})', () => {
|
|
23
|
+
expect(resolveApiUrl({ extra: { apiUrl: {}, apiPort: 4000 }, hostUri: '192.168.1.5:8081' })).toBe(
|
|
24
|
+
'http://192.168.1.5:4000',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('falls back to localhost', () => {
|
|
29
|
+
expect(resolveApiUrl({ extra: { apiUrl: null, apiPort: 4000 }, hostUri: undefined })).toBe(
|
|
30
|
+
'http://localhost:4000',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('sets process.env', () => {
|
|
35
|
+
expect(apiUrl).toBe('http://192.168.1.5:4000');
|
|
36
|
+
expect(process.env.SITE_ROOT).toBe(apiUrl);
|
|
37
|
+
expect(process.env.DATA_ROOT).toBe(`${apiUrl}/data`);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
// Shared by globalSetup, globalTeardown and the tests: where the test API and Fuseki live.
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export const API_PORT = 4100;
|
|
7
|
+
export const API_URL = `http://localhost:${API_PORT}`;
|
|
8
|
+
// globalSetup deletes this dataset, so it must end in `-test` (checked there).
|
|
9
|
+
export const FUSEKI_DATASET = 'app-test';
|
|
10
|
+
|
|
11
|
+
export const repoRoot = resolve(__dirname, '../../../..');
|
|
12
|
+
export const apiDir = resolve(repoRoot, 'services/api');
|
|
13
|
+
|
|
14
|
+
/** Minimal KEY=VALUE parser for services/api/.env: no expansion, no multi-line values. */
|
|
15
|
+
export function parseEnvFile(text: string): Record<string, string> {
|
|
16
|
+
const vars: Record<string, string> = {};
|
|
17
|
+
for (const line of text.split(/\r?\n/)) {
|
|
18
|
+
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
|
|
19
|
+
if (!match) continue;
|
|
20
|
+
vars[match[1]] = match[2].replace(/^(['"])(.*)\1$/, '$2');
|
|
21
|
+
}
|
|
22
|
+
return vars;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** services/api/.env as key/value pairs; empty when the file does not exist. */
|
|
26
|
+
export function readApiEnvFile(): Record<string, string> {
|
|
27
|
+
const envFile = resolve(apiDir, '.env');
|
|
28
|
+
return existsSync(envFile) ? parseEnvFile(readFileSync(envFile, 'utf8')) : {};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type FusekiEnv = { baseUrl: string; user: string; password: string };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The Fuseki the tests talk to. services/api/.env is the source, and the shell wins: FUSEKI_BASE_URL, or
|
|
35
|
+
* FUSEKI_PORT (the Compose host port) as a shorthand for localhost.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveFusekiEnv(env: NodeJS.ProcessEnv = process.env): FusekiEnv {
|
|
38
|
+
const file = readApiEnvFile();
|
|
39
|
+
const baseUrl =
|
|
40
|
+
env.FUSEKI_BASE_URL ||
|
|
41
|
+
(env.FUSEKI_PORT ? `http://localhost:${env.FUSEKI_PORT}` : '') ||
|
|
42
|
+
file.FUSEKI_BASE_URL ||
|
|
43
|
+
'http://localhost:3030';
|
|
44
|
+
return {
|
|
45
|
+
baseUrl: baseUrl.replace(/\/$/, ''),
|
|
46
|
+
user: env.FUSEKI_USER || file.FUSEKI_USER || 'admin',
|
|
47
|
+
password: env.FUSEKI_PASSWORD || file.FUSEKI_PASSWORD || 'admin',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
// Before the integration tests: check that the target is this repo's Compose Fuseki, reset the in-memory test
|
|
3
|
+
// dataset, start the API-only backend.
|
|
4
|
+
import { type ChildProcess, spawn, spawnSync } from 'node:child_process';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import { API_PORT, API_URL, FUSEKI_DATASET, apiDir, readApiEnvFile, repoRoot, resolveFusekiEnv } from './apiEnv';
|
|
8
|
+
|
|
9
|
+
// The variables that select S3FileStore (services/api/src/fileStoreEnv.ts); blanked even when unset anywhere.
|
|
10
|
+
const S3_ENV_KEYS = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION', 'S3_BUCKET_ENDPOINT', 'S3_FILES_BUCKET_NAME'];
|
|
11
|
+
|
|
12
|
+
// Test hooks: INTEGRATION_API_BIN replaces `linked` (e.g. with a process that never answers) and
|
|
13
|
+
// INTEGRATION_API_TIMEOUT_MS shortens the wait, to exercise the failure cleanup.
|
|
14
|
+
const apiBin = process.env.INTEGRATION_API_BIN || resolve(repoRoot, 'node_modules/.bin/linked');
|
|
15
|
+
const startTimeoutMs = Number(process.env.INTEGRATION_API_TIMEOUT_MS) || 60_000;
|
|
16
|
+
|
|
17
|
+
async function answers(url: string): Promise<boolean> {
|
|
18
|
+
try {
|
|
19
|
+
await fetch(url, { signal: AbortSignal.timeout(1000) });
|
|
20
|
+
return true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Refuses unless the target is safe to wipe: the dataset name ends in `-test`, and the Fuseki URL is on localhost at
|
|
28
|
+
* the host port Docker Compose publishes for this repo's `fuseki` service (so never another project's Fuseki).
|
|
29
|
+
*/
|
|
30
|
+
function assertSafeTarget(baseUrl: string) {
|
|
31
|
+
const target = `${baseUrl}/$/datasets/${FUSEKI_DATASET}`;
|
|
32
|
+
const refuse = (reason: string): never => {
|
|
33
|
+
throw new Error(`Refusing to reset ${target}: ${reason}`);
|
|
34
|
+
};
|
|
35
|
+
if (!FUSEKI_DATASET.endsWith('-test')) refuse('the dataset name does not end in "-test".');
|
|
36
|
+
|
|
37
|
+
const url = new URL(baseUrl);
|
|
38
|
+
if (!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) refuse(`${url.hostname} is not localhost.`);
|
|
39
|
+
const urlPort = url.port || (url.protocol === 'https:' ? '443' : '80');
|
|
40
|
+
|
|
41
|
+
const compose = spawnSync('docker', ['compose', 'port', 'fuseki', '3030'], { cwd: repoRoot, encoding: 'utf8' });
|
|
42
|
+
const composePort = compose.status === 0 ? /:(\d+)\s*$/.exec(compose.stdout.trim())?.[1] : undefined;
|
|
43
|
+
if (!composePort) {
|
|
44
|
+
refuse(
|
|
45
|
+
`this repo's Compose Fuseki is not running (\`docker compose port fuseki 3030\`: ` +
|
|
46
|
+
`${(compose.stderr || compose.error?.message || compose.stdout || '').trim()}). Run \`npm run fuseki:up\`.`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (composePort !== urlPort) {
|
|
50
|
+
refuse(`port ${urlPort} is not the Compose Fuseki port ${composePort}. Set FUSEKI_PORT=${composePort}.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Kills the API's whole process group; safe to call repeatedly and after it exited. */
|
|
55
|
+
function killGroup(api: ChildProcess, signal: NodeJS.Signals = 'SIGTERM') {
|
|
56
|
+
if (!api.pid) return;
|
|
57
|
+
try {
|
|
58
|
+
process.kill(-api.pid, signal);
|
|
59
|
+
} catch {
|
|
60
|
+
// Already gone.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export default async function globalSetup() {
|
|
65
|
+
const fuseki = resolveFusekiEnv();
|
|
66
|
+
|
|
67
|
+
// 1. Refuse a foreign target before anything is deleted.
|
|
68
|
+
assertSafeTarget(fuseki.baseUrl);
|
|
69
|
+
|
|
70
|
+
// 2. Fail fast when Fuseki is down. FUSEKI_BASE_URL is passed so the script does not read services/api/.env.
|
|
71
|
+
const wait = spawnSync(process.execPath, [resolve(apiDir, 'scripts/wait-for-fuseki.mjs')], {
|
|
72
|
+
env: { ...process.env, FUSEKI_BASE_URL: fuseki.baseUrl },
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
});
|
|
75
|
+
if (wait.status !== 0) throw new Error(wait.stderr.trim() || `wait-for-fuseki exited ${wait.status}`);
|
|
76
|
+
|
|
77
|
+
if (await answers(API_URL)) {
|
|
78
|
+
throw new Error(`Port ${API_PORT} is already in use; stop the process on it before running integration tests.`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 3. Reset: drop the dataset; the API's ensureDatasetExists() recreates it empty on boot.
|
|
82
|
+
const auth = `Basic ${Buffer.from(`${fuseki.user}:${fuseki.password}`).toString('base64')}`;
|
|
83
|
+
const del = await fetch(`${fuseki.baseUrl}/$/datasets/${FUSEKI_DATASET}`, {
|
|
84
|
+
method: 'DELETE',
|
|
85
|
+
headers: { Authorization: auth },
|
|
86
|
+
});
|
|
87
|
+
if (!del.ok && del.status !== 404) {
|
|
88
|
+
throw new Error(`Deleting Fuseki dataset ${FUSEKI_DATASET} failed: ${del.status} ${await del.text()}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 4. Start the API. Every Fuseki variable is explicit, and the S3 ones are blanked, so nothing is taken from
|
|
92
|
+
// services/api/.env (`linked start` does not override variables that are already set, even when empty).
|
|
93
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
94
|
+
const s3Keys = [...Object.keys(process.env), ...Object.keys(readApiEnvFile()), ...S3_ENV_KEYS];
|
|
95
|
+
for (const key of s3Keys) if (/^(AWS|S3)_/.test(key)) env[key] = '';
|
|
96
|
+
// Except AWS_REGION: @_linked/s3 builds a client at import with `AWS_REGION ?? 'us-east-1'`, and an empty region
|
|
97
|
+
// throws "Region is missing". Removed instead; the blank S3_FILES_BUCKET_NAME already rules out S3FileStore.
|
|
98
|
+
delete env.AWS_REGION;
|
|
99
|
+
Object.assign(env, {
|
|
100
|
+
NODE_ENV: 'development',
|
|
101
|
+
PORT: String(API_PORT),
|
|
102
|
+
SITE_ROOT: API_URL,
|
|
103
|
+
DATA_ROOT: `${API_URL}/data`,
|
|
104
|
+
FUSEKI_BASE_URL: fuseki.baseUrl,
|
|
105
|
+
FUSEKI_USER: fuseki.user,
|
|
106
|
+
FUSEKI_PASSWORD: fuseki.password,
|
|
107
|
+
FUSEKI_DATASET,
|
|
108
|
+
FUSEKI_DB_TYPE: 'mem',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
let output = '';
|
|
112
|
+
const api = spawn(apiBin, ['start', '--api-only'], {
|
|
113
|
+
cwd: apiDir,
|
|
114
|
+
env,
|
|
115
|
+
// `linked start` spawns its own children (Vite, the server). `detached` gives the CLI its own process group, so
|
|
116
|
+
// one `kill(-pid)` stops all of them; killing only the CLI's PID would leave the server listening on the port.
|
|
117
|
+
detached: true,
|
|
118
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
119
|
+
});
|
|
120
|
+
api.stdout?.on('data', (d) => (output += d));
|
|
121
|
+
api.stderr?.on('data', (d) => (output += d));
|
|
122
|
+
let exited: number | null = null;
|
|
123
|
+
api.on('exit', (code) => (exited = code ?? -1));
|
|
124
|
+
|
|
125
|
+
// A detached group outlives Jest unless it is killed: on exit, Ctrl+C and SIGTERM too, not only in teardown.
|
|
126
|
+
const onExit = () => killGroup(api, 'SIGKILL');
|
|
127
|
+
const onSignal = (signal: NodeJS.Signals) => {
|
|
128
|
+
killGroup(api);
|
|
129
|
+
// Re-raise when no one else handles it, so the default (terminate) still happens.
|
|
130
|
+
if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
|
|
131
|
+
};
|
|
132
|
+
process.once('exit', onExit);
|
|
133
|
+
process.once('SIGINT', onSignal);
|
|
134
|
+
process.once('SIGTERM', onSignal);
|
|
135
|
+
const stop = () => {
|
|
136
|
+
process.off('exit', onExit);
|
|
137
|
+
process.off('SIGINT', onSignal);
|
|
138
|
+
process.off('SIGTERM', onSignal);
|
|
139
|
+
killGroup(api);
|
|
140
|
+
};
|
|
141
|
+
(globalThis as any).__LINKED_API__ = { api, stop, killGroup: (signal: NodeJS.Signals) => killGroup(api, signal) };
|
|
142
|
+
|
|
143
|
+
const fail = (message: string): never => {
|
|
144
|
+
stop();
|
|
145
|
+
throw new Error(message);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// 5. Wait until it answers HTTP (page routes 404, which is fine).
|
|
149
|
+
const deadline = Date.now() + startTimeoutMs;
|
|
150
|
+
while (Date.now() < deadline) {
|
|
151
|
+
if (exited !== null) fail(`The API exited with ${exited} before it answered:\n${output}`);
|
|
152
|
+
if (await answers(API_URL)) return;
|
|
153
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
154
|
+
}
|
|
155
|
+
fail(`The API did not answer on ${API_URL} within ${startTimeoutMs / 1000} s:\n${output}`);
|
|
156
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
// Stops the API started by globalSetup (its whole process group) and removes its exit and signal handlers.
|
|
3
|
+
import type { ChildProcess } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
type StartedApi = { api: ChildProcess; stop: () => void; killGroup: (signal: NodeJS.Signals) => void };
|
|
6
|
+
|
|
7
|
+
export default async function globalTeardown() {
|
|
8
|
+
const started: StartedApi | undefined = (globalThis as any).__LINKED_API__;
|
|
9
|
+
if (!started) return;
|
|
10
|
+
const { api, stop, killGroup } = started;
|
|
11
|
+
if (!api.pid || api.exitCode !== null || api.signalCode !== null) {
|
|
12
|
+
stop();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const exited = new Promise((r) => api.once('exit', r));
|
|
16
|
+
stop();
|
|
17
|
+
const timer = setTimeout(() => killGroup('SIGKILL'), 5000);
|
|
18
|
+
await exited;
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
}
|