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,12 @@
1
+ import { http, HttpResponse } from 'msw';
2
+
3
+ // Wildcard prefix matches any origin — works in both browser (setupWorker)
4
+ // and Node.js (setupServer / Vitest) contexts without needing a hardcoded host.
5
+ export const handlers = [
6
+ http.get('*/health', () => {
7
+ return HttpResponse.json({ status: 'ok', timestamp: new Date().toISOString() });
8
+ }),
9
+ http.get('*/api/info', () => {
10
+ return HttpResponse.json({ name: 'AppyStack', version: '1.0.0' });
11
+ }),
12
+ ];
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
2
+ import { server } from './server.js';
3
+ import { http, HttpResponse } from 'msw';
4
+
5
+ // MSW uses its own fetch interception layer (via @mswjs/interceptors).
6
+ // The global setup.ts stubs fetch with vi.fn() for existing tests — those
7
+ // stubs take precedence and are intentional. In this test file we bypass
8
+ // the stub so we can demonstrate MSW handler interception working correctly.
9
+ //
10
+ // Strategy: save the stub installed by setup.ts, replace it with the real
11
+ // underlying fetch for the duration of each test, then put it back.
12
+
13
+ // Start/stop MSW for this file only — MSW is opt-in, not global.
14
+ // Global setup.ts stubs fetch with vi.fn() for other tests.
15
+ // Here we unstub to let MSW's interceptors handle real fetch calls.
16
+ beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
17
+ afterEach(() => server.resetHandlers());
18
+ afterAll(() => server.close());
19
+
20
+ let realFetch: typeof fetch;
21
+
22
+ beforeEach(() => {
23
+ // Unstub the vi.fn() installed by setup.ts so MSW interceptors work
24
+ vi.unstubAllGlobals();
25
+ realFetch = globalThis.fetch;
26
+ });
27
+
28
+ afterEach(() => {
29
+ // Reinstall the stub so other test files are not affected
30
+ vi.stubGlobal('fetch', vi.fn());
31
+ });
32
+
33
+ describe('MSW example — handler interception', () => {
34
+ it('intercepts GET /health and returns mocked response', async () => {
35
+ const res = await realFetch('http://localhost/health');
36
+ const data = (await res.json()) as { status: string; timestamp: string };
37
+
38
+ expect(res.ok).toBe(true);
39
+ expect(data.status).toBe('ok');
40
+ expect(typeof data.timestamp).toBe('string');
41
+ });
42
+
43
+ it('intercepts GET /api/info and returns mocked response', async () => {
44
+ const res = await realFetch('http://localhost/api/info');
45
+ const data = (await res.json()) as { name: string; version: string };
46
+
47
+ expect(res.ok).toBe(true);
48
+ expect(data.name).toBe('AppyStack');
49
+ expect(data.version).toBe('1.0.0');
50
+ });
51
+
52
+ it('allows runtime handler override with server.use()', async () => {
53
+ server.use(
54
+ http.get('*/health', () => {
55
+ return HttpResponse.json(
56
+ { status: 'degraded', timestamp: new Date().toISOString() },
57
+ { status: 503 }
58
+ );
59
+ })
60
+ );
61
+
62
+ const res = await realFetch('http://localhost/health');
63
+ const data = (await res.json()) as { status: string };
64
+
65
+ expect(res.status).toBe(503);
66
+ expect(data.status).toBe('degraded');
67
+ // setup.ts afterEach calls server.resetHandlers() — restoring defaults for next test
68
+ });
69
+ });
@@ -0,0 +1,14 @@
1
+ import { setupServer } from 'msw/node';
2
+ import { http, passthrough } from 'msw';
3
+ import { handlers } from './handlers.js';
4
+
5
+ // Pass socket.io requests through without interception.
6
+ // MSW intercepting WebSocket upgrade requests causes a libuv assertion failure
7
+ // (uv__io_active) when components with live socket.io connections are rendered
8
+ // in tests. Returning passthrough() for all socket.io paths prevents this.
9
+ const socketIoPassthrough = [
10
+ http.get('*/socket.io/:rest*', () => passthrough()),
11
+ http.post('*/socket.io/:rest*', () => passthrough()),
12
+ ];
13
+
14
+ export const server = setupServer(...socketIoPassthrough, ...handlers);
@@ -0,0 +1,10 @@
1
+ import '@testing-library/jest-dom';
2
+ import { vi, beforeEach } from 'vitest';
3
+
4
+ // Global fetch stub for all tests.
5
+ // Tests that need real fetch interception (e.g. MSW) should call
6
+ // vi.unstubAllGlobals() in their own beforeEach and manage their own
7
+ // MSW server lifecycle — see src/test/msw/msw-example.test.ts for the pattern.
8
+ beforeEach(() => {
9
+ vi.stubGlobal('fetch', vi.fn());
10
+ });
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { api, ApiError } from './api.js';
3
+
4
+ describe('api wrapper', () => {
5
+ beforeEach(() => {
6
+ vi.mocked(fetch).mockReset();
7
+ });
8
+
9
+ it('api.get returns parsed JSON on success', async () => {
10
+ const data = { id: 1, name: 'test' };
11
+ vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify(data), { status: 200 }));
12
+
13
+ const result = await api.get<typeof data>('/test');
14
+
15
+ expect(result).toEqual(data);
16
+ });
17
+
18
+ it('api.get throws ApiError with status on non-ok response', async () => {
19
+ vi.mocked(fetch).mockResolvedValue(
20
+ new Response('Not Found', { status: 404, statusText: 'Not Found' })
21
+ );
22
+
23
+ await expect(api.get('/missing')).rejects.toThrow(ApiError);
24
+ await expect(api.get('/missing')).rejects.toMatchObject({ status: 404, name: 'ApiError' });
25
+ });
26
+
27
+ it('api.post sends correct method, body, and Content-Type header', async () => {
28
+ const responseData = { created: true };
29
+ vi.mocked(fetch).mockResolvedValueOnce(
30
+ new Response(JSON.stringify(responseData), { status: 201 })
31
+ );
32
+
33
+ const payload = { name: 'new item' };
34
+ await api.post('/items', payload);
35
+
36
+ expect(fetch).toHaveBeenCalledOnce();
37
+ const [, options] = vi.mocked(fetch).mock.calls[0];
38
+ expect(options?.method).toBe('POST');
39
+ expect(options?.body).toBe(JSON.stringify(payload));
40
+ expect((options?.headers as Record<string, string>)['Content-Type']).toBe('application/json');
41
+ });
42
+
43
+ it('api.post throws ApiError on 404', async () => {
44
+ vi.mocked(fetch).mockResolvedValueOnce(
45
+ new Response('Not Found', { status: 404, statusText: 'Not Found' })
46
+ );
47
+
48
+ let caught: unknown;
49
+ try {
50
+ await api.post('/missing', { data: 'value' });
51
+ } catch (err) {
52
+ caught = err;
53
+ }
54
+
55
+ expect(caught).toBeInstanceOf(ApiError);
56
+ expect((caught as ApiError).status).toBe(404);
57
+ expect((caught as ApiError).name).toBe('ApiError');
58
+ });
59
+
60
+ it('AbortSignal is passed through to fetch', async () => {
61
+ vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }));
62
+
63
+ const controller = new AbortController();
64
+ await api.get('/test', controller.signal);
65
+
66
+ const [, options] = vi.mocked(fetch).mock.calls[0];
67
+ expect(options?.signal).toBe(controller.signal);
68
+ });
69
+
70
+ it('AbortSignal is passed through to fetch for post requests', async () => {
71
+ vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }));
72
+
73
+ const controller = new AbortController();
74
+ await api.post('/test', { x: 1 }, controller.signal);
75
+
76
+ const [, options] = vi.mocked(fetch).mock.calls[0];
77
+ expect(options?.signal).toBe(controller.signal);
78
+ });
79
+ });
@@ -0,0 +1,42 @@
1
+ const BASE_URL = import.meta.env.VITE_API_URL ?? '';
2
+
3
+ /** Error thrown when an API request receives a non-2xx HTTP response. */
4
+ class ApiError extends Error {
5
+ constructor(
6
+ public status: number,
7
+ message: string
8
+ ) {
9
+ super(message);
10
+ this.name = 'ApiError';
11
+ }
12
+ }
13
+
14
+ async function request<T>(
15
+ path: string,
16
+ options?: RequestInit & { signal?: AbortSignal }
17
+ ): Promise<T> {
18
+ const res = await fetch(`${BASE_URL}${path}`, options);
19
+ if (!res.ok) {
20
+ throw new ApiError(res.status, `Request failed: ${res.status} ${res.statusText}`);
21
+ }
22
+ return res.json() as Promise<T>;
23
+ }
24
+
25
+ /**
26
+ * Typed HTTP request helpers for the AppyStack template.
27
+ * All methods throw ApiError on non-2xx responses.
28
+ */
29
+ export const api = {
30
+ /** Send a GET request and return the parsed JSON response. */
31
+ get: <T>(path: string, signal?: AbortSignal) => request<T>(path, { signal }),
32
+ /** Send a POST request with a JSON body and return the parsed JSON response. */
33
+ post: <T>(path: string, body: unknown, signal?: AbortSignal) =>
34
+ request<T>(path, {
35
+ method: 'POST',
36
+ body: JSON.stringify(body),
37
+ headers: { 'Content-Type': 'application/json' },
38
+ signal,
39
+ }),
40
+ };
41
+
42
+ export { ApiError };
@@ -0,0 +1,13 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // Extend this interface to add type-safe environment variables.
4
+ // Variables must be prefixed with VITE_ to be exposed to the client.
5
+ // Add matching entries to .env.example for documentation.
6
+ interface ImportMetaEnv {
7
+ readonly VITE_API_URL?: string;
8
+ readonly VITE_APP_NAME?: string;
9
+ }
10
+
11
+ interface ImportMeta {
12
+ readonly env: ImportMetaEnv;
13
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "@appydave/appystack-config/typescript/react.json",
3
+ "compilerOptions": {
4
+ "baseUrl": ".",
5
+ "paths": {
6
+ "@/*": ["src/*"],
7
+ "@components/*": ["src/components/*"],
8
+ "@hooks/*": ["src/hooks/*"],
9
+ "@pages/*": ["src/pages/*"],
10
+ "@utils/*": ["src/utils/*"],
11
+ "@contexts/*": ["src/contexts/*"],
12
+ "@config/*": ["src/config/*"]
13
+ }
14
+ },
15
+ "include": ["src/**/*"],
16
+ "exclude": ["node_modules"]
17
+ }
@@ -0,0 +1,38 @@
1
+ import { defineConfig } from 'vite';
2
+ import react from '@vitejs/plugin-react';
3
+ import tailwindcss from '@tailwindcss/vite';
4
+ import path from 'node:path';
5
+
6
+ // TODO: Update port and proxy target for your project
7
+ export default defineConfig({
8
+ plugins: [react(), tailwindcss()],
9
+ resolve: {
10
+ alias: {
11
+ '@': path.resolve(__dirname, 'src'),
12
+ '@components': path.resolve(__dirname, 'src/components'),
13
+ '@hooks': path.resolve(__dirname, 'src/hooks'),
14
+ '@pages': path.resolve(__dirname, 'src/pages'),
15
+ '@utils': path.resolve(__dirname, 'src/utils'),
16
+ '@contexts': path.resolve(__dirname, 'src/contexts'),
17
+ '@config': path.resolve(__dirname, 'src/config'),
18
+ },
19
+ },
20
+ server: {
21
+ port: 5500,
22
+ proxy: {
23
+ '/api': {
24
+ target: 'http://localhost:5501',
25
+ changeOrigin: true,
26
+ },
27
+ '/health': {
28
+ target: 'http://localhost:5501',
29
+ changeOrigin: true,
30
+ },
31
+ '/socket.io': {
32
+ target: 'http://localhost:5501',
33
+ changeOrigin: true,
34
+ ws: true,
35
+ },
36
+ },
37
+ },
38
+ });
@@ -0,0 +1,36 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import react from '@vitejs/plugin-react';
3
+ import path from 'node:path';
4
+
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ resolve: {
8
+ alias: {
9
+ '@': path.resolve(__dirname, 'src'),
10
+ '@components': path.resolve(__dirname, 'src/components'),
11
+ '@hooks': path.resolve(__dirname, 'src/hooks'),
12
+ '@pages': path.resolve(__dirname, 'src/pages'),
13
+ '@utils': path.resolve(__dirname, 'src/utils'),
14
+ '@contexts': path.resolve(__dirname, 'src/contexts'),
15
+ '@config': path.resolve(__dirname, 'src/config'),
16
+ },
17
+ },
18
+ test: {
19
+ globals: true,
20
+ environment: 'jsdom',
21
+ setupFiles: ['./src/test/setup.ts'],
22
+ include: ['src/**/*.test.{ts,tsx}'],
23
+ coverage: {
24
+ provider: 'v8',
25
+ reporter: ['text', 'lcov'],
26
+ include: ['src/**/*.{ts,tsx}'],
27
+ exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**'],
28
+ thresholds: {
29
+ lines: 80,
30
+ functions: 70,
31
+ branches: 70,
32
+ statements: 80,
33
+ },
34
+ },
35
+ },
36
+ });
@@ -0,0 +1,19 @@
1
+ services:
2
+ app:
3
+ build:
4
+ context: .
5
+ dockerfile: Dockerfile
6
+ target: production
7
+ ports:
8
+ - '5501:5501'
9
+ environment:
10
+ - NODE_ENV=production
11
+ - PORT=5501
12
+ # CLIENT_URL is not needed in production — server serves the client directly
13
+ healthcheck:
14
+ test: ['CMD', 'wget', '-qO-', 'http://localhost:5501/health']
15
+ interval: 30s
16
+ timeout: 5s
17
+ retries: 3
18
+ start_period: 10s
19
+ restart: unless-stopped
@@ -0,0 +1,95 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { spawn } from 'node:child_process';
3
+ import type { ChildProcess } from 'node:child_process';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const templateRoot = path.resolve(__dirname, '..');
9
+
10
+ const SERVER_PORT = 5501;
11
+ const CLIENT_PORT = 5500;
12
+ const STARTUP_TIMEOUT_MS = 30_000;
13
+ const POLL_INTERVAL_MS = 500;
14
+
15
+ async function waitForUrl(url: string, timeoutMs: number): Promise<void> {
16
+ const start = Date.now();
17
+ while (Date.now() - start < timeoutMs) {
18
+ try {
19
+ const res = await fetch(url);
20
+ if (res.ok) return;
21
+ } catch {
22
+ // Not ready yet — keep polling
23
+ }
24
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
25
+ }
26
+ throw new Error(`Timed out waiting for ${url} after ${timeoutMs}ms`);
27
+ }
28
+
29
+ function killProcess(proc: ChildProcess): void {
30
+ try {
31
+ if (proc.pid !== undefined) {
32
+ // Kill the entire process group (catches nodemon child processes)
33
+ process.kill(-proc.pid, 'SIGTERM');
34
+ } else {
35
+ proc.kill('SIGTERM');
36
+ }
37
+ } catch {
38
+ // Process may already be dead — ignore
39
+ }
40
+ }
41
+
42
+ let serverProcess: ChildProcess;
43
+ let clientProcess: ChildProcess;
44
+
45
+ test.beforeAll(async () => {
46
+ // detached: true creates a new process group so we can kill all children
47
+ serverProcess = spawn('npm', ['run', 'dev', '-w', 'server'], {
48
+ cwd: templateRoot,
49
+ stdio: 'pipe',
50
+ detached: true,
51
+ env: { ...process.env, NODE_ENV: 'development' },
52
+ });
53
+
54
+ clientProcess = spawn('npm', ['run', 'dev', '-w', 'client'], {
55
+ cwd: templateRoot,
56
+ stdio: 'pipe',
57
+ detached: true,
58
+ env: { ...process.env, NODE_ENV: 'development' },
59
+ });
60
+
61
+ // Wait for both servers to be ready before running tests
62
+ await Promise.all([
63
+ waitForUrl(`http://localhost:${SERVER_PORT}/health`, STARTUP_TIMEOUT_MS),
64
+ waitForUrl(`http://localhost:${CLIENT_PORT}`, STARTUP_TIMEOUT_MS),
65
+ ]);
66
+ });
67
+
68
+ test.afterAll(async () => {
69
+ // Use try/finally pattern to ensure both processes are killed even if one throws
70
+ try {
71
+ killProcess(serverProcess);
72
+ } finally {
73
+ killProcess(clientProcess);
74
+ }
75
+ });
76
+
77
+ test('page title contains AppyStack', async ({ page }) => {
78
+ await page.goto(`http://localhost:${CLIENT_PORT}`);
79
+ await expect(page).toHaveTitle(/AppyStack/);
80
+ });
81
+
82
+ test('at least one status card is visible', async ({ page }) => {
83
+ await page.goto(`http://localhost:${CLIENT_PORT}`);
84
+ await page.waitForLoadState('networkidle');
85
+ // The status grid renders cards for server health info
86
+ const statusGrid = page.locator('[data-testid="status-grid"]');
87
+ await expect(statusGrid).toBeVisible();
88
+ });
89
+
90
+ test('/health endpoint returns 200', async () => {
91
+ const res = await fetch(`http://localhost:${SERVER_PORT}/health`);
92
+ expect(res.status).toBe(200);
93
+ const body = (await res.json()) as { status: string };
94
+ expect(body.status).toBe('ok');
95
+ });
@@ -0,0 +1,96 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { spawn } from 'node:child_process';
3
+ import type { ChildProcess } from 'node:child_process';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const templateRoot = path.resolve(__dirname, '..');
9
+
10
+ const SERVER_PORT = 5501;
11
+ const CLIENT_PORT = 5500;
12
+ const STARTUP_TIMEOUT_MS = 30_000;
13
+ const POLL_INTERVAL_MS = 500;
14
+
15
+ async function waitForUrl(url: string, timeoutMs: number): Promise<void> {
16
+ const start = Date.now();
17
+ while (Date.now() - start < timeoutMs) {
18
+ try {
19
+ const res = await fetch(url);
20
+ if (res.ok) return;
21
+ } catch {
22
+ // Not ready yet — keep polling
23
+ }
24
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
25
+ }
26
+ throw new Error(`Timed out waiting for ${url} after ${timeoutMs}ms`);
27
+ }
28
+
29
+ function killProcess(proc: ChildProcess): void {
30
+ try {
31
+ if (proc.pid !== undefined) {
32
+ // Kill the entire process group (catches nodemon child processes)
33
+ process.kill(-proc.pid, 'SIGTERM');
34
+ } else {
35
+ proc.kill('SIGTERM');
36
+ }
37
+ } catch {
38
+ // Process may already be dead — ignore
39
+ }
40
+ }
41
+
42
+ let serverProcess: ChildProcess;
43
+ let clientProcess: ChildProcess;
44
+
45
+ test.beforeAll(async () => {
46
+ // detached: true creates a new process group so we can kill all children
47
+ serverProcess = spawn('npm', ['run', 'dev', '-w', 'server'], {
48
+ cwd: templateRoot,
49
+ stdio: 'pipe',
50
+ detached: true,
51
+ env: { ...process.env, NODE_ENV: 'development' },
52
+ });
53
+
54
+ clientProcess = spawn('npm', ['run', 'dev', '-w', 'client'], {
55
+ cwd: templateRoot,
56
+ stdio: 'pipe',
57
+ detached: true,
58
+ env: { ...process.env, NODE_ENV: 'development' },
59
+ });
60
+
61
+ // Wait for both servers to be ready before running tests
62
+ await Promise.all([
63
+ waitForUrl(`http://localhost:${SERVER_PORT}/health`, STARTUP_TIMEOUT_MS),
64
+ waitForUrl(`http://localhost:${CLIENT_PORT}`, STARTUP_TIMEOUT_MS),
65
+ ]);
66
+ });
67
+
68
+ test.afterAll(async () => {
69
+ // Use try/finally pattern to ensure both processes are killed even if one throws
70
+ try {
71
+ killProcess(serverProcess);
72
+ } finally {
73
+ killProcess(clientProcess);
74
+ }
75
+ });
76
+
77
+ test('socket ping-pong flow sends ping and receives pong response', async ({ page }) => {
78
+ await page.goto(`http://localhost:${CLIENT_PORT}`);
79
+ await page.waitForLoadState('networkidle');
80
+
81
+ // The Send Ping button is disabled until the socket connects — wait for it to be enabled
82
+ const sendPingButton = page.getByRole('button', { name: 'Send Ping' });
83
+ await expect(sendPingButton).toBeVisible();
84
+ await expect(sendPingButton).toBeEnabled({ timeout: 10_000 });
85
+
86
+ // Click Send Ping
87
+ await sendPingButton.click();
88
+
89
+ // Wait for the pong response — the component renders "server:pong received — ..."
90
+ const pongResponse = page.getByText(/server:pong received/);
91
+ await expect(pongResponse).toBeVisible({ timeout: 10_000 });
92
+
93
+ // The Send Ping button should be available again (not in waiting state)
94
+ await expect(sendPingButton).toBeVisible();
95
+ await expect(sendPingButton).toBeEnabled();
96
+ });
@@ -0,0 +1,2 @@
1
+ import appyConfig from '@appydave/appystack-config/eslint/react';
2
+ export default [...appyConfig];
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@appydave/appystack-template",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "RVETS stack boilerplate (React, Vite, Express, TypeScript, Socket.io)",
6
+ "type": "module",
7
+ "workspaces": [
8
+ "shared",
9
+ "server",
10
+ "client"
11
+ ],
12
+ "scripts": {
13
+ "dev": "npm run build -w shared && concurrently -n server,client -c blue,green \"npm run dev -w server\" \"npm run dev -w client\"",
14
+ "build": "npm run build -w shared && npm run build -w server && npm run build -w client",
15
+ "test": "npm run test -w server -w client",
16
+ "test:coverage": "npm run test:coverage -w server -w client",
17
+ "test:e2e": "playwright test",
18
+ "lint": "eslint .",
19
+ "lint:fix": "eslint . --fix",
20
+ "format": "prettier --write .",
21
+ "format:check": "prettier --check .",
22
+ "typecheck": "npm run typecheck -w shared -w server -w client",
23
+ "clean": "rm -rf node_modules shared/dist server/dist client/dist",
24
+ "prepare": "husky",
25
+ "customize": "tsx scripts/customize.ts"
26
+ },
27
+ "devDependencies": {
28
+ "@appydave/appystack-config": "^1.0.3",
29
+ "@clack/prompts": "^1.0.1",
30
+ "@eslint/js": "^9.17.0",
31
+ "@playwright/test": "^1.58.2",
32
+ "@typescript-eslint/eslint-plugin": "^8.20.0",
33
+ "@typescript-eslint/parser": "^8.20.0",
34
+ "concurrently": "^9.1.2",
35
+ "eslint": "^9.17.0",
36
+ "eslint-plugin-react": "^7.37.3",
37
+ "eslint-plugin-react-hooks": "^5.1.0",
38
+ "globals": "^15.14.0",
39
+ "husky": "^9.1.7",
40
+ "lint-staged": "^16.2.7",
41
+ "prettier": "^3.4.2",
42
+ "typescript": "^5.7.3"
43
+ },
44
+ "lint-staged": {
45
+ "*.{ts,tsx,js,json,css,md}": "prettier --write",
46
+ "*.{ts,tsx,js}": "eslint --fix"
47
+ },
48
+ "author": "David Cruwys",
49
+ "license": "MIT"
50
+ }
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from '@playwright/test';
2
+
3
+ export default defineConfig({
4
+ testDir: './e2e',
5
+ timeout: 60_000,
6
+ fullyParallel: false,
7
+ retries: 0,
8
+ workers: 1,
9
+ reporter: 'list',
10
+ use: {
11
+ headless: true,
12
+ baseURL: 'http://localhost:5500',
13
+ },
14
+ });