// Local-only browser acceptance runner. It uses the disposable fixtures served by browser-qa.mjs. import { spawn } from 'node:child_process'; import { writeFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { chromium } from 'playwright'; const server = spawn(process.execPath, ['tests/browser-qa.mjs'], { cwd: new URL('..', import.meta.url), stdio: ['ignore', 'pipe', 'inherit'] }); let output = ''; const url = await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('QA sunucusu başlatılamadı.')), 30_000); server.stdout.on('data', chunk => { output += chunk; const match = /QA_URL=(\S+)/.exec(output); if (match) { clearTimeout(timeout); resolve(match[1]); } }); server.once('exit', code => { clearTimeout(timeout); reject(new Error(`QA sunucusu ${code} koduyla kapandı.`)); }); }); const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ timezoneId: 'America/New_York' }); const page = await context.newPage(); await page.setViewportSize({ width: 1440, height: 1000 }); const evidence = { url, checks: { browserTimezone: 'America/New_York' }, scope: 'Yerel sentetik QA; gerçek başvuru veya dış sağlayıcı çağrısı yapılmadı.' }; try { const response = await page.goto(url, { waitUntil: 'networkidle' }); evidence.checks.signInStatus = response?.status(); await page.getByRole('button', { name: 'Test hesabıyla gir' }).click(); await page.waitForLoadState('networkidle'); const hideExpired = page.getByLabel('Süresi geçenleri gizle', { exact: true }); if (await hideExpired.isChecked()) await hideExpired.uncheck(); evidence.checks.visibleCities = await Promise.all(['Giresun', 'Kayseri', 'Akdeniz', 'Aksaray', 'Tepecik'].map(async city => ({ city, count: await page.getByText(city, { exact: false }).count() }))); if (evidence.checks.visibleCities.some(item => item.count < 1)) throw new Error('Beş sentetik şehirden biri görünmedi.'); await page.getByRole('button', { name: 'Günlük Bülten', exact: true }).click(); await page.getByRole('heading', { name: 'Günlük İhale Bülteni', exact: true }).waitFor({ state: 'attached', timeout: 10_000 }); evidence.checks.dailyBulletinHeading = await page.getByRole('heading', { name: 'Günlük İhale Bülteni', exact: true }).count() === 1; if (!evidence.checks.dailyBulletinHeading) throw new Error('Günlük bülten başlığı açılmadı.'); const calendarPage = await context.newPage(); try { await calendarPage.goto(`${url.replace(/\/qa-signin$/, '')}/qa-calendar-links`, { waitUntil: 'networkidle' }); const googleLink = calendarPage.getByRole('link', { name: 'Google Takvim taslağı', exact: true }); const outlookLink = calendarPage.getByRole('link', { name: 'Outlook taslağı', exact: true }); await googleLink.waitFor({ state: 'attached', timeout: 10_000 }); await outlookLink.waitFor({ state: 'attached', timeout: 10_000 }); const [googleHref, outlookHref] = await Promise.all([googleLink.getAttribute('href'), outlookLink.getAttribute('href')]); const googleUrl = new URL(googleHref); const outlookUrl = new URL(outlookHref); evidence.checks.calendarProviderLinks = { googleHost: googleUrl.hostname, googleDates: googleUrl.searchParams.get('dates'), outlookHost: outlookUrl.hostname, outlookStart: outlookUrl.searchParams.get('startdt'), outlookEnd: outlookUrl.searchParams.get('enddt'), }; if (googleUrl.hostname !== 'calendar.google.com' || googleUrl.searchParams.get('dates') !== '20260910T070000Z/20260910T070000Z' || outlookUrl.hostname !== 'outlook.live.com' || outlookUrl.searchParams.get('startdt') !== '2026-09-10T07:00:00Z' || outlookUrl.searchParams.get('enddt') !== '2026-09-10T07:00:00Z') { throw new Error(`Takvim provider bağlantısı kaynak son tarihi korumuyor: ${JSON.stringify(evidence.checks.calendarProviderLinks)}`); } const missingCalendarPage = await context.newPage(); try { await missingCalendarPage.goto(`${url.replace(/\/qa-signin$/, '')}/qa-calendar-links?missing=1`, { waitUntil: 'networkidle' }); evidence.checks.calendarMissingTimeGuard = { googleDisabled: await missingCalendarPage.getByText('Google Takvim (saat gerekli)', { exact: true }).count() === 1, outlookDisabled: await missingCalendarPage.getByText('Outlook (saat gerekli)', { exact: true }).count() === 1, providerLinks: await missingCalendarPage.locator('a').filter({ hasText: /Google Takvim taslağı|Outlook taslağı/ }).count(), }; if (!evidence.checks.calendarMissingTimeGuard.googleDisabled || !evidence.checks.calendarMissingTimeGuard.outlookDisabled || evidence.checks.calendarMissingTimeGuard.providerLinks !== 0) { throw new Error(`Saat bilinmiyorken takvim bağlantısı pasifleşmedi: ${JSON.stringify(evidence.checks.calendarMissingTimeGuard)}`); } } finally { await missingCalendarPage.close(); } } finally { await calendarPage.close(); } await page.getByRole('button', { name: 'Ana İhale Listesi', exact: true }).click(); await page.getByRole('button', { name: 'Pazar', exact: true }).click(); await page.getByRole('button', { name: 'Analitik Dashboard', exact: true }).click(); await page.getByLabel('Ciro hedefi (TL)', { exact: true }).waitFor({ state: 'attached', timeout: 10_000 }); evidence.checks.analyticsTargetLabel = await page.getByLabel('Ciro hedefi (TL)', { exact: true }).count() === 1; if (!evidence.checks.analyticsTargetLabel) throw new Error('Analitik ciro hedefi alanı erişilebilir ad taşımıyor.'); const primaryTarget = page.getByLabel('Ciro hedefi (TL)', { exact: true }); await primaryTarget.fill('1250'); await page.waitForFunction(() => localStorage.getItem('ihaleist_ciro_hedefi:browser-qa%40example.test') === '1250'); await page.goto(`${url.replace(/\/qa-signin$/, '')}/qa-switch-account?account=other`, { waitUntil: 'networkidle' }); await page.getByRole('button', { name: 'Pazar', exact: true }).click(); await page.getByRole('button', { name: 'Analitik Dashboard', exact: true }).click(); await page.getByLabel('Ciro hedefi (TL)', { exact: true }).waitFor({ state: 'attached', timeout: 10_000 }); evidence.checks.accountScopedAnalyticsTarget = await page.getByLabel('Ciro hedefi (TL)', { exact: true }).inputValue() === ''; if (!evidence.checks.accountScopedAnalyticsTarget) throw new Error('İkinci hesap ilk hesabın ciro hedefini devraldı.'); await page.getByLabel('Ciro hedefi (TL)', { exact: true }).fill('980'); await page.waitForFunction(() => localStorage.getItem('ihaleist_ciro_hedefi:browser-qa-other%40example.test') === '980'); await page.goto(`${url.replace(/\/qa-signin$/, '')}/qa-switch-account?account=primary`, { waitUntil: 'networkidle' }); await page.getByRole('button', { name: 'Pazar', exact: true }).click(); await page.getByRole('button', { name: 'Analitik Dashboard', exact: true }).click(); await page.getByRole('button', { name: 'Ciro hedefini düzenle', exact: true }).click(); await page.getByLabel('Ciro hedefi (TL)', { exact: true }).waitFor({ state: 'attached', timeout: 10_000 }); evidence.checks.primaryAnalyticsTargetRestored = await page.getByLabel('Ciro hedefi (TL)', { exact: true }).inputValue() === '1250'; if (!evidence.checks.primaryAnalyticsTargetRestored) throw new Error('İlk hesabın ciro hedefi hesap değişiminden sonra geri yüklenmedi.'); await page.getByRole('button', { name: 'Keşfet', exact: true }).click(); await page.getByRole('button', { name: 'İhale Takvimi' }).click(); await page.waitForLoadState('networkidle'); const icsDir = await mkdtemp(join(tmpdir(), 'ihaleist-ics-')); const icsPath = join(icsDir, 'qa.ics'); await writeFile(icsPath, 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:qa-calendar\r\nDTSTART:20260910T070000Z\r\nSUMMARY:Harici Mikroskop\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n'); try { await page.locator('input[aria-label="ICS takvim dosyası seç"]').setInputFiles(icsPath); await page.getByText('1 harici etkinlik içe aktarıldı.', { exact: true }).waitFor(); evidence.checks.icsImported = true; evidence.checks.icsStorageScoped = await page.evaluate(() => Object.keys(localStorage).some(key => key.startsWith('ihaleist_imported_calendar:'))); if (!evidence.checks.icsStorageScoped) throw new Error('ICS kaydı hesap kapsamlı localStorage anahtarı kullanmıyor.'); await page.getByRole('button', { name: 'Keşfet' }).click(); await page.getByRole('button', { name: 'İhale Takvimi' }).click(); evidence.checks.icsPersistsAfterTabChange = await page.getByText('1 harici etkinlik içe aktarıldı.', { exact: true }).count() === 1; if (!evidence.checks.icsPersistsAfterTabChange) throw new Error('ICS kaydı sekme değişiminden sonra korunmadı.'); await page.reload({ waitUntil: 'networkidle' }); await page.getByRole('button', { name: 'Keşfet', exact: true }).click(); await page.getByRole('button', { name: 'İhale Takvimi' }).click(); await page.getByText('1 harici etkinlik içe aktarıldı.', { exact: true }).waitFor({ state: 'attached', timeout: 10_000 }); evidence.checks.icsPersistsAfterReload = await page.getByText('1 harici etkinlik içe aktarıldı.', { exact: true }).count() === 1; if (!evidence.checks.icsPersistsAfterReload) throw new Error('ICS kaydı sayfa yenilemesinden sonra korunmadı.'); await page.goto(`${url.replace(/\/qa-signin$/, '')}/qa-switch-account?account=other`, { waitUntil: 'networkidle' }); await page.getByRole('button', { name: 'Keşfet' }).click(); await page.getByRole('button', { name: 'İhale Takvimi' }).click(); evidence.checks.accountScopedIcs = await page.getByText('1 harici etkinlik içe aktarıldı.', { exact: true }).count() === 0; if (!evidence.checks.accountScopedIcs) throw new Error('İkinci hesap ilk hesabın ICS etkinliğini devraldı.'); await page.goto(`${url.replace(/\/qa-signin$/, '')}/qa-switch-account?account=primary`, { waitUntil: 'networkidle' }); await page.getByRole('button', { name: 'Keşfet' }).click(); await page.getByRole('button', { name: 'İhale Takvimi' }).click(); await page.getByText('1 harici etkinlik içe aktarıldı.', { exact: true }).waitFor({ state: 'attached', timeout: 10_000 }); evidence.checks.primaryIcsRestored = await page.getByText('1 harici etkinlik içe aktarıldı.', { exact: true }).count() === 1; if (!evidence.checks.primaryIcsRestored) { const storage = await page.evaluate(() => Object.fromEntries(Object.keys(localStorage).filter(key => key.startsWith('ihaleist_imported_calendar:')).map(key => [key, localStorage.getItem(key)]))); throw new Error(`İlk hesabın ICS etkinliği hesap değişiminden sonra geri yüklenmedi: ${JSON.stringify(storage)}`); } } finally { await rm(icsDir, { recursive: true, force: true }); } const readyPage = await context.newPage(); await readyPage.setViewportSize({ width: 1440, height: 1000 }); try { const readyResponse = await readyPage.goto(`${url.replace(/\/qa-signin$/, '')}/qa-source-update?ready=1&file=1`, { waitUntil: 'networkidle' }); evidence.checks.readyPreparationStatus = readyResponse?.status(); const outputButtons = readyPage.getByRole('button').filter({ hasText: /Teklif Mektubu|Birim Fiyat Teklif Cetveli|Excel Teklif Cetveli|Evrak Kontrol Listesi/ }); evidence.checks.readyOutputButtons = await outputButtons.count(); evidence.checks.readyOutputButtonsEnabled = await outputButtons.evaluateAll(buttons => buttons.every(button => !button.disabled)); if (evidence.checks.readyOutputButtons !== 4 || !evidence.checks.readyOutputButtonsEnabled) throw new Error('Hazır taslak çıktı düğmeleri etkin değil.'); const popupPromise = readyPage.waitForEvent('popup'); await readyPage.getByRole('button', { name: /Evrak Kontrol Listesi/ }).click(); const popup = await popupPromise; await popup.waitForLoadState('domcontentloaded'); evidence.checks.printPopupTitle = await popup.title(); if (evidence.checks.printPopupTitle !== 'Evrak Kontrol Listesi') throw new Error('Yazdırma penceresi beklenen başlığı üretmedi.'); evidence.checks.printPopupTurkishContent = await popup.locator('h1').filter({ hasText: 'EVRAK KONTROL LİSTESİ' }).count() === 1; evidence.checks.printPopupChecklistItems = await popup.locator('ul li').count(); if (!evidence.checks.printPopupTurkishContent || evidence.checks.printPopupChecklistItems < 1) throw new Error('Evrak çıktısı beklenen Türkçe başlık veya kontrol listesini içermiyor.'); await popup.emulateMedia({ media: 'print' }); evidence.checks.printButtonHidden = await popup.locator('.yazdirma').evaluate(container => getComputedStyle(container).display === 'none'); if (!evidence.checks.printButtonHidden) throw new Error('Yazdırma düğmesi print ortamında gizlenmedi.'); await popup.close(); } finally { await readyPage.close(); } const overview = await page.goto(`${url.replace(/\/qa-signin$/, '')}/qa-overview`, { waitUntil: 'networkidle' }); evidence.checks.overviewStatus = overview?.status(); evidence.checks.overviewFrames = await page.locator('iframe[title^="Hazırlık"]').count(); if (evidence.checks.overviewFrames !== 3) throw new Error('Hazırlık özeti üç görünüm alanında açılmadı.'); const overviewFrame = page.frameLocator('iframe[title="Hazırlık 390"]'); await overviewFrame.getByText('Belge sorumluları:', { exact: false }).waitFor({ state: 'attached', timeout: 10_000 }); evidence.checks.documentOwnerInPreparationOverview = await overviewFrame.getByText('Belge sorumluları: QA zorunlu belge: QA Belge Sorumlusu', { exact: true }).count() === 1; if (!evidence.checks.documentOwnerInPreparationOverview) throw new Error('Belge sorumlusu hazırlık özetinde görünmedi.'); const mobile = await context.newPage(); await mobile.setViewportSize({ width: 390, height: 844 }); try { const mobileResponse = await mobile.goto(url, { waitUntil: 'networkidle' }); evidence.checks.mobileStatus = mobileResponse?.status(); const mobileOverflow = await mobile.evaluate(() => ({ viewport: window.innerWidth, scrollWidth: document.documentElement.scrollWidth, horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth + 1, })); evidence.checks.mobileOverflow = mobileOverflow; if (mobileOverflow.horizontalOverflow) throw new Error('390px görünümünde yatay taşma var.'); await mobile.getByRole('button', { name: 'Test hesabıyla gir' }).click(); await mobile.waitForLoadState('networkidle'); evidence.checks.mobileTenderList = await mobile.getByLabel('İhale listesi').count() === 1; if (!evidence.checks.mobileTenderList) throw new Error('390px görünümünde ihale listesi bulunamadı.'); } finally { await mobile.close(); } } finally { await page.close(); await context.close(); await browser.close(); server.kill('SIGTERM'); } await mkdir('docs/quality/evidence', { recursive: true }); await writeFile('docs/quality/evidence/qa-browser-acceptance-runtime.json', JSON.stringify(evidence, null, 2) + '\n'); console.log(JSON.stringify(evidence));