openobserve/tests/ui-testing/playwright-tests/utils/global-setup-alpha1.js

720 lines
30 KiB
JavaScript

const { chromium } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
const testLogger = require('./test-logger.js');
const logsdata = require('../../../test-data/logs_data.json');
// Auth storage paths. Filenames stay canonical (user.json / cloud-config.json)
// because ~20 spec files and shared utils (cloud-auth.js, enhanced-baseFixtures.js)
// read these exact paths. Multi-user splitting (ALPHA1_USER_INDEX, 1|2|3) is
// achieved at the CI layer instead: each shard runs on its own runner and
// downloads only ITS user's artifact into this dir, so the canonical file always
// holds the right user's session. USER_INDEX here only selects which Dex user to
// log in as (email resolution below).
const USER_INDEX = (process.env.ALPHA1_USER_INDEX || '1').trim();
const AUTH_DIR = path.join(__dirname, 'auth');
const AUTH_FILE = path.join(AUTH_DIR, 'user.json');
const CLOUD_CONFIG_FILE = path.join(AUTH_DIR, 'cloud-config.json');
/**
* Global setup for Alpha1 cloud tests
* Handles Dex "Continue with Email" login flow with retry logic:
* alpha.o2aks1.internal.zinclabs.dev → Dex → email/password → approval → redirect back
*
* Retries up to 3 times with fresh browser per attempt and stagger delay
* to handle Dex concurrency when 13 parallel shards login simultaneously.
*/
async function globalSetup() {
testLogger.info('[alpha1] Starting global setup - Dex email login');
if (!fs.existsSync(AUTH_DIR)) {
fs.mkdirSync(AUTH_DIR, { recursive: true });
}
const baseUrl = (process.env.ZO_BASE_URL || '').replace(/\/$/, '');
if (!baseUrl) {
throw new Error('ZO_BASE_URL must be set');
}
// Resolve this shard's Dex user by index: ALPHA1_USER_EMAIL_<N> when provided,
// else fall back to the base ALPHA1_USER_EMAIL. This keeps the workflow safe to
// roll out incrementally — if _2/_3 aren't set yet, every shard just uses user 1.
// Password is shared across all users (single ALPHA1_USER_PASSWORD secret).
const userEmail = (process.env[`ALPHA1_USER_EMAIL_${USER_INDEX}`]
|| process.env.ALPHA1_USER_EMAIL || '').trim();
const userPassword = (process.env.ALPHA1_USER_PASSWORD || '').trim();
if (!userEmail || !userPassword) {
throw new Error('ALPHA1_USER_EMAIL and ALPHA1_USER_PASSWORD must be set');
}
testLogger.info(`[alpha1] Using Dex user index ${USER_INDEX} (${userEmail})`);
// Check if shared auth state exists (downloaded from cleanup job artifact)
// If valid, skip the entire Dex login flow — just verify and ingest
if (fs.existsSync(AUTH_FILE) && fs.existsSync(CLOUD_CONFIG_FILE)) {
testLogger.info('[alpha1] Found shared auth state from cleanup job — skipping Dex login');
try {
const valid = await verifySharedAuth(baseUrl);
if (valid) {
testLogger.info('[alpha1] Shared auth state is valid');
// Still need to do ingestion for shard-specific streams
const specFiles = process.argv.filter(arg => /\.spec\.(js|ts)$/.test(arg));
const isCleanupOnly = specFiles.length === 1 && /cleanup\.spec\.(js|ts)$/.test(specFiles[0]);
if (!isCleanupOnly && process.env.SKIP_INGESTION !== 'true') {
await performGlobalIngestionWithFetch();
}
testLogger.info('[alpha1] Global setup completed successfully (shared auth)');
return;
}
testLogger.warn('[alpha1] Shared auth state invalid — falling back to Dex login');
} catch (e) {
testLogger.warn(`[alpha1] Shared auth verification failed: ${e.message} — falling back to Dex login`);
}
}
// Fallback: perform Dex login with retries
const maxAttempts = 3;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const staggerMs = attempt === 1
? Math.floor(Math.random() * 15000)
: 5000 + Math.floor(Math.random() * 10000);
testLogger.info(`[alpha1] Login attempt ${attempt}/${maxAttempts} (stagger: ${staggerMs}ms)`);
await new Promise(r => setTimeout(r, staggerMs));
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: 1500, height: 1024 },
});
const page = await context.newPage();
page.on('framenavigated', (frame) => {
if (frame === page.mainFrame()) {
testLogger.debug(`[alpha1] [nav] ${frame.url()}`);
}
});
try {
await performDexLogin(page, baseUrl, userEmail, userPassword);
// Switch to target org via UI dropdown — URL ?org_identifier=... alone
// does NOT update the Pinia store, so API calls keep using the user's
// default org. The dropdown click triggers the proper store update,
// and the active org is then persisted in cookies/localStorage which
// storageState captures below.
const targetOrg = process.env.ORGNAME;
if (targetOrg && targetOrg !== 'default') {
await switchOrgViaDropdown(page, targetOrg);
}
await context.storageState({ path: AUTH_FILE });
testLogger.info(`[alpha1] Auth state saved to ${AUTH_FILE}`);
await fetchCloudConfig(page);
const specFiles = process.argv.filter(arg => /\.spec\.(js|ts)$/.test(arg));
const isCleanupOnly = specFiles.length === 1 && /cleanup\.spec\.(js|ts)$/.test(specFiles[0]);
if (isCleanupOnly || process.env.SKIP_INGESTION === 'true') {
testLogger.info('[alpha1] Skipping data ingestion (cleanup-only run or SKIP_INGESTION=true)');
} else {
await performGlobalIngestion(page);
}
testLogger.info('[alpha1] Global setup completed successfully');
return;
} catch (error) {
lastError = error;
const debugDir = path.join(__dirname, '..', '..', 'test-results');
if (!fs.existsSync(debugDir)) {
fs.mkdirSync(debugDir, { recursive: true });
}
const screenshotPath = path.join(debugDir, `debug-alpha1-login-attempt${attempt}.png`);
await page.screenshot({ path: screenshotPath }).catch(() => {});
if (attempt < maxAttempts) {
testLogger.warn(`[alpha1] Attempt ${attempt} failed: ${error.message}. Retrying...`);
} else {
testLogger.error(`[alpha1] All ${maxAttempts} login attempts failed.`);
testLogger.error(`[alpha1] Final error: ${error.message}`);
if (fs.existsSync(AUTH_FILE)) {
fs.unlinkSync(AUTH_FILE);
}
}
} finally {
await context.close();
await browser.close();
}
}
throw lastError;
}
/**
* Perform Dex OIDC login flow.
* Navigates to app → Dex → fills credentials → handles approval → verifies login.
*/
async function performDexLogin(page, baseUrl, userEmail, userPassword) {
// Step 1: Navigate to alpha1 — this redirects to Dex login page
const loginUrl = `${baseUrl}/web/`;
testLogger.info(`[alpha1] Navigating to ${loginUrl}`);
await page.goto(loginUrl, { timeout: 60000 });
await page.waitForLoadState('domcontentloaded');
testLogger.info(`[alpha1] Current URL: ${page.url()}`);
// Check if already logged in (valid session from previous attempt or shared cookies)
// If the main menu is visible, skip the entire Dex flow
const menuItem = page.locator('[data-test="menu-link-\\/-item"]');
const alreadyLoggedIn = await menuItem.isVisible().catch(() => false);
if (alreadyLoggedIn) {
testLogger.info('[alpha1] Already logged in — skipping Dex flow');
return;
}
// Step 2: Click "Continue with Email" on the Dex page
// The page may be at /web/login (app login page with Dex button) or already on Dex
const continueWithEmail = page.getByText('Continue with Email');
await continueWithEmail.waitFor({ state: 'visible', timeout: 20000 });
testLogger.info('[alpha1] Clicking "Continue with Email"...');
await continueWithEmail.click();
await page.waitForLoadState('domcontentloaded');
testLogger.info(`[alpha1] On Dex local login page: ${page.url()}`);
// Step 3: Fill in the Dex local login form
const emailField = page.locator(
'input[name="login"], input[name="email"], input[name="username"], ' +
'input[type="email"], input[type="text"][id="login"]'
);
const passwordField = page.locator(
'input[name="password"], input[type="password"]'
);
await emailField.first().waitFor({ state: 'visible', timeout: 10000 });
await emailField.first().fill(userEmail);
testLogger.info('[alpha1] Email entered');
// Handle multi-step Dex login: if password field not immediately visible,
// click Continue/Next to advance to the password step
const passwordVisible = await passwordField.first().isVisible().catch(() => false);
if (!passwordVisible) {
const continueBtn = page.locator('button:has-text("Continue"), button:has-text("Next")').first();
const continueBtnVisible = await continueBtn.isVisible().catch(() => false);
if (continueBtnVisible) {
testLogger.info('[alpha1] Clicking Continue to advance to password step...');
await continueBtn.click();
await page.waitForLoadState('domcontentloaded');
}
}
await passwordField.first().waitFor({ state: 'visible', timeout: 10000 });
await passwordField.first().fill(userPassword);
testLogger.info('[alpha1] Password entered');
// Step 4: Submit the login form
await submitDexLoginForm(page);
// Step 5: Wait for redirect back to alpha1
// AKS env routes through /config/redirect (OAuth callback) before reaching /web/
await Promise.race([
page.waitForURL(/web\/|config\/redirect|dex\/approval|dex\/auth.*error/, { timeout: 60000 }),
page.locator('.flash-error, .alert, [class*="error"]').first()
.waitFor({ state: 'visible', timeout: 60000 })
.then(() => { throw new Error('Dex login error: invalid credentials or server error'); }),
]);
// If landed on OAuth callback URL, wait for SPA to process and redirect to /web/
if (page.url().includes('config/redirect')) {
testLogger.info('[alpha1] On OAuth callback URL, waiting for SPA redirect to /web/...');
await page.waitForURL(/web\//, { timeout: 60000 });
}
// Check if login failed (Dex error page)
if (/dex\/.*error/.test(page.url())) {
throw new Error(`Login failed — Dex returned error page: ${page.url()}`);
}
testLogger.info(`[alpha1] After submit navigation: ${page.url()}`);
// Step 6: Check if we're on a Dex approval page
const currentUrl = page.url();
if (currentUrl.includes('dex/approval') || currentUrl.includes('dex/auth')) {
testLogger.info('[alpha1] On Dex approval/auth page, looking for grant button...');
const grantButton = page.locator(
'button:has-text("Grant Access"), button:has-text("Approve"), button[value="approve"], ' +
'input[type="submit"][value="Approve"], button[type="submit"]'
);
try {
await grantButton.first().waitFor({ state: 'visible', timeout: 5000 });
testLogger.info('[alpha1] Found grant/approve button, clicking...');
await grantButton.first().click();
await page.waitForURL(
url => !url.toString().includes('dex'),
{ timeout: 15000 }
);
testLogger.info(`[alpha1] After grant approval: ${page.url()}`);
} catch (e) {
if (e.name === 'TimeoutError') {
testLogger.info('[alpha1] No grant button found, continuing...');
} else {
throw e;
}
}
}
// Step 7: Wait for the app to fully load
await page.waitForLoadState('domcontentloaded');
testLogger.info(`[alpha1] After settling: ${page.url()}`);
// If still on Dex, wait longer for the redirect
if (page.url().includes('dex')) {
testLogger.info('[alpha1] Still on Dex, waiting for redirect...');
await page.waitForURL(
url => !url.toString().includes('dex'),
{ timeout: 30000 }
);
}
// If on callback URL (/web/cb#id_token=...), the SPA needs time to process
// the token from the hash before the session is valid.
if (page.url().includes('/cb#') || page.url().includes('/cb?')) {
testLogger.info('[alpha1] On callback URL — waiting for SPA token processing...');
try {
await page.waitForURL(
url => !url.toString().includes('/cb'),
{ timeout: 90000 }
);
testLogger.info(`[alpha1] Callback processed, now at: ${page.url()}`);
} catch (e) {
// Reload the current page (preserves the hash fragment with the id_token)
// so the SPA gets another chance to process the token.
// page.goto('/web/') would lose the hash and the session.
testLogger.warn('[alpha1] Callback did not auto-redirect after 90s, reloading to retry...');
await page.reload({ waitUntil: 'domcontentloaded' });
// Give SPA another 30s to process after reload
try {
await page.waitForURL(
url => !url.toString().includes('/cb'),
{ timeout: 30000 }
);
testLogger.info(`[alpha1] Callback processed after reload, now at: ${page.url()}`);
} catch (e2) {
testLogger.warn('[alpha1] Callback still stuck after reload — session likely lost');
}
}
}
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch((e) =>
testLogger.warn('[alpha1] networkidle timeout:', { error: e.message })
);
// Step 8: Verify login success
testLogger.info(`[alpha1] Verifying login at: ${page.url()}`);
if (page.url().includes('dex')) {
throw new Error(`Login failed — redirected back to Dex after token exchange: ${page.url()}`);
}
await page.locator('[data-test="menu-link-\\/-item"]').waitFor({ state: 'visible', timeout: 15000 });
testLogger.info('[alpha1] Login successful — main menu visible');
}
/**
* Submit the Dex login form with multiple strategies.
* Tries: 1) Enter on password field, 2) force-click submit button, 3) form.submit() via JS
*
* Enter on password field is tried first because it's the most reliable —
* the cursor is already in the password field after fill(), and pressing Enter
* on a focused input submits the containing form.
*/
async function submitDexLoginForm(page) {
// Strategy 1: Press Enter on the password field (cursor is there after fill)
// passwordField.press('Enter') focuses the element first, unlike page.keyboard.press
try {
const pwField = page.locator('input[name="password"], input[type="password"]').first();
await pwField.press('Enter');
testLogger.info('[alpha1] Form submitted via Enter on password field');
return;
} catch (e) {
testLogger.warn('[alpha1] Enter on password field failed, trying button click...');
}
// Strategy 2: Force-click the submit button
try {
const submitButton = page.locator(
'button[type="submit"], form button:has-text("Login"), form button:has-text("Sign In"), form button:has-text("Log In")'
).first();
await submitButton.click({ force: true, timeout: 5000 });
testLogger.info('[alpha1] Form submitted via button click');
return;
} catch (e) {
testLogger.warn('[alpha1] Button click failed, trying JS form.submit()...');
}
// Strategy 3: Submit via JavaScript
await page.evaluate(() => {
const form = document.querySelector('form');
if (!form) throw new Error('No form found on Dex login page');
form.submit();
});
testLogger.info('[alpha1] Form submitted via JS form.submit()');
}
/**
* Switch the active org via the navbar dropdown — necessary because the
* Pinia store binds API calls to whatever org was loaded at login. Setting
* ?org_identifier=... in the URL does NOT update the store; only the
* dropdown click flow does.
*
* @param {import('@playwright/test').Page} page
* @param {string} targetOrgId org identifier from ORGNAME env (e.g. 3B4JlN…)
*/
async function switchOrgViaDropdown(page, targetOrgId) {
testLogger.info(`[alpha1] Switching active org to: ${targetOrgId}`);
// Land on /web/ so the navbar (and its org dropdown) renders
const baseUrl = (process.env.ZO_BASE_URL || '').replace(/\/$/, '');
await page.goto(`${baseUrl}/web/`, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.locator('[data-test="menu-link-\\/-item"]').waitFor({ state: 'visible', timeout: 20000 });
await page.waitForTimeout(2000); // navbar dropdown needs SPA hydration
// Map identifier → org name (dropdown options are by name, not identifier)
const orgs = await page.evaluate(async () => {
const r = await fetch('/api/organizations?page_num=0&page_size=100');
return r.ok ? (await r.json()).data : null;
});
if (!orgs || !orgs.length) {
throw new Error('[alpha1] /api/organizations returned no data — cannot resolve target org');
}
const target = orgs.find(o => o.identifier === targetOrgId);
if (!target) {
const available = orgs.map(o => `${o.name} (${o.identifier})`).join(', ');
throw new Error(`[alpha1] Target org ${targetOrgId} not found. Available: ${available}`);
}
testLogger.info(`[alpha1] Target org resolved: ${target.name} (${target.identifier})`);
// Open dropdown → search-filter → click menu item
// (search-input + menu-item-label is more reliable than role=option matching)
// Post-UX-revamp: the org selector opens via a dedicated trigger button
// ([data-test="navbar-organizations-select-trigger"]); the old inline
// 'arrow_drop_down' text no longer exists (now an SVG icon).
const dropdown = page.locator('[data-test="navbar-organizations-select-trigger"]');
await dropdown.waitFor({ state: 'visible', timeout: 15000 });
await dropdown.click();
await page.waitForTimeout(1500);
// Post-UX-revamp: data-test moved to the OSearchInput wrapper <div>;
// the real editable element is the nested native <input>.
const searchInput = page.locator('[data-test="organization-search-input"] input');
await searchInput.waitFor({ state: 'visible', timeout: 10000 });
await searchInput.fill(target.name);
await page.waitForTimeout(1500);
// Post-UX-revamp: each org row carries its identifier on a
// data-test-org-identifier attribute — match that exactly instead of
// parsing "name | id" row text (whose format changed in the revamp).
const menuItem = page
.locator(`[data-test-org-identifier="${targetOrgId}"]`)
.first();
await menuItem.waitFor({ state: 'visible', timeout: 5000 });
await menuItem.click();
// Wait for URL to reflect new org (Vue router updates on store change)
await page.waitForFunction(
(orgId) => new URL(location.href).searchParams.get('org_identifier') === orgId,
targetOrgId,
{ timeout: 15000 },
).catch(() => testLogger.warn('[alpha1] URL did not reflect new org after switch'));
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
testLogger.info(`[alpha1] Org switch complete; current URL: ${page.url()}`);
}
/**
* Fetch org identifier and passcode, save to cloud-config.json.
*/
async function fetchCloudConfig(page) {
testLogger.info('[alpha1] Fetching org identifier and passcode...');
try {
const orgsResponse = await page.evaluate(async () => {
const r = await fetch('/api/organizations?page_num=0&page_size=100');
return r.ok ? await r.json() : null;
});
if (orgsResponse && orgsResponse.data && orgsResponse.data.length > 0) {
// Use ORGNAME from env/yml to find the correct org, fall back to first non-_meta
const envOrgId = process.env.ORGNAME;
let org;
if (envOrgId && envOrgId !== 'default') {
org = orgsResponse.data.find(o => o.identifier === envOrgId);
if (org) {
testLogger.info(`[alpha1] Matched org from ORGNAME env: ${org.name} (${org.identifier})`);
} else {
testLogger.warn(`[alpha1] ORGNAME "${envOrgId}" not found in user orgs, falling back to first`);
}
}
if (!org) {
org = orgsResponse.data.find(o => o.identifier !== '_meta') || orgsResponse.data[0];
}
const orgIdentifier = org.identifier;
testLogger.info(`[alpha1] User org: ${org.name} (${orgIdentifier})`);
const passcodeResponse = await page.evaluate(async (orgId) => {
const r = await fetch('/api/' + orgId + '/passcode');
return r.ok ? await r.json() : null;
}, orgIdentifier);
if (passcodeResponse && passcodeResponse.data) {
const cloudConfig = {
orgIdentifier,
orgName: org.name,
userEmail: passcodeResponse.data.user,
passcode: passcodeResponse.data.passcode,
};
fs.writeFileSync(CLOUD_CONFIG_FILE, JSON.stringify(cloudConfig, null, 2));
testLogger.info(`[alpha1] Cloud config saved to ${CLOUD_CONFIG_FILE}`);
} else {
testLogger.warn('[alpha1] Could not fetch passcode');
}
} else {
testLogger.warn('[alpha1] Could not fetch organizations');
}
} catch (e) {
testLogger.warn('[alpha1] Failed to fetch cloud config', { error: e.message });
}
}
/**
* Ingest test data into streams so they appear in UI dropdowns.
* Uses cloud auth (email:passcode) from cloud-config.json.
*/
async function performGlobalIngestion(page) {
let headers;
let orgId;
try {
const cloudConfig = JSON.parse(fs.readFileSync(CLOUD_CONFIG_FILE, 'utf-8'));
// Ingest into THIS shard's org (ORGNAME) rather than the org baked into
// cloud-config.json by the shared-auth barrier. The passcode is user-level
// (identity), so it authorizes any org the user is a member of — only the
// target org differs per shard. Enables per-shard org isolation.
orgId = process.env.ORGNAME || cloudConfig.orgIdentifier;
const basicAuth = Buffer.from(`${cloudConfig.userEmail}:${cloudConfig.passcode}`).toString('base64');
headers = {
'Authorization': `Basic ${basicAuth}`,
'Content-Type': 'application/json',
};
} catch (e) {
testLogger.warn('[alpha1] Cannot read cloud-config.json for ingestion — skipping', { error: e.message });
return;
}
// Only e2e_automate is pre-provisioned as a shared fixture. The Alerts ui-operations
// tests that used to rely on a shared 'auto_playwright_stream' now self-ingest their
// own unique per-run streams, so we no longer pre-create (or wait on) that stream —
// on the shared cloud org it was routinely stuck "being deleted" by other branch runs.
const streams = [
{ name: 'e2e_automate', data: logsdata },
];
for (const stream of streams) {
try {
const response = await page.evaluate(async ({ headers, orgId, streamName, data }) => {
const r = await fetch(`/api/${orgId}/${streamName}/_json`, {
method: 'POST',
headers,
body: JSON.stringify(data),
});
return { status: r.status, text: await r.text() };
}, { headers, orgId, streamName: stream.name, data: stream.data });
if (response.status === 200) {
testLogger.info(`[alpha1] Ingested test data into '${stream.name}'`, { status: response.status });
} else {
testLogger.warn(`[alpha1] Ingestion into '${stream.name}' returned ${response.status}`, { body: response.text.substring(0, 200) });
}
} catch (e) {
testLogger.warn(`[alpha1] Failed to ingest into '${stream.name}'`, { error: e.message });
}
}
// Wait for streams to be indexed
testLogger.info('[alpha1] Waiting for streams to be indexed...');
const maxWaitMs = 90000;
const pollIntervalMs = 3000;
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
try {
const streamsResult = await page.evaluate(async ({ orgId, headers }) => {
const r = await fetch(`/api/${orgId}/streams?type=logs&page_num=0&page_size=1000`, { headers });
if (!r.ok) return { ok: false, status: r.status };
const data = await r.json();
const names = (data.list || []).map(s => s.name);
return { ok: true, names };
}, { orgId, headers });
if (streamsResult.ok) {
const hasE2e = streamsResult.names.includes('e2e_automate');
if (hasE2e) {
testLogger.info(`[alpha1] e2e_automate indexed after ${Date.now() - startTime}ms`);
break;
}
testLogger.debug(`[alpha1] e2e_automate not yet indexed, waiting...`);
} else {
testLogger.debug(`[alpha1] Streams API returned ${streamsResult.status}, retrying...`);
}
} catch (e) {
testLogger.debug(`[alpha1] Error checking streams: ${e.message}`);
}
await page.waitForTimeout(pollIntervalMs);
}
if (Date.now() - startTime >= maxWaitMs) {
testLogger.warn(`[alpha1] Streams not fully indexed after ${maxWaitMs}ms — tests may fail on stream selection`);
}
}
/**
* Verify that shared auth state (from cleanup job artifact) is still valid.
* Opens a browser with the storageState and checks if the session works.
*/
async function verifySharedAuth(baseUrl) {
const browser = await chromium.launch();
const context = await browser.newContext({
storageState: AUTH_FILE,
viewport: { width: 1500, height: 1024 },
});
const page = await context.newPage();
try {
// Navigate with org_identifier so the SPA loads the target org and the
// URL reflects it — without this, page.url() never has the param and
// the active-org check below would always trigger a needless re-switch.
const targetOrg = process.env.ORGNAME;
const navUrl = (targetOrg && targetOrg !== 'default')
? `${baseUrl}/web/?org_identifier=${encodeURIComponent(targetOrg)}`
: `${baseUrl}/web/`;
await page.goto(navUrl, { timeout: 60000, waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {});
// If we ended up on Dex or login page, the session is invalid
const url = page.url();
if (url.includes('dex') || url.endsWith('/web/login')) {
testLogger.warn(`[alpha1] Shared auth redirected to login: ${url}`);
return false;
}
// Verify the main menu is visible
const menuItem = page.locator('[data-test="menu-link-\\/-item"]');
await menuItem.waitFor({ state: 'visible', timeout: 15000 });
testLogger.info('[alpha1] Shared auth verified — menu visible');
// Note: no dropdown org-switch needed here. Each test pins its own org by
// navigating to /web/?org_identifier=<ORGNAME> (see navigateToBase), which the
// app honours on a fresh page load. Forcing a dropdown switch here is both
// redundant and flaky (the virtualized selector under load), so we rely on the
// per-test URL instead. The passcode below is still fetched per shard org.
// Re-fetch THIS shard's own org passcode via the session. The downloaded
// cloud-config.json holds the shared-auth barrier org's ingestion token,
// which 401s against any other org — each org has its own o2oi_ default
// ingestion token (see core/organization.rs get_passcode). Overwrite
// cloud-config.json with ORGNAME's passcode so per-shard ingestion succeeds.
await fetchCloudConfig(page);
return true;
} catch (e) {
testLogger.warn(`[alpha1] Shared auth verification error: ${e.message}`);
return false;
} finally {
await context.close();
await browser.close();
}
}
/**
* Perform data ingestion using Node.js fetch (no browser needed).
* Used when shared auth state is available — avoids opening a browser page.
*/
async function performGlobalIngestionWithFetch() {
let headers;
let orgId;
let baseUrl;
try {
const cloudConfig = JSON.parse(fs.readFileSync(CLOUD_CONFIG_FILE, 'utf-8'));
// Ingest into THIS shard's org (ORGNAME) rather than the org baked into
// cloud-config.json by the shared-auth barrier. The passcode is user-level
// (identity), so it authorizes any org the user is a member of — only the
// target org differs per shard. Enables per-shard org isolation.
orgId = process.env.ORGNAME || cloudConfig.orgIdentifier;
const basicAuth = Buffer.from(`${cloudConfig.userEmail}:${cloudConfig.passcode}`).toString('base64');
headers = {
'Authorization': `Basic ${basicAuth}`,
'Content-Type': 'application/json',
};
baseUrl = (process.env.ZO_BASE_URL || '').replace(/\/$/, '');
} catch (e) {
testLogger.warn('[alpha1] Cannot read cloud-config.json for ingestion — skipping', { error: e.message });
return;
}
// Only e2e_automate is pre-provisioned as a shared fixture. The Alerts ui-operations
// tests that used to rely on a shared 'auto_playwright_stream' now self-ingest their
// own unique per-run streams, so we no longer pre-create (or wait on) that stream —
// on the shared cloud org it was routinely stuck "being deleted" by other branch runs.
const streams = [
{ name: 'e2e_automate', data: logsdata },
];
for (const stream of streams) {
try {
const r = await fetch(`${baseUrl}/api/${orgId}/${stream.name}/_json`, {
method: 'POST',
headers,
body: JSON.stringify(stream.data),
});
if (r.ok) {
testLogger.info(`[alpha1] Ingested test data into '${stream.name}'`, { status: r.status });
} else {
const text = await r.text();
testLogger.warn(`[alpha1] Ingestion into '${stream.name}' returned ${r.status}`, { body: text.substring(0, 200) });
}
} catch (e) {
testLogger.warn(`[alpha1] Failed to ingest into '${stream.name}'`, { error: e.message });
}
}
// Wait for streams to be indexed
testLogger.info('[alpha1] Waiting for streams to be indexed...');
const maxWaitMs = 90000;
const pollIntervalMs = 3000;
const startTime = Date.now();
while (Date.now() - startTime < maxWaitMs) {
try {
const r = await fetch(`${baseUrl}/api/${orgId}/streams?type=logs&page_num=0&page_size=1000`, { headers });
if (r.ok) {
const data = await r.json();
const names = (data.list || []).map(s => s.name);
if (names.includes('e2e_automate')) {
testLogger.info(`[alpha1] e2e_automate indexed after ${Date.now() - startTime}ms`);
return;
}
}
} catch (e) {
testLogger.debug(`[alpha1] Error checking streams: ${e.message}`);
}
await new Promise(r => setTimeout(r, pollIntervalMs));
}
testLogger.warn(`[alpha1] Streams not fully indexed after ${maxWaitMs}ms — tests may fail on stream selection`);
}
module.exports = globalSetup;