@_linked/cli 1.13.0 → 1.15.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 +71 -0
- package/README.md +78 -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__/composePort.test.ts +33 -0
- package/defaults/app-react-native/apps/mobile/__tests__/env.test.ts +39 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/apiEnv.ts +75 -0
- package/defaults/app-react-native/apps/mobile/__tests__/integration/globalSetup.ts +160 -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 +17 -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 +7 -2
- package/lib/esm/cli-methods.d.ts.map +1 -1
- package/lib/esm/cli-methods.js +147 -104
- package/lib/esm/cli-methods.js.map +1 -1
- package/lib/esm/cli.js +24 -11
- package/lib/esm/cli.js.map +1 -1
- package/lib/esm/commands/start.d.ts.map +1 -1
- package/lib/esm/commands/start.js +12 -8
- package/lib/esm/commands/start.js.map +1 -1
- package/lib/esm/utils/esmSpecifiers.d.ts +12 -2
- package/lib/esm/utils/esmSpecifiers.d.ts.map +1 -1
- package/lib/esm/utils/esmSpecifiers.js +57 -14
- package/lib/esm/utils/esmSpecifiers.js.map +1 -1
- 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/utils.d.ts +16 -7
- package/lib/esm/utils.d.ts.map +1 -1
- package/lib/esm/utils.js +92 -40
- package/lib/esm/utils.js.map +1 -1
- package/lib/esm/vite-config.js +6 -6
- 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
|
@@ -0,0 +1,160 @@
|
|
|
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 {
|
|
8
|
+
API_PORT,
|
|
9
|
+
API_URL,
|
|
10
|
+
FUSEKI_DATASET,
|
|
11
|
+
apiDir,
|
|
12
|
+
describeComposePortFailure,
|
|
13
|
+
readApiEnvFile,
|
|
14
|
+
repoRoot,
|
|
15
|
+
resolveFusekiEnv,
|
|
16
|
+
} from './apiEnv';
|
|
17
|
+
|
|
18
|
+
// The variables that select S3FileStore (services/api/src/fileStoreEnv.ts); blanked even when unset anywhere.
|
|
19
|
+
const S3_ENV_KEYS = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY', 'AWS_REGION', 'S3_BUCKET_ENDPOINT', 'S3_FILES_BUCKET_NAME'];
|
|
20
|
+
|
|
21
|
+
// Test hooks: INTEGRATION_API_BIN replaces `linked` (e.g. with a process that never answers) and
|
|
22
|
+
// INTEGRATION_API_TIMEOUT_MS shortens the wait, to exercise the failure cleanup.
|
|
23
|
+
const apiBin = process.env.INTEGRATION_API_BIN || resolve(repoRoot, 'node_modules/.bin/linked');
|
|
24
|
+
const startTimeoutMs = Number(process.env.INTEGRATION_API_TIMEOUT_MS) || 60_000;
|
|
25
|
+
|
|
26
|
+
async function answers(url: string): Promise<boolean> {
|
|
27
|
+
try {
|
|
28
|
+
await fetch(url, { signal: AbortSignal.timeout(1000) });
|
|
29
|
+
return true;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Refuses unless the target is safe to wipe: the dataset name ends in `-test`, and the Fuseki URL is on localhost at
|
|
37
|
+
* the host port Docker Compose publishes for this repo's `fuseki` service (so never another project's Fuseki).
|
|
38
|
+
*/
|
|
39
|
+
function assertSafeTarget(baseUrl: string) {
|
|
40
|
+
const target = `${baseUrl}/$/datasets/${FUSEKI_DATASET}`;
|
|
41
|
+
const refuse = (reason: string): never => {
|
|
42
|
+
throw new Error(`Refusing to reset ${target}: ${reason}`);
|
|
43
|
+
};
|
|
44
|
+
if (!FUSEKI_DATASET.endsWith('-test')) refuse('the dataset name does not end in "-test".');
|
|
45
|
+
|
|
46
|
+
const url = new URL(baseUrl);
|
|
47
|
+
if (!['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) refuse(`${url.hostname} is not localhost.`);
|
|
48
|
+
const urlPort = url.port || (url.protocol === 'https:' ? '443' : '80');
|
|
49
|
+
|
|
50
|
+
const compose = spawnSync('docker', ['compose', 'port', 'fuseki', '3030'], { cwd: repoRoot, encoding: 'utf8' });
|
|
51
|
+
const composePort = compose.status === 0 ? /:(\d+)\s*$/.exec(compose.stdout.trim())?.[1] : undefined;
|
|
52
|
+
if (!composePort) refuse(describeComposePortFailure(compose));
|
|
53
|
+
if (composePort !== urlPort) {
|
|
54
|
+
refuse(`port ${urlPort} is not the Compose Fuseki port ${composePort}. Set FUSEKI_PORT=${composePort}.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Kills the API's whole process group; safe to call repeatedly and after it exited. */
|
|
59
|
+
function killGroup(api: ChildProcess, signal: NodeJS.Signals = 'SIGTERM') {
|
|
60
|
+
if (!api.pid) return;
|
|
61
|
+
try {
|
|
62
|
+
process.kill(-api.pid, signal);
|
|
63
|
+
} catch {
|
|
64
|
+
// Already gone.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default async function globalSetup() {
|
|
69
|
+
const fuseki = resolveFusekiEnv();
|
|
70
|
+
|
|
71
|
+
// 1. Refuse a foreign target before anything is deleted.
|
|
72
|
+
assertSafeTarget(fuseki.baseUrl);
|
|
73
|
+
|
|
74
|
+
// 2. Fail fast when Fuseki is down. FUSEKI_BASE_URL is passed so the script does not read services/api/.env.
|
|
75
|
+
const wait = spawnSync(process.execPath, [resolve(apiDir, 'scripts/wait-for-fuseki.mjs')], {
|
|
76
|
+
env: { ...process.env, FUSEKI_BASE_URL: fuseki.baseUrl },
|
|
77
|
+
encoding: 'utf8',
|
|
78
|
+
});
|
|
79
|
+
if (wait.status !== 0) throw new Error(wait.stderr.trim() || `wait-for-fuseki exited ${wait.status}`);
|
|
80
|
+
|
|
81
|
+
if (await answers(API_URL)) {
|
|
82
|
+
throw new Error(`Port ${API_PORT} is already in use; stop the process on it before running integration tests.`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 3. Reset: drop the dataset; the API's ensureDatasetExists() recreates it empty on boot.
|
|
86
|
+
const auth = `Basic ${Buffer.from(`${fuseki.user}:${fuseki.password}`).toString('base64')}`;
|
|
87
|
+
const del = await fetch(`${fuseki.baseUrl}/$/datasets/${FUSEKI_DATASET}`, {
|
|
88
|
+
method: 'DELETE',
|
|
89
|
+
headers: { Authorization: auth },
|
|
90
|
+
});
|
|
91
|
+
if (!del.ok && del.status !== 404) {
|
|
92
|
+
throw new Error(`Deleting Fuseki dataset ${FUSEKI_DATASET} failed: ${del.status} ${await del.text()}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 4. Start the API. Every Fuseki variable is explicit, and the S3 ones are blanked, so nothing is taken from
|
|
96
|
+
// services/api/.env (`linked start` does not override variables that are already set, even when empty).
|
|
97
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
98
|
+
const s3Keys = [...Object.keys(process.env), ...Object.keys(readApiEnvFile()), ...S3_ENV_KEYS];
|
|
99
|
+
for (const key of s3Keys) if (/^(AWS|S3)_/.test(key)) env[key] = '';
|
|
100
|
+
// Except AWS_REGION: @_linked/s3 builds a client at import with `AWS_REGION ?? 'us-east-1'`, and an empty region
|
|
101
|
+
// throws "Region is missing". Removed instead; the blank S3_FILES_BUCKET_NAME already rules out S3FileStore.
|
|
102
|
+
delete env.AWS_REGION;
|
|
103
|
+
Object.assign(env, {
|
|
104
|
+
NODE_ENV: 'development',
|
|
105
|
+
PORT: String(API_PORT),
|
|
106
|
+
SITE_ROOT: API_URL,
|
|
107
|
+
DATA_ROOT: `${API_URL}/data`,
|
|
108
|
+
FUSEKI_BASE_URL: fuseki.baseUrl,
|
|
109
|
+
FUSEKI_USER: fuseki.user,
|
|
110
|
+
FUSEKI_PASSWORD: fuseki.password,
|
|
111
|
+
FUSEKI_DATASET,
|
|
112
|
+
FUSEKI_DB_TYPE: 'mem',
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
let output = '';
|
|
116
|
+
const api = spawn(apiBin, ['start', '--api-only'], {
|
|
117
|
+
cwd: apiDir,
|
|
118
|
+
env,
|
|
119
|
+
// `linked start` spawns its own children (Vite, the server). `detached` gives the CLI its own process group, so
|
|
120
|
+
// one `kill(-pid)` stops all of them; killing only the CLI's PID would leave the server listening on the port.
|
|
121
|
+
detached: true,
|
|
122
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
123
|
+
});
|
|
124
|
+
api.stdout?.on('data', (d) => (output += d));
|
|
125
|
+
api.stderr?.on('data', (d) => (output += d));
|
|
126
|
+
let exited: number | null = null;
|
|
127
|
+
api.on('exit', (code) => (exited = code ?? -1));
|
|
128
|
+
|
|
129
|
+
// A detached group outlives Jest unless it is killed: on exit, Ctrl+C and SIGTERM too, not only in teardown.
|
|
130
|
+
const onExit = () => killGroup(api, 'SIGKILL');
|
|
131
|
+
const onSignal = (signal: NodeJS.Signals) => {
|
|
132
|
+
killGroup(api);
|
|
133
|
+
// Re-raise when no one else handles it, so the default (terminate) still happens.
|
|
134
|
+
if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
|
|
135
|
+
};
|
|
136
|
+
process.once('exit', onExit);
|
|
137
|
+
process.once('SIGINT', onSignal);
|
|
138
|
+
process.once('SIGTERM', onSignal);
|
|
139
|
+
const stop = () => {
|
|
140
|
+
process.off('exit', onExit);
|
|
141
|
+
process.off('SIGINT', onSignal);
|
|
142
|
+
process.off('SIGTERM', onSignal);
|
|
143
|
+
killGroup(api);
|
|
144
|
+
};
|
|
145
|
+
(globalThis as any).__LINKED_API__ = { api, stop, killGroup: (signal: NodeJS.Signals) => killGroup(api, signal) };
|
|
146
|
+
|
|
147
|
+
const fail = (message: string): never => {
|
|
148
|
+
stop();
|
|
149
|
+
throw new Error(message);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// 5. Wait until it answers HTTP (page routes 404, which is fine).
|
|
153
|
+
const deadline = Date.now() + startTimeoutMs;
|
|
154
|
+
while (Date.now() < deadline) {
|
|
155
|
+
if (exited !== null) fail(`The API exited with ${exited} before it answered:\n${output}`);
|
|
156
|
+
if (await answers(API_URL)) return;
|
|
157
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
158
|
+
}
|
|
159
|
+
fail(`The API did not answer on ${API_URL} within ${startTimeoutMs / 1000} s:\n${output}`);
|
|
160
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The react-native test environment (jest-environment-node) with Node's own fetch saved before any setup file
|
|
2
|
+
// runs. jest-expo's setup and `expo`'s winter runtime replace globalThis.fetch with `expo/fetch`, whose native
|
|
3
|
+
// module jest-expo stubs (it never sends a request). restoreNodeFetch.ts puts Node's fetch back.
|
|
4
|
+
const ReactNativeEnv = require('@react-native/jest-preset/jest/react-native-env.js');
|
|
5
|
+
|
|
6
|
+
const FETCH_GLOBALS = ['fetch', 'Headers', 'Request', 'Response', 'FormData'];
|
|
7
|
+
|
|
8
|
+
module.exports = class NodeFetchEnvironment extends ReactNativeEnv {
|
|
9
|
+
constructor(config, context) {
|
|
10
|
+
super(config, context);
|
|
11
|
+
this.global.__NODE_FETCH_GLOBALS__ = Object.fromEntries(FETCH_GLOBALS.map((name) => [name, globalThis[name]]));
|
|
12
|
+
}
|
|
13
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The example PersonOverview screen with the real BackendAPIStore and real fetch, against the API started by
|
|
2
|
+
// globalSetup on the reset app-test dataset: list 0, add (1), edit (the name changes and persists), delete (0).
|
|
3
|
+
import '@_linked/react/native';
|
|
4
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
|
|
5
|
+
|
|
6
|
+
import { PersonOverview } from '../../src/components/PersonOverview';
|
|
7
|
+
import { API_URL } from './apiEnv';
|
|
8
|
+
|
|
9
|
+
// env.ts resolves the API URL from Expo's config; point it at the test API. (jest.mock is hoisted above imports.)
|
|
10
|
+
jest.mock('expo-constants', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
default: { expoConfig: { extra: { apiUrl: 'http://localhost:4100', apiPort: 4000 } } },
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const WAIT = { timeout: 10000 };
|
|
16
|
+
|
|
17
|
+
describe('PersonOverview against the API', () => {
|
|
18
|
+
it('uses the test API and a real fetch', () => {
|
|
19
|
+
expect(process.env.SITE_ROOT).toBe(API_URL);
|
|
20
|
+
// Node's fetch, not jest-expo's stubbed expo/fetch (see nodeFetchEnvironment.js).
|
|
21
|
+
expect(globalThis.fetch).toBe((globalThis as any).__NODE_FETCH_GLOBALS__.fetch);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('lists 0, adds one (1), edits it, deletes it (0)', async () => {
|
|
25
|
+
await render(<PersonOverview />);
|
|
26
|
+
await waitFor(() => expect(screen.getByTestId('person-empty')).toBeOnTheScreen(), WAIT);
|
|
27
|
+
|
|
28
|
+
// Add
|
|
29
|
+
await fireEvent.changeText(screen.getByTestId('person-add-given'), 'Ada');
|
|
30
|
+
await fireEvent.changeText(screen.getByTestId('person-add-family'), 'Lovelace');
|
|
31
|
+
await fireEvent.press(screen.getByTestId('person-add'));
|
|
32
|
+
await waitFor(() => expect(screen.getAllByTestId('person-row')).toHaveLength(1), WAIT);
|
|
33
|
+
await waitFor(() => expect(screen.getByTestId('person-name')).toHaveTextContent('Ada Lovelace'), WAIT);
|
|
34
|
+
|
|
35
|
+
// Edit
|
|
36
|
+
await fireEvent.press(screen.getByTestId('person-edit-button'));
|
|
37
|
+
await fireEvent.changeText(screen.getByTestId('person-edit-given'), 'Grace');
|
|
38
|
+
await fireEvent.press(screen.getByTestId('person-save'));
|
|
39
|
+
await waitFor(() => expect(screen.getByTestId('person-name')).toHaveTextContent('Grace Lovelace'), WAIT);
|
|
40
|
+
|
|
41
|
+
// The edit is stored, not only patched locally: a fresh mount reads it back from the API.
|
|
42
|
+
await screen.unmount();
|
|
43
|
+
await render(<PersonOverview />);
|
|
44
|
+
await waitFor(() => expect(screen.getByTestId('person-name')).toHaveTextContent('Grace Lovelace'), WAIT);
|
|
45
|
+
|
|
46
|
+
// Delete
|
|
47
|
+
await fireEvent.press(screen.getByTestId('person-delete'));
|
|
48
|
+
await waitFor(() => expect(screen.getByTestId('person-empty')).toBeOnTheScreen(), WAIT);
|
|
49
|
+
expect(screen.queryAllByTestId('person-row')).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Runs after the setup files: installs expo's winter runtime first (it swaps in a stubbed `expo/fetch`), then
|
|
2
|
+
// restores Node's fetch globals saved by nodeFetchEnvironment.js, so BackendAPIStore reaches the real API.
|
|
3
|
+
require('expo');
|
|
4
|
+
|
|
5
|
+
const saved: Record<string, unknown> = (globalThis as any).__NODE_FETCH_GLOBALS__;
|
|
6
|
+
for (const [name, value] of Object.entries(saved)) {
|
|
7
|
+
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true, enumerable: true });
|
|
8
|
+
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
// Asserts the cause of the React Native crash in @_linked/react's
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
import '../src/shell/linkedDefaults';
|
|
5
|
-
|
|
1
|
+
// Asserts the cause of the React Native crash in @_linked/react's root loader and error elements: they render
|
|
2
|
+
// DOM host elements (<svg>), which have no native view config. @_linked/react/native replaces them on import;
|
|
3
|
+
// importing linkedComponent from the root barrel instead must make both tests fail with Received: ["svg", ...].
|
|
6
4
|
import { act, render, screen } from '@testing-library/react-native';
|
|
7
5
|
import { LinkedStorage } from '@_linked/core/utils/LinkedStorage';
|
|
8
6
|
import { setQueryDispatch } from '@_linked/core/queries/queryDispatch';
|
|
9
|
-
import { linkedComponent } from '@_linked/react';
|
|
7
|
+
import { linkedComponent } from '@_linked/react/native';
|
|
10
8
|
import { Text } from 'react-native';
|
|
11
9
|
import { Example } from 'app-shapes';
|
|
12
10
|
|
|
@@ -44,7 +42,7 @@ function lowercaseHostTypes(tree: Node | Node[] | null): string[] {
|
|
|
44
42
|
|
|
45
43
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
46
44
|
|
|
47
|
-
describe('
|
|
45
|
+
describe('@_linked/react/native render defaults', () => {
|
|
48
46
|
test('a loading component renders only React Native elements', async () => {
|
|
49
47
|
mode = 'loading';
|
|
50
48
|
await render(<ExampleLabel of={{ id: 'urn:example:1' }} />);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Importing storage alone must be enough: it pulls ./env, so SITE_ROOT is set before BackendAPIStore is constructed.
|
|
2
|
+
jest.mock('expo-constants', () => ({
|
|
3
|
+
__esModule: true,
|
|
4
|
+
default: { expoConfig: { extra: { apiUrl: 'http://api.test:9000', apiPort: 4000 } } },
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
const constructedWith: (string | undefined)[] = [];
|
|
8
|
+
jest.mock('@_linked/server/shapes/quadstores/BackendAPIStore', () => ({
|
|
9
|
+
BackendAPIStore: class {
|
|
10
|
+
constructor() {
|
|
11
|
+
constructedWith.push(process.env.SITE_ROOT);
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
test('importing src/shell/storage sets SITE_ROOT before BackendAPIStore is constructed', () => {
|
|
17
|
+
delete process.env.SITE_ROOT;
|
|
18
|
+
delete process.env.DATA_ROOT;
|
|
19
|
+
|
|
20
|
+
require('../src/shell/storage');
|
|
21
|
+
|
|
22
|
+
expect(constructedWith).toEqual(['http://api.test:9000']);
|
|
23
|
+
expect(process.env.DATA_ROOT).toBe('http://api.test:9000/data');
|
|
24
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { transformSync } from '@babel/core';
|
|
2
|
+
|
|
3
|
+
import stripJsonImportAttributes from '../babel/stripJsonImportAttributes';
|
|
4
|
+
|
|
5
|
+
const transform = (code: string, withPlugin = true, parserOpts: Record<string, unknown> = {}) =>
|
|
6
|
+
transformSync(code, {
|
|
7
|
+
babelrc: false,
|
|
8
|
+
configFile: false,
|
|
9
|
+
parserOpts,
|
|
10
|
+
plugins: withPlugin ? [stripJsonImportAttributes] : [],
|
|
11
|
+
})!.code;
|
|
12
|
+
|
|
13
|
+
// The plugin must produce exactly what Babel prints without it.
|
|
14
|
+
const expectUnchanged = (code: string) => expect(transform(code)).toBe(transform(code, false));
|
|
15
|
+
|
|
16
|
+
describe('stripJsonImportAttributes', () => {
|
|
17
|
+
test('drops the options of a dynamic JSON import', () => {
|
|
18
|
+
expect(transform("import('./a.json', { with: { type: 'json' } });")).toBe("import('./a.json');");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('handles a template literal specifier', () => {
|
|
22
|
+
expect(transform("import(`./a.json`, { with: { type: 'json' } });")).toBe('import(`./a.json`);');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('handles ImportExpression nodes (createImportExpressions)', () => {
|
|
26
|
+
const code = "import('./a.json', { with: { type: 'json' } });";
|
|
27
|
+
expect(transform(code, true, { createImportExpressions: true })).toBe("import('./a.json');");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('leaves a non-JSON dynamic import unchanged', () => {
|
|
31
|
+
expectUnchanged("import('./a.js', { x: 1 });");
|
|
32
|
+
expect(transform("import('./a.js', { x: 1 });")).toContain('x: 1');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('leaves a non-literal specifier unchanged', () => {
|
|
36
|
+
expectUnchanged("import(variable, { with: { type: 'json' } });");
|
|
37
|
+
expect(transform("import(variable, { with: { type: 'json' } });")).toContain("type: 'json'");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('leaves a static JSON import with attributes unchanged', () => {
|
|
41
|
+
const code = "import d from './d.json' with { type: 'json' };";
|
|
42
|
+
expectUnchanged(code);
|
|
43
|
+
expect(transform(code)).toContain("with { type: 'json' }");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ConfigContext, ExpoConfig } from 'expo/config';
|
|
2
|
+
|
|
3
|
+
// The default API port lives in src/shell/apiPort.json (services/api/.env.example PORT), shared with env.ts. JSON,
|
|
4
|
+
// because Expo's config loader cannot import a TypeScript module.
|
|
5
|
+
import { defaultApiPort } from './src/shell/apiPort.json';
|
|
6
|
+
|
|
7
|
+
// src/shell/env.ts combines the API port with the dev machine's host when EXPO_PUBLIC_API_URL is not set, so the
|
|
8
|
+
// Simulator and a device on the LAN both work.
|
|
9
|
+
const API_PORT = defaultApiPort;
|
|
10
|
+
|
|
11
|
+
// The static app identity (name, slug, bundle identifier) stays in app.json; Expo passes it in as `config`.
|
|
12
|
+
export default ({ config }: ConfigContext): ExpoConfig => ({
|
|
13
|
+
...config,
|
|
14
|
+
name: config.name ?? 'App',
|
|
15
|
+
slug: config.slug ?? 'app',
|
|
16
|
+
extra: {
|
|
17
|
+
...config.extra,
|
|
18
|
+
apiUrl: process.env.EXPO_PUBLIC_API_URL ?? null,
|
|
19
|
+
apiPort: API_PORT,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Babel plugin: drop import attributes from dynamic `import()` of JSON files.
|
|
3
|
+
*
|
|
4
|
+
* Linked ontology packages load their data with
|
|
5
|
+
* import('../data/x.json', { with: { type: 'json' } })
|
|
6
|
+
* Node requires the attribute for JSON, so the packages keep it. Metro's dependency collector rejects any
|
|
7
|
+
* `import()` with more than one argument ("Invalid call at line N"). This plugin removes the options argument
|
|
8
|
+
* when the specifier is a static string ending in `.json`, so Metro bundles it as an ordinary async JSON module.
|
|
9
|
+
* Everything else, including static `import x from './a.json' with { type: 'json' }`, is left untouched.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
function staticSpecifier(node) {
|
|
13
|
+
if (!node) return null;
|
|
14
|
+
if (node.type === 'StringLiteral') return node.value;
|
|
15
|
+
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0].value.cooked;
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const isJson = (specifier) => typeof specifier === 'string' && /\.json$/i.test(specifier);
|
|
20
|
+
|
|
21
|
+
module.exports = function stripJsonImportAttributes() {
|
|
22
|
+
return {
|
|
23
|
+
name: 'strip-json-import-attributes',
|
|
24
|
+
visitor: {
|
|
25
|
+
// Default parser output (what Metro and babel-preset-expo use): `import(...)` is a CallExpression with an
|
|
26
|
+
// `Import` callee.
|
|
27
|
+
CallExpression(path) {
|
|
28
|
+
const { callee, arguments: args } = path.node;
|
|
29
|
+
if (callee.type !== 'Import' || args.length < 2) return;
|
|
30
|
+
if (!isJson(staticSpecifier(args[0]))) return;
|
|
31
|
+
path.node.arguments = [args[0]];
|
|
32
|
+
},
|
|
33
|
+
// With the parser option `createImportExpressions`, it is an ImportExpression with `source` and `options`.
|
|
34
|
+
ImportExpression(path) {
|
|
35
|
+
const { node } = path;
|
|
36
|
+
if (!node.options || !isJson(staticSpecifier(node.source))) return;
|
|
37
|
+
node.options = null;
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Metro passes this file as `extends` for every module it transforms, node_modules included (so @_linked/*), and
|
|
2
|
+
// Jest (jest-expo) picks it up too. No root babel.config.js is needed: Metro's Babel root is apps/mobile.
|
|
3
|
+
module.exports = function (api) {
|
|
4
|
+
// Cached for the process lifetime and in Metro's transform cache: after editing this file (or the plugin),
|
|
5
|
+
// restart with `npx expo start --clear`.
|
|
6
|
+
api.cache(true);
|
|
7
|
+
return {
|
|
8
|
+
// babel-preset-expo is installed nested under expo; resolve it from there, as Expo's own default config does.
|
|
9
|
+
presets: [require.resolve('babel-preset-expo', { paths: [require.resolve('expo/package.json')] })],
|
|
10
|
+
// Metro rejects `import('x.json', { with: { type: 'json' } })`, used by every Linked ontology package.
|
|
11
|
+
plugins: [require.resolve('./babel/stripJsonImportAttributes')],
|
|
12
|
+
};
|
|
13
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Integration tests against a real API and Fuseki: `npm run fuseki:up && npm run test:integration`.
|
|
2
|
+
// Same preset, transforms and module mapping as the unit config in package.json. jest-expo replaces the global
|
|
3
|
+
// fetch with a stubbed `expo/fetch` that never sends a request (it is not a jest mock), so the environment saves
|
|
4
|
+
// Node's fetch and restoreNodeFetch.ts reinstates it.
|
|
5
|
+
const { jest: unitConfig } = require('./package.json');
|
|
6
|
+
|
|
7
|
+
module.exports = {
|
|
8
|
+
...unitConfig,
|
|
9
|
+
testMatch: ['<rootDir>/__tests__/integration/**/*.test.[jt]s?(x)'],
|
|
10
|
+
testPathIgnorePatterns: ['/node_modules/'],
|
|
11
|
+
testEnvironment: '<rootDir>/__tests__/integration/nodeFetchEnvironment.js',
|
|
12
|
+
setupFilesAfterEnv: ['<rootDir>/__tests__/integration/restoreNodeFetch.ts'],
|
|
13
|
+
globalSetup: '<rootDir>/__tests__/integration/globalSetup.ts',
|
|
14
|
+
globalTeardown: '<rootDir>/__tests__/integration/globalTeardown.ts',
|
|
15
|
+
testTimeout: 30000,
|
|
16
|
+
};
|
|
@@ -7,13 +7,17 @@
|
|
|
7
7
|
"start": "expo start",
|
|
8
8
|
"ios": "expo run:ios",
|
|
9
9
|
"android": "expo run:android",
|
|
10
|
-
"test": "jest"
|
|
10
|
+
"test": "jest",
|
|
11
|
+
"test:integration": "jest -c jest.integration.config.js --runInBand"
|
|
11
12
|
},
|
|
12
13
|
"dependencies": {
|
|
13
|
-
"@_linked/core": "2.18.
|
|
14
|
-
"@_linked/react": "1.
|
|
14
|
+
"@_linked/core": "2.18.1",
|
|
15
|
+
"@_linked/react": "1.5.0",
|
|
16
|
+
"@_linked/schema": "1.1.2",
|
|
17
|
+
"@_linked/server": "2.3.0",
|
|
15
18
|
"app-shapes": "*",
|
|
16
19
|
"expo": "57.0.22",
|
|
20
|
+
"expo-constants": "57.0.18",
|
|
17
21
|
"expo-dev-client": "57.0.19",
|
|
18
22
|
"react": "19.2.3",
|
|
19
23
|
"react-native": "0.86.3"
|
|
@@ -30,6 +34,10 @@
|
|
|
30
34
|
},
|
|
31
35
|
"jest": {
|
|
32
36
|
"preset": "jest-expo",
|
|
37
|
+
"testPathIgnorePatterns": [
|
|
38
|
+
"/node_modules/",
|
|
39
|
+
"<rootDir>/__tests__/integration/"
|
|
40
|
+
],
|
|
33
41
|
"transformIgnorePatterns": [
|
|
34
42
|
"node_modules/(?!((jest-)?react-native|@react-native(-community)?|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|@_linked/.*|app-shapes))"
|
|
35
43
|
],
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Example PersonOverview — demonstrates @_linked/react's data binding:
|
|
2
|
+
// linkedSetComponent for the list (auto-runs the query, injects results and
|
|
3
|
+
// an injected _refresh); linkedComponent for each row (see PersonPreview).
|
|
4
|
+
// After Person.create the form calls _refresh() to re-run the list query.
|
|
5
|
+
// A React Native port of the web app-template's example (linked-cm/app-template).
|
|
6
|
+
//
|
|
7
|
+
// `linkedSetComponent` checks LinkedStorage when this module loads, so storage is imported first.
|
|
8
|
+
// Replace or extend this with your own shapes (see https://linked.cm).
|
|
9
|
+
import '../shell/storage';
|
|
10
|
+
|
|
11
|
+
import React, { useCallback, useRef, useState } from 'react';
|
|
12
|
+
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
|
|
13
|
+
import { linkedSetComponent } from '@_linked/react';
|
|
14
|
+
import { Person } from '@_linked/schema/shapes/Person';
|
|
15
|
+
import { PersonPreview } from './PersonPreview';
|
|
16
|
+
import { PersonListRefreshProvider } from './PersonOverviewContext';
|
|
17
|
+
|
|
18
|
+
const PersonList = linkedSetComponent(
|
|
19
|
+
Person.select((p) => [p.givenName, p.familyName]),
|
|
20
|
+
({ linkedData = [], _refresh, refreshRef }: any) => {
|
|
21
|
+
// Forward the list's _refresh up via a ref so the sibling form +
|
|
22
|
+
// child rows can trigger a re-fetch through context.
|
|
23
|
+
if (refreshRef) {
|
|
24
|
+
refreshRef.current = _refresh;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (linkedData.length === 0) {
|
|
28
|
+
return (
|
|
29
|
+
<Text style={styles.empty} testID="person-empty">
|
|
30
|
+
No people yet. Add one below.
|
|
31
|
+
</Text>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<View style={styles.list} testID="person-list">
|
|
37
|
+
{linkedData.map((p: { id: string }) => (
|
|
38
|
+
<PersonPreview key={p.id} of={{ id: p.id }} />
|
|
39
|
+
))}
|
|
40
|
+
</View>
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
function PersonAddForm({ onAdded }: { onAdded: () => void }) {
|
|
46
|
+
const [given, setGiven] = useState('');
|
|
47
|
+
const [family, setFamily] = useState('');
|
|
48
|
+
const [busy, setBusy] = useState(false);
|
|
49
|
+
|
|
50
|
+
async function submit() {
|
|
51
|
+
if (!given && !family) return;
|
|
52
|
+
setBusy(true);
|
|
53
|
+
try {
|
|
54
|
+
await Person.create({ givenName: given, familyName: family });
|
|
55
|
+
setGiven('');
|
|
56
|
+
setFamily('');
|
|
57
|
+
onAdded();
|
|
58
|
+
} finally {
|
|
59
|
+
setBusy(false);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<View style={styles.form}>
|
|
65
|
+
<TextInput
|
|
66
|
+
style={styles.input}
|
|
67
|
+
value={given}
|
|
68
|
+
placeholder="First name"
|
|
69
|
+
onChangeText={setGiven}
|
|
70
|
+
testID="person-add-given"
|
|
71
|
+
/>
|
|
72
|
+
<TextInput
|
|
73
|
+
style={styles.input}
|
|
74
|
+
value={family}
|
|
75
|
+
placeholder="Last name"
|
|
76
|
+
onChangeText={setFamily}
|
|
77
|
+
testID="person-add-family"
|
|
78
|
+
/>
|
|
79
|
+
<Pressable style={styles.button} onPress={submit} disabled={busy} testID="person-add">
|
|
80
|
+
<Text style={styles.buttonText}>Add</Text>
|
|
81
|
+
</Pressable>
|
|
82
|
+
</View>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function PersonOverview() {
|
|
87
|
+
const refreshRef = useRef<() => void>(() => {});
|
|
88
|
+
const refresh = useCallback(() => refreshRef.current(), []);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<PersonListRefreshProvider value={refresh}>
|
|
92
|
+
<View style={styles.root} testID="person-overview">
|
|
93
|
+
<Text style={styles.title}>People</Text>
|
|
94
|
+
<Text style={styles.intro}>
|
|
95
|
+
Example code using the @_linked query DSL against your local Fuseki dataset. Edit
|
|
96
|
+
src/components/PersonOverview.tsx to extend it.
|
|
97
|
+
</Text>
|
|
98
|
+
<PersonList refreshRef={refreshRef} />
|
|
99
|
+
<PersonAddForm onAdded={refresh} />
|
|
100
|
+
</View>
|
|
101
|
+
</PersonListRefreshProvider>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const styles = StyleSheet.create({
|
|
106
|
+
root: { alignSelf: 'stretch', padding: 16, gap: 12 },
|
|
107
|
+
title: { fontSize: 22, fontWeight: '600' },
|
|
108
|
+
intro: { color: '#555' },
|
|
109
|
+
empty: { color: '#888', fontStyle: 'italic' },
|
|
110
|
+
list: { gap: 8 },
|
|
111
|
+
form: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, alignItems: 'center' },
|
|
112
|
+
input: {
|
|
113
|
+
flexGrow: 1,
|
|
114
|
+
minWidth: 100,
|
|
115
|
+
borderWidth: 1,
|
|
116
|
+
borderColor: '#ccc',
|
|
117
|
+
borderRadius: 6,
|
|
118
|
+
paddingHorizontal: 8,
|
|
119
|
+
paddingVertical: 6,
|
|
120
|
+
},
|
|
121
|
+
button: { backgroundColor: '#222', borderRadius: 6, paddingHorizontal: 14, paddingVertical: 8 },
|
|
122
|
+
buttonText: { color: '#fff', fontWeight: '600' },
|
|
123
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Small context that lets PersonPreview rows trigger a list refresh on
|
|
2
|
+
// their parent PersonOverview without prop-drilling through the
|
|
3
|
+
// linkedSetComponent's wrapped renderer.
|
|
4
|
+
import React, { useContext } from 'react';
|
|
5
|
+
|
|
6
|
+
const PersonListRefreshContext = React.createContext<() => void>(() => {});
|
|
7
|
+
|
|
8
|
+
export const PersonListRefreshProvider = PersonListRefreshContext.Provider;
|
|
9
|
+
|
|
10
|
+
export function usePersonListRefresh(): () => void {
|
|
11
|
+
return useContext(PersonListRefreshContext);
|
|
12
|
+
}
|