Skip to content

Commit 2fbffbf

Browse files
committed
test: E2E Playwright login-logout en repo (contrato de sesion) + job e2e en CI + fix schemas factory
1 parent 870811c commit 2fbffbf

8 files changed

Lines changed: 190 additions & 41 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,21 @@ jobs:
3232
working-directory: app
3333
- run: npm run check:bundlesize
3434
working-directory: app
35+
36+
e2e:
37+
runs-on: ubuntu-latest
38+
steps:
39+
- uses: actions/checkout@v4
40+
- uses: actions/setup-node@v4
41+
with:
42+
node-version: 22
43+
- run: npm ci
44+
working-directory: server
45+
- run: npm ci
46+
working-directory: app
47+
- run: npm run build
48+
working-directory: app
49+
- run: npx playwright install --with-deps chromium
50+
working-directory: app
51+
- run: npm run test:e2e
52+
working-directory: app

‎app/e2e/auth.spec.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// Contrato de sesión (auth.md): login → logout → debe quedarse en /login SIN recargar la SPA.
4+
5+
test('login incorrecto muestra error inline', async ({ page }) => {
6+
await page.goto('/');
7+
await expect(page.locator('#login-user')).toBeVisible();
8+
await page.fill('#login-user', 'admin');
9+
await page.fill('#login-pass', 'incorrecta');
10+
await page.click('button[type=submit]');
11+
await expect(page.locator('[role=alert]')).toBeVisible();
12+
});
13+
14+
test('login → dashboard → logout → vuelve al login', async ({ page }) => {
15+
await page.goto('/');
16+
await page.fill('#login-user', 'admin');
17+
await page.fill('#login-pass', 'testpass123');
18+
await page.click('button[type=submit]');
19+
20+
// Tras login, el formulario desaparece (dashboard)
21+
await expect(page.locator('#login-user')).toHaveCount(0);
22+
23+
// Logout desde Ajustes: la SPA NO se recarga, AuthGate muestra el login
24+
await page.goto('/ajustes');
25+
await page.getByRole('button', { name: /cerrar sesión|log out|退出登录/i }).click();
26+
await expect(page.locator('#login-user')).toBeVisible();
27+
});

‎app/package-lock.json‎

Lines changed: 64 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎app/package.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"lint": "eslint . && node scripts/theme-audit.mjs",
1010
"format": "prettier --write .",
1111
"check:bundlesize": "node scripts/check-bundle-size.mjs",
12+
"test:e2e": "playwright test",
1213
"preview": "vite preview"
1314
},
1415
"dependencies": {
@@ -41,6 +42,7 @@
4142
},
4243
"devDependencies": {
4344
"@eslint/js": "^9.39.1",
45+
"@playwright/test": "^1.62.1",
4446
"@types/node": "^24.10.1",
4547
"@types/react": "^19.2.5",
4648
"@types/react-dom": "^19.2.3",

‎app/playwright.config.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { defineConfig } from '@playwright/test';
2+
import path from 'node:path';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import { fileURLToPath } from 'node:url';
6+
7+
const dirname = path.dirname(fileURLToPath(import.meta.url));
8+
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'helios-e2e-'));
9+
10+
export default defineConfig({
11+
testDir: './e2e',
12+
timeout: 30000,
13+
retries: 0,
14+
use: { baseURL: 'http://127.0.0.1:8199' },
15+
webServer: {
16+
command: 'node ../server/src/index.js',
17+
url: 'http://127.0.0.1:8199/health',
18+
timeout: 20000,
19+
reuseExistingServer: !process.env.CI,
20+
env: {
21+
PORT: '8199',
22+
HOST: '127.0.0.1',
23+
HAOS_TOKEN: 'dummy-e2e',
24+
AUTH_USER: 'admin',
25+
AUTH_PASS: 'testpass123',
26+
DATA_DIR: dataDir,
27+
STATIC_DIR: path.join(dirname, 'dist'),
28+
},
29+
},
30+
});

‎app/test-results/.last-run.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"status": "passed",
3+
"failedTests": []
4+
}

‎server/src/index.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'node:fs'
22
import crypto from 'node:crypto'
33
import path from 'node:path'
44
import { fileURLToPath } from 'node:url'
5+
import { z } from 'zod'
56
import bcrypt from 'bcryptjs'
67

78
const dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -22,7 +23,7 @@ const { HAClient } = await import('./ha.js')
2223
const dbModule = await import('./db.js')
2324
const solar = await import('./solar.js')
2425
const auth = await import('./auth.js')
25-
const schemas = await import('../../shared/schemas.js')
26+
const schemas = (await import('../../shared/schemas.js')).createSchemas(z)
2627
const { dateSchema, loginSchema, registerSchema, profileSchema, passwordSchema, historyQuerySchema, auditQuerySchema, adminPasswordSchema, adminLanguageSchema, adminRoleSchema } = schemas
2728

2829
const db = dbModule.openDb(config.dataDir)

‎shared/schemas.js‎

Lines changed: 43 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,45 @@
11
// Contrato API compartido (única fuente de verdad de los payloads del servidor).
22
// El frontend tipa contra estas mismas formas en app/src/data/types.ts.
3-
import { z } from 'zod'
4-
5-
export const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional()
6-
7-
export const loginSchema = z.object({
8-
username: z.string().min(1),
9-
password: z.string().min(1),
10-
})
11-
12-
export const registerSchema = z.object({
13-
username: z.string().min(3).max(50),
14-
password: z.string().min(6),
15-
language: z.string().regex(/^(es|en|zh-CN)$/).optional(),
16-
role: z.enum(['user', 'admin']).optional(),
17-
})
18-
19-
export const profileSchema = z.object({
20-
email: z.string().email().optional().nullable(),
21-
phone: z.string().optional().nullable(),
22-
language: z.string().regex(/^(es|en|zh-CN)$/).optional(),
23-
})
24-
25-
export const passwordSchema = z.object({
26-
current: z.string().min(1),
27-
password: z.string().min(6),
28-
})
29-
30-
export const historyQuerySchema = z.object({
31-
limit: z.coerce.number().int().min(1).max(1000).default(1000),
32-
offset: z.coerce.number().int().min(0).default(0),
33-
})
34-
35-
export const auditQuerySchema = z.object({
36-
limit: z.coerce.number().int().min(1).max(200).default(50),
37-
offset: z.coerce.number().int().min(0).default(0),
38-
})
39-
40-
export const adminPasswordSchema = z.object({ password: z.string().min(6) })
41-
export const adminLanguageSchema = z.object({ language: z.string().regex(/^(es|en|zh-CN)$/) })
42-
export const adminRoleSchema = z.object({ role: z.enum(['user', 'admin']) })
3+
// Factory: recibe zod del consumidor (shared/ no tiene node_modules propio).
4+
export function createSchemas(z) {
5+
return {
6+
dateSchema: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
7+
8+
loginSchema: z.object({
9+
username: z.string().min(1),
10+
password: z.string().min(1),
11+
}),
12+
13+
registerSchema: z.object({
14+
username: z.string().min(3).max(50),
15+
password: z.string().min(6),
16+
language: z.string().regex(/^(es|en|zh-CN)$/).optional(),
17+
role: z.enum(['user', 'admin']).optional(),
18+
}),
19+
20+
profileSchema: z.object({
21+
email: z.string().email().optional().nullable(),
22+
phone: z.string().optional().nullable(),
23+
language: z.string().regex(/^(es|en|zh-CN)$/).optional(),
24+
}),
25+
26+
passwordSchema: z.object({
27+
current: z.string().min(1),
28+
password: z.string().min(6),
29+
}),
30+
31+
historyQuerySchema: z.object({
32+
limit: z.coerce.number().int().min(1).max(1000).default(1000),
33+
offset: z.coerce.number().int().min(0).default(0),
34+
}),
35+
36+
auditQuerySchema: z.object({
37+
limit: z.coerce.number().int().min(1).max(200).default(50),
38+
offset: z.coerce.number().int().min(0).default(0),
39+
}),
40+
41+
adminPasswordSchema: z.object({ password: z.string().min(6) }),
42+
adminLanguageSchema: z.object({ language: z.string().regex(/^(es|en|zh-CN)$/) }),
43+
adminRoleSchema: z.object({ role: z.enum(['user', 'admin']) }),
44+
}
45+
}

0 commit comments

Comments
 (0)