mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 16:43:36 +00:00
Merge branch 'main' into paper-first-fillout
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -17,3 +17,6 @@ paper/src/auto/*
|
|||||||
lib/
|
lib/
|
||||||
docs/goals/*.md
|
docs/goals/*.md
|
||||||
PHANTOM.wiki/
|
PHANTOM.wiki/
|
||||||
|
tests/e2e/node_modules/**
|
||||||
|
**/auto/*.el
|
||||||
|
*.old
|
||||||
|
|||||||
54
Makefile
54
Makefile
@@ -11,16 +11,15 @@ PYTEST := $(VENV)/bin/pytest
|
|||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
all: pdf
|
.PHONY: help
|
||||||
|
help:
|
||||||
run.webapp:
|
@echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines"
|
||||||
@cd web && npm install && npm run dev
|
|
||||||
|
|
||||||
$(BUILDDIR):
|
$(BUILDDIR):
|
||||||
mkdir -p paper/$(BUILDDIR)
|
mkdir -p paper/$(BUILDDIR)
|
||||||
|
|
||||||
pdf: $(BUILDDIR)
|
.PHONY: pdf.build
|
||||||
@echo "Concatenating source code..."
|
pdf.build: $(BUILDDIR)
|
||||||
@bash paper/concat_code.sh
|
@bash paper/concat_code.sh
|
||||||
@cd $(SRCDIR) && \
|
@cd $(SRCDIR) && \
|
||||||
$(LATEXMK) -pdf -jobname=$(JOBNAME) -f \
|
$(LATEXMK) -pdf -jobname=$(JOBNAME) -f \
|
||||||
@@ -28,32 +27,53 @@ pdf: $(BUILDDIR)
|
|||||||
-r ../.latexmkrc \
|
-r ../.latexmkrc \
|
||||||
-outdir=../$(BUILDDIR) $(TEX)
|
-outdir=../$(BUILDDIR) $(TEX)
|
||||||
|
|
||||||
watch: $(BUILDDIR)
|
.PHONY: pdf.watch
|
||||||
|
pdf.watch: $(BUILDDIR)
|
||||||
@cd $(SRCDIR) && \
|
@cd $(SRCDIR) && \
|
||||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) -f \
|
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) -f \
|
||||||
-interaction=nonstopmode -file-line-error \
|
-interaction=nonstopmode -file-line-error \
|
||||||
-r ../.latexmkrc \
|
-r ../.latexmkrc \
|
||||||
-outdir=../$(BUILDDIR) $(TEX)
|
-outdir=../$(BUILDDIR) $(TEX)
|
||||||
|
|
||||||
clean:
|
.PHONY: pdf.clean
|
||||||
|
pdf.clean:
|
||||||
@cd $(SRCDIR) && \
|
@cd $(SRCDIR) && \
|
||||||
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
||||||
rm -rf paper/$(BUILDDIR)/*
|
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):
|
$(VENV):
|
||||||
python3 -m venv $(VENV)
|
python3 -m venv $(VENV)
|
||||||
$(PIP) install --upgrade pip
|
$(PIP) install --upgrade pip
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
install: $(VENV)
|
install: $(VENV)
|
||||||
$(PIP) install -r requirements.txt
|
$(PIP) install -r requirements.txt
|
||||||
|
|
||||||
test: $(VENV)
|
.PHONY: stats.lines
|
||||||
$(PYTEST) -v
|
stats.lines:
|
||||||
|
|
||||||
count-lines:
|
|
||||||
@find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \
|
@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
|
\( -name "*.ts" -o -name "*.py" \) -type f -print0 | xargs -0 cat | wc -l
|
||||||
|
|
||||||
|
.PHONY wordcount
|
||||||
wordcount:
|
wordcount:
|
||||||
@echo "Counting words in main text (excluding appendix)..."
|
@echo "Counting words in main text (excluding appendix)..."
|
||||||
@texcount -nosub -total -sum -1 \
|
@texcount -nosub -total -sum -1 \
|
||||||
@@ -64,4 +84,12 @@ wordcount:
|
|||||||
$(SRCDIR)/chapters/05-discussion.tex \
|
$(SRCDIR)/chapters/05-discussion.tex \
|
||||||
$(SRCDIR)/chapters/06-conclusion.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
|
||||||
1
tests/e2e/__init__.py
Normal file
1
tests/e2e/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""E2E test suite for PHANTOM dynamic pricing pipeline."""
|
||||||
17
tests/e2e/fixtures.ts
Normal file
17
tests/e2e/fixtures.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { test as base } from '@playwright/test';
|
||||||
|
|
||||||
|
type TestFixtures = {
|
||||||
|
backendUrl: string;
|
||||||
|
pricingUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const test = base.extend<TestFixtures>({
|
||||||
|
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';
|
||||||
69
tests/e2e/helpers/api.ts
Normal file
69
tests/e2e/helpers/api.ts
Normal file
@@ -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<PriceResponse> {
|
||||||
|
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<PriceResponse> {
|
||||||
|
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<string, any>
|
||||||
|
): Promise<void> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
219
tests/e2e/helpers/interactions.ts
Normal file
219
tests/e2e/helpers/interactions.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { Page } from '@playwright/test';
|
||||||
|
|
||||||
|
export async function getSessionId(page: Page): Promise<string | null> {
|
||||||
|
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<boolean> {
|
||||||
|
const currentSessionId = await getSessionId(page);
|
||||||
|
return currentSessionId === expectedSessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFreshSession(page: Page, storeType: 'hotel' | 'airline' = 'hotel'): Promise<string> {
|
||||||
|
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<void> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
await page.goto(`/products/${productId}`);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function viewProductViaFlow(page: Page, storeType: 'hotel' | 'airline' = 'hotel', searchParams?: SearchParams): Promise<string> {
|
||||||
|
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<string[]> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
const addBtn = page.locator('button:has-text("Add to Cart")');
|
||||||
|
await addBtn.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
39
tests/e2e/helpers/kafka.ts
Normal file
39
tests/e2e/helpers/kafka.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
interface InteractionEvent {
|
||||||
|
sessionId: string;
|
||||||
|
event: string;
|
||||||
|
productId?: string;
|
||||||
|
timestamp: string;
|
||||||
|
metadata?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<InteractionEvent | null> => {
|
||||||
|
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<void>(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);
|
||||||
19
tests/e2e/package.json
Normal file
19
tests/e2e/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
25
tests/e2e/playwright.config.ts
Normal file
25
tests/e2e/playwright.config.ts
Normal file
@@ -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'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
156
tests/e2e/scenarios/session-aware.spec.ts
Normal file
156
tests/e2e/scenarios/session-aware.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
111
tests/e2e/scenarios/surge-pricing.spec.ts
Normal file
111
tests/e2e/scenarios/surge-pricing.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
tests/e2e/tsconfig.json
Normal file
15
tests/e2e/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user