create-appystack 0.1.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/README.md +54 -0
- package/bin/index.js +243 -0
- package/package.json +39 -0
- package/template/.claude/skills/recipe/SKILL.md +71 -0
- package/template/.claude/skills/recipe/domains/care-provider-operations.md +185 -0
- package/template/.claude/skills/recipe/domains/youtube-launch-optimizer.md +154 -0
- package/template/.claude/skills/recipe/references/file-crud.md +295 -0
- package/template/.claude/skills/recipe/references/nav-shell.md +233 -0
- package/template/.dockerignore +39 -0
- package/template/.env.example +13 -0
- package/template/.github/workflows/ci.yml +43 -0
- package/template/.husky/pre-commit +1 -0
- package/template/.prettierignore +7 -0
- package/template/.prettierrc +8 -0
- package/template/.vscode/launch.json +59 -0
- package/template/CLAUDE.md +114 -0
- package/template/Dockerfile +56 -0
- package/template/README.md +219 -0
- package/template/client/index.html +13 -0
- package/template/client/package.json +43 -0
- package/template/client/src/App.test.tsx +67 -0
- package/template/client/src/App.tsx +11 -0
- package/template/client/src/components/ErrorFallback.test.tsx +64 -0
- package/template/client/src/components/ErrorFallback.tsx +18 -0
- package/template/client/src/config/env.test.ts +64 -0
- package/template/client/src/config/env.ts +34 -0
- package/template/client/src/contexts/AppContext.test.tsx +81 -0
- package/template/client/src/contexts/AppContext.tsx +52 -0
- package/template/client/src/demo/ContactForm.test.tsx +97 -0
- package/template/client/src/demo/ContactForm.tsx +100 -0
- package/template/client/src/demo/DemoPage.tsx +56 -0
- package/template/client/src/demo/SocketDemo.test.tsx +160 -0
- package/template/client/src/demo/SocketDemo.tsx +65 -0
- package/template/client/src/demo/StatusGrid.test.tsx +181 -0
- package/template/client/src/demo/StatusGrid.tsx +77 -0
- package/template/client/src/demo/TechStackDisplay.test.tsx +63 -0
- package/template/client/src/demo/TechStackDisplay.tsx +75 -0
- package/template/client/src/hooks/useServerStatus.test.ts +133 -0
- package/template/client/src/hooks/useServerStatus.ts +67 -0
- package/template/client/src/hooks/useSocket.test.ts +152 -0
- package/template/client/src/hooks/useSocket.ts +43 -0
- package/template/client/src/lib/utils.test.ts +33 -0
- package/template/client/src/lib/utils.ts +14 -0
- package/template/client/src/main.test.tsx +113 -0
- package/template/client/src/main.tsx +14 -0
- package/template/client/src/pages/LandingPage.test.tsx +30 -0
- package/template/client/src/pages/LandingPage.tsx +29 -0
- package/template/client/src/styles/index.css +50 -0
- package/template/client/src/test/msw/browser.ts +4 -0
- package/template/client/src/test/msw/handlers.ts +12 -0
- package/template/client/src/test/msw/msw-example.test.ts +69 -0
- package/template/client/src/test/msw/server.ts +14 -0
- package/template/client/src/test/setup.ts +10 -0
- package/template/client/src/utils/api.test.ts +79 -0
- package/template/client/src/utils/api.ts +42 -0
- package/template/client/src/vite-env.d.ts +13 -0
- package/template/client/tsconfig.json +17 -0
- package/template/client/vite.config.ts +38 -0
- package/template/client/vitest.config.ts +36 -0
- package/template/docker-compose.yml +19 -0
- package/template/e2e/smoke.test.ts +95 -0
- package/template/e2e/socket.test.ts +96 -0
- package/template/eslint.config.js +2 -0
- package/template/package.json +50 -0
- package/template/playwright.config.ts +14 -0
- package/template/scripts/customize.ts +175 -0
- package/template/server/nodemon.json +5 -0
- package/template/server/package.json +45 -0
- package/template/server/src/app.test.ts +103 -0
- package/template/server/src/config/env.test.ts +97 -0
- package/template/server/src/config/env.ts +29 -0
- package/template/server/src/config/logger.test.ts +58 -0
- package/template/server/src/config/logger.ts +17 -0
- package/template/server/src/helpers/response.test.ts +53 -0
- package/template/server/src/helpers/response.ts +17 -0
- package/template/server/src/index.ts +118 -0
- package/template/server/src/middleware/errorHandler.test.ts +84 -0
- package/template/server/src/middleware/errorHandler.ts +27 -0
- package/template/server/src/middleware/rateLimiter.test.ts +68 -0
- package/template/server/src/middleware/rateLimiter.ts +8 -0
- package/template/server/src/middleware/requestLogger.test.ts +111 -0
- package/template/server/src/middleware/requestLogger.ts +17 -0
- package/template/server/src/middleware/validate.test.ts +213 -0
- package/template/server/src/middleware/validate.ts +23 -0
- package/template/server/src/routes/health.test.ts +17 -0
- package/template/server/src/routes/health.ts +12 -0
- package/template/server/src/routes/info.test.ts +20 -0
- package/template/server/src/routes/info.ts +19 -0
- package/template/server/src/shared.test.ts +53 -0
- package/template/server/src/shutdown.test.ts +98 -0
- package/template/server/src/socket.test.ts +185 -0
- package/template/server/src/static.test.ts +166 -0
- package/template/server/tsconfig.json +16 -0
- package/template/server/vitest.config.ts +22 -0
- package/template/shared/package.json +19 -0
- package/template/shared/src/constants.ts +11 -0
- package/template/shared/src/index.ts +8 -0
- package/template/shared/src/types.ts +33 -0
- package/template/shared/tsconfig.json +10 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
|
|
2
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import express from 'express';
|
|
4
|
+
import type { Server } from 'node:http';
|
|
5
|
+
import type { AddressInfo } from 'node:net';
|
|
6
|
+
import { useServerStatus } from './useServerStatus.js';
|
|
7
|
+
|
|
8
|
+
// Capture the native fetch at module load time, before any test setup stubs it
|
|
9
|
+
const nativeFetch = globalThis.fetch;
|
|
10
|
+
|
|
11
|
+
let server: Server;
|
|
12
|
+
let serverPort: number;
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
return new Promise<void>((resolve) => {
|
|
16
|
+
const app = express();
|
|
17
|
+
|
|
18
|
+
app.get('/health', (_req, res) => {
|
|
19
|
+
res.json({ status: 'ok', data: { status: 'ok' }, timestamp: new Date().toISOString() });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
app.get('/api/info', (_req, res) => {
|
|
23
|
+
res.json({
|
|
24
|
+
status: 'ok',
|
|
25
|
+
data: {
|
|
26
|
+
nodeVersion: 'v20.0.0',
|
|
27
|
+
environment: 'test',
|
|
28
|
+
port: 0,
|
|
29
|
+
clientUrl: 'http://localhost:5500',
|
|
30
|
+
uptime: 42,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
app.get('/error', (_req, res) => {
|
|
36
|
+
res.status(500).json({ status: 'error', error: 'Internal Server Error' });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
server = app.listen(0, () => {
|
|
40
|
+
serverPort = (server.address() as AddressInfo).port;
|
|
41
|
+
resolve();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Route relative fetch calls to the real test server; runs after setup.ts beforeEach stub
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
|
49
|
+
const url =
|
|
50
|
+
typeof input === 'string' && input.startsWith('/')
|
|
51
|
+
? `http://localhost:${serverPort}${input}`
|
|
52
|
+
: input;
|
|
53
|
+
return nativeFetch(url, init);
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterAll(() => {
|
|
58
|
+
return new Promise<void>((resolve) => {
|
|
59
|
+
server.close(() => resolve());
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('useServerStatus', () => {
|
|
64
|
+
it('starts with loading=true', () => {
|
|
65
|
+
const { result } = renderHook(() => useServerStatus());
|
|
66
|
+
expect(result.current.loading).toBe(true);
|
|
67
|
+
expect(result.current.health).toBeNull();
|
|
68
|
+
expect(result.current.info).toBeNull();
|
|
69
|
+
expect(result.current.error).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('fetches /health and sets health state', async () => {
|
|
73
|
+
const { result } = renderHook(() => useServerStatus());
|
|
74
|
+
|
|
75
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
76
|
+
|
|
77
|
+
expect(result.current.health).not.toBeNull();
|
|
78
|
+
expect(result.current.health?.status).toBe('ok');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('fetches /api/info and sets info state', async () => {
|
|
82
|
+
const { result } = renderHook(() => useServerStatus());
|
|
83
|
+
|
|
84
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
85
|
+
|
|
86
|
+
expect(result.current.info).not.toBeNull();
|
|
87
|
+
expect(result.current.info?.nodeVersion).toBe('v20.0.0');
|
|
88
|
+
expect(result.current.info?.environment).toBe('test');
|
|
89
|
+
expect(typeof result.current.info?.uptime).toBe('number');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('sets error=null on success', async () => {
|
|
93
|
+
const { result } = renderHook(() => useServerStatus());
|
|
94
|
+
|
|
95
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
96
|
+
|
|
97
|
+
expect(result.current.error).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('sets error state when server returns 500', async () => {
|
|
101
|
+
// Temporarily override fetch to return 500 for both endpoints
|
|
102
|
+
const savedFetch = globalThis.fetch;
|
|
103
|
+
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
104
|
+
ok: false,
|
|
105
|
+
status: 500,
|
|
106
|
+
json: async () => ({ status: 'error', error: 'Internal Server Error' }),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const { result } = renderHook(() => useServerStatus());
|
|
110
|
+
|
|
111
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
112
|
+
|
|
113
|
+
expect(result.current.error).toBe('Server returned an error');
|
|
114
|
+
expect(result.current.health).toBeNull();
|
|
115
|
+
|
|
116
|
+
globalThis.fetch = savedFetch;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('sets error state when server is unreachable', async () => {
|
|
120
|
+
// Temporarily override fetch to simulate network failure
|
|
121
|
+
const savedFetch = globalThis.fetch;
|
|
122
|
+
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Failed to fetch'));
|
|
123
|
+
|
|
124
|
+
const { result } = renderHook(() => useServerStatus());
|
|
125
|
+
|
|
126
|
+
await waitFor(() => expect(result.current.loading).toBe(false));
|
|
127
|
+
|
|
128
|
+
expect(result.current.error).toBe('Failed to fetch');
|
|
129
|
+
expect(result.current.loading).toBe(false);
|
|
130
|
+
|
|
131
|
+
globalThis.fetch = savedFetch;
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import type { HealthResponse, ServerInfo } from '@appystack-template/shared';
|
|
3
|
+
|
|
4
|
+
interface ServerStatus {
|
|
5
|
+
health: HealthResponse | null;
|
|
6
|
+
info: ServerInfo | null;
|
|
7
|
+
loading: boolean;
|
|
8
|
+
error: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Fetches server health (/health) and metadata (/api/info) on mount.
|
|
13
|
+
* @returns health status, server info, loading flag, and error string
|
|
14
|
+
*/
|
|
15
|
+
export function useServerStatus() {
|
|
16
|
+
const [status, setStatus] = useState<ServerStatus>({
|
|
17
|
+
health: null,
|
|
18
|
+
info: null,
|
|
19
|
+
loading: true,
|
|
20
|
+
error: null,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const controller = new AbortController();
|
|
25
|
+
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(10000)]);
|
|
26
|
+
|
|
27
|
+
async function fetchStatus() {
|
|
28
|
+
try {
|
|
29
|
+
const [healthRes, infoRes] = await Promise.all([
|
|
30
|
+
fetch('/health', { signal }),
|
|
31
|
+
fetch('/api/info', { signal }),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
if (!healthRes.ok || !infoRes.ok) {
|
|
35
|
+
throw new Error('Server returned an error');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const healthBody = await healthRes.json();
|
|
39
|
+
const infoBody = await infoRes.json();
|
|
40
|
+
|
|
41
|
+
setStatus({
|
|
42
|
+
health: healthBody.data,
|
|
43
|
+
info: infoBody.data,
|
|
44
|
+
loading: false,
|
|
45
|
+
error: null,
|
|
46
|
+
});
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
setStatus((prev) => ({
|
|
52
|
+
...prev,
|
|
53
|
+
loading: false,
|
|
54
|
+
error: err instanceof Error ? err.message : 'Failed to connect to server',
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
fetchStatus();
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
controller.abort();
|
|
63
|
+
};
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
return status;
|
|
67
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
2
|
+
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import type { Server as HttpServer } from 'node:http';
|
|
5
|
+
import type { AddressInfo } from 'node:net';
|
|
6
|
+
import { Server as SocketServer } from 'socket.io';
|
|
7
|
+
import type { ServerToClientEvents, ClientToServerEvents } from '@appystack-template/shared';
|
|
8
|
+
import { SOCKET_EVENTS } from '@appystack-template/shared';
|
|
9
|
+
import { useSocket, getSocketUrl } from './useSocket.js';
|
|
10
|
+
|
|
11
|
+
let httpServer: HttpServer;
|
|
12
|
+
let io: SocketServer<ClientToServerEvents, ServerToClientEvents>;
|
|
13
|
+
let serverPort: number;
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
return new Promise<void>((resolve) => {
|
|
17
|
+
httpServer = createServer();
|
|
18
|
+
|
|
19
|
+
io = new SocketServer<ClientToServerEvents, ServerToClientEvents>(httpServer, {
|
|
20
|
+
cors: { origin: '*' },
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
io.on('connection', (socket) => {
|
|
24
|
+
socket.on(SOCKET_EVENTS.CLIENT_PING, () => {
|
|
25
|
+
socket.emit(SOCKET_EVENTS.SERVER_PONG, {
|
|
26
|
+
message: 'pong',
|
|
27
|
+
timestamp: new Date().toISOString(),
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
httpServer.listen(0, () => {
|
|
33
|
+
serverPort = (httpServer.address() as AddressInfo).port;
|
|
34
|
+
|
|
35
|
+
// Set jsdom's location so socket.io-client connects to our test server
|
|
36
|
+
Object.defineProperty(window, 'location', {
|
|
37
|
+
value: new URL(`http://localhost:${serverPort}`),
|
|
38
|
+
writable: true,
|
|
39
|
+
configurable: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
resolve();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterAll(() => {
|
|
48
|
+
return new Promise<void>((resolve) => {
|
|
49
|
+
io.close(() => {
|
|
50
|
+
httpServer.close(() => resolve());
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('useSocket', () => {
|
|
56
|
+
it('starts with connected=false', () => {
|
|
57
|
+
const { result, unmount } = renderHook(() => useSocket());
|
|
58
|
+
expect(result.current.connected).toBe(false);
|
|
59
|
+
unmount();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('connects to the server and sets connected=true', async () => {
|
|
63
|
+
const { result, unmount } = renderHook(() => useSocket());
|
|
64
|
+
|
|
65
|
+
await waitFor(
|
|
66
|
+
() => {
|
|
67
|
+
expect(result.current.connected).toBe(true);
|
|
68
|
+
},
|
|
69
|
+
{ timeout: 5000 }
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
unmount();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('exposes the socket instance after connecting', async () => {
|
|
76
|
+
const { result, unmount } = renderHook(() => useSocket());
|
|
77
|
+
|
|
78
|
+
await waitFor(
|
|
79
|
+
() => {
|
|
80
|
+
expect(result.current.connected).toBe(true);
|
|
81
|
+
},
|
|
82
|
+
{ timeout: 5000 }
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// After connecting, the socket should be accessible
|
|
86
|
+
// Note: socketRef.current is returned; it may be set after the state update
|
|
87
|
+
expect(result.current.socket).toBeDefined();
|
|
88
|
+
|
|
89
|
+
unmount();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('sets connected=false after unmount (disconnect)', async () => {
|
|
93
|
+
const { result, unmount } = renderHook(() => useSocket());
|
|
94
|
+
|
|
95
|
+
await waitFor(
|
|
96
|
+
() => {
|
|
97
|
+
expect(result.current.connected).toBe(true);
|
|
98
|
+
},
|
|
99
|
+
{ timeout: 5000 }
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Capture the socket instance before unmounting so we can verify it is disconnectable
|
|
103
|
+
const socket = result.current.socket;
|
|
104
|
+
expect(typeof socket?.disconnect).toBe('function');
|
|
105
|
+
|
|
106
|
+
act(() => {
|
|
107
|
+
unmount();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('getSocketUrl', () => {
|
|
113
|
+
it('returns window.location.origin when VITE_SOCKET_URL is not set', () => {
|
|
114
|
+
// Ensure VITE_SOCKET_URL is absent; vi.stubEnv mutates import.meta.env in place
|
|
115
|
+
vi.stubEnv('VITE_SOCKET_URL', undefined as unknown as string);
|
|
116
|
+
// window.location was set in beforeAll to the test server origin
|
|
117
|
+
expect(getSocketUrl()).toBe(window.location.origin);
|
|
118
|
+
vi.unstubAllEnvs();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('returns VITE_SOCKET_URL value when it is set', () => {
|
|
122
|
+
const customUrl = 'http://custom-socket-server:4000';
|
|
123
|
+
vi.stubEnv('VITE_SOCKET_URL', customUrl);
|
|
124
|
+
expect(getSocketUrl()).toBe(customUrl);
|
|
125
|
+
vi.unstubAllEnvs();
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('useSocket — connect_error handling', () => {
|
|
130
|
+
it('exposes a socket that supports registering a connect_error listener without throwing', async () => {
|
|
131
|
+
const { result, unmount } = renderHook(() => useSocket());
|
|
132
|
+
|
|
133
|
+
await waitFor(
|
|
134
|
+
() => {
|
|
135
|
+
expect(result.current.connected).toBe(true);
|
|
136
|
+
},
|
|
137
|
+
{ timeout: 5000 }
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Verify that the socket exposes the .on() method so callers can listen for connect_error.
|
|
141
|
+
// Socket.io reserves connect_error as an internal event — it cannot be user-emitted,
|
|
142
|
+
// but consumers can register listeners for it. Confirm the hook surface is correct.
|
|
143
|
+
expect(typeof result.current.socket?.on).toBe('function');
|
|
144
|
+
|
|
145
|
+
// Register a connect_error listener — this must not throw
|
|
146
|
+
expect(() => {
|
|
147
|
+
result.current.socket?.on('connect_error', () => {});
|
|
148
|
+
}).not.toThrow();
|
|
149
|
+
|
|
150
|
+
unmount();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { io } from 'socket.io-client';
|
|
3
|
+
import type { Socket } from 'socket.io-client';
|
|
4
|
+
import type { ServerToClientEvents, ClientToServerEvents } from '@appystack-template/shared';
|
|
5
|
+
|
|
6
|
+
/** Typed Socket.io client instance for the AppyStack template event contracts. */
|
|
7
|
+
export type AppSocket = Socket<ServerToClientEvents, ClientToServerEvents>;
|
|
8
|
+
|
|
9
|
+
// Resolve the socket URL at connection time so test environments can set window.location first.
|
|
10
|
+
// Override via VITE_SOCKET_URL env var if a different server is needed.
|
|
11
|
+
export function getSocketUrl(): string {
|
|
12
|
+
return (import.meta.env.VITE_SOCKET_URL as string | undefined) ?? window.location.origin;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Socket.io connection hook for the AppyStack template.
|
|
17
|
+
* @returns socket ref (null until connected) and connected boolean
|
|
18
|
+
*/
|
|
19
|
+
export function useSocket() {
|
|
20
|
+
const socketRef = useRef<AppSocket | null>(null);
|
|
21
|
+
const [connected, setConnected] = useState(false);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const socket: AppSocket = io(getSocketUrl(), {
|
|
25
|
+
transports: ['websocket', 'polling'],
|
|
26
|
+
reconnection: true,
|
|
27
|
+
reconnectionAttempts: 5, // give up after 5 attempts; set to Infinity to retry forever
|
|
28
|
+
reconnectionDelay: 1000, // initial delay before first reconnect attempt (ms)
|
|
29
|
+
reconnectionDelayMax: 5000, // maximum delay between attempts (ms)
|
|
30
|
+
randomizationFactor: 0.5, // jitter factor to avoid thundering herd (0 = no jitter)
|
|
31
|
+
});
|
|
32
|
+
socketRef.current = socket;
|
|
33
|
+
|
|
34
|
+
socket.on('connect', () => setConnected(true));
|
|
35
|
+
socket.on('disconnect', () => setConnected(false));
|
|
36
|
+
|
|
37
|
+
return () => {
|
|
38
|
+
socket.disconnect();
|
|
39
|
+
};
|
|
40
|
+
}, []);
|
|
41
|
+
|
|
42
|
+
return { socket: socketRef.current, connected };
|
|
43
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { cn } from './utils.js';
|
|
3
|
+
|
|
4
|
+
describe('cn', () => {
|
|
5
|
+
it('merges class strings', () => {
|
|
6
|
+
expect(cn('px-4', 'py-2')).toBe('px-4 py-2');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('resolves Tailwind conflicts — later class wins', () => {
|
|
10
|
+
expect(cn('bg-red-500', 'bg-blue-500')).toBe('bg-blue-500');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('handles conditional classes — falsy values are skipped', () => {
|
|
14
|
+
const condition = false;
|
|
15
|
+
expect(cn('base', condition && 'skip', 'keep')).toBe('base keep');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('handles undefined gracefully', () => {
|
|
19
|
+
expect(cn('a', undefined, 'b')).toBe('a b');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('handles null gracefully', () => {
|
|
23
|
+
expect(cn('a', null, 'b')).toBe('a b');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns empty string when given no arguments', () => {
|
|
27
|
+
expect(cn()).toBe('');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('handles multiple Tailwind conflicts — last occurrence wins', () => {
|
|
31
|
+
expect(cn('p-2', 'p-4', 'p-8')).toBe('p-8');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
2
|
+
import { twMerge } from 'tailwind-merge';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Merge Tailwind CSS classes without conflicts.
|
|
6
|
+
* Combines clsx (conditional classes) with tailwind-merge (deduplication).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* cn('bg-red-500', condition && 'text-white', 'bg-blue-500')
|
|
10
|
+
* // → 'text-white bg-blue-500' (bg-red-500 is overridden by bg-blue-500)
|
|
11
|
+
*/
|
|
12
|
+
export function cn(...inputs: ClassValue[]): string {
|
|
13
|
+
return twMerge(clsx(inputs));
|
|
14
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { StrictMode } from 'react';
|
|
4
|
+
import { ErrorBoundary } from 'react-error-boundary';
|
|
5
|
+
import express from 'express';
|
|
6
|
+
import type { Server } from 'node:http';
|
|
7
|
+
import ErrorFallback from './components/ErrorFallback.js';
|
|
8
|
+
import App from './App.js';
|
|
9
|
+
|
|
10
|
+
// Capture native fetch before any stub replaces it
|
|
11
|
+
const nativeFetch = globalThis.fetch;
|
|
12
|
+
|
|
13
|
+
let server: Server;
|
|
14
|
+
let serverPort: number;
|
|
15
|
+
|
|
16
|
+
beforeAll(
|
|
17
|
+
() =>
|
|
18
|
+
new Promise<void>((resolve) => {
|
|
19
|
+
const app = express();
|
|
20
|
+
app.get('/health', (_, res) =>
|
|
21
|
+
res.json({ status: 'ok', timestamp: new Date().toISOString() })
|
|
22
|
+
);
|
|
23
|
+
app.get('/api/info', (_, res) =>
|
|
24
|
+
res.json({
|
|
25
|
+
status: 'ok',
|
|
26
|
+
data: { nodeVersion: 'test', environment: 'test', port: 0, clientUrl: '', uptime: 0 },
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
server = app.listen(0, () => {
|
|
30
|
+
serverPort = (server.address() as { port: number }).port;
|
|
31
|
+
resolve();
|
|
32
|
+
});
|
|
33
|
+
})
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
globalThis.fetch = (input, init) => {
|
|
38
|
+
const url =
|
|
39
|
+
typeof input === 'string' && input.startsWith('/')
|
|
40
|
+
? `http://localhost:${serverPort}${input}`
|
|
41
|
+
: input;
|
|
42
|
+
return nativeFetch(url, init);
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterAll(
|
|
47
|
+
() =>
|
|
48
|
+
new Promise<void>((resolve) => {
|
|
49
|
+
server?.close(() => resolve());
|
|
50
|
+
})
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Component that always throws — used to test ErrorBoundary catching
|
|
54
|
+
function ThrowingComponent(): never {
|
|
55
|
+
throw new Error('deliberate test error');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe('main.tsx wiring — ErrorBoundary', () => {
|
|
59
|
+
it('renders without throwing when the app tree is mounted', () => {
|
|
60
|
+
expect(() =>
|
|
61
|
+
render(
|
|
62
|
+
<StrictMode>
|
|
63
|
+
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
|
64
|
+
<App />
|
|
65
|
+
</ErrorBoundary>
|
|
66
|
+
</StrictMode>
|
|
67
|
+
)
|
|
68
|
+
).not.toThrow();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('shows ErrorFallback when a child component throws', () => {
|
|
72
|
+
// Suppress the expected console.error from react-error-boundary
|
|
73
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
74
|
+
|
|
75
|
+
render(
|
|
76
|
+
<StrictMode>
|
|
77
|
+
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
|
78
|
+
<ThrowingComponent />
|
|
79
|
+
</ErrorBoundary>
|
|
80
|
+
</StrictMode>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
84
|
+
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
|
85
|
+
expect(screen.getByText('deliberate test error')).toBeInTheDocument();
|
|
86
|
+
|
|
87
|
+
consoleSpy.mockRestore();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('renders a Try again button in the error fallback', () => {
|
|
91
|
+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
92
|
+
|
|
93
|
+
render(
|
|
94
|
+
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
|
95
|
+
<ThrowingComponent />
|
|
96
|
+
</ErrorBoundary>
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
|
|
100
|
+
|
|
101
|
+
consoleSpy.mockRestore();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('renders App content inside the ErrorBoundary wrapper', () => {
|
|
105
|
+
render(
|
|
106
|
+
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
|
107
|
+
<App />
|
|
108
|
+
</ErrorBoundary>
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
expect(screen.getByText(/Production-ready RVETS stack boilerplate/)).toBeInTheDocument();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { StrictMode } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import { ErrorBoundary } from 'react-error-boundary';
|
|
4
|
+
import App from './App.js';
|
|
5
|
+
import ErrorFallback from './components/ErrorFallback.js';
|
|
6
|
+
import './styles/index.css';
|
|
7
|
+
|
|
8
|
+
createRoot(document.getElementById('root')!).render(
|
|
9
|
+
<StrictMode>
|
|
10
|
+
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
|
11
|
+
<App />
|
|
12
|
+
</ErrorBoundary>
|
|
13
|
+
</StrictMode>
|
|
14
|
+
);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import LandingPage from './LandingPage.js';
|
|
4
|
+
|
|
5
|
+
describe('LandingPage', () => {
|
|
6
|
+
it('renders the AppyStack ASCII banner tagline', () => {
|
|
7
|
+
render(<LandingPage />);
|
|
8
|
+
expect(screen.getByText(/Production-ready RVETS stack boilerplate/)).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('renders the placeholder content message', () => {
|
|
12
|
+
render(<LandingPage />);
|
|
13
|
+
expect(screen.getByText(/Your app content goes here/)).toBeInTheDocument();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('references DemoPage in the placeholder text', () => {
|
|
17
|
+
render(<LandingPage />);
|
|
18
|
+
expect(screen.getByText('src/demo/DemoPage.tsx')).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('renders a main content area', () => {
|
|
22
|
+
render(<LandingPage />);
|
|
23
|
+
expect(document.querySelector('main')).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders a header element', () => {
|
|
27
|
+
render(<LandingPage />);
|
|
28
|
+
expect(document.querySelector('header')).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// TODO: Replace this placeholder content with your app
|
|
2
|
+
|
|
3
|
+
const ASCII_BANNER = ` _ ____ _ _
|
|
4
|
+
/ \\ _ __ _ __ _ _/ ___|| |_ __ _ ___| | __
|
|
5
|
+
/ _ \\ | '_ \\| '_ \\| | | \\___ \\| __/ _\` |/ __| |/ /
|
|
6
|
+
/ ___ \\| |_) | |_) | |_| |___) | || (_| | (__| <
|
|
7
|
+
/_/ \\_\\ .__/| .__/ \\__, |____/ \\__\\__,_|\\___|_|\\_\\
|
|
8
|
+
|_| |_| |___/`;
|
|
9
|
+
|
|
10
|
+
export default function LandingPage() {
|
|
11
|
+
return (
|
|
12
|
+
<div className="min-h-screen">
|
|
13
|
+
<header className="py-16 text-center bg-background">
|
|
14
|
+
<pre className="inline-block text-left text-sm md:text-base leading-tight text-primary font-mono">
|
|
15
|
+
{ASCII_BANNER}
|
|
16
|
+
</pre>
|
|
17
|
+
<p className="mt-4 text-lg text-primary/70 font-mono">
|
|
18
|
+
Production-ready RVETS stack boilerplate
|
|
19
|
+
</p>
|
|
20
|
+
</header>
|
|
21
|
+
<main className="max-w-5xl mx-auto px-6 py-12">
|
|
22
|
+
{/* TODO: Add your app content here */}
|
|
23
|
+
<p className="text-muted-foreground text-center">
|
|
24
|
+
Your app content goes here. See <code>src/demo/DemoPage.tsx</code> for feature examples.
|
|
25
|
+
</p>
|
|
26
|
+
</main>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
@import 'tailwindcss';
|
|
2
|
+
|
|
3
|
+
@source "../";
|
|
4
|
+
|
|
5
|
+
@theme {
|
|
6
|
+
/* Base */
|
|
7
|
+
--color-background: oklch(0.152 0.034 260);
|
|
8
|
+
--color-foreground: oklch(0.966 0.009 246);
|
|
9
|
+
|
|
10
|
+
/* Card */
|
|
11
|
+
--color-card: oklch(0.217 0.041 260);
|
|
12
|
+
--color-card-foreground: oklch(0.966 0.009 246);
|
|
13
|
+
|
|
14
|
+
/* Border */
|
|
15
|
+
--color-border: oklch(0.33 0.046 259);
|
|
16
|
+
|
|
17
|
+
/* Primary — terminal green */
|
|
18
|
+
--color-primary: oklch(0.723 0.213 142);
|
|
19
|
+
--color-primary-foreground: oklch(0.152 0.034 260);
|
|
20
|
+
|
|
21
|
+
/* Muted */
|
|
22
|
+
--color-muted: oklch(0.217 0.041 260);
|
|
23
|
+
--color-muted-foreground: oklch(0.672 0.025 249);
|
|
24
|
+
|
|
25
|
+
/* Destructive */
|
|
26
|
+
--color-destructive: oklch(0.628 0.219 27);
|
|
27
|
+
--color-destructive-foreground: oklch(0.966 0.009 246);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
:root {
|
|
31
|
+
/* Semantic aliases matching ShadCN convention */
|
|
32
|
+
--background: oklch(0.152 0.034 260);
|
|
33
|
+
--foreground: oklch(0.966 0.009 246);
|
|
34
|
+
--card: oklch(0.217 0.041 260);
|
|
35
|
+
--border: oklch(0.33 0.046 259);
|
|
36
|
+
--primary: oklch(0.723 0.213 142);
|
|
37
|
+
--primary-foreground: oklch(0.152 0.034 260);
|
|
38
|
+
--muted-foreground: oklch(0.672 0.025 249);
|
|
39
|
+
--destructive: oklch(0.628 0.219 27);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
body {
|
|
43
|
+
margin: 0;
|
|
44
|
+
background-color: var(--background);
|
|
45
|
+
color: var(--foreground);
|
|
46
|
+
font-family:
|
|
47
|
+
system-ui,
|
|
48
|
+
-apple-system,
|
|
49
|
+
sans-serif;
|
|
50
|
+
}
|