diff --git a/.gitignore b/.gitignore index a2ed6c8..9db7742 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ paper/src/auto/* lib/ docs/goals/*.md PHANTOM.wiki/ +tests/e2e/node_modules/** +**/auto/*.el +*.old diff --git a/Makefile b/Makefile index 7bf1e4e..0c51bb3 100644 --- a/Makefile +++ b/Makefile @@ -11,16 +11,15 @@ PYTEST := $(VENV)/bin/pytest .DEFAULT_GOAL := help -all: pdf - -run.webapp: - @cd web && npm install && npm run dev +.PHONY: help +help: + @echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines" $(BUILDDIR): mkdir -p paper/$(BUILDDIR) -pdf: $(BUILDDIR) - @echo "Concatenating source code..." +.PHONY: pdf.build +pdf.build: $(BUILDDIR) @bash paper/concat_code.sh @cd $(SRCDIR) && \ $(LATEXMK) -pdf -jobname=$(JOBNAME) -f \ @@ -28,32 +27,53 @@ pdf: $(BUILDDIR) -r ../.latexmkrc \ -outdir=../$(BUILDDIR) $(TEX) -watch: $(BUILDDIR) +.PHONY: pdf.watch +pdf.watch: $(BUILDDIR) @cd $(SRCDIR) && \ $(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) -f \ -interaction=nonstopmode -file-line-error \ -r ../.latexmkrc \ -outdir=../$(BUILDDIR) $(TEX) -clean: +.PHONY: pdf.clean +pdf.clean: @cd $(SRCDIR) && \ $(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true rm -rf paper/$(BUILDDIR)/* +.PHONY: test.backend +test.backend: $(VENV) + $(PYTEST) -v + +.PHONY: test.e2e +test.e2e: + @cd tests/e2e && npm install + @cd tests/e2e && npx playwright install chromium + @timeout 30 bash -c 'until curl -sf http://localhost:5000/health > /dev/null 2>&1; do sleep 1; done' || (echo "Backend not ready" && exit 1) + @timeout 30 bash -c 'until curl -sf http://localhost:3000 > /dev/null 2>&1; do sleep 1; done' || (echo "Web app not ready" && exit 1) + @cd tests/e2e && npm test + +.PHONY: test.all +test.all: test.backend test.e2e + +.PHONY: web.dev +web.dev: + @cd web && npm install && npm run dev + $(VENV): python3 -m venv $(VENV) $(PIP) install --upgrade pip +.PHONY: install install: $(VENV) $(PIP) install -r requirements.txt -test: $(VENV) - $(PYTEST) -v - -count-lines: +.PHONY: stats.lines +stats.lines: @find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \ \( -name "*.ts" -o -name "*.py" \) -type f -print0 | xargs -0 cat | wc -l +.PHONY wordcount wordcount: @echo "Counting words in main text (excluding appendix)..." @texcount -nosub -total -sum -1 \ @@ -64,4 +84,12 @@ wordcount: $(SRCDIR)/chapters/05-discussion.tex \ $(SRCDIR)/chapters/06-conclusion.tex -.PHONY: all pdf clean watch run.webapp install test wordcount + +.PHONY: pdf clean watch run.webapp test count-lines all +pdf: pdf.build +clean: pdf.clean +watch: pdf.watch +run.webapp: web.dev +test: test.backend +count-lines: stats.lines +all: pdf.build \ No newline at end of file diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..533c6ab --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1 @@ +"""E2E test suite for PHANTOM dynamic pricing pipeline.""" diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 0000000..90b787f --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,17 @@ +import { test as base } from '@playwright/test'; + +type TestFixtures = { + backendUrl: string; + pricingUrl: string; +}; + +export const test = base.extend({ + backendUrl: async ({}, use) => { + await use(process.env.BACKEND_URL || 'http://localhost:5000'); + }, + pricingUrl: async ({}, use) => { + await use(process.env.PRICING_PROVIDER_URL || 'http://localhost:5001'); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts new file mode 100644 index 0000000..d6317ad --- /dev/null +++ b/tests/e2e/helpers/api.ts @@ -0,0 +1,69 @@ +interface PriceResponse { + price: number; + base_price: number; + markup: number; + model_version?: string; +} + +export async function fetchPrice( + baseUrl: string, + productId: string, + mode: string = 'simple_surge', + sessionId?: string +): Promise { + const params = new URLSearchParams(); + if (sessionId) params.set('sessionId', sessionId); + + const url = `${baseUrl}/api/pricing?mode=${mode}&productId=${productId}&${params}`; + const resp = await fetch(url); + + if (!resp.ok) { + throw new Error(`Price fetch failed: ${resp.status}`); + } + + return resp.json(); +} + +export async function waitForPriceChange( + baseUrl: string, + productId: string, + baselinePrice: number, + mode: string, + sessionId?: string, + maxRetries: number = 10, + pollInterval: number = 500 +): Promise { + for (let i = 0; i < maxRetries; i++) { + const priceResp = await fetchPrice(baseUrl, productId, mode, sessionId); + if (Math.abs(priceResp.price - baselinePrice) > 0.01) { + return priceResp; + } + await new Promise(r => setTimeout(r, pollInterval)); + } + + throw new Error(`Price did not change after ${maxRetries} retries`); +} + +export async function ingestEvent( + baseUrl: string, + sessionId: string, + event: string, + productId?: string, + metadata?: Record +): Promise { + const resp = await fetch(`${baseUrl}/api/ingest`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId, + event, + productId, + timestamp: new Date().toISOString(), + metadata, + }), + }); + + if (!resp.ok) { + throw new Error(`Event ingest failed: ${resp.status}`); + } +} diff --git a/tests/e2e/helpers/interactions.ts b/tests/e2e/helpers/interactions.ts new file mode 100644 index 0000000..a1b99ca --- /dev/null +++ b/tests/e2e/helpers/interactions.ts @@ -0,0 +1,219 @@ +import { Page } from '@playwright/test'; + +export async function getSessionId(page: Page): Promise { + const cookies = await page.context().cookies(); + const sessionCookie = cookies.find(c => c.name === 'phantom_session_id'); + return sessionCookie?.value || null; +} + +export async function verifySessionConsistency(page: Page, expectedSessionId: string): Promise { + const currentSessionId = await getSessionId(page); + return currentSessionId === expectedSessionId; +} + +export async function createFreshSession(page: Page, storeType: 'hotel' | 'airline' = 'hotel'): Promise { + await page.context().clearCookies(); + await page.goto('/'); + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(500); + + const sid = await getSessionId(page); + if (!sid) throw new Error('Session not created'); + return sid; +} + +interface SearchParams { + destination?: string; + checkIn?: string; + guests?: number; + rooms?: number; + origin?: string; + departure?: string; + adults?: number; +} + +export async function performSearch(page: Page, params: SearchParams, storeType: 'hotel' | 'airline' = 'hotel' ): Promise { + await page.waitForLoadState('networkidle'); + + if (storeType === 'hotel') { + const destInput = page.locator('input#destination'); + await destInput.fill(params.destination || 'New York'); + + const checkInInput = page.locator('input#checkIn'); + const checkInDate = params.checkIn || new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0]; + await checkInInput.fill(checkInDate); + + const searchBtn = page.locator('button:has-text("Search Rooms")'); + await searchBtn.click(); + } else { + const originDropdown = page.locator('button:has-text("Select origin")').or( + page.locator('[id="origin"]').locator('button').first() + ); + await originDropdown.click(); + await page.waitForTimeout(200); + const originOption = page.locator(`button:has-text("${params.origin || 'JFK'}")`).first(); + await originOption.click(); + await page.waitForTimeout(200); + + const destDropdown = page.locator('button:has-text("Select destination")').or( + page.locator('[id="destination"]').locator('button').first() + ); + await destDropdown.click(); + await page.waitForTimeout(200); + const destOption = page.locator(`button:has-text("${params.destination || 'LAX'}")`).first(); + await destOption.click(); + await page.waitForTimeout(200); + + const departInput = page.locator('input#departDate'); + const departDate = params.departure || new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0]; + await departInput.fill(departDate); + + const searchBtn = page.locator('button:has-text("Search Flights")'); + await searchBtn.click(); + } + + await page.waitForLoadState('networkidle'); +} + +export async function selectRandomProduct(page: Page, storeType: 'hotel' | 'airline' = 'hotel'): Promise { + await page.waitForLoadState('networkidle'); + + const cardClass = storeType === 'hotel' ? '.hotel-card' : '.flight-card'; + const productCards = page.locator(cardClass); + + const count = await productCards.count(); + if (count === 0) throw new Error('No products found on listing page'); + + const randomIdx = Math.floor(Math.random() * count); + return randomIdx.toString(); +} + +export async function openProductFromListing(page: Page, productId?: string): Promise { + await page.waitForLoadState('networkidle'); + + const hotelCards = page.locator('.hotel-card'); + const flightCards = page.locator('.flight-card'); + + const hotelCount = await hotelCards.count(); + const flightCount = await flightCards.count(); + + let productCards; + if (hotelCount > 0) { + productCards = hotelCards; + } else if (flightCount > 0) { + productCards = flightCards; + } else { + throw new Error('No products found on listing page'); + } + + const count = await productCards.count(); + const randomIdx = productId ? 0 : Math.floor(Math.random() * count); + await productCards.nth(randomIdx).click(); + + await page.waitForLoadState('networkidle'); + + const url = page.url(); + const match = url.match(/\/products\/([^/?]+)/); + if (!match) throw new Error('Cannot parse product ID from URL after navigation'); + + return match[1]; +} + +export async function getPriceFromDOM(page: Page): Promise { + await page.waitForLoadState('networkidle'); + + await page.waitForSelector('.price-amount', { timeout: 15000 }).catch(() => null); + + const priceSelectors = [ + '.price-amount', + '.price-display', + '[data-testid="price"]', + '[data-price]', + ]; + + for (const selector of priceSelectors) { + const priceEl = page.locator(selector).first(); + if (await priceEl.count() > 0) { + const text = await priceEl.textContent(); + if (!text) continue; + + const match = text.match(/[\$]?\s*([\d,]+(?:\.\d{2})?)/); + if (match) { + const priceStr = match[1].replace(/,/g, ''); + return parseFloat(priceStr); + } + } + } + + const dataPrice = await page.locator('[data-price]').first().getAttribute('data-price').catch(() => null); + if (dataPrice) return parseFloat(dataPrice); + + throw new Error('Cannot extract price from DOM'); +} + +export async function navigateToProduct(page: Page,productId: string,storeType: 'hotel' | 'airline' = 'hotel'): Promise { + await page.goto(`/products/${productId}`); + await page.waitForLoadState('networkidle'); +} + +export async function viewProductViaFlow(page: Page, storeType: 'hotel' | 'airline' = 'hotel', searchParams?: SearchParams): Promise { + const params = new URLSearchParams(); + params.set('dateIndex', '7'); + + if (storeType === 'hotel') { + params.set('destination', searchParams?.destination || 'New York'); + params.set('adults', '2'); + params.set('rooms', '1'); + } else { + params.set('origin', searchParams?.origin || 'JFK'); + params.set('destination', searchParams?.destination || 'LAX'); + params.set('adults', '1'); + params.set('children', '0'); + params.set('infants', '0'); + } + + await page.goto(`/products?${params.toString()}`); + await page.waitForLoadState('networkidle'); + + const productId = await openProductFromListing(page); + await page.waitForTimeout(500); + return productId; +} + +export async function rapidViewProductViaFlow(page: Page, count: number, delayMs: number = 100, storeType: 'hotel' | 'airline' = 'hotel'): Promise { + const productIds: string[] = []; + + for (let i = 0; i < count; i++) { + const productId = await viewProductViaFlow(page, storeType); + productIds.push(productId); + + await page.waitForTimeout(delayMs); + } + + return productIds; +} + +export async function humanLikeViewProduct(page: Page, storeType: 'hotel' | 'airline' = 'hotel' +): Promise { + const productId = await viewProductViaFlow(page, storeType); + + await page.hover('h1'); + await page.waitForTimeout(800 + Math.random() * 400); + + await page.mouse.wheel(0, 200); + await page.waitForTimeout(500 + Math.random() * 300); + + const paragraphs = await page.locator('p').all(); + if (paragraphs.length > 0) { + await paragraphs[0].hover(); + await page.waitForTimeout(600 + Math.random() * 400); + } + + return productId; +} + +export async function addToCart(page: Page): Promise { + const addBtn = page.locator('button:has-text("Add to Cart")'); + await addBtn.click(); + await page.waitForTimeout(500); +} diff --git a/tests/e2e/helpers/kafka.ts b/tests/e2e/helpers/kafka.ts new file mode 100644 index 0000000..c0a95dd --- /dev/null +++ b/tests/e2e/helpers/kafka.ts @@ -0,0 +1,39 @@ +interface InteractionEvent { + sessionId: string; + event: string; + productId?: string; + timestamp: string; + metadata?: Record; +} + +const dumpKafkaTopic = async (backendUrl: string, topic: string) => { + const resp = await fetch(`${backendUrl}/api/kafka/dump?topic=${topic}`); + if (!resp.ok) throw new Error(`Kafka dump failed: ${resp.status}`); + const { messages = [] } = await resp.json(); + return messages as any[]; +}; + +export const waitForInteractionEvent = async ( + backendUrl: string, + sessionId: string, + eventType: string, + maxRetries = 10, + pollInterval = 500 +): Promise => { + for (let i = 0; i < maxRetries; i++) { + const msgs = await dumpKafkaTopic(backendUrl, "user-interactions"); + const hit = msgs.find(m => m.sessionId === sessionId && m.event === eventType); + if (hit) return hit as InteractionEvent; + await new Promise(r => setTimeout(r, pollInterval)); + } + return null; +}; + +export const countProductViews = async (backendUrl: string, productId: string) => + (await dumpKafkaTopic(backendUrl, "user-interactions")).reduce( + (n, m) => n + (m.productId === productId && m.event === "view_item_page" ? 1 : 0), + 0 + ); + +export const getSessionEvents = async (backendUrl: string, sessionId: string) => + (await dumpKafkaTopic(backendUrl, "user-interactions")).filter(m => m.sessionId === sessionId); diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..312c3bf --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,19 @@ +{ + "name": "e2e", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:debug": "playwright test --debug" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@playwright/test": "^1.57.0", + "@types/node": "^25.0.6", + "typescript": "^5.9.3" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000..54a5561 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './scenarios', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: 0, + workers: 5, + reporter: 'list', + use: { + baseURL: process.env.WEB_URL || 'http://localhost:3000', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + timeout: 60000, + expect: { + timeout: 10000, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/tests/e2e/scenarios/session-aware.spec.ts b/tests/e2e/scenarios/session-aware.spec.ts new file mode 100644 index 0000000..b204984 --- /dev/null +++ b/tests/e2e/scenarios/session-aware.spec.ts @@ -0,0 +1,156 @@ +import { test, expect } from '../fixtures'; +import { + createFreshSession, + viewProductViaFlow, + rapidViewProductViaFlow, + humanLikeViewProduct, + getPriceFromDOM, + verifySessionConsistency, + addToCart, +} from '../helpers/interactions'; +import { getSessionEvents } from '../helpers/kafka'; + +test.describe('SessionAwarePricer E2E', () => { + const STORE_TYPE = 'hotel'; + + test('baseline: human-like behavior maintains base price', async ({ page, backendUrl }) => { + const sessionId = await createFreshSession(page, STORE_TYPE); + + const productId1 = await humanLikeViewProduct(page, STORE_TYPE); + const baselinePrice = await getPriceFromDOM(page); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + + await page.waitForTimeout(1500); + + const productId2 = await humanLikeViewProduct(page, STORE_TYPE); + const secondPrice = await getPriceFromDOM(page); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + + expect(Math.abs(secondPrice - baselinePrice) / baselinePrice).toBeLessThan(0.1); + }); + + test('agent detection: rapid robot-like behavior increases price', async ({ page, backendUrl }) => { + const sessionId = await createFreshSession(page, STORE_TYPE); + + const productId = await viewProductViaFlow(page, STORE_TYPE); + const baselinePrice = await getPriceFromDOM(page); + + await page.waitForTimeout(500); + + await rapidViewProductViaFlow(page, 8, 100, STORE_TYPE); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + + await page.waitForTimeout(2500); + + const events = await getSessionEvents(backendUrl, sessionId); + expect(events.length).toBeGreaterThanOrEqual(8); + + await page.goto(`/products/${productId}`); + await page.waitForLoadState('networkidle'); + const agentPrice = await getPriceFromDOM(page); + + expect(agentPrice).toBeGreaterThan(baselinePrice); + expect((agentPrice - baselinePrice) / baselinePrice).toBeGreaterThan(0.01); + }); + + test('velocity threshold: high event rate triggers detection', async ({ page, backendUrl }) => { + const sessionId = await createFreshSession(page, STORE_TYPE); + + const productId = await viewProductViaFlow(page, STORE_TYPE); + const baselinePrice = await getPriceFromDOM(page); + + const startTime = Date.now(); + await rapidViewProductViaFlow(page, 10, 80, STORE_TYPE); + const duration = (Date.now() - startTime) / 1000; + + const eventsPerSec = 10 / duration; + expect(eventsPerSec).toBeGreaterThan(2.0); + + await page.waitForTimeout(2000); + + await page.goto(`/products/${productId}`); + await page.waitForLoadState('networkidle'); + const agentPrice = await getPriceFromDOM(page); + + expect(agentPrice).toBeGreaterThan(baselinePrice); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + }); + + test('cart ratio: high cart/view ratio signals intent', async ({ page, backendUrl }) => { + const sessionId = await createFreshSession(page, STORE_TYPE); + + const productId = await viewProductViaFlow(page, STORE_TYPE); + const baselinePrice = await getPriceFromDOM(page); + + await page.waitForTimeout(500); + await addToCart(page); + + await page.waitForTimeout(2000); + + await page.goto(`/products/${productId}`); + await page.waitForLoadState('networkidle'); + const cartPrice = await getPriceFromDOM(page); + + expect(cartPrice).toBeGreaterThanOrEqual(baselinePrice); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + }); + + test('mixed behavior: occasional fast actions tolerated', async ({ page, backendUrl }) => { + const sessionId = await createFreshSession(page, STORE_TYPE); + + const productId1 = await humanLikeViewProduct(page, STORE_TYPE); + const baselinePrice = await getPriceFromDOM(page); + + await page.waitForTimeout(1200); + + await rapidViewProductViaFlow(page, 2, 150, STORE_TYPE); + + await page.waitForTimeout(1500); + await humanLikeViewProduct(page, STORE_TYPE); + const finalPrice = await getPriceFromDOM(page); + + expect(Math.abs(finalPrice - baselinePrice) / baselinePrice).toBeLessThan(0.3); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + }); + + test('session isolation: agent behavior in one session does not affect others', async ({ + page, + context, + backendUrl, + }) => { + const sessionIdA = await createFreshSession(page, STORE_TYPE); + const productId = await viewProductViaFlow(page, STORE_TYPE); + const basePrice = await getPriceFromDOM(page); + + await rapidViewProductViaFlow(page, 10, 100, STORE_TYPE); + await page.waitForTimeout(2000); + + await page.goto(`/products/${productId}`); + await page.waitForLoadState('networkidle'); + const agentPrice = await getPriceFromDOM(page); + expect(agentPrice).toBeGreaterThan(basePrice * 0.99); + + const page2 = await context.newPage(); + const sessionIdB = await createFreshSession(page2, STORE_TYPE); + + await page2.goto(`/products/${productId}`); + await page2.waitForLoadState('networkidle'); + const cleanPrice = await getPriceFromDOM(page2); + + expect(Math.abs(cleanPrice - basePrice) / basePrice).toBeLessThan(0.1); + expect(sessionIdA).not.toBe(sessionIdB); + }); + + test('session persistence: session ID maintained across views', async ({ page }) => { + const sessionId = await createFreshSession(page, STORE_TYPE); + + await viewProductViaFlow(page, STORE_TYPE); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + + await viewProductViaFlow(page, STORE_TYPE); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + + await viewProductViaFlow(page, STORE_TYPE); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + }); +}); diff --git a/tests/e2e/scenarios/surge-pricing.spec.ts b/tests/e2e/scenarios/surge-pricing.spec.ts new file mode 100644 index 0000000..e3e2f8d --- /dev/null +++ b/tests/e2e/scenarios/surge-pricing.spec.ts @@ -0,0 +1,111 @@ +import { test, expect } from '../fixtures'; +import { + createFreshSession, + viewProductViaFlow, + rapidViewProductViaFlow, + getPriceFromDOM, + verifySessionConsistency, +} from '../helpers/interactions'; +import { waitForInteractionEvent, countProductViews } from '../helpers/kafka'; + +test.describe('SimpleSurgePricer E2E', () => { + const STORE_TYPE = 'hotel'; + + test('baseline: initial price equals base price', async ({ page, backendUrl }) => { + const sessionId = await createFreshSession(page, STORE_TYPE); + + const productId = await viewProductViaFlow(page, STORE_TYPE); + const price = await getPriceFromDOM(page); + + expect(price).toBeGreaterThan(0); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + }); + + test('surge: rapid views trigger price increase', async ({ page, backendUrl }) => { + const sessionId = await createFreshSession(page, STORE_TYPE); + + const productId = await viewProductViaFlow(page, STORE_TYPE); + const baselinePrice = await getPriceFromDOM(page); + + await rapidViewProductViaFlow(page, 5, 200, STORE_TYPE); + + await page.waitForTimeout(2000); + + const evt = await waitForInteractionEvent(backendUrl, sessionId, 'view_item_page'); + expect(evt).not.toBeNull(); + + const viewCount = await countProductViews(backendUrl, productId); + expect(viewCount).toBeGreaterThanOrEqual(5); + + await page.goto(`/products/${productId}`); + await page.waitForLoadState('networkidle'); + const surgedPrice = await getPriceFromDOM(page); + + expect(surgedPrice).toBeGreaterThan(baselinePrice); + expect((surgedPrice - baselinePrice) / baselinePrice).toBeGreaterThan(0.01); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + }); + + test('threshold: price unchanged below threshold', async ({ page, backendUrl }) => { + const sessionId = await createFreshSession(page, STORE_TYPE); + + const productId = await viewProductViaFlow(page, STORE_TYPE); + const baselinePrice = await getPriceFromDOM(page); + + await rapidViewProductViaFlow(page, 2, 300, STORE_TYPE); + + await page.waitForTimeout(1500); + + await page.goto(`/products/${productId}`); + await page.waitForLoadState('networkidle'); + const currentPrice = await getPriceFromDOM(page); + + expect(Math.abs(currentPrice - baselinePrice) / baselinePrice).toBeLessThan(0.05); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + }); + + test('window: surge decays after window expires', async ({ page, backendUrl }) => { + const sessionId = await createFreshSession(page, STORE_TYPE); + + const productId = await viewProductViaFlow(page, STORE_TYPE); + const baselinePrice = await getPriceFromDOM(page); + + await rapidViewProductViaFlow(page, 5, 150, STORE_TYPE); + + await page.waitForTimeout(1500); + + await page.goto(`/products/${productId}`); + await page.waitForLoadState('networkidle'); + const surgedPrice = await getPriceFromDOM(page); + expect(surgedPrice).toBeGreaterThan(baselinePrice); + + await page.waitForTimeout(12000); + + await page.goto(`/products/${productId}`); + await page.waitForLoadState('networkidle'); + const decayedPrice = await getPriceFromDOM(page); + expect(decayedPrice).toBeLessThan(surgedPrice); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + }); + + test('isolation: different products have independent surge', async ({ page, backendUrl }) => { + const sessionId = await createFreshSession(page, STORE_TYPE); + + const productIdA = await viewProductViaFlow(page, STORE_TYPE); + const basePriceA = await getPriceFromDOM(page); + + await rapidViewProductViaFlow(page, 5, 200, STORE_TYPE); + await page.waitForTimeout(2000); + + await page.goto(`/products/${productIdA}`); + await page.waitForLoadState('networkidle'); + const surgedPriceA = await getPriceFromDOM(page); + + const productIdB = await viewProductViaFlow(page, STORE_TYPE); + const priceB = await getPriceFromDOM(page); + + expect(surgedPriceA).toBeGreaterThan(basePriceA * 0.99); + expect(productIdA).not.toBe(productIdB); + expect(await verifySessionConsistency(page, sessionId)).toBeTruthy(); + }); +}); diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000..1107d1c --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node", "@playwright/test"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +}