feat: přidání testů – Jest unit testy + Playwright E2E + CI pipeline
Server: - Jest unit testy (88 testů): auth, utils, restaurants, service, voting, pizza - in-memory storage mock pro izolaci testů - oprava race condition při inicializaci Redis (storageReady promise) - dev route dostupná i pro NODE_ENV=test - getStatsMock deterministický (nahrazení Math.random) - exporty interních helperů pro testovatelnost - /api/health endpoint pro Playwright readiness check - tsconfig vylučuje test soubory z produkčního buildu E2E (e2e/): - Playwright s Firefoxem + Chromiem - testy: login, menu, výběr jídla, pizza day životní cyklus, QR/nastavení - trusted-header auth bypass pro testy, video + trace při selhání CI (Woodpecker): - pipeline spouštěna na všech větvích a PR (nejen master) - redis-stack-server service pro E2E – čistý Redis per větev automaticky - kroky: unit testy, build, E2E testy (parallel kde možné) - Docker build zůstává pouze pro master Co-Authored-By: Claude Opus (extra usage) 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@luncher/e2e",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui",
|
||||
"test:debug": "playwright test --debug",
|
||||
"report": "playwright show-report"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
// Use 127.0.0.1 explicitly — on Node.js 18+/Windows, `localhost` may resolve to ::1
|
||||
// (IPv6) while the HTTP server only binds to 0.0.0.0 (IPv4), causing the webServer
|
||||
// readiness poll to time out even though the server is listening.
|
||||
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3001';
|
||||
|
||||
// Server env vars injected for local runs. In CI these are set at the step level.
|
||||
const serverEnv: Record<string, string> = {
|
||||
NODE_ENV: 'test',
|
||||
MOCK_DATA: 'true',
|
||||
STORAGE: process.env.STORAGE ?? 'json',
|
||||
JWT_SECRET: process.env.JWT_SECRET ?? 'e2e-test-secret-min-32-chars-aaaa',
|
||||
HTTP_REMOTE_USER_ENABLED: 'true',
|
||||
HTTP_REMOTE_USER_HEADER_NAME: 'remote-user',
|
||||
HTTP_REMOTE_TRUSTED_IPS: process.env.HTTP_REMOTE_TRUSTED_IPS ?? '127.0.0.1,::1,::ffff:127.0.0.1',
|
||||
};
|
||||
if (process.env.REDIS_HOST) {
|
||||
serverEnv.REDIS_HOST = process.env.REDIS_HOST;
|
||||
serverEnv.REDIS_PORT = process.env.REDIS_PORT ?? '6379';
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
timeout: 30_000,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
// Default: every test authenticates as e2e-user via trusted header.
|
||||
// Tests that need the real login form should override this in their own context.
|
||||
extraHTTPHeaders: {
|
||||
'remote-user': 'e2e-user',
|
||||
},
|
||||
trace: 'retain-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||
],
|
||||
// Pre-built server must be started before tests. In CI the step does this
|
||||
// explicitly. Locally: build types+server+client, cp -r client/dist server/public,
|
||||
// then `cd e2e && yarn test` OR let webServer below do it if reuseExistingServer=true
|
||||
// is set and the server is already running.
|
||||
webServer: {
|
||||
command: 'node dist/server/src/index.js',
|
||||
cwd: path.resolve(__dirname, '../server'),
|
||||
// Poll a dedicated health endpoint — polling '/' can stall in Express 5 when
|
||||
// server/public/ doesn't exist in the working directory (no finalhandler match).
|
||||
url: `http://127.0.0.1:3001/api/health`,
|
||||
timeout: 15_000,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
env: serverEnv,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Page, APIRequestContext } from '@playwright/test';
|
||||
|
||||
/** Přihlásí uživatele přes POST /api/login a uloží JWT do localStorage. */
|
||||
export async function loginViaApi(page: Page, login: string): Promise<void> {
|
||||
const response = await page.request.post('/api/login', {
|
||||
headers: { 'Content-Type': 'application/json', 'remote-user': login },
|
||||
data: {},
|
||||
});
|
||||
const token = await response.json() as string;
|
||||
await page.goto('/');
|
||||
await page.evaluate((t) => localStorage.setItem('token', t), token);
|
||||
}
|
||||
|
||||
/** Vyčistí stav pizza dne pro zadaný dayIndex (0=pondělí…4=pátek) přes dev API. */
|
||||
export async function clearPizzaDay(request: APIRequestContext): Promise<void> {
|
||||
const today = new Date('2025-01-10'); // MOCK_DATA pins to Friday = dayIndex 4
|
||||
await request.post('/api/dev/clear', {
|
||||
headers: { 'Content-Type': 'application/json', 'remote-user': 'e2e-user' },
|
||||
data: { dayIndex: 4 },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Tento test záměrně NEPOUŽÍVÁ trusted-header – testuje reálný login formulář.
|
||||
test.use({ extraHTTPHeaders: {} });
|
||||
|
||||
test('uživatel se přihlásí formulářem a uvidí obsah aplikace', async ({ page }) => {
|
||||
// Server běží s HTTP_REMOTE_USER_ENABLED=true, takže POST /api/login vždy vyžaduje
|
||||
// hlavičku remote-user. Zachytíme požadavky z formuláře (mají tělo s polem login)
|
||||
// a přidáme hlavičku; požadavek auto-loginu (bez těla) projde bez hlavičky a selže,
|
||||
// čímž formulář zůstane viditelný.
|
||||
await page.route('**/api/login', async (route) => {
|
||||
const body = route.request().postData();
|
||||
let login: string | undefined;
|
||||
try { login = body ? JSON.parse(body)?.login : undefined; } catch {}
|
||||
await route.continue({
|
||||
headers: login
|
||||
? { ...route.request().headers(), 'remote-user': login }
|
||||
: route.request().headers(),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/');
|
||||
|
||||
// Formulář musí být viditelný – auto-login selhal (nepřišla hlavička)
|
||||
const loginInput = page.locator('#login-input');
|
||||
await expect(loginInput).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Vyplnění loginu a odeslání Enterem
|
||||
await loginInput.fill('testuser');
|
||||
await loginInput.press('Enter');
|
||||
|
||||
// Po přihlášení musí zmizet login formulář
|
||||
await expect(loginInput).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// JWT musí být uloženo v localStorage jako 3-dílný token
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'));
|
||||
expect(token).toBeTruthy();
|
||||
expect((token as string).split('.')).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('trusted-header přihlášení proběhne automaticky bez formuláře', async ({ page, context }) => {
|
||||
// Obnoví trusted header (přepíše prázdný extraHTTPHeaders z test.use výše)
|
||||
await context.setExtraHTTPHeaders({ 'remote-user': 'e2e-auto-user' });
|
||||
await page.goto('/');
|
||||
|
||||
// Login formulář by se neměl nikdy zobrazit, nebo se ihned schová
|
||||
await page.waitForLoadState('networkidle');
|
||||
const loginInput = page.locator('#login-input');
|
||||
await expect(loginInput).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { clearPizzaDay } from './helpers';
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
// Vyčistíme volby dne, aby testy neovlivnily navzájem
|
||||
await request.post('/api/dev/clear', {
|
||||
data: { dayIndex: 4 },
|
||||
});
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
// Počkáme, až se zobrazí volba stravování
|
||||
await expect(page.locator('.choice-section select').first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('výběr restaurace zobrazí seznam jídel', async ({ page }) => {
|
||||
const locationSelect = page.locator('.choice-section select').first();
|
||||
|
||||
// Vybereme Sladovnickou – mock menu existuje
|
||||
await locationSelect.selectOption('SLADOVNICKA');
|
||||
|
||||
// Po výběru restaurace se zobrazí druhý select s jídly
|
||||
const foodSelect = page.locator('.choice-section select').nth(1);
|
||||
await expect(foodSelect).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Select musí obsahovat alespoň 2 možnosti (empty + ≥1 jídlo)
|
||||
const options = foodSelect.locator('option');
|
||||
expect(await options.count()).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test('výběr jídla se uloží a přetrvá po reload', async ({ page }) => {
|
||||
const locationSelect = page.locator('.choice-section select').first();
|
||||
await locationSelect.selectOption('SLADOVNICKA');
|
||||
|
||||
const foodSelect = page.locator('.choice-section select').nth(1);
|
||||
await expect(foodSelect).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Vybereme první nenulovou možnost
|
||||
const options = await foodSelect.locator('option:not([value=""])').all();
|
||||
if (options.length === 0) {
|
||||
test.skip(); // Mock data nejsou dostupná pro tuto restauraci
|
||||
}
|
||||
const firstValue = await options[0].getAttribute('value');
|
||||
await foodSelect.selectOption({ value: firstValue! });
|
||||
|
||||
// Počkáme, až se volba přenese na server
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Po reload musí volba přetrvat v tabulce choices
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
const choicesTable = page.locator('.choices-table');
|
||||
await expect(choicesTable).toBeVisible({ timeout: 5_000 });
|
||||
await expect(choicesTable.locator('text=Sladovnická')).toBeVisible();
|
||||
});
|
||||
|
||||
test('přepnutí na NEOBEDVAM odstraní výběr restaurace', async ({ page }) => {
|
||||
// Nejprve zvolíme restauraci
|
||||
const locationSelect = page.locator('.choice-section select').first();
|
||||
await locationSelect.selectOption('SLADOVNICKA');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Přepneme na "Neobědvám"
|
||||
await locationSelect.selectOption('NEOBEDVAM');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Tabulka choices musí zobrazovat "Neobědvám"
|
||||
const choicesTable = page.locator('.choices-table');
|
||||
await expect(choicesTable).toBeVisible({ timeout: 5_000 });
|
||||
await expect(choicesTable.locator('text=Neobědvám')).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// Pizza day testy musí běžet sekvenčně (sdílejí stav mock dne)
|
||||
test.describe.serial('pizza day životní cyklus', () => {
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
// Vyčistíme data mock dne před každým testem
|
||||
await request.post('/api/dev/clear', { data: { dayIndex: 4 } });
|
||||
});
|
||||
|
||||
test('zobrazí sekci Pizza Day bez aktivního dne', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const pizzaSection = page.locator('.pizza-section');
|
||||
await expect(pizzaSection).toBeVisible({ timeout: 10_000 });
|
||||
await expect(pizzaSection.locator('text=není aktuálně založen')).toBeVisible();
|
||||
});
|
||||
|
||||
test('vytvoří, uzamkne a dokončí pizza day', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// --- CREATED ---
|
||||
const createBtn = page.locator('.pizza-section button', { hasText: 'Založit Pizza day' });
|
||||
await expect(createBtn).toBeVisible({ timeout: 10_000 });
|
||||
await createBtn.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('.pizza-section')).toContainText('spravován uživatelem', { timeout: 5_000 });
|
||||
|
||||
// Přidáme pizzu přes API (obejde komplex SelectSearch)
|
||||
const token = await page.evaluate(() => localStorage.getItem('token'));
|
||||
const addResp = await page.request.post('/api/pizza/add', {
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { pizzaIndex: 0, pizzaSizeIndex: 0 },
|
||||
});
|
||||
expect(addResp.ok()).toBeTruthy();
|
||||
|
||||
// Reload – server aktualizoval data přes WebSocket, ale reload je jistější
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// --- LOCK ---
|
||||
const lockBtn = page.locator('.pizza-section button', { hasText: 'Uzamknout' });
|
||||
await expect(lockBtn).toBeEnabled({ timeout: 5_000 });
|
||||
await lockBtn.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('.pizza-section')).toContainText('uzamčeny', { timeout: 5_000 });
|
||||
|
||||
// --- ORDERED ---
|
||||
const orderBtn = page.locator('.pizza-section button', { hasText: 'Objednáno' });
|
||||
await expect(orderBtn).toBeEnabled({ timeout: 5_000 });
|
||||
await orderBtn.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('.pizza-section')).toContainText('objednány', { timeout: 5_000 });
|
||||
|
||||
// --- DELIVERED ---
|
||||
const deliverBtn = page.locator('.pizza-section button', { hasText: 'Doručeno' });
|
||||
await expect(deliverBtn).toBeVisible({ timeout: 5_000 });
|
||||
// window.confirm dialog − Playwright automaticky potvrdí
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
await deliverBtn.click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('.pizza-section')).toContainText('doručeny', { timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page, request }) => {
|
||||
// Naseedujeme 5 uživatelů pro dnešní den – GenerateQrModal pracuje se stávajícími choices
|
||||
await request.post('/api/dev/generate', { data: { dayIndex: 4, count: 5 } });
|
||||
|
||||
// Přednastavíme bankovní účet v localStorage (SettingsContext čte z LS při inicializaci)
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('bank_account_number', '2400000000/2010');
|
||||
localStorage.setItem('bank_account_holder_name', 'Test User');
|
||||
});
|
||||
// Reload tak, aby SettingsContext načetl nové hodnoty z localStorage
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('Nastavení ukládají číslo účtu a jméno do localStorage', async ({ page }) => {
|
||||
// Otevření nastavení
|
||||
await page.locator('#basic-nav-dropdown').click();
|
||||
await page.locator('text=Nastavení').click();
|
||||
|
||||
// Modal musí být viditelný
|
||||
await expect(page.locator('.modal-title')).toContainText('Nastavení', { timeout: 5_000 });
|
||||
|
||||
// Změníme číslo účtu
|
||||
const accountInput = page.getByPlaceholder('123456-1234567890/1234');
|
||||
await accountInput.clear();
|
||||
await accountInput.fill('1234567890/5500');
|
||||
|
||||
// Změníme jméno
|
||||
const nameInput = page.getByPlaceholder('Jan Novák');
|
||||
await nameInput.clear();
|
||||
await nameInput.fill('Nové Jméno');
|
||||
|
||||
// Uložíme
|
||||
await page.locator('.modal-footer button', { hasText: 'Uložit' }).click();
|
||||
|
||||
// Ověříme v localStorage
|
||||
const bankAccount = await page.evaluate(() => localStorage.getItem('bank_account_number'));
|
||||
const holderName = await page.evaluate(() => localStorage.getItem('bank_account_holder_name'));
|
||||
expect(bankAccount).toBe('1234567890/5500');
|
||||
expect(holderName).toBe('Nové Jméno');
|
||||
});
|
||||
|
||||
test('otevře modal Generování QR kódů pokud je nastaven účet', async ({ page }) => {
|
||||
// Otevření dropdown menu
|
||||
await page.locator('#basic-nav-dropdown').click();
|
||||
await page.locator('text=Generování QR kódů').click();
|
||||
|
||||
// Modal se otevře
|
||||
await expect(page.locator('.modal')).toBeVisible({ timeout: 5_000 });
|
||||
// Modal musí obsahovat seznam uživatelů nebo prázdný stav
|
||||
await expect(page.locator('.modal-body')).toBeVisible();
|
||||
});
|
||||
|
||||
test('upozorní pokud není nastaven bankovní účet', async ({ page }) => {
|
||||
// Odebereme nastavení účtu
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem('bank_account_number');
|
||||
localStorage.removeItem('bank_account_holder_name');
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Dialog místo modalu
|
||||
page.on('dialog', async dialog => {
|
||||
expect(dialog.message()).toContain('číslo účtu');
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
await page.locator('#basic-nav-dropdown').click();
|
||||
await page.locator('text=Generování QR kódů').click();
|
||||
|
||||
// Modal se NESMÍ otevřít
|
||||
await expect(page.locator('.modal')).not.toBeVisible({ timeout: 3_000 });
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Trusted-header login runs automatically when Login mounts.
|
||||
// networkidle zaručí, že fetch('/api/data') byl dokončen.
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('zobrazí mock datum 10.01.2025', async ({ page }) => {
|
||||
// MOCK_DATA=true pins today to 2025-01-10
|
||||
await expect(page.locator('text=10.01')).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('zobrazí čtyři restaurační karty z mock dat', async ({ page }) => {
|
||||
// Každá restaurace je obalena v .restaurant-card
|
||||
const cards = page.locator('.restaurant-card');
|
||||
await expect(cards).toHaveCount(4, { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test('zobrazí alespoň jedno jídlo v menu každé restaurace', async ({ page }) => {
|
||||
await expect(page.locator('.restaurant-card').first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Každá karta musí mít aspoň jeden řádek v .food-table
|
||||
const cards = page.locator('.restaurant-card');
|
||||
const count = await cards.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const card = cards.nth(i);
|
||||
const rows = card.locator('.food-table tr');
|
||||
expect(await rows.count()).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('zobrazí volbu stravování před menu', async ({ page }) => {
|
||||
// Sekce .choice-section obsahuje select pro výběr stravování
|
||||
const choiceSection = page.locator('.choice-section');
|
||||
await expect(choiceSection).toBeVisible({ timeout: 10_000 });
|
||||
await expect(choiceSection.locator('select').first()).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@playwright/test@^1.50.0":
|
||||
version "1.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.59.1.tgz#5c4d38eac84a61527af466602ae20277685a02d6"
|
||||
integrity sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==
|
||||
dependencies:
|
||||
playwright "1.59.1"
|
||||
|
||||
"@types/node@^22.0.0":
|
||||
version "22.19.17"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.17.tgz#09c71fb34ba2510f8ac865361b1fcb9552b8a581"
|
||||
integrity sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==
|
||||
dependencies:
|
||||
undici-types "~6.21.0"
|
||||
|
||||
fsevents@2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
|
||||
integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
|
||||
|
||||
playwright-core@1.59.1:
|
||||
version "1.59.1"
|
||||
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.59.1.tgz#d8a2b28bcb8f2bd08ef3df93b02ae83c813244b2"
|
||||
integrity sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==
|
||||
|
||||
playwright@1.59.1:
|
||||
version "1.59.1"
|
||||
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.59.1.tgz#f7b0ca61637ae25264cec370df671bbe1f368a4a"
|
||||
integrity sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==
|
||||
dependencies:
|
||||
playwright-core "1.59.1"
|
||||
optionalDependencies:
|
||||
fsevents "2.3.2"
|
||||
|
||||
typescript@^5.9.3:
|
||||
version "5.9.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f"
|
||||
integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==
|
||||
|
||||
undici-types@~6.21.0:
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
|
||||
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
|
||||
Reference in New Issue
Block a user