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.
Files changed (99) hide show
  1. package/README.md +54 -0
  2. package/bin/index.js +243 -0
  3. package/package.json +39 -0
  4. package/template/.claude/skills/recipe/SKILL.md +71 -0
  5. package/template/.claude/skills/recipe/domains/care-provider-operations.md +185 -0
  6. package/template/.claude/skills/recipe/domains/youtube-launch-optimizer.md +154 -0
  7. package/template/.claude/skills/recipe/references/file-crud.md +295 -0
  8. package/template/.claude/skills/recipe/references/nav-shell.md +233 -0
  9. package/template/.dockerignore +39 -0
  10. package/template/.env.example +13 -0
  11. package/template/.github/workflows/ci.yml +43 -0
  12. package/template/.husky/pre-commit +1 -0
  13. package/template/.prettierignore +7 -0
  14. package/template/.prettierrc +8 -0
  15. package/template/.vscode/launch.json +59 -0
  16. package/template/CLAUDE.md +114 -0
  17. package/template/Dockerfile +56 -0
  18. package/template/README.md +219 -0
  19. package/template/client/index.html +13 -0
  20. package/template/client/package.json +43 -0
  21. package/template/client/src/App.test.tsx +67 -0
  22. package/template/client/src/App.tsx +11 -0
  23. package/template/client/src/components/ErrorFallback.test.tsx +64 -0
  24. package/template/client/src/components/ErrorFallback.tsx +18 -0
  25. package/template/client/src/config/env.test.ts +64 -0
  26. package/template/client/src/config/env.ts +34 -0
  27. package/template/client/src/contexts/AppContext.test.tsx +81 -0
  28. package/template/client/src/contexts/AppContext.tsx +52 -0
  29. package/template/client/src/demo/ContactForm.test.tsx +97 -0
  30. package/template/client/src/demo/ContactForm.tsx +100 -0
  31. package/template/client/src/demo/DemoPage.tsx +56 -0
  32. package/template/client/src/demo/SocketDemo.test.tsx +160 -0
  33. package/template/client/src/demo/SocketDemo.tsx +65 -0
  34. package/template/client/src/demo/StatusGrid.test.tsx +181 -0
  35. package/template/client/src/demo/StatusGrid.tsx +77 -0
  36. package/template/client/src/demo/TechStackDisplay.test.tsx +63 -0
  37. package/template/client/src/demo/TechStackDisplay.tsx +75 -0
  38. package/template/client/src/hooks/useServerStatus.test.ts +133 -0
  39. package/template/client/src/hooks/useServerStatus.ts +67 -0
  40. package/template/client/src/hooks/useSocket.test.ts +152 -0
  41. package/template/client/src/hooks/useSocket.ts +43 -0
  42. package/template/client/src/lib/utils.test.ts +33 -0
  43. package/template/client/src/lib/utils.ts +14 -0
  44. package/template/client/src/main.test.tsx +113 -0
  45. package/template/client/src/main.tsx +14 -0
  46. package/template/client/src/pages/LandingPage.test.tsx +30 -0
  47. package/template/client/src/pages/LandingPage.tsx +29 -0
  48. package/template/client/src/styles/index.css +50 -0
  49. package/template/client/src/test/msw/browser.ts +4 -0
  50. package/template/client/src/test/msw/handlers.ts +12 -0
  51. package/template/client/src/test/msw/msw-example.test.ts +69 -0
  52. package/template/client/src/test/msw/server.ts +14 -0
  53. package/template/client/src/test/setup.ts +10 -0
  54. package/template/client/src/utils/api.test.ts +79 -0
  55. package/template/client/src/utils/api.ts +42 -0
  56. package/template/client/src/vite-env.d.ts +13 -0
  57. package/template/client/tsconfig.json +17 -0
  58. package/template/client/vite.config.ts +38 -0
  59. package/template/client/vitest.config.ts +36 -0
  60. package/template/docker-compose.yml +19 -0
  61. package/template/e2e/smoke.test.ts +95 -0
  62. package/template/e2e/socket.test.ts +96 -0
  63. package/template/eslint.config.js +2 -0
  64. package/template/package.json +50 -0
  65. package/template/playwright.config.ts +14 -0
  66. package/template/scripts/customize.ts +175 -0
  67. package/template/server/nodemon.json +5 -0
  68. package/template/server/package.json +45 -0
  69. package/template/server/src/app.test.ts +103 -0
  70. package/template/server/src/config/env.test.ts +97 -0
  71. package/template/server/src/config/env.ts +29 -0
  72. package/template/server/src/config/logger.test.ts +58 -0
  73. package/template/server/src/config/logger.ts +17 -0
  74. package/template/server/src/helpers/response.test.ts +53 -0
  75. package/template/server/src/helpers/response.ts +17 -0
  76. package/template/server/src/index.ts +118 -0
  77. package/template/server/src/middleware/errorHandler.test.ts +84 -0
  78. package/template/server/src/middleware/errorHandler.ts +27 -0
  79. package/template/server/src/middleware/rateLimiter.test.ts +68 -0
  80. package/template/server/src/middleware/rateLimiter.ts +8 -0
  81. package/template/server/src/middleware/requestLogger.test.ts +111 -0
  82. package/template/server/src/middleware/requestLogger.ts +17 -0
  83. package/template/server/src/middleware/validate.test.ts +213 -0
  84. package/template/server/src/middleware/validate.ts +23 -0
  85. package/template/server/src/routes/health.test.ts +17 -0
  86. package/template/server/src/routes/health.ts +12 -0
  87. package/template/server/src/routes/info.test.ts +20 -0
  88. package/template/server/src/routes/info.ts +19 -0
  89. package/template/server/src/shared.test.ts +53 -0
  90. package/template/server/src/shutdown.test.ts +98 -0
  91. package/template/server/src/socket.test.ts +185 -0
  92. package/template/server/src/static.test.ts +166 -0
  93. package/template/server/tsconfig.json +16 -0
  94. package/template/server/vitest.config.ts +22 -0
  95. package/template/shared/package.json +19 -0
  96. package/template/shared/src/constants.ts +11 -0
  97. package/template/shared/src/index.ts +8 -0
  98. package/template/shared/src/types.ts +33 -0
  99. package/template/shared/tsconfig.json +10 -0
