feat(qa): add smoke test suite + post-deploy workflow
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 12s
Deploy / Build AI Services Image (push) Failing after 10s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m25s
Security Scanning / Trivy Scan — Web Image (push) Failing after 46s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 43s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 32s
Deploy / Build API Image (push) Failing after 26s
Deploy / Build Web Image (push) Failing after 10s
E2E Tests / Playwright E2E (push) Failing after 21s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 5s
Security Scanning / Trivy Filesystem Scan (push) Failing after 42s
Deploy / Rollback Production (push) Has been skipped
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 12s
Deploy / Build AI Services Image (push) Failing after 10s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m25s
Security Scanning / Trivy Scan — Web Image (push) Failing after 46s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 43s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 32s
Deploy / Build API Image (push) Failing after 26s
Deploy / Build Web Image (push) Failing after 10s
E2E Tests / Playwright E2E (push) Failing after 21s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 5s
Security Scanning / Trivy Filesystem Scan (push) Failing after 42s
Deploy / Rollback Production (push) Has been skipped
- e2e/api/smoke.spec.ts — 9 @smoke API tests covering health, auth roundtrip, token refresh, listings, search, payments, subscriptions, and inquiries - e2e/web/smoke.spec.ts — 7 @smoke Web tests covering homepage, login/register pages, listings, search, listing detail 404 handling, and console-error check - playwright.config.ts — smoke-api and smoke-web projects (grep: /@smoke/) allowing targeted post-deploy execution without the full suite - .github/workflows/smoke.yml — workflow_dispatch + workflow_call trigger for running only the @smoke subset against staging or production URLs Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
103
.github/workflows/smoke.yml
vendored
Normal file
103
.github/workflows/smoke.yml
vendored
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
name: Smoke Tests (Post-Deploy)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
description: 'Target environment'
|
||||||
|
required: true
|
||||||
|
default: 'staging'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- staging
|
||||||
|
- production
|
||||||
|
api_url:
|
||||||
|
description: 'API base URL (overrides default for env)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
web_url:
|
||||||
|
description: 'Web base URL (overrides default for env)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
environment:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: 'staging'
|
||||||
|
api_url:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
web_url:
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: smoke-${{ inputs.environment || 'staging' }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
smoke:
|
||||||
|
name: Smoke — ${{ inputs.environment || 'staging' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
API_BASE_URL: ${{ inputs.api_url || (inputs.environment == 'production' && vars.PROD_API_URL) || vars.STAGING_API_URL || 'http://localhost:3001/api/v1/' }}
|
||||||
|
WEB_BASE_URL: ${{ inputs.web_url || (inputs.environment == 'production' && vars.PROD_WEB_URL) || vars.STAGING_WEB_URL || 'http://localhost:3000' }}
|
||||||
|
CI: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
id: playwright-cache
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
|
run: npx playwright install --with-deps chromium
|
||||||
|
|
||||||
|
- name: Install Playwright system deps
|
||||||
|
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||||
|
run: npx playwright install-deps chromium
|
||||||
|
|
||||||
|
- name: Run smoke tests (API)
|
||||||
|
run: npx playwright test --project=smoke-api
|
||||||
|
env:
|
||||||
|
API_BASE_URL: ${{ env.API_BASE_URL }}
|
||||||
|
|
||||||
|
- name: Run smoke tests (Web)
|
||||||
|
run: npx playwright test --project=smoke-web
|
||||||
|
env:
|
||||||
|
WEB_BASE_URL: ${{ env.WEB_BASE_URL }}
|
||||||
|
API_BASE_URL: ${{ env.API_BASE_URL }}
|
||||||
|
|
||||||
|
- name: Upload smoke report
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: smoke-report-${{ inputs.environment || 'staging' }}-${{ github.run_id }}
|
||||||
|
path: playwright-report/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Notify failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
echo "::error::Smoke tests FAILED on ${{ inputs.environment || 'staging' }}. Check the uploaded playwright-report artifact for details."
|
||||||
123
e2e/api/smoke.spec.ts
Normal file
123
e2e/api/smoke.spec.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Smoke suite — API
|
||||||
|
*
|
||||||
|
* Runs after every deploy to verify core API paths are healthy.
|
||||||
|
* Tests are tagged with @smoke so they can be isolated:
|
||||||
|
*
|
||||||
|
* npx playwright test --project=api --grep @smoke
|
||||||
|
*
|
||||||
|
* Intentionally lightweight: each test makes at most one API call and
|
||||||
|
* asserts only on status code + structural shape. No heavy fixtures,
|
||||||
|
* no side effects that require cleanup.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { createTestUser, registerUser } from '../fixtures';
|
||||||
|
|
||||||
|
// ── Health ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke API health check returns 200', async ({ request }) => {
|
||||||
|
// Playwright baseURL ends with /api/v1/ — health is at the root
|
||||||
|
const res = await request.get('../../health');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.status).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke auth register + login roundtrip', async ({ request }) => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const registered = await registerUser(request, user);
|
||||||
|
|
||||||
|
expect(registered).toHaveProperty('accessToken');
|
||||||
|
expect(registered).toHaveProperty('refreshToken');
|
||||||
|
expect(typeof registered.accessToken).toBe('string');
|
||||||
|
|
||||||
|
const loginRes = await request.post('auth/login', {
|
||||||
|
data: { phone: user.phone, password: user.password },
|
||||||
|
});
|
||||||
|
expect(loginRes.status()).toBe(201);
|
||||||
|
const tokens = await loginRes.json();
|
||||||
|
expect(tokens).toHaveProperty('accessToken');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@smoke auth token refresh', async ({ request }) => {
|
||||||
|
const user = createTestUser();
|
||||||
|
const { refreshToken } = await registerUser(request, user);
|
||||||
|
|
||||||
|
const res = await request.post('auth/refresh', {
|
||||||
|
data: { refreshToken },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty('accessToken');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@smoke protected route rejects unauthenticated request', async ({ request }) => {
|
||||||
|
const res = await request.get('auth/profile');
|
||||||
|
expect([401, 403]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Listings ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke listings list returns paginated results', async ({ request }) => {
|
||||||
|
const res = await request.get('listings', {
|
||||||
|
params: { page: 1, limit: 5 },
|
||||||
|
});
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveProperty('data');
|
||||||
|
expect(Array.isArray(body.data)).toBeTruthy();
|
||||||
|
expect(body).toHaveProperty('meta');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@smoke listing creation requires auth', async ({ request }) => {
|
||||||
|
const res = await request.post('listings', {
|
||||||
|
data: { title: 'Smoke test listing' },
|
||||||
|
});
|
||||||
|
expect([401, 403]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Search ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke search endpoint is reachable', async ({ request }) => {
|
||||||
|
const res = await request.get('search', {
|
||||||
|
params: { q: 'apartment', limit: 5 },
|
||||||
|
});
|
||||||
|
// 200 = Typesense available; 500/503 = service unavailable (accepted in smoke)
|
||||||
|
expect([200, 500, 503]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@smoke geo search endpoint is reachable', async ({ request }) => {
|
||||||
|
const res = await request.get('search/geo', {
|
||||||
|
params: { lat: 10.7769, lng: 106.7009, radius: 5000, limit: 5 },
|
||||||
|
});
|
||||||
|
expect([200, 500, 503]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Payments ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke payment initiation requires auth', async ({ request }) => {
|
||||||
|
const res = await request.post('payments', {
|
||||||
|
data: { amount: 100000, gateway: 'VNPAY' },
|
||||||
|
});
|
||||||
|
expect([401, 403]).toContain(res.status());
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Subscriptions ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke subscription plans are publicly readable', async ({ request }) => {
|
||||||
|
const res = await request.get('subscriptions/plans');
|
||||||
|
expect(res.status()).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(Array.isArray(body)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Inquiries ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke inquiry creation requires auth', async ({ request }) => {
|
||||||
|
const res = await request.post('inquiries', {
|
||||||
|
data: { listingId: 'smoke-listing-id', message: 'Smoke test inquiry' },
|
||||||
|
});
|
||||||
|
expect([401, 403]).toContain(res.status());
|
||||||
|
});
|
||||||
111
e2e/web/smoke.spec.ts
Normal file
111
e2e/web/smoke.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Smoke suite — Web
|
||||||
|
*
|
||||||
|
* Runs after every deploy to verify critical frontend pages load and
|
||||||
|
* render key UI elements. Tagged with @smoke for targeted execution:
|
||||||
|
*
|
||||||
|
* npx playwright test --project=web --grep @smoke
|
||||||
|
*
|
||||||
|
* Tests avoid user interactions that require a live API/auth session so
|
||||||
|
* they remain fast and resilient in staging environments.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// ── Homepage ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke homepage loads', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveTitle(/.+/);
|
||||||
|
// Search bar or hero section must be visible
|
||||||
|
const searchInput = page.getByRole('searchbox').or(page.getByPlaceholder(/tìm kiếm|search/i));
|
||||||
|
await expect(searchInput.first()).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Auth pages ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke login page renders form', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.getByRole('heading', { name: /đăng nhập/i })).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/số điện thoại/i)).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/mật khẩu/i)).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /đăng nhập/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@smoke register page renders form', async ({ page }) => {
|
||||||
|
await page.goto('/register');
|
||||||
|
await expect(page.getByRole('heading', { name: /đăng ký/i })).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/số điện thoại/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Listings ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke listings page loads without JS errors', async ({ page }) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
page.on('pageerror', (err) => errors.push(err.message));
|
||||||
|
|
||||||
|
await page.goto('/listings');
|
||||||
|
// Allow time for async data loading
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {
|
||||||
|
// networkidle may not be reached if polling; continue
|
||||||
|
});
|
||||||
|
|
||||||
|
// Page must not show an unhandled error boundary
|
||||||
|
const errorHeading = page.getByRole('heading', { name: /500|something went wrong|lỗi/i });
|
||||||
|
await expect(errorHeading).not.toBeVisible();
|
||||||
|
|
||||||
|
// Filter JS errors that are not related to missing env vars in test
|
||||||
|
const fatalErrors = errors.filter(
|
||||||
|
(e) => !e.includes('NEXT_PUBLIC') && !e.includes('mapbox'),
|
||||||
|
);
|
||||||
|
expect(fatalErrors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Search ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke search page is accessible', async ({ page }) => {
|
||||||
|
await page.goto('/search?q=apartment');
|
||||||
|
await expect(page).not.toHaveTitle(/404|not found/i);
|
||||||
|
// Some results container or empty-state must be present
|
||||||
|
const container = page
|
||||||
|
.locator('[data-testid="search-results"]')
|
||||||
|
.or(page.locator('[data-testid="empty-state"]'))
|
||||||
|
.or(page.getByText(/kết quả|results|không tìm thấy/i));
|
||||||
|
// Be lenient — search service may be unavailable in staging
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
// Just confirm no crash
|
||||||
|
const errorBoundary = page.getByRole('heading', { name: /500|server error/i });
|
||||||
|
await expect(errorBoundary).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Listing Detail ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke listing detail page handles missing id gracefully', async ({ page }) => {
|
||||||
|
const res = await page.goto('/listings/nonexistent-smoke-test-id');
|
||||||
|
// Should render 404 page, not crash with 500
|
||||||
|
const status = res?.status();
|
||||||
|
if (status && status >= 500) {
|
||||||
|
throw new Error(`Listing detail returned ${status} for unknown ID (expected 404)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Static / infra ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('@smoke no console errors on login page', async ({ page }) => {
|
||||||
|
const consoleErrors: string[] = [];
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
if (msg.type() === 'error') consoleErrors.push(msg.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
|
// Filter noise from third-party scripts / mapbox in test env
|
||||||
|
const meaningful = consoleErrors.filter(
|
||||||
|
(e) =>
|
||||||
|
!e.includes('mapbox') &&
|
||||||
|
!e.includes('NEXT_PUBLIC') &&
|
||||||
|
!e.includes('Failed to load resource') &&
|
||||||
|
!e.includes('net::ERR'),
|
||||||
|
);
|
||||||
|
expect(meaningful).toHaveLength(0);
|
||||||
|
});
|
||||||
@@ -58,6 +58,24 @@ export default defineConfig({
|
|||||||
baseURL: process.env.WEB_BASE_URL ?? `http://localhost:${WEB_PORT}`,
|
baseURL: process.env.WEB_BASE_URL ?? `http://localhost:${WEB_PORT}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// Smoke projects — subsets of api/web tagged @smoke for post-deploy checks
|
||||||
|
{
|
||||||
|
name: 'smoke-api',
|
||||||
|
testDir: './e2e/api',
|
||||||
|
grep: /@smoke/,
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.API_BASE_URL ?? `http://localhost:${API_PORT}/api/v1/`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'smoke-web',
|
||||||
|
testDir: './e2e/web',
|
||||||
|
grep: /@smoke/,
|
||||||
|
use: {
|
||||||
|
...devices['Desktop Chrome'],
|
||||||
|
baseURL: process.env.WEB_BASE_URL ?? `http://localhost:${WEB_PORT}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
webServer: [
|
webServer: [
|
||||||
|
|||||||
Reference in New Issue
Block a user