@shipfox/client-shell 1.0.0 → 3.0.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.
- package/assets/apple-touch-icon.png +0 -0
- package/assets/favicon-96x96.png +0 -0
- package/assets/favicon.ico +0 -0
- package/assets/favicon.svg +1 -0
- package/assets/site.webmanifest +22 -0
- package/assets/web-app-manifest-192x192.png +0 -0
- package/assets/web-app-manifest-512x512.png +0 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/dist/vite/index.d.ts +1 -0
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +1 -0
- package/dist/vite/index.js.map +1 -1
- package/dist/vite/manifest.d.ts +3 -0
- package/dist/vite/manifest.d.ts.map +1 -0
- package/dist/vite/manifest.js +140 -0
- package/dist/vite/manifest.js.map +1 -0
- package/package.json +15 -9
- package/.turbo/turbo-build.log +0 -2
- package/.turbo/turbo-type$colon$emit.log +0 -1
- package/.turbo/turbo-type.log +0 -1
- package/CHANGELOG.md +0 -41
- package/src/components/main-layout.tsx +0 -45
- package/src/components/nav-bar.tsx +0 -41
- package/src/components/nav-tabs.test.tsx +0 -60
- package/src/components/nav-tabs.tsx +0 -47
- package/src/components/not-found-page.tsx +0 -14
- package/src/components/settings-nav.tsx +0 -35
- package/src/components/user-menu.tsx +0 -59
- package/src/components/workspace-crumb.tsx +0 -43
- package/src/components/workspace-switcher.tsx +0 -75
- package/src/compose/compose-routes.test.ts +0 -112
- package/src/compose/compose-routes.ts +0 -60
- package/src/compose/errors.ts +0 -54
- package/src/compose/merge-config.test.ts +0 -26
- package/src/compose/merge-config.ts +0 -27
- package/src/compose/normalize-route-path.ts +0 -6
- package/src/compose/validate-providers.test.ts +0 -31
- package/src/compose/validate-providers.ts +0 -36
- package/src/compose/validate-registries.test.ts +0 -92
- package/src/compose/validate-registries.ts +0 -61
- package/src/config/config-screen.test.tsx +0 -37
- package/src/config/shell-config.ts +0 -10
- package/src/contract.ts +0 -47
- package/src/index.ts +0 -1
- package/src/runtime/active-workspace.ts +0 -13
- package/src/runtime/anchor-paths.ts +0 -18
- package/src/runtime/anchors.tsx +0 -106
- package/src/runtime/assemble-route-tree.ts +0 -42
- package/src/runtime/auth.tsx +0 -213
- package/src/runtime/chrome-context.tsx +0 -22
- package/src/runtime/compose-client-app.tsx +0 -122
- package/src/runtime/define-route.test.ts +0 -15
- package/src/runtime/define-route.ts +0 -33
- package/src/runtime/index.ts +0 -19
- package/src/runtime/last-workspace.ts +0 -22
- package/src/runtime/nav-order.ts +0 -1
- package/src/runtime/provider-stack.tsx +0 -39
- package/src/runtime/providers.test.tsx +0 -51
- package/src/runtime/registries.test.tsx +0 -100
- package/src/runtime/registries.ts +0 -42
- package/src/runtime/router-context.ts +0 -9
- package/src/runtime/routes.test.tsx +0 -48
- package/src/runtime/workspace-setup.tsx +0 -61
- package/src/testing/index.test.tsx +0 -57
- package/src/testing/index.tsx +0 -51
- package/src/vite/evaluate-features.test.ts +0 -89
- package/src/vite/evaluate-features.ts +0 -114
- package/src/vite/generate.test.ts +0 -22
- package/src/vite/generate.ts +0 -99
- package/src/vite/generated-router.test.tsx +0 -58
- package/src/vite/index.ts +0 -1
- package/src/vite/plugin.test.ts +0 -231
- package/src/vite/plugin.ts +0 -101
- package/src/vite/validate.test.ts +0 -166
- package/test/default-route-impl.tsx +0 -3
- package/test/external/FINDINGS.md +0 -41
- package/test/external/README.md +0 -37
- package/test/external/fixture/index.html +0 -12
- package/test/external/fixture/package.json +0 -31
- package/test/external/fixture/src/app.fixture.tsx +0 -104
- package/test/external/fixture/src/features/external-settings.tsx +0 -19
- package/test/external/fixture/src/features/login-override.tsx +0 -9
- package/test/external/fixture/src/features.collision.ts +0 -15
- package/test/external/fixture/src/features.ts +0 -50
- package/test/external/fixture/src/link-typecheck.tsx +0 -10
- package/test/external/fixture/src/main.tsx +0 -86
- package/test/external/fixture/src/provider.ts +0 -45
- package/test/external/fixture/tsconfig.json +0 -15
- package/test/external/fixture/vite.config.ts +0 -21
- package/test/external/fixture/vitest.config.ts +0 -17
- package/test/external/verify.mjs +0 -504
- package/test/fixtures/features.ts +0 -56
- package/test/named-route-impl.tsx +0 -3
- package/test/render.tsx +0 -67
- package/test/search-route-impl.tsx +0 -7
- package/test/setup.ts +0 -3
- package/test/typecheck/shipfox-app.gen.ts +0 -85
- package/test/typecheck/types.tsx +0 -23
- package/tsconfig.build.json +0 -10
- package/tsconfig.build.tsbuildinfo +0 -1
- package/tsconfig.json +0 -1
- package/tsconfig.test.json +0 -12
- package/vitest.config.ts +0 -22
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import {QueryClient, useQueryClient} from '@tanstack/react-query';
|
|
2
|
-
import {render} from '@testing-library/react';
|
|
3
|
-
import {createStore, useStore} from 'jotai';
|
|
4
|
-
import type {PropsWithChildren} from 'react';
|
|
5
|
-
import {defineClientFeature} from '#contract.js';
|
|
6
|
-
import {ShellProviderStack} from './provider-stack.js';
|
|
7
|
-
|
|
8
|
-
describe('ShellProviderStack', () => {
|
|
9
|
-
test('gives providers the shell query client and store in feature declaration order', () => {
|
|
10
|
-
const queryClient = new QueryClient();
|
|
11
|
-
const store = createStore();
|
|
12
|
-
const breadcrumbs: string[] = [];
|
|
13
|
-
const received: Array<{queryClient: QueryClient; store: ReturnType<typeof createStore>}> = [];
|
|
14
|
-
const Probe = ({id, children}: PropsWithChildren<{id: string}>) => {
|
|
15
|
-
received.push({queryClient: useQueryClient(), store: useStore()});
|
|
16
|
-
breadcrumbs.push(id);
|
|
17
|
-
return <>{children}</>;
|
|
18
|
-
};
|
|
19
|
-
const features = [
|
|
20
|
-
defineClientFeature({
|
|
21
|
-
id: 'shipfox.first',
|
|
22
|
-
providers: [{id: 'first', Component: ({children}) => <Probe id="first">{children}</Probe>}],
|
|
23
|
-
}),
|
|
24
|
-
defineClientFeature({
|
|
25
|
-
id: 'acme.second',
|
|
26
|
-
providers: [
|
|
27
|
-
{id: 'second', Component: ({children}) => <Probe id="second">{children}</Probe>},
|
|
28
|
-
{id: 'third', Component: ({children}) => <Probe id="third">{children}</Probe>},
|
|
29
|
-
],
|
|
30
|
-
}),
|
|
31
|
-
];
|
|
32
|
-
|
|
33
|
-
render(
|
|
34
|
-
<ShellProviderStack
|
|
35
|
-
features={features}
|
|
36
|
-
queryClient={queryClient}
|
|
37
|
-
store={store}
|
|
38
|
-
auth={{effects: false}}
|
|
39
|
-
>
|
|
40
|
-
<div>Content</div>
|
|
41
|
-
</ShellProviderStack>,
|
|
42
|
-
);
|
|
43
|
-
|
|
44
|
-
expect(breadcrumbs).toEqual(['first', 'second', 'third']);
|
|
45
|
-
expect(received).toEqual([
|
|
46
|
-
{queryClient, store},
|
|
47
|
-
{queryClient, store},
|
|
48
|
-
{queryClient, store},
|
|
49
|
-
]);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import {screen, within} from '@testing-library/react';
|
|
2
|
-
import {defineClientFeature} from '#contract.js';
|
|
3
|
-
import {renderComposedShell} from '#test/render.js';
|
|
4
|
-
import {defineRoute} from './define-route.js';
|
|
5
|
-
|
|
6
|
-
describe('composition registries', () => {
|
|
7
|
-
test('sorts navigation and settings entries by order, feature position, then declaration order', async () => {
|
|
8
|
-
const features = [
|
|
9
|
-
defineClientFeature({
|
|
10
|
-
id: 'shipfox.first',
|
|
11
|
-
navigation: [
|
|
12
|
-
{
|
|
13
|
-
id: 'first-a',
|
|
14
|
-
scope: 'workspace',
|
|
15
|
-
label: 'First A',
|
|
16
|
-
to: '/workspaces/$wid/first-a/',
|
|
17
|
-
order: 100,
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
id: 'first-b',
|
|
21
|
-
scope: 'workspace',
|
|
22
|
-
label: 'First B',
|
|
23
|
-
to: '/workspaces/$wid/first-b',
|
|
24
|
-
order: 100,
|
|
25
|
-
},
|
|
26
|
-
],
|
|
27
|
-
settingsSections: [
|
|
28
|
-
{id: 'first', pathSegment: 'first', label: 'First setting', icon: 'userLine', order: 100},
|
|
29
|
-
],
|
|
30
|
-
routes: [
|
|
31
|
-
{path: '/workspaces/$wid/first-a', parent: 'workspaceLayout', impl: 'first-a'},
|
|
32
|
-
{path: '/workspaces/$wid/first-b', parent: 'workspaceLayout', impl: 'first-b'},
|
|
33
|
-
{
|
|
34
|
-
path: '/workspaces/$wid/settings/first',
|
|
35
|
-
parent: 'workspaceSettings',
|
|
36
|
-
impl: 'first-setting',
|
|
37
|
-
},
|
|
38
|
-
],
|
|
39
|
-
}),
|
|
40
|
-
defineClientFeature({
|
|
41
|
-
id: 'acme.second',
|
|
42
|
-
navigation: [
|
|
43
|
-
{
|
|
44
|
-
id: 'second',
|
|
45
|
-
scope: 'workspace',
|
|
46
|
-
label: 'Second',
|
|
47
|
-
to: '/workspaces/$wid/second',
|
|
48
|
-
order: 100,
|
|
49
|
-
},
|
|
50
|
-
],
|
|
51
|
-
settingsSections: [
|
|
52
|
-
{
|
|
53
|
-
id: 'second',
|
|
54
|
-
pathSegment: 'second',
|
|
55
|
-
label: 'Second setting',
|
|
56
|
-
icon: 'userLine',
|
|
57
|
-
order: 200,
|
|
58
|
-
},
|
|
59
|
-
],
|
|
60
|
-
routes: [
|
|
61
|
-
{path: '/workspaces/$wid/second', parent: 'workspaceLayout', impl: 'second'},
|
|
62
|
-
{
|
|
63
|
-
path: '/workspaces/$wid/settings/second',
|
|
64
|
-
parent: 'workspaceSettings',
|
|
65
|
-
impl: 'second-setting',
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
}),
|
|
69
|
-
];
|
|
70
|
-
|
|
71
|
-
await renderComposedShell({
|
|
72
|
-
features,
|
|
73
|
-
initialPath: '/workspaces/workspace/settings/first',
|
|
74
|
-
resolveImpl: () => defineRoute({component: () => <h1>Settings page</h1>}),
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
expect(await screen.findByRole('heading', {name: 'Workspace settings'})).toBeVisible();
|
|
78
|
-
expect((await screen.findAllByRole('tab')).map((tab) => tab.textContent)).toEqual([
|
|
79
|
-
'First A',
|
|
80
|
-
'First B',
|
|
81
|
-
'Second',
|
|
82
|
-
]);
|
|
83
|
-
expect((await screen.findAllByRole('tab'))[0]).toHaveAttribute(
|
|
84
|
-
'href',
|
|
85
|
-
'/workspaces/workspace/first-a',
|
|
86
|
-
);
|
|
87
|
-
const settingsNavigation = screen.getByRole('navigation', {name: 'Workspace settings'});
|
|
88
|
-
expect(settingsNavigation.parentElement).toHaveClass('grid', 'grid-cols-[180px_minmax(0,1fr)]');
|
|
89
|
-
const settingsLinks = within(settingsNavigation).getAllByRole('link');
|
|
90
|
-
const [firstSettingsLink] = settingsLinks;
|
|
91
|
-
if (!firstSettingsLink) throw new Error('Expected at least one settings link.');
|
|
92
|
-
|
|
93
|
-
expect(settingsLinks.map((link) => link.textContent)).toEqual([
|
|
94
|
-
'First setting',
|
|
95
|
-
'Second setting',
|
|
96
|
-
]);
|
|
97
|
-
expect(firstSettingsLink).toHaveClass('w-full', 'justify-start');
|
|
98
|
-
expect(firstSettingsLink.querySelector('svg')).toHaveClass('size-16');
|
|
99
|
-
});
|
|
100
|
-
});
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import {normalizeRoutePath} from '#compose/normalize-route-path.js';
|
|
2
|
-
import type {ClientFeature, NavTabEntry, SettingsSectionEntry} from '#contract.js';
|
|
3
|
-
|
|
4
|
-
interface Ordered<T> {
|
|
5
|
-
entry: T;
|
|
6
|
-
featureIndex: number;
|
|
7
|
-
declarationIndex: number;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
function byOrder<T extends {order?: number}>(left: Ordered<T>, right: Ordered<T>): number {
|
|
11
|
-
return (
|
|
12
|
-
(left.entry.order ?? 500) - (right.entry.order ?? 500) ||
|
|
13
|
-
left.featureIndex - right.featureIndex ||
|
|
14
|
-
left.declarationIndex - right.declarationIndex
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function navigationEntries(features: readonly ClientFeature[]): NavTabEntry[] {
|
|
19
|
-
return features
|
|
20
|
-
.flatMap((feature, featureIndex) =>
|
|
21
|
-
(feature.navigation ?? []).map((entry, declarationIndex) => ({
|
|
22
|
-
entry,
|
|
23
|
-
featureIndex,
|
|
24
|
-
declarationIndex,
|
|
25
|
-
})),
|
|
26
|
-
)
|
|
27
|
-
.sort(byOrder)
|
|
28
|
-
.map(({entry}) => ({...entry, to: normalizeRoutePath(entry.to)}));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function settingsEntries(features: readonly ClientFeature[]): SettingsSectionEntry[] {
|
|
32
|
-
return features
|
|
33
|
-
.flatMap((feature, featureIndex) =>
|
|
34
|
-
(feature.settingsSections ?? []).map((entry, declarationIndex) => ({
|
|
35
|
-
entry,
|
|
36
|
-
featureIndex,
|
|
37
|
-
declarationIndex,
|
|
38
|
-
})),
|
|
39
|
-
)
|
|
40
|
-
.sort(byOrder)
|
|
41
|
-
.map(({entry}) => entry);
|
|
42
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
import type {QueryClient} from '@tanstack/react-query';
|
|
2
|
-
import type {AuthStateValue} from './auth.js';
|
|
3
|
-
import type {WorkspaceSetupGate} from './workspace-setup.js';
|
|
4
|
-
|
|
5
|
-
export interface RouterContext {
|
|
6
|
-
auth: AuthStateValue | undefined;
|
|
7
|
-
queryClient: QueryClient | undefined;
|
|
8
|
-
workspaceSetup?: WorkspaceSetupGate;
|
|
9
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import {screen} from '@testing-library/react';
|
|
2
|
-
import {defineClientFeature} from '#contract.js';
|
|
3
|
-
import {renderComposedShell} from '#test/render.js';
|
|
4
|
-
import {defineRoute} from './define-route.js';
|
|
5
|
-
|
|
6
|
-
describe('composed routes', () => {
|
|
7
|
-
test('renders a feature-added route through memory history', async () => {
|
|
8
|
-
const feature = defineClientFeature({
|
|
9
|
-
id: 'acme.insights',
|
|
10
|
-
routes: [{path: '/insights', parent: 'root', impl: 'insights'}],
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
await renderComposedShell({
|
|
14
|
-
features: [feature],
|
|
15
|
-
initialPath: '/insights',
|
|
16
|
-
resolveImpl: () => defineRoute({component: () => <h1>Insights</h1>}),
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
expect(await screen.findByRole('heading', {name: 'Insights'})).toBeVisible();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test('renders an explicit route override instead of the upstream route', async () => {
|
|
23
|
-
const features = [
|
|
24
|
-
defineClientFeature({
|
|
25
|
-
id: 'shipfox.projects',
|
|
26
|
-
routes: [{path: '/projects', parent: 'root', impl: 'upstream'}],
|
|
27
|
-
}),
|
|
28
|
-
defineClientFeature({
|
|
29
|
-
id: 'acme.projects',
|
|
30
|
-
routes: [{path: '/projects', parent: 'root', override: true, impl: 'override'}],
|
|
31
|
-
}),
|
|
32
|
-
];
|
|
33
|
-
|
|
34
|
-
await renderComposedShell({
|
|
35
|
-
features,
|
|
36
|
-
initialPath: '/projects',
|
|
37
|
-
resolveImpl: (specifier) =>
|
|
38
|
-
defineRoute({
|
|
39
|
-
component: () => (
|
|
40
|
-
<h1>{specifier === 'override' ? 'Commercial projects' : 'Upstream projects'}</h1>
|
|
41
|
-
),
|
|
42
|
-
}),
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
expect(await screen.findByRole('heading', {name: 'Commercial projects'})).toBeVisible();
|
|
46
|
-
expect(screen.queryByText('Upstream projects')).not.toBeInTheDocument();
|
|
47
|
-
});
|
|
48
|
-
});
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import {ApiError} from '@shipfox/client-api';
|
|
2
|
-
import {Button} from '@shipfox/react-ui/button';
|
|
3
|
-
import {Callout} from '@shipfox/react-ui/callout';
|
|
4
|
-
import {FullPageLoader} from '@shipfox/react-ui/loader';
|
|
5
|
-
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
6
|
-
import type {QueryClient} from '@tanstack/react-query';
|
|
7
|
-
import {type ErrorComponentProps, useRouter} from '@tanstack/react-router';
|
|
8
|
-
|
|
9
|
-
export interface WorkspaceSetupState {
|
|
10
|
-
hideProjectNavigation: boolean;
|
|
11
|
-
}
|
|
12
|
-
export interface WorkspaceSetupRouteOptions {
|
|
13
|
-
queryClient: QueryClient;
|
|
14
|
-
workspaceId: string;
|
|
15
|
-
pathname: string;
|
|
16
|
-
}
|
|
17
|
-
export type WorkspaceSetupGate = (
|
|
18
|
-
options: WorkspaceSetupRouteOptions,
|
|
19
|
-
) => Promise<WorkspaceSetupState>;
|
|
20
|
-
|
|
21
|
-
export class WorkspaceSetupLoadError extends Error {
|
|
22
|
-
constructor(public override readonly cause: unknown) {
|
|
23
|
-
super(cause instanceof Error ? cause.message : 'Workspace setup load failed');
|
|
24
|
-
this.name = 'WorkspaceSetupLoadError';
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
export function WorkspaceSetupPending() {
|
|
28
|
-
return <FullPageLoader />;
|
|
29
|
-
}
|
|
30
|
-
export function WorkspaceLayoutErrorRoute({error, reset}: ErrorComponentProps) {
|
|
31
|
-
const router = useRouter();
|
|
32
|
-
const retry = () => {
|
|
33
|
-
reset();
|
|
34
|
-
void router.invalidate();
|
|
35
|
-
};
|
|
36
|
-
const setupError = error instanceof WorkspaceSetupLoadError;
|
|
37
|
-
const message =
|
|
38
|
-
error instanceof ApiError
|
|
39
|
-
? error.message
|
|
40
|
-
: setupError && error.cause instanceof ApiError
|
|
41
|
-
? error.cause.message
|
|
42
|
-
: 'Try again in a moment.';
|
|
43
|
-
return (
|
|
44
|
-
<main className="min-h-screen bg-background-subtle-base px-24 py-32 max-[520px]:px-16">
|
|
45
|
-
<div className="mx-auto flex w-full max-w-[640px] flex-col gap-24">
|
|
46
|
-
<Header variant="h1">{setupError ? 'Workspace setup' : 'Workspace'}</Header>
|
|
47
|
-
<Callout role="alert" type="error">
|
|
48
|
-
<div className="flex flex-col gap-8">
|
|
49
|
-
<Text size="sm" bold>
|
|
50
|
-
{setupError ? 'Could not load workspace setup' : 'Could not load workspace'}
|
|
51
|
-
</Text>
|
|
52
|
-
<Text size="sm">{message}</Text>
|
|
53
|
-
<Button size="sm" variant="secondary" onClick={retry} className="w-fit">
|
|
54
|
-
Retry
|
|
55
|
-
</Button>
|
|
56
|
-
</div>
|
|
57
|
-
</Callout>
|
|
58
|
-
</div>
|
|
59
|
-
</main>
|
|
60
|
-
);
|
|
61
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import {getLoadedConfig} from '@shipfox/client-config';
|
|
2
|
-
import {QueryClient, useQueryClient} from '@tanstack/react-query';
|
|
3
|
-
import {render, screen} from '@testing-library/react';
|
|
4
|
-
import {createStore, useStore} from 'jotai';
|
|
5
|
-
import {createShellDecorator, ShellProviders} from './index.js';
|
|
6
|
-
|
|
7
|
-
describe('shell testing helpers', () => {
|
|
8
|
-
test('uses caller-owned state and installs runtime config', () => {
|
|
9
|
-
const queryClient = new QueryClient();
|
|
10
|
-
const store = createStore();
|
|
11
|
-
const config = {apiUrl: 'https://example.test'};
|
|
12
|
-
const Probe = () => {
|
|
13
|
-
const receivedQueryClient = useQueryClient();
|
|
14
|
-
const receivedStore = useStore();
|
|
15
|
-
return (
|
|
16
|
-
<div>
|
|
17
|
-
{receivedQueryClient === queryClient && receivedStore === store
|
|
18
|
-
? 'caller state'
|
|
19
|
-
: 'default state'}
|
|
20
|
-
</div>
|
|
21
|
-
);
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
render(
|
|
25
|
-
<ShellProviders queryClient={queryClient} store={store} config={config}>
|
|
26
|
-
<Probe />
|
|
27
|
-
</ShellProviders>,
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
expect(screen.getByText('caller state')).toBeInTheDocument();
|
|
31
|
-
expect(getLoadedConfig()).toEqual(config);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test('creates a configured Storybook decorator', () => {
|
|
35
|
-
const DecoratedStory = createShellDecorator({config: {mode: 'storybook'}})(
|
|
36
|
-
() => <div>Story content</div>,
|
|
37
|
-
{} as never,
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
render(DecoratedStory);
|
|
41
|
-
|
|
42
|
-
expect(screen.getByText('Story content')).toBeInTheDocument();
|
|
43
|
-
expect(getLoadedConfig()).toEqual({mode: 'storybook'});
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test('does not refresh auth when rendering static testing providers', () => {
|
|
47
|
-
const fetchSpy = vi.spyOn(globalThis, 'fetch');
|
|
48
|
-
|
|
49
|
-
render(
|
|
50
|
-
<ShellProviders>
|
|
51
|
-
<div>Static content</div>
|
|
52
|
-
</ShellProviders>,
|
|
53
|
-
);
|
|
54
|
-
|
|
55
|
-
expect(fetchSpy).not.toHaveBeenCalled();
|
|
56
|
-
});
|
|
57
|
-
});
|
package/src/testing/index.tsx
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
import {setLoadedConfig} from '@shipfox/client-config';
|
|
2
|
-
import type {Decorator} from '@storybook/react';
|
|
3
|
-
import {QueryClient, type QueryClient as QueryClientInstance} from '@tanstack/react-query';
|
|
4
|
-
import {createStore} from 'jotai';
|
|
5
|
-
import {type PropsWithChildren, useState} from 'react';
|
|
6
|
-
import type {ClientFeature} from '#contract.js';
|
|
7
|
-
import {ShellProviderStack} from '../runtime/provider-stack.js';
|
|
8
|
-
|
|
9
|
-
type Store = ReturnType<typeof createStore>;
|
|
10
|
-
|
|
11
|
-
export interface ShellProvidersOptions {
|
|
12
|
-
features?: readonly ClientFeature[];
|
|
13
|
-
queryClient?: QueryClientInstance;
|
|
14
|
-
store?: Store;
|
|
15
|
-
config?: unknown;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function ShellProviders({
|
|
19
|
-
features = [],
|
|
20
|
-
queryClient: providedQueryClient,
|
|
21
|
-
store: providedStore,
|
|
22
|
-
config,
|
|
23
|
-
children,
|
|
24
|
-
}: PropsWithChildren<ShellProvidersOptions>) {
|
|
25
|
-
const [defaultQueryClient] = useState(
|
|
26
|
-
() => new QueryClient({defaultOptions: {queries: {retry: false}}}),
|
|
27
|
-
);
|
|
28
|
-
const [defaultStore] = useState(createStore);
|
|
29
|
-
if (config !== undefined) setLoadedConfig(config);
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
<ShellProviderStack
|
|
33
|
-
features={features}
|
|
34
|
-
queryClient={providedQueryClient ?? defaultQueryClient}
|
|
35
|
-
store={providedStore ?? defaultStore}
|
|
36
|
-
auth={{effects: false}}
|
|
37
|
-
>
|
|
38
|
-
{children}
|
|
39
|
-
</ShellProviderStack>
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function createShellDecorator(options: ShellProvidersOptions = {}): Decorator {
|
|
44
|
-
return (Story) => (
|
|
45
|
-
<ShellProviders {...options}>
|
|
46
|
-
<Story />
|
|
47
|
-
</ShellProviders>
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export const shellDecorator = createShellDecorator();
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import {mkdtemp, realpath, rm, writeFile} from 'node:fs/promises';
|
|
2
|
-
import {tmpdir} from 'node:os';
|
|
3
|
-
import {join} from 'node:path';
|
|
4
|
-
import {fileURLToPath} from 'node:url';
|
|
5
|
-
import {evaluateFeatures} from './evaluate-features.js';
|
|
6
|
-
|
|
7
|
-
const fixtureFeatures = fileURLToPath(new URL('../../test/fixtures/features.ts', import.meta.url));
|
|
8
|
-
const contractSource = fileURLToPath(new URL('../contract.ts', import.meta.url));
|
|
9
|
-
|
|
10
|
-
describe('evaluateFeatures', () => {
|
|
11
|
-
let directory: string;
|
|
12
|
-
|
|
13
|
-
beforeEach(async () => {
|
|
14
|
-
directory = await mkdtemp(join(tmpdir(), 'shipfox-client-shell-vite-'));
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
afterEach(async () => {
|
|
18
|
-
await rm(directory, {recursive: true, force: true});
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test('reloads dependencies and reports them as watched files', async () => {
|
|
22
|
-
const features = join(directory, 'features.ts');
|
|
23
|
-
const contribution = join(directory, 'contribution.ts');
|
|
24
|
-
await writeFile(
|
|
25
|
-
features,
|
|
26
|
-
`import {feature} from './contribution.ts';
|
|
27
|
-
export const features = [feature];`,
|
|
28
|
-
);
|
|
29
|
-
await writeFile(contribution, `export const feature = {id: 'acme.projects'};`);
|
|
30
|
-
|
|
31
|
-
await evaluateFeatures(features);
|
|
32
|
-
await writeFile(contribution, `export const feature = {id: 'acme.reports'};`);
|
|
33
|
-
|
|
34
|
-
const evaluated = await evaluateFeatures(features);
|
|
35
|
-
|
|
36
|
-
expect(evaluated.features).toEqual([{id: 'acme.reports'}]);
|
|
37
|
-
expect(evaluated.loadedFiles).toContain(await realpath(contribution));
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test('reloads JSON dependencies and reports them as watched files', async () => {
|
|
41
|
-
const features = join(directory, 'features.ts');
|
|
42
|
-
const contribution = join(directory, 'contribution.json');
|
|
43
|
-
await writeFile(
|
|
44
|
-
features,
|
|
45
|
-
`import feature from './contribution.json';
|
|
46
|
-
export const features = [feature];`,
|
|
47
|
-
);
|
|
48
|
-
await writeFile(contribution, JSON.stringify({id: 'acme.projects'}));
|
|
49
|
-
|
|
50
|
-
await evaluateFeatures(features);
|
|
51
|
-
await writeFile(contribution, JSON.stringify({id: 'acme.reports'}));
|
|
52
|
-
|
|
53
|
-
const evaluated = await evaluateFeatures(features);
|
|
54
|
-
|
|
55
|
-
expect(evaluated.features).toEqual([{id: 'acme.reports'}]);
|
|
56
|
-
expect(evaluated.loadedFiles).toContain(await realpath(contribution));
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('evicts JSON dependencies after evaluation fails', async () => {
|
|
60
|
-
const features = join(directory, 'features.ts');
|
|
61
|
-
const contribution = join(directory, 'contribution.json');
|
|
62
|
-
await writeFile(
|
|
63
|
-
features,
|
|
64
|
-
`import feature from './contribution.json';
|
|
65
|
-
throw new Error('fixture failure');
|
|
66
|
-
export const features = [feature];`,
|
|
67
|
-
);
|
|
68
|
-
await writeFile(contribution, JSON.stringify({id: 'acme.projects'}));
|
|
69
|
-
|
|
70
|
-
const failedEvaluation = evaluateFeatures(features);
|
|
71
|
-
await expect(failedEvaluation).rejects.toThrow('fixture failure');
|
|
72
|
-
await writeFile(
|
|
73
|
-
features,
|
|
74
|
-
`import feature from './contribution.json';
|
|
75
|
-
export const features = [feature];`,
|
|
76
|
-
);
|
|
77
|
-
await writeFile(contribution, JSON.stringify({id: 'acme.reports'}));
|
|
78
|
-
|
|
79
|
-
const evaluated = await evaluateFeatures(features);
|
|
80
|
-
|
|
81
|
-
expect(evaluated.features).toEqual([{id: 'acme.reports'}]);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('resolves package imports through the workspace source condition', async () => {
|
|
85
|
-
const evaluated = await evaluateFeatures(fixtureFeatures);
|
|
86
|
-
|
|
87
|
-
expect(evaluated.loadedFiles).toContain(await realpath(contractSource));
|
|
88
|
-
});
|
|
89
|
-
});
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import {randomUUID} from 'node:crypto';
|
|
2
|
-
import {realpath} from 'node:fs/promises';
|
|
3
|
-
import {createRequire} from 'node:module';
|
|
4
|
-
import {resolve} from 'node:path';
|
|
5
|
-
import {register} from 'tsx/cjs/api';
|
|
6
|
-
import type {ClientFeature} from '#contract.js';
|
|
7
|
-
|
|
8
|
-
const require = createRequire(import.meta.url);
|
|
9
|
-
const nodeModulesPathPattern = /[\\/]node_modules[\\/]/;
|
|
10
|
-
|
|
11
|
-
export interface EvaluatedFeatures {
|
|
12
|
-
features: readonly ClientFeature[];
|
|
13
|
-
loadedFiles: readonly string[];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function loadedModuleGraph(namespaceQuery: string): {
|
|
17
|
-
cacheKeys: Set<string>;
|
|
18
|
-
loadedPaths: Set<string>;
|
|
19
|
-
} {
|
|
20
|
-
const cacheKeys = new Set<string>();
|
|
21
|
-
const loadedPaths = new Set<string>();
|
|
22
|
-
const keysByModule = new Map<NodeJS.Module, string[]>();
|
|
23
|
-
|
|
24
|
-
for (const [cacheKey, cachedModule] of Object.entries(require.cache)) {
|
|
25
|
-
if (!cachedModule) continue;
|
|
26
|
-
const keys = keysByModule.get(cachedModule) ?? [];
|
|
27
|
-
keys.push(cacheKey);
|
|
28
|
-
keysByModule.set(cachedModule, keys);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const visited = new Set<NodeJS.Module>();
|
|
32
|
-
function visit(cachedModule: NodeJS.Module): void {
|
|
33
|
-
if (visited.has(cachedModule)) return;
|
|
34
|
-
visited.add(cachedModule);
|
|
35
|
-
|
|
36
|
-
for (const cacheKey of keysByModule.get(cachedModule) ?? []) {
|
|
37
|
-
const path = cacheKey.endsWith(namespaceQuery)
|
|
38
|
-
? cacheKey.slice(0, -namespaceQuery.length)
|
|
39
|
-
: cacheKey;
|
|
40
|
-
const isLocal = !nodeModulesPathPattern.test(path);
|
|
41
|
-
if (isLocal) loadedPaths.add(path);
|
|
42
|
-
if (isLocal || cacheKey.endsWith(namespaceQuery)) cacheKeys.add(cacheKey);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
for (const child of cachedModule.children) visit(child);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
for (const [cacheKey, cachedModule] of Object.entries(require.cache)) {
|
|
49
|
-
if (cachedModule && cacheKey.endsWith(namespaceQuery)) visit(cachedModule);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return {cacheKeys, loadedPaths};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function localFiles(paths: Iterable<string>): Promise<string[]> {
|
|
56
|
-
const files = new Set<string>();
|
|
57
|
-
for (const path of paths) {
|
|
58
|
-
if (nodeModulesPathPattern.test(path)) continue;
|
|
59
|
-
files.add(await realpath(path).catch(() => path));
|
|
60
|
-
}
|
|
61
|
-
return [...files];
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export async function evaluateFeatures(featuresModule: string): Promise<EvaluatedFeatures> {
|
|
65
|
-
const resolvedFeaturesModule = await realpath(resolve(featuresModule));
|
|
66
|
-
const namespace = randomUUID();
|
|
67
|
-
const namespaceQuery = `?namespace=${namespace}`;
|
|
68
|
-
const loader = register({namespace});
|
|
69
|
-
const loadedPaths = new Set<string>([resolvedFeaturesModule]);
|
|
70
|
-
const cacheKeys = new Set<string>();
|
|
71
|
-
const initialCacheKeys = new Set(Object.keys(require.cache));
|
|
72
|
-
let module: {default?: unknown; features?: unknown};
|
|
73
|
-
try {
|
|
74
|
-
module = loader.require(resolvedFeaturesModule, import.meta.url) as {
|
|
75
|
-
default?: unknown;
|
|
76
|
-
features?: unknown;
|
|
77
|
-
};
|
|
78
|
-
} catch (error) {
|
|
79
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
80
|
-
throw new Error(
|
|
81
|
-
`Failed to evaluate features module "${resolvedFeaturesModule}". Features modules must be Node-safe: ${message}`,
|
|
82
|
-
{cause: error},
|
|
83
|
-
);
|
|
84
|
-
} finally {
|
|
85
|
-
const graph = loadedModuleGraph(namespaceQuery);
|
|
86
|
-
for (const path of graph.loadedPaths) loadedPaths.add(path);
|
|
87
|
-
for (const cacheKey of graph.cacheKeys) cacheKeys.add(cacheKey);
|
|
88
|
-
for (const cacheKey of Object.keys(require.cache)) {
|
|
89
|
-
if (initialCacheKeys.has(cacheKey)) continue;
|
|
90
|
-
const path = cacheKey.endsWith(namespaceQuery)
|
|
91
|
-
? cacheKey.slice(0, -namespaceQuery.length)
|
|
92
|
-
: cacheKey;
|
|
93
|
-
const isLocal = !nodeModulesPathPattern.test(path);
|
|
94
|
-
if (isLocal) loadedPaths.add(path);
|
|
95
|
-
if (isLocal || cacheKey.endsWith(namespaceQuery)) cacheKeys.add(cacheKey);
|
|
96
|
-
}
|
|
97
|
-
loader.unregister();
|
|
98
|
-
for (const cacheKey of cacheKeys) delete require.cache[cacheKey];
|
|
99
|
-
for (const cacheKey of Object.keys(require.cache))
|
|
100
|
-
if (cacheKey.endsWith(namespaceQuery)) delete require.cache[cacheKey];
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const features = module.features ?? module.default;
|
|
104
|
-
if (!Array.isArray(features)) {
|
|
105
|
-
throw new TypeError(
|
|
106
|
-
`Features module "${resolvedFeaturesModule}" must export a features array.`,
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
features: features as readonly ClientFeature[],
|
|
112
|
-
loadedFiles: await localFiles(loadedPaths),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import {readFile} from 'node:fs/promises';
|
|
2
|
-
import {fileURLToPath} from 'node:url';
|
|
3
|
-
import {composeRoutes} from '#compose/compose-routes.js';
|
|
4
|
-
import {navigationEntries, settingsEntries} from '#runtime/registries.js';
|
|
5
|
-
import {features} from '#test/fixtures/features.js';
|
|
6
|
-
import {generateAppModule} from './generate.js';
|
|
7
|
-
|
|
8
|
-
const goldenFile = fileURLToPath(
|
|
9
|
-
new URL('../../test/typecheck/shipfox-app.gen.ts', import.meta.url),
|
|
10
|
-
);
|
|
11
|
-
|
|
12
|
-
describe('generateAppModule', () => {
|
|
13
|
-
test('matches the checked composition fixture', async () => {
|
|
14
|
-
const generated = generateAppModule({
|
|
15
|
-
routes: composeRoutes(features),
|
|
16
|
-
navigation: navigationEntries(features),
|
|
17
|
-
settingsSections: settingsEntries(features),
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
await expect(readFile(goldenFile, 'utf8')).resolves.toBe(generated);
|
|
21
|
-
});
|
|
22
|
-
});
|