snice 3.7.0 → 3.8.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 +2 -2
- package/bin/snice.js +3 -5
- package/bin/templates/CLAUDE.md +5 -5
- package/bin/templates/pwa/README.md +31 -16
- package/bin/templates/pwa/index.html +0 -1
- package/bin/templates/pwa/package.json +9 -2
- package/bin/templates/pwa/src/fetcher.ts +15 -0
- package/bin/templates/pwa/src/guards/auth.ts +6 -4
- package/bin/templates/pwa/src/middleware/auth.ts +7 -6
- package/bin/templates/pwa/src/middleware/error.ts +16 -5
- package/bin/templates/pwa/src/middleware/retry.ts +7 -3
- package/bin/templates/pwa/src/pages/dashboard.ts +4 -3
- package/bin/templates/pwa/src/pages/login.ts +7 -7
- package/bin/templates/pwa/src/pages/notifications.ts +2 -2
- package/bin/templates/pwa/src/pages/profile.ts +9 -8
- package/bin/templates/pwa/src/router.ts +8 -4
- package/bin/templates/pwa/src/types/auth.ts +1 -1
- package/bin/templates/pwa/tests/helpers/test-utils.ts +84 -0
- package/bin/templates/pwa/tests/middleware/auth.test.ts +67 -0
- package/bin/templates/pwa/tests/middleware/error.test.ts +105 -0
- package/bin/templates/pwa/tests/middleware/retry.test.ts +103 -0
- package/bin/templates/pwa/tests/services/auth.test.ts +89 -0
- package/bin/templates/pwa/tests/services/jwt.test.ts +76 -0
- package/bin/templates/pwa/tests/services/storage.test.ts +69 -0
- package/bin/templates/{social/vite.config.ts → pwa/vitest.config.ts} +12 -17
- package/dist/index.cjs +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.iife.js +1 -1
- package/dist/symbols.cjs +1 -1
- package/dist/symbols.esm.js +1 -1
- package/dist/transitions.cjs +1 -1
- package/dist/transitions.esm.js +1 -1
- package/docs/ai/patterns.md +1 -1
- package/docs/routing.md +9 -8
- package/package.json +1 -1
- package/bin/templates/pwa/public/manifest.json +0 -24
- package/bin/templates/pwa/src/utils/fetch.ts +0 -39
- package/bin/templates/social/README.md +0 -42
- package/bin/templates/social/global.d.ts +0 -14
- package/bin/templates/social/index.html +0 -13
- package/bin/templates/social/package.json +0 -21
- package/bin/templates/social/public/vite.svg +0 -1
- package/bin/templates/social/src/main.ts +0 -33
- package/bin/templates/social/src/pages/feed-page.ts +0 -111
- package/bin/templates/social/src/pages/messages-page.ts +0 -102
- package/bin/templates/social/src/pages/not-found-page.ts +0 -46
- package/bin/templates/social/src/pages/profile-page.ts +0 -99
- package/bin/templates/social/src/pages/settings-page.ts +0 -119
- package/bin/templates/social/src/router.ts +0 -9
- package/bin/templates/social/src/styles/global.css +0 -156
- package/bin/templates/social/tsconfig.json +0 -22
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
+
import { Context } from 'snice';
|
|
3
|
+
import { errorMiddleware } from '../../src/middleware/error';
|
|
4
|
+
import * as storage from '../../src/services/storage';
|
|
5
|
+
import type { Principal } from '../../src/types/auth';
|
|
6
|
+
|
|
7
|
+
describe('Error Middleware', () => {
|
|
8
|
+
let mockContext: Context;
|
|
9
|
+
let mockNext: ReturnType<typeof vi.fn>;
|
|
10
|
+
let originalLocation: Location;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
localStorage.clear();
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
|
|
16
|
+
// Save original location
|
|
17
|
+
originalLocation = window.location;
|
|
18
|
+
|
|
19
|
+
// Mock window.location
|
|
20
|
+
delete (window as any).location;
|
|
21
|
+
window.location = { href: '' } as Location;
|
|
22
|
+
|
|
23
|
+
// Create mock context
|
|
24
|
+
mockContext = {
|
|
25
|
+
application: {
|
|
26
|
+
principal: {
|
|
27
|
+
user: { id: '1', name: 'Test', email: 'test@example.com' },
|
|
28
|
+
isAuthenticated: true,
|
|
29
|
+
} as Principal,
|
|
30
|
+
},
|
|
31
|
+
navigation: {
|
|
32
|
+
route: '/',
|
|
33
|
+
params: {},
|
|
34
|
+
},
|
|
35
|
+
update: vi.fn(),
|
|
36
|
+
} as unknown as Context;
|
|
37
|
+
|
|
38
|
+
// Create mock next function
|
|
39
|
+
const mockResponse = new Response('{}', { status: 200 });
|
|
40
|
+
mockNext = vi.fn(() => Promise.resolve(mockResponse));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
window.location = originalLocation;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should pass through successful responses', async () => {
|
|
48
|
+
const mockResponse = new Response('{"data":"ok"}', { status: 200 });
|
|
49
|
+
const response = await errorMiddleware.call(mockContext, mockResponse, mockNext);
|
|
50
|
+
|
|
51
|
+
expect(mockNext).toHaveBeenCalled();
|
|
52
|
+
expect(response).toBeInstanceOf(Response);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should handle 401 unauthorized by clearing token and redirecting', async () => {
|
|
56
|
+
storage.setToken('test-token');
|
|
57
|
+
const mockResponse = new Response('Unauthorized', { status: 401 });
|
|
58
|
+
|
|
59
|
+
await expect(
|
|
60
|
+
errorMiddleware.call(mockContext, mockResponse, mockNext)
|
|
61
|
+
).rejects.toThrow('Unauthorized - redirecting to login');
|
|
62
|
+
|
|
63
|
+
expect(storage.getToken()).toBeNull();
|
|
64
|
+
const principal = mockContext.application.principal as Principal;
|
|
65
|
+
expect(principal.user).toBeNull();
|
|
66
|
+
expect(principal.isAuthenticated).toBe(false);
|
|
67
|
+
expect(window.location.href).toBe('#/login');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should throw error for failed requests with JSON error message', async () => {
|
|
71
|
+
const mockResponse = new Response(
|
|
72
|
+
JSON.stringify({ message: 'Custom error message' }),
|
|
73
|
+
{
|
|
74
|
+
status: 400,
|
|
75
|
+
headers: { 'content-type': 'application/json' },
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
await expect(
|
|
80
|
+
errorMiddleware.call(mockContext, mockResponse, mockNext)
|
|
81
|
+
).rejects.toThrow('Custom error message');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should throw error for failed requests without JSON body', async () => {
|
|
85
|
+
const mockResponse = new Response('Internal Server Error', {
|
|
86
|
+
status: 500,
|
|
87
|
+
headers: { 'content-type': 'text/plain' },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await expect(
|
|
91
|
+
errorMiddleware.call(mockContext, mockResponse, mockNext)
|
|
92
|
+
).rejects.toThrow('Request failed with status 500');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should throw generic error for failed JSON requests without message', async () => {
|
|
96
|
+
const mockResponse = new Response(JSON.stringify({}), {
|
|
97
|
+
status: 400,
|
|
98
|
+
headers: { 'content-type': 'application/json' },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await expect(
|
|
102
|
+
errorMiddleware.call(mockContext, mockResponse, mockNext)
|
|
103
|
+
).rejects.toThrow('Request failed with status 400');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { Context } from 'snice';
|
|
3
|
+
import { createRetryMiddleware } from '../../src/middleware/retry';
|
|
4
|
+
|
|
5
|
+
describe('Retry Middleware', () => {
|
|
6
|
+
let mockContext: Context;
|
|
7
|
+
let mockRequest: Request;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.clearAllMocks();
|
|
11
|
+
|
|
12
|
+
mockContext = {
|
|
13
|
+
application: {
|
|
14
|
+
principal: {
|
|
15
|
+
user: null,
|
|
16
|
+
isAuthenticated: false,
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
navigation: {
|
|
20
|
+
route: '/',
|
|
21
|
+
params: {},
|
|
22
|
+
},
|
|
23
|
+
update: vi.fn(),
|
|
24
|
+
} as unknown as Context;
|
|
25
|
+
|
|
26
|
+
mockRequest = new Request('https://api.example.com/data');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should return response on first successful attempt', async () => {
|
|
30
|
+
const retryMiddleware = createRetryMiddleware(3, 10);
|
|
31
|
+
const mockResponse = new Response('{}', { status: 200 });
|
|
32
|
+
const mockNext = vi.fn(() => Promise.resolve(mockResponse));
|
|
33
|
+
|
|
34
|
+
const response = await retryMiddleware.call(mockContext, mockRequest, mockNext);
|
|
35
|
+
|
|
36
|
+
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(response).toBe(mockResponse);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should retry on failure and eventually succeed', async () => {
|
|
41
|
+
const retryMiddleware = createRetryMiddleware(3, 10);
|
|
42
|
+
const mockResponse = new Response('{}', { status: 200 });
|
|
43
|
+
const mockNext = vi
|
|
44
|
+
.fn()
|
|
45
|
+
.mockRejectedValueOnce(new Error('Network error'))
|
|
46
|
+
.mockRejectedValueOnce(new Error('Network error'))
|
|
47
|
+
.mockResolvedValueOnce(mockResponse);
|
|
48
|
+
|
|
49
|
+
const response = await retryMiddleware.call(mockContext, mockRequest, mockNext);
|
|
50
|
+
|
|
51
|
+
expect(mockNext).toHaveBeenCalledTimes(3);
|
|
52
|
+
expect(response).toBe(mockResponse);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should throw error after max retries', async () => {
|
|
56
|
+
const retryMiddleware = createRetryMiddleware(3, 10);
|
|
57
|
+
const error = new Error('Network error');
|
|
58
|
+
const mockNext = vi.fn(() => Promise.reject(error));
|
|
59
|
+
|
|
60
|
+
await expect(
|
|
61
|
+
retryMiddleware.call(mockContext, mockRequest, mockNext)
|
|
62
|
+
).rejects.toThrow('Network error');
|
|
63
|
+
|
|
64
|
+
expect(mockNext).toHaveBeenCalledTimes(3);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should not retry on Unauthorized errors', async () => {
|
|
68
|
+
const retryMiddleware = createRetryMiddleware(3, 10);
|
|
69
|
+
const error = new Error('Unauthorized');
|
|
70
|
+
const mockNext = vi.fn(() => Promise.reject(error));
|
|
71
|
+
|
|
72
|
+
await expect(
|
|
73
|
+
retryMiddleware.call(mockContext, mockRequest, mockNext)
|
|
74
|
+
).rejects.toThrow('Unauthorized');
|
|
75
|
+
|
|
76
|
+
// Should only try once, no retries
|
|
77
|
+
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should use exponential backoff', async () => {
|
|
81
|
+
vi.useFakeTimers();
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const retryMiddleware = createRetryMiddleware(3, 100);
|
|
85
|
+
const mockNext = vi.fn(() => Promise.reject(new Error('Network error')));
|
|
86
|
+
|
|
87
|
+
const promise = retryMiddleware.call(mockContext, mockRequest, mockNext).catch(err => err);
|
|
88
|
+
|
|
89
|
+
// Run all timers to completion
|
|
90
|
+
await vi.runAllTimersAsync();
|
|
91
|
+
|
|
92
|
+
// All 3 attempts should have been made
|
|
93
|
+
expect(mockNext).toHaveBeenCalledTimes(3);
|
|
94
|
+
|
|
95
|
+
// Promise should have rejected with network error
|
|
96
|
+
const result = await promise;
|
|
97
|
+
expect(result).toBeInstanceOf(Error);
|
|
98
|
+
expect(result.message).toBe('Network error');
|
|
99
|
+
} finally {
|
|
100
|
+
vi.useRealTimers();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { login, logout, isAuthenticated, refreshToken } from '../../src/services/auth';
|
|
3
|
+
import * as storage from '../../src/services/storage';
|
|
4
|
+
|
|
5
|
+
describe('Auth Service', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
localStorage.clear();
|
|
8
|
+
vi.clearAllMocks();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe('login', () => {
|
|
12
|
+
it('should login with valid credentials', async () => {
|
|
13
|
+
const credentials = { email: 'demo@example.com', password: 'demo' };
|
|
14
|
+
const result = await login(credentials);
|
|
15
|
+
|
|
16
|
+
expect(result).toHaveProperty('token');
|
|
17
|
+
expect(result).toHaveProperty('user');
|
|
18
|
+
expect(result.user.email).toBe('demo@example.com');
|
|
19
|
+
expect(result.user.name).toBe('Demo User');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should store token and user on successful login', async () => {
|
|
23
|
+
const credentials = { email: 'demo@example.com', password: 'demo' };
|
|
24
|
+
await login(credentials);
|
|
25
|
+
|
|
26
|
+
expect(storage.getToken()).toBeTruthy();
|
|
27
|
+
expect(storage.getUser()).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should throw error for invalid credentials', async () => {
|
|
31
|
+
const credentials = { email: 'wrong@example.com', password: 'wrong' };
|
|
32
|
+
|
|
33
|
+
await expect(login(credentials)).rejects.toThrow('Invalid credentials');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should throw error for wrong password', async () => {
|
|
37
|
+
const credentials = { email: 'demo@example.com', password: 'wrong' };
|
|
38
|
+
|
|
39
|
+
await expect(login(credentials)).rejects.toThrow('Invalid credentials');
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('logout', () => {
|
|
44
|
+
it('should clear token on logout', async () => {
|
|
45
|
+
storage.setToken('test-token');
|
|
46
|
+
storage.setUser({ id: '1', name: 'Test' });
|
|
47
|
+
|
|
48
|
+
await logout();
|
|
49
|
+
|
|
50
|
+
expect(storage.getToken()).toBeNull();
|
|
51
|
+
expect(storage.getUser()).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('isAuthenticated', () => {
|
|
56
|
+
it('should return true when valid token exists', () => {
|
|
57
|
+
// Non-expired token
|
|
58
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJkZW1vQGV4YW1wbGUuY29tIiwiaWF0IjoxNjE2MjM5MDIyLCJleHAiOjk5OTk5OTk5OTl9.mock';
|
|
59
|
+
storage.setToken(token);
|
|
60
|
+
|
|
61
|
+
expect(isAuthenticated()).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should return false when no token exists', () => {
|
|
65
|
+
expect(isAuthenticated()).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should return false when token is expired', () => {
|
|
69
|
+
// Expired token
|
|
70
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZXhwIjoxNjAwMDAwMDAwfQ.mock';
|
|
71
|
+
storage.setToken(token);
|
|
72
|
+
|
|
73
|
+
expect(isAuthenticated()).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('refreshToken', () => {
|
|
78
|
+
it('should return new token', async () => {
|
|
79
|
+
const newToken = await refreshToken();
|
|
80
|
+
expect(newToken).toBeTruthy();
|
|
81
|
+
expect(typeof newToken).toBe('string');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should update stored token', async () => {
|
|
85
|
+
await refreshToken();
|
|
86
|
+
expect(storage.getToken()).toBeTruthy();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { decodeJWT, isTokenExpired, getTokenExpiration } from '../../src/services/jwt';
|
|
3
|
+
|
|
4
|
+
describe('JWT Service', () => {
|
|
5
|
+
describe('decodeJWT', () => {
|
|
6
|
+
it('should decode a valid JWT', () => {
|
|
7
|
+
// Valid JWT: {"sub":"123","name":"Test","exp":9999999999}
|
|
8
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiVGVzdCIsImV4cCI6OTk5OTk5OTk5OX0.mock';
|
|
9
|
+
const payload = decodeJWT(token);
|
|
10
|
+
|
|
11
|
+
expect(payload).toBeTruthy();
|
|
12
|
+
expect(payload?.sub).toBe('123');
|
|
13
|
+
expect(payload?.name).toBe('Test');
|
|
14
|
+
expect(payload?.exp).toBe(9999999999);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should return null for invalid JWT format', () => {
|
|
18
|
+
const payload = decodeJWT('invalid-token');
|
|
19
|
+
expect(payload).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should return null for malformed JWT', () => {
|
|
23
|
+
const payload = decodeJWT('part1.part2');
|
|
24
|
+
expect(payload).toBeNull();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should return null for JWT with invalid base64', () => {
|
|
28
|
+
const payload = decodeJWT('header.!!!invalid!!!.signature');
|
|
29
|
+
expect(payload).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('isTokenExpired', () => {
|
|
34
|
+
it('should return false for non-expired token', () => {
|
|
35
|
+
// Token expires in year 2286
|
|
36
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock';
|
|
37
|
+
expect(isTokenExpired(token)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should return true for expired token', () => {
|
|
41
|
+
// Token expired in 2020
|
|
42
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE2MDAwMDAwMDB9.mock';
|
|
43
|
+
expect(isTokenExpired(token)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should return true for token without exp claim', () => {
|
|
47
|
+
// Token without exp: {"sub":"123"}
|
|
48
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.mock';
|
|
49
|
+
expect(isTokenExpired(token)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should return true for invalid token', () => {
|
|
53
|
+
expect(isTokenExpired('invalid')).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('getTokenExpiration', () => {
|
|
58
|
+
it('should return expiration date for valid token', () => {
|
|
59
|
+
// Token with exp: 9999999999 (Sat Nov 20 2286)
|
|
60
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock';
|
|
61
|
+
const expiration = getTokenExpiration(token);
|
|
62
|
+
|
|
63
|
+
expect(expiration).toBeInstanceOf(Date);
|
|
64
|
+
expect(expiration?.getTime()).toBe(9999999999 * 1000);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should return null for token without exp', () => {
|
|
68
|
+
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.mock';
|
|
69
|
+
expect(getTokenExpiration(token)).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should return null for invalid token', () => {
|
|
73
|
+
expect(getTokenExpiration('invalid')).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { getToken, setToken, clearToken, getUser, setUser } from '../../src/services/storage';
|
|
3
|
+
|
|
4
|
+
describe('Storage Service', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
localStorage.clear();
|
|
7
|
+
vi.clearAllMocks();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
describe('Token Management', () => {
|
|
11
|
+
it('should set and get token', () => {
|
|
12
|
+
const token = 'test-token-123';
|
|
13
|
+
setToken(token);
|
|
14
|
+
expect(getToken()).toBe(token);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should return null when no token exists', () => {
|
|
18
|
+
expect(getToken()).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should clear token and user', () => {
|
|
22
|
+
setToken('test-token');
|
|
23
|
+
setUser({ id: '1', name: 'Test' });
|
|
24
|
+
|
|
25
|
+
clearToken();
|
|
26
|
+
|
|
27
|
+
expect(getToken()).toBeNull();
|
|
28
|
+
expect(getUser()).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('User Management', () => {
|
|
33
|
+
it('should set and get user object', () => {
|
|
34
|
+
const user = { id: '1', name: 'Test User', email: 'test@example.com' };
|
|
35
|
+
setUser(user);
|
|
36
|
+
expect(getUser()).toEqual(user);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return null when no user exists', () => {
|
|
40
|
+
expect(getUser()).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should handle complex user objects', () => {
|
|
44
|
+
const user = {
|
|
45
|
+
id: '1',
|
|
46
|
+
name: 'Test User',
|
|
47
|
+
email: 'test@example.com',
|
|
48
|
+
metadata: {
|
|
49
|
+
preferences: {
|
|
50
|
+
theme: 'dark',
|
|
51
|
+
notifications: true,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
setUser(user);
|
|
57
|
+
expect(getUser()).toEqual(user);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should overwrite existing user', () => {
|
|
61
|
+
setUser({ id: '1', name: 'User 1' });
|
|
62
|
+
setUser({ id: '2', name: 'User 2' });
|
|
63
|
+
|
|
64
|
+
const user = getUser<{ id: string; name: string }>();
|
|
65
|
+
expect(user?.id).toBe('2');
|
|
66
|
+
expect(user?.name).toBe('User 2');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { defineConfig } from '
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
2
|
import swc from 'unplugin-swc';
|
|
3
3
|
|
|
4
4
|
export default defineConfig({
|
|
@@ -18,21 +18,16 @@ export default defineConfig({
|
|
|
18
18
|
},
|
|
19
19
|
}),
|
|
20
20
|
],
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
21
|
+
test: {
|
|
22
|
+
environment: 'happy-dom',
|
|
23
|
+
globals: true,
|
|
24
|
+
coverage: {
|
|
25
|
+
reporter: ['text', 'json', 'html'],
|
|
26
|
+
exclude: [
|
|
27
|
+
'node_modules/',
|
|
28
|
+
'dist/',
|
|
29
|
+
'tests/**',
|
|
30
|
+
],
|
|
31
31
|
},
|
|
32
|
-
sourcemap: true,
|
|
33
|
-
chunkSizeWarningLimit: 500
|
|
34
32
|
},
|
|
35
|
-
|
|
36
|
-
drop: process.env.NODE_ENV === 'production' ? ['debugger'] : []
|
|
37
|
-
}
|
|
38
|
-
});
|
|
33
|
+
});
|
package/dist/index.cjs
CHANGED
package/dist/index.esm.js
CHANGED
package/dist/index.iife.js
CHANGED
package/dist/symbols.cjs
CHANGED
package/dist/symbols.esm.js
CHANGED
package/dist/transitions.cjs
CHANGED
package/dist/transitions.esm.js
CHANGED
package/docs/ai/patterns.md
CHANGED
package/docs/routing.md
CHANGED
|
@@ -207,13 +207,14 @@ class DashboardPage extends HTMLElement {
|
|
|
207
207
|
The Context object passed to `@context()` methods has the following structure:
|
|
208
208
|
|
|
209
209
|
```typescript
|
|
210
|
-
interface Context
|
|
211
|
-
application:
|
|
210
|
+
interface Context {
|
|
211
|
+
application: AppContext; // Your router context (e.g., { user, theme, config })
|
|
212
212
|
navigation: {
|
|
213
213
|
placards: Placard[]; // All page placards
|
|
214
214
|
route: string; // Current route name
|
|
215
215
|
params: Record<string, string>; // Route parameters
|
|
216
216
|
};
|
|
217
|
+
fetch: typeof globalThis.fetch; // Fetch function with middleware support
|
|
217
218
|
update(): void; // Notify all subscribers of changes
|
|
218
219
|
}
|
|
219
220
|
```
|
|
@@ -223,10 +224,10 @@ interface Context<T = any> {
|
|
|
223
224
|
```typescript
|
|
224
225
|
@page({ tag: 'user-page', routes: ['/users/:userId'] })
|
|
225
226
|
class UserPage extends HTMLElement {
|
|
226
|
-
private ctx?: Context
|
|
227
|
+
private ctx?: Context;
|
|
227
228
|
|
|
228
229
|
@context()
|
|
229
|
-
handleContext(ctx: Context
|
|
230
|
+
handleContext(ctx: Context) {
|
|
230
231
|
this.ctx = ctx;
|
|
231
232
|
|
|
232
233
|
// Access application state
|
|
@@ -250,10 +251,10 @@ When you modify the application context, call `update()` to notify all subscribe
|
|
|
250
251
|
```typescript
|
|
251
252
|
@page({ tag: 'settings-page', routes: ['/settings'] })
|
|
252
253
|
class SettingsPage extends HTMLElement {
|
|
253
|
-
private ctx?: Context
|
|
254
|
+
private ctx?: Context;
|
|
254
255
|
|
|
255
256
|
@context()
|
|
256
|
-
handleContext(ctx: Context
|
|
257
|
+
handleContext(ctx: Context) {
|
|
257
258
|
this.ctx = ctx;
|
|
258
259
|
this.requestRender();
|
|
259
260
|
}
|
|
@@ -1066,7 +1067,7 @@ class DashboardPage extends HTMLElement {
|
|
|
1066
1067
|
private appContext?: AppContext;
|
|
1067
1068
|
|
|
1068
1069
|
@context()
|
|
1069
|
-
handleContext(ctx: Context
|
|
1070
|
+
handleContext(ctx: Context) {
|
|
1070
1071
|
this.appContext = ctx.application;
|
|
1071
1072
|
this.requestRender();
|
|
1072
1073
|
}
|
|
@@ -1093,7 +1094,7 @@ class LoginPage extends HTMLElement {
|
|
|
1093
1094
|
private appContext?: AppContext;
|
|
1094
1095
|
|
|
1095
1096
|
@context()
|
|
1096
|
-
handleContext(ctx: Context
|
|
1097
|
+
handleContext(ctx: Context) {
|
|
1097
1098
|
this.appContext = ctx.application;
|
|
1098
1099
|
}
|
|
1099
1100
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "snice",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Imperative TypeScript framework for building vanilla web components with decorators, differential rendering, routing, and controllers. No virtual DOM, no build complexity.",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "{{projectName}}",
|
|
3
|
-
"short_name": "{{projectName}}",
|
|
4
|
-
"description": "PWA built with Snice",
|
|
5
|
-
"start_url": "/",
|
|
6
|
-
"display": "standalone",
|
|
7
|
-
"background_color": "#ffffff",
|
|
8
|
-
"theme_color": "#6366f1",
|
|
9
|
-
"orientation": "portrait-primary",
|
|
10
|
-
"icons": [
|
|
11
|
-
{
|
|
12
|
-
"src": "/icons/icon-192.png",
|
|
13
|
-
"sizes": "192x192",
|
|
14
|
-
"type": "image/png",
|
|
15
|
-
"purpose": "any maskable"
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"src": "/icons/icon-512.png",
|
|
19
|
-
"sizes": "512x512",
|
|
20
|
-
"type": "image/png",
|
|
21
|
-
"purpose": "any maskable"
|
|
22
|
-
}
|
|
23
|
-
]
|
|
24
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
export type Middleware = (
|
|
2
|
-
url: string,
|
|
3
|
-
options: RequestInit,
|
|
4
|
-
next: () => Promise<Response>
|
|
5
|
-
) => Promise<Response>;
|
|
6
|
-
|
|
7
|
-
export type FetchConfig = {
|
|
8
|
-
baseURL?: string;
|
|
9
|
-
middleware?: Middleware[];
|
|
10
|
-
headers?: Record<string, string>;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export function createFetch(config: FetchConfig = {}) {
|
|
14
|
-
return async (url: string, options: RequestInit = {}): Promise<Response> => {
|
|
15
|
-
const fullURL = config.baseURL ? `${config.baseURL}${url}` : url;
|
|
16
|
-
|
|
17
|
-
const execute = async (): Promise<Response> => {
|
|
18
|
-
return fetch(fullURL, {
|
|
19
|
-
...options,
|
|
20
|
-
headers: {
|
|
21
|
-
...config.headers,
|
|
22
|
-
...options.headers,
|
|
23
|
-
},
|
|
24
|
-
});
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
// Chain middleware (reverse order so first middleware wraps execution)
|
|
28
|
-
let chain = execute;
|
|
29
|
-
if (config.middleware) {
|
|
30
|
-
for (let i = config.middleware.length - 1; i >= 0; i--) {
|
|
31
|
-
const middleware = config.middleware[i];
|
|
32
|
-
const next = chain;
|
|
33
|
-
chain = () => middleware(fullURL, options, next);
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return chain();
|
|
38
|
-
};
|
|
39
|
-
}
|