@@ -0,0 +1,160 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, screen, fireEvent, act } from '@testing-library/react';
3
+
4
+ // Hoisted mutable state so vi.mock factory can reference it
5
+ const mockSocketState = vi.hoisted(() => ({
6
+ socket: null as { once: ReturnType<typeof vi.fn>; emit: ReturnType<typeof vi.fn> } | null,
7
+ connected: false,
8
+ mockOnce: vi.fn(),
9
+ mockEmit: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('../hooks/useSocket.js', () => ({
13
+ useSocket: () => ({
14
+ socket: mockSocketState.socket,
15
+ connected: mockSocketState.connected,
16
+ }),
17
+ }));
18
+
19
+ import SocketDemo from './SocketDemo.js';
20
+
21
+ describe('SocketDemo — disconnected (default)', () => {
22
+ beforeEach(() => {
23
+ mockSocketState.socket = null;
24
+ mockSocketState.connected = false;
25
+ mockSocketState.mockOnce.mockReset();
26
+ mockSocketState.mockEmit.mockReset();
27
+ });
28
+
29
+ it('renders the Socket.io Demo heading', () => {
30
+ render(<SocketDemo />);
31
+ expect(screen.getByText('Socket.io Demo')).toBeInTheDocument();
32
+ });
33
+
34
+ it('renders the Send Ping button', () => {
35
+ render(<SocketDemo />);
36
+ expect(screen.getByRole('button', { name: 'Send Ping' })).toBeInTheDocument();
37
+ });
38
+
39
+ it('renders the Send Ping button disabled when disconnected', () => {
40
+ render(<SocketDemo />);
41
+ const button = screen.getByRole('button', { name: 'Send Ping' });
42
+ expect(button).toBeDisabled();
43
+ });
44
+
45
+ it('shows disconnected status on initial render', () => {
46
+ render(<SocketDemo />);
47
+ expect(screen.getByText('Status: disconnected')).toBeInTheDocument();
48
+ });
49
+
50
+ it('does not show pong response before any ping is sent', () => {
51
+ render(<SocketDemo />);
52
+ expect(screen.queryByText(/server:pong received/)).not.toBeInTheDocument();
53
+ });
54
+ });
55
+
56
+ describe('SocketDemo — connected state', () => {
57
+ beforeEach(() => {
58
+ mockSocketState.mockOnce.mockReset();
59
+ mockSocketState.mockEmit.mockReset();
60
+ mockSocketState.socket = {
61
+ once: mockSocketState.mockOnce,
62
+ emit: mockSocketState.mockEmit,
63
+ };
64
+ mockSocketState.connected = true;
65
+ });
66
+
67
+ it('shows connected status when socket is connected', () => {
68
+ render(<SocketDemo />);
69
+ expect(screen.getByText('Status: connected')).toBeInTheDocument();
70
+ });
71
+
72
+ it('button is enabled when connected', () => {
73
+ render(<SocketDemo />);
74
+ const button = screen.getByRole('button', { name: 'Send Ping' });
75
+ expect(button).not.toBeDisabled();
76
+ });
77
+
78
+ it('sendPing calls socket.once and socket.emit when clicked', async () => {
79
+ render(<SocketDemo />);
80
+ const button = screen.getByRole('button', { name: 'Send Ping' });
81
+
82
+ await act(async () => {
83
+ fireEvent.click(button);
84
+ });
85
+
86
+ expect(mockSocketState.mockOnce).toHaveBeenCalledTimes(1);
87
+ expect(mockSocketState.mockEmit).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it('shows waiting state after ping is sent', async () => {
91
+ // mockOnce never fires, so waiting=true persists
92
+ mockSocketState.mockOnce.mockImplementation(() => {});
93
+
94
+ render(<SocketDemo />);
95
+ const button = screen.getByRole('button', { name: 'Send Ping' });
96
+
97
+ await act(async () => {
98
+ fireEvent.click(button);
99
+ });
100
+
101
+ expect(screen.getByText('Waiting for pong...')).toBeInTheDocument();
102
+ });
103
+
104
+ it('shows pong response after server:pong event fires', async () => {
105
+ let pongHandler: ((data: { message: string; timestamp: string }) => void) | null = null;
106
+ mockSocketState.mockOnce.mockImplementation(
107
+ (_event: string, handler: (data: { message: string; timestamp: string }) => void) => {
108
+ pongHandler = handler;
109
+ }
110
+ );
111
+
112
+ render(<SocketDemo />);
113
+ const button = screen.getByRole('button', { name: 'Send Ping' });
114
+
115
+ await act(async () => {
116
+ fireEvent.click(button);
117
+ });
118
+
119
+ await act(async () => {
120
+ pongHandler?.({ message: 'pong', timestamp: new Date().toISOString() });
121
+ });
122
+
123
+ expect(screen.getByText(/server:pong received/)).toBeInTheDocument();
124
+ });
125
+ });
126
+
127
+ describe('SocketDemo — disconnect after connect', () => {
128
+ it('Send Ping button is disabled when socket disconnects', () => {
129
+ // Start disconnected — simulate a socket that has dropped
130
+ mockSocketState.socket = null;
131
+ mockSocketState.connected = false;
132
+
133
+ render(<SocketDemo />);
134
+ const button = screen.getByRole('button', { name: 'Send Ping' });
135
+ expect(button).toBeDisabled();
136
+ });
137
+
138
+ it('shows disconnected status text when socket is not connected', () => {
139
+ mockSocketState.socket = null;
140
+ mockSocketState.connected = false;
141
+
142
+ render(<SocketDemo />);
143
+ expect(screen.getByText('Status: disconnected')).toBeInTheDocument();
144
+ });
145
+
146
+ it('Send Ping button becomes enabled when socket reconnects', () => {
147
+ // Simulate reconnection: connected=true and socket object available
148
+ mockSocketState.mockOnce.mockReset();
149
+ mockSocketState.mockEmit.mockReset();
150
+ mockSocketState.socket = {
151
+ once: mockSocketState.mockOnce,
152
+ emit: mockSocketState.mockEmit,
153
+ };
154
+ mockSocketState.connected = true;
155
+
156
+ render(<SocketDemo />);
157
+ const button = screen.getByRole('button', { name: 'Send Ping' });
158
+ expect(button).not.toBeDisabled();
159
+ });
160
+ });
@@ -0,0 +1,65 @@
1
+ import { useState } from 'react';
2
+ import { SOCKET_EVENTS } from '@appystack-template/shared';
3
+ import { useSocket } from '../hooks/useSocket.js';
4
+ import { cn } from '@/lib/utils.js';
5
+
6
+ interface PongResponse {
7
+ message: string;
8
+ timestamp: string;
9
+ }
10
+
11
+ export default function SocketDemo() {
12
+ const { socket, connected } = useSocket();
13
+ const [lastPong, setLastPong] = useState<PongResponse | null>(null);
14
+ const [waiting, setWaiting] = useState(false);
15
+
16
+ const sendPing = () => {
17
+ if (!socket || !connected) return;
18
+
19
+ setWaiting(true);
20
+
21
+ socket.once(SOCKET_EVENTS.SERVER_PONG, (data) => {
22
+ setLastPong(data);
23
+ setWaiting(false);
24
+ });
25
+
26
+ socket.emit(SOCKET_EVENTS.CLIENT_PING);
27
+ };
28
+
29
+ return (
30
+ <div className="rounded-xl p-5 bg-card border border-border">
31
+ <div className="flex items-center gap-2 mb-3">
32
+ <span
33
+ className={`inline-block w-3 h-3 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
34
+ />
35
+ <h3 className="text-lg font-semibold text-foreground">Socket.io Demo</h3>
36
+ </div>
37
+
38
+ <div className="text-sm space-y-3 text-muted-foreground">
39
+ <p>Status: {connected ? 'connected' : 'disconnected'}</p>
40
+
41
+ <button
42
+ onClick={sendPing}
43
+ disabled={!connected || waiting}
44
+ className={cn(
45
+ 'px-4 py-2 rounded text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
46
+ connected
47
+ ? 'bg-primary text-primary-foreground'
48
+ : 'bg-card border border-border text-muted-foreground'
49
+ )}
50
+ >
51
+ {waiting ? 'Waiting for pong...' : 'Send Ping'}
52
+ </button>
53
+
54
+ {lastPong && (
55
+ <div className="mt-3 p-3 rounded text-sm bg-background border border-primary">
56
+ <p className="text-primary">server:pong received — {lastPong.message}</p>
57
+ <p className="mt-1 text-primary/70">
58
+ {new Date(lastPong.timestamp).toLocaleTimeString()}
59
+ </p>
60
+ </div>
61
+ )}
62
+ </div>
63
+ </div>
64
+ );
65
+ }
@@ -0,0 +1,181 @@
1
+ import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
2
+ import { render, screen, 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 StatusGrid from './StatusGrid.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: 5501,
29
+ clientUrl: 'http://localhost:5500',
30
+ uptime: 99,
31
+ },
32
+ });
33
+ });
34
+
35
+ server = app.listen(0, () => {
36
+ serverPort = (server.address() as AddressInfo).port;
37
+ resolve();
38
+ });
39
+ });
40
+ });
41
+
42
+ beforeEach(() => {
43
+ globalThis.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
44
+ const url =
45
+ typeof input === 'string' && input.startsWith('/')
46
+ ? `http://localhost:${serverPort}${input}`
47
+ : input;
48
+ return nativeFetch(url, init);
49
+ };
50
+ });
51
+
52
+ afterAll(() => {
53
+ return new Promise<void>((resolve) => {
54
+ server.close(() => resolve());
55
+ });
56
+ });
57
+
58
+ describe('StatusGrid', () => {
59
+ it('shows loading state on initial render', () => {
60
+ render(<StatusGrid />);
61
+ expect(screen.getByText('Connecting to server...')).toBeInTheDocument();
62
+ });
63
+
64
+ it('renders the status-grid container once loaded', async () => {
65
+ render(<StatusGrid />);
66
+ await waitFor(() => expect(screen.getByTestId('status-grid')).toBeInTheDocument(), {
67
+ timeout: 5000,
68
+ });
69
+ });
70
+
71
+ it('shows API Health card after loading', async () => {
72
+ render(<StatusGrid />);
73
+ await waitFor(() => expect(screen.getByText('API Health')).toBeInTheDocument(), {
74
+ timeout: 5000,
75
+ });
76
+ });
77
+
78
+ it('shows WebSocket card after loading', async () => {
79
+ render(<StatusGrid />);
80
+ await waitFor(() => expect(screen.getByText('WebSocket')).toBeInTheDocument(), {
81
+ timeout: 5000,
82
+ });
83
+ });
84
+
85
+ it('shows Environment card after loading', async () => {
86
+ render(<StatusGrid />);
87
+ await waitFor(() => expect(screen.getByText('Environment')).toBeInTheDocument(), {
88
+ timeout: 5000,
89
+ });
90
+ });
91
+
92
+ it('shows Runtime card after loading', async () => {
93
+ render(<StatusGrid />);
94
+ await waitFor(() => expect(screen.getByText('Runtime')).toBeInTheDocument(), {
95
+ timeout: 5000,
96
+ });
97
+ });
98
+
99
+ it('shows server health status ok after loading', async () => {
100
+ render(<StatusGrid />);
101
+ await waitFor(() => expect(screen.getByText('Status: ok')).toBeInTheDocument(), {
102
+ timeout: 5000,
103
+ });
104
+ });
105
+
106
+ it('shows environment info from server', async () => {
107
+ render(<StatusGrid />);
108
+ await waitFor(() => expect(screen.getByText('Mode: test')).toBeInTheDocument(), {
109
+ timeout: 5000,
110
+ });
111
+ });
112
+ });
113
+
114
+ describe('StatusGrid — error state (server unreachable)', () => {
115
+ beforeEach(() => {
116
+ // Override fetch to simulate a server that is completely unreachable
117
+ vi.stubGlobal('fetch', () => Promise.reject(new Error('Network error')));
118
+ });
119
+
120
+ afterEach(() => {
121
+ vi.unstubAllGlobals();
122
+ });
123
+
124
+ it('shows error fallback for API Health card', async () => {
125
+ render(<StatusGrid />);
126
+ await waitFor(
127
+ () => {
128
+ // When health is null and there is an error, the fallback paragraph should appear
129
+ // StatusGrid renders "Unable to reach server" or the error message
130
+ const errorTexts = screen.queryAllByText(/Network error|Unable to reach server/);
131
+ expect(errorTexts.length).toBeGreaterThan(0);
132
+ },
133
+ { timeout: 5000 }
134
+ );
135
+ });
136
+
137
+ it('shows error fallback for Environment card when info is null', async () => {
138
+ render(<StatusGrid />);
139
+ await waitFor(
140
+ () => {
141
+ // When info is null, both Environment and Runtime cards show the error
142
+ const errorTexts = screen.queryAllByText(/Network error|No data/);
143
+ expect(errorTexts.length).toBeGreaterThan(0);
144
+ },
145
+ { timeout: 5000 }
146
+ );
147
+ });
148
+ });
149
+
150
+ describe('StatusDot — isolation via StatusGrid', () => {
151
+ it('renders green indicator dots when server is reachable (ok=true)', async () => {
152
+ render(<StatusGrid />);
153
+ await waitFor(() => expect(screen.getByTestId('status-grid')).toBeInTheDocument(), {
154
+ timeout: 5000,
155
+ });
156
+ // After loading with a healthy server, API Health and info cards are ok=true
157
+ // StatusDot renders bg-green-500 when ok=true
158
+ const greenDots = document.querySelectorAll('.bg-green-500');
159
+ expect(greenDots.length).toBeGreaterThan(0);
160
+ });
161
+
162
+ it('renders red indicator dots when server is unreachable (ok=false)', async () => {
163
+ vi.stubGlobal('fetch', () => Promise.reject(new Error('Network error')));
164
+
165
+ render(<StatusGrid />);
166
+
167
+ // Wait for loading to complete (the loading text disappears)
168
+ await waitFor(
169
+ () => {
170
+ expect(screen.queryByText('Connecting to server...')).not.toBeInTheDocument();
171
+ },
172
+ { timeout: 5000 }
173
+ );
174
+
175
+ // StatusDot renders bg-red-500 when ok=false (health and info both null)
176
+ const redDots = document.querySelectorAll('.bg-red-500');
177
+ expect(redDots.length).toBeGreaterThan(0);
178
+
179
+ vi.unstubAllGlobals();
180
+ });
181
+ });
@@ -0,0 +1,77 @@
1
+ import { useServerStatus } from '../hooks/useServerStatus.js';
2
+ import { useSocket } from '../hooks/useSocket.js';
3
+
4
+ function StatusDot({ ok }: { ok: boolean }) {
5
+ return (
6
+ <span className={`inline-block w-3 h-3 rounded-full ${ok ? 'bg-green-500' : 'bg-red-500'}`} />
7
+ );
8
+ }
9
+
10
+ function StatusCard({
11
+ title,
12
+ ok,
13
+ children,
14
+ }: {
15
+ title: string;
16
+ ok: boolean;
17
+ children: React.ReactNode;
18
+ }) {
19
+ return (
20
+ <div className="rounded-xl p-5 bg-card border border-border">
21
+ <div className="flex items-center gap-2 mb-3">
22
+ <StatusDot ok={ok} />
23
+ <h3 className="text-lg font-semibold text-foreground">{title}</h3>
24
+ </div>
25
+ <div className="text-sm text-muted-foreground">{children}</div>
26
+ </div>
27
+ );
28
+ }
29
+
30
+ export default function StatusGrid() {
31
+ const { health, info, loading, error } = useServerStatus();
32
+ const { connected } = useSocket();
33
+
34
+ if (loading) {
35
+ return <div className="text-center py-8 text-muted-foreground">Connecting to server...</div>;
36
+ }
37
+
38
+ return (
39
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4" data-testid="status-grid">
40
+ <StatusCard title="API Health" ok={!!health}>
41
+ {health ? (
42
+ <>
43
+ <p>Status: {health.status}</p>
44
+ </>
45
+ ) : (
46
+ <p className="text-red-400">{error || 'Unable to reach server'}</p>
47
+ )}
48
+ </StatusCard>
49
+
50
+ <StatusCard title="WebSocket" ok={connected}>
51
+ <p>Status: {connected ? 'connected' : 'disconnected'}</p>
52
+ </StatusCard>
53
+
54
+ <StatusCard title="Environment" ok={!!info}>
55
+ {info ? (
56
+ <>
57
+ <p>Mode: {info.environment}</p>
58
+ <p>Port: {info.port}</p>
59
+ </>
60
+ ) : (
61
+ <p className="text-red-400">{error || 'No data'}</p>
62
+ )}
63
+ </StatusCard>
64
+
65
+ <StatusCard title="Runtime" ok={!!info}>
66
+ {info ? (
67
+ <>
68
+ <p>Node: {info.nodeVersion}</p>
69
+ <p>Uptime: {Math.floor(info.uptime)}s</p>
70
+ </>
71
+ ) : (
72
+ <p className="text-red-400">{error || 'No data'}</p>
73
+ )}
74
+ </StatusCard>
75
+ </div>
76
+ );
77
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import TechStackDisplay from './TechStackDisplay.js';
4
+
5
+ describe('TechStackDisplay', () => {
6
+ it('renders the Tech Stack heading', () => {
7
+ render(<TechStackDisplay />);
8
+ expect(screen.getByText('Tech Stack')).toBeInTheDocument();
9
+ });
10
+
11
+ it('renders the Client category', () => {
12
+ render(<TechStackDisplay />);
13
+ expect(screen.getByText('Client')).toBeInTheDocument();
14
+ });
15
+
16
+ it('renders the Server category', () => {
17
+ render(<TechStackDisplay />);
18
+ expect(screen.getByText('Server')).toBeInTheDocument();
19
+ });
20
+
21
+ it('renders the Cross-cutting category', () => {
22
+ render(<TechStackDisplay />);
23
+ expect(screen.getByText('Cross-cutting')).toBeInTheDocument();
24
+ });
25
+
26
+ it('renders the Testing & Quality category', () => {
27
+ render(<TechStackDisplay />);
28
+ expect(screen.getByText('Testing & Quality')).toBeInTheDocument();
29
+ });
30
+
31
+ it('renders all 4 categories in the tech stack', () => {
32
+ render(<TechStackDisplay />);
33
+ const container = screen.getByTestId('tech-stack');
34
+ // 4 category headings: Client, Server, Cross-cutting, Testing & Quality
35
+ const categoryHeadings = container.querySelectorAll('h3');
36
+ expect(categoryHeadings).toHaveLength(4);
37
+ });
38
+
39
+ it('renders React in the Client category', () => {
40
+ render(<TechStackDisplay />);
41
+ expect(screen.getByText('React')).toBeInTheDocument();
42
+ });
43
+
44
+ it('renders Express in the Server category', () => {
45
+ render(<TechStackDisplay />);
46
+ expect(screen.getByText('Express')).toBeInTheDocument();
47
+ });
48
+
49
+ it('renders TypeScript in the Cross-cutting category', () => {
50
+ render(<TechStackDisplay />);
51
+ expect(screen.getByText('TypeScript')).toBeInTheDocument();
52
+ });
53
+
54
+ it('renders Vitest in the Testing & Quality category', () => {
55
+ render(<TechStackDisplay />);
56
+ expect(screen.getByText('Vitest')).toBeInTheDocument();
57
+ });
58
+
59
+ it('renders the data-testid attribute', () => {
60
+ render(<TechStackDisplay />);
61
+ expect(screen.getByTestId('tech-stack')).toBeInTheDocument();
62
+ });
63
+ });
@@ -0,0 +1,75 @@
1
+ interface TechItem {
2
+ name: string;
3
+ version: string;
4
+ description: string;
5
+ }
6
+
7
+ interface TechCategory {
8
+ title: string;
9
+ items: TechItem[];
10
+ }
11
+
12
+ const techStack: TechCategory[] = [
13
+ {
14
+ title: 'Client',
15
+ items: [
16
+ { name: 'React', version: '19', description: 'UI component library' },
17
+ { name: 'Vite', version: '7', description: 'Build tool & dev server' },
18
+ { name: 'TailwindCSS', version: '4', description: 'Utility-first CSS framework' },
19
+ { name: 'Socket.io Client', version: '4', description: 'Real-time WebSocket client' },
20
+ ],
21
+ },
22
+ {
23
+ title: 'Server',
24
+ items: [
25
+ { name: 'Express', version: '5', description: 'HTTP server framework' },
26
+ { name: 'Socket.io', version: '4', description: 'Real-time WebSocket server' },
27
+ { name: 'Pino', version: '9', description: 'High-performance JSON logger' },
28
+ { name: 'Zod', version: '3', description: 'TypeScript-first schema validation' },
29
+ ],
30
+ },
31
+ {
32
+ title: 'Cross-cutting',
33
+ items: [
34
+ { name: 'TypeScript', version: '5', description: 'Type-safe JavaScript' },
35
+ { name: 'ESLint', version: '9', description: 'Flat config linting' },
36
+ { name: 'Prettier', version: '3', description: 'Code formatting' },
37
+ { name: 'npm Workspaces', version: '-', description: 'Monorepo package management' },
38
+ ],
39
+ },
40
+ {
41
+ title: 'Testing & Quality',
42
+ items: [
43
+ { name: 'Vitest', version: '4', description: 'Unit & integration testing' },
44
+ { name: 'Testing Library', version: '16', description: 'React component testing' },
45
+ { name: 'Supertest', version: '7', description: 'HTTP assertion testing' },
46
+ { name: 'Helmet', version: '8', description: 'Security headers middleware' },
47
+ ],
48
+ },
49
+ ];
50
+
51
+ export default function TechStackDisplay() {
52
+ return (
53
+ <div data-testid="tech-stack">
54
+ <h2 className="text-2xl font-bold mb-6 text-foreground">Tech Stack</h2>
55
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
56
+ {techStack.map((category) => (
57
+ <div key={category.title} className="rounded-xl p-5 bg-card border border-border">
58
+ <h3 className="text-lg font-semibold mb-3 text-primary">{category.title}</h3>
59
+ <ul className="space-y-2">
60
+ {category.items.map((item) => (
61
+ <li key={item.name}>
62
+ <span className="font-medium text-foreground">{item.name}</span>
63
+ {item.version !== '-' && (
64
+ <span className="text-xs ml-1 text-muted-foreground">v{item.version}</span>
65
+ )}
66
+ <p className="text-xs text-muted-foreground">{item.description}</p>
67
+ </li>
68
+ ))}
69
+ </ul>
70
+ </div>
71
+ ))}
72
+ </div>
73
+ </div>
74
+ );
75
+ }