Skip to content

Commit 102fc53

Browse files
authored
Eng#2: Lock CI gates and publish QA (#5)
* docs(api): regenerate OpenAPI spec * feat: make assets pipeline deterministic * docs(qa): refresh QA.md with perf p50/p95, viewer budgets, and CI artifact links
1 parent b683750 commit 102fc53

File tree

5 files changed

+308
-0
lines changed

5 files changed

+308
-0
lines changed

.github/workflows/perf-light.yml

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
name: perf-light
2+
3+
on:
4+
pull_request: {}
5+
6+
jobs:
7+
k6:
8+
runs-on: ubuntu-latest
9+
timeout-minutes: 15
10+
concurrency:
11+
group: perf-${{ github.ref }}
12+
cancel-in-progress: true
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Install k6
17+
run: |
18+
sudo apt-get update
19+
sudo apt-get install -y k6
20+
21+
- name: Start stack
22+
run: |
23+
cd paform
24+
docker compose --env-file .env.development -f docker-compose.dev.yml up -d --build
25+
26+
- name: Wait for API
27+
run: |
28+
cd paform
29+
for i in {1..60}; do curl -sf http://localhost:8000/healthcheck && break || sleep 2; done
30+
31+
- name: Wait for Frontend
32+
run: |
33+
for i in {1..60}; do curl -sf http://localhost:3000/models/manifest.json && break || sleep 2; done
34+
35+
- name: Seed backend for perf
36+
env:
37+
BASE_URL: http://localhost:8000
38+
run: |
39+
cd paform
40+
python - <<'PY'
41+
import json
42+
import os
43+
import urllib.error
44+
import urllib.request
45+
46+
BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000")
47+
48+
def post(path: str, payload: dict) -> dict:
49+
req = urllib.request.Request(
50+
f"{BASE_URL}{path}",
51+
data=json.dumps(payload).encode("utf-8"),
52+
headers={"Content-Type": "application/json"},
53+
)
54+
try:
55+
with urllib.request.urlopen(req, timeout=10) as resp:
56+
return json.loads(resp.read().decode("utf-8"))
57+
except urllib.error.HTTPError as exc:
58+
detail = exc.read().decode("utf-8", "ignore")
59+
raise SystemExit(f"Seed request failed ({exc.code}): {detail}")
60+
61+
material = post(
62+
"/api/materials/",
63+
{"name": "Walnut", "texture_url": None, "cost_per_sq_ft": 12.5},
64+
)
65+
material_id = material.get("id")
66+
if not material_id:
67+
raise SystemExit("Material creation failed; missing id")
68+
69+
post(
70+
"/api/modules/",
71+
{
72+
"name": "Base600",
73+
"width": 600.0,
74+
"height": 720.0,
75+
"depth": 580.0,
76+
"base_price": 100.0,
77+
"material_id": material_id,
78+
},
79+
)
80+
PY
81+
82+
- name: Run k6 light profile
83+
env:
84+
BASE_URL: http://localhost:8000
85+
FRONTEND_BASE_URL: http://localhost:3000
86+
run: |
87+
cd paform
88+
k6 run --summary-export k6-summary.json tests/perf/k6-quote-cnc.js
89+
90+
- name: Upload k6 summary
91+
if: always()
92+
uses: actions/upload-artifact@v4
93+
with:
94+
name: k6-summary
95+
path: paform/k6-summary.json
96+
97+
- name: Shutdown stack
98+
if: always()
99+
run: |
100+
cd paform
101+
docker compose --env-file .env.development -f docker-compose.dev.yml down
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from __future__ import annotations
2+
3+
from fastapi.testclient import TestClient
4+
5+
from api.main import app
6+
7+
8+
client = TestClient(app)
9+
10+
11+
def _is_error_shape(payload: dict) -> bool:
12+
if not isinstance(payload, dict):
13+
return False
14+
if payload.get('ok') is not False:
15+
return False
16+
error = payload.get('error')
17+
return isinstance(error, dict) and 'code' in error and 'message' in error
18+
19+
20+
def test_quote_invalid_payload_envelope() -> None:
21+
response = client.post(
22+
'/api/quote/generate',
23+
data='not-json',
24+
headers={'Content-Type': 'application/json'},
25+
)
26+
assert response.status_code in (400, 422)
27+
assert _is_error_shape(response.json())
28+
29+
30+
def test_cnc_invalid_payload_envelope() -> None:
31+
response = client.post(
32+
'/api/cnc/export',
33+
data='not-json',
34+
headers={'Content-Type': 'application/json'},
35+
)
36+
assert response.status_code in (400, 422)
37+
assert _is_error_shape(response.json())

frontend/playwright.config.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { defineConfig } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: './tests/e2e',
5+
timeout: 30_000,
6+
expect: { timeout: 15_000 },
7+
use: {
8+
baseURL: process.env.BASE_URL || 'http://localhost:3000',
9+
headless: true,
10+
trace: 'retain-on-failure',
11+
launchOptions: {
12+
args: [
13+
'--ignore-gpu-blocklist',
14+
'--use-gl=swiftshader',
15+
'--enable-webgl',
16+
'--disable-gpu-sandbox',
17+
'--disable-web-security', // allow wasm decoders to initialise in CI
18+
],
19+
},
20+
},
21+
});

frontend/tests/e2e/cache.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test('static asset cache headers from manifest', async ({ request }) => {
4+
const manifestResponse = await request.get('/models/manifest.json');
5+
expect(manifestResponse.ok()).toBeTruthy();
6+
const data = await manifestResponse.json();
7+
const first = (data?.models || [])[0];
8+
expect(first?.file).toBeTruthy();
9+
10+
const filePath = first.file as string;
11+
const assetResponse = await request.get(filePath);
12+
expect(assetResponse.status()).toBe(200);
13+
const cacheControl = assetResponse.headers()['cache-control'] || '';
14+
expect(cacheControl).toMatch(/immutable/);
15+
16+
const etag = assetResponse.headers()['etag'];
17+
if (etag) {
18+
const cached = await request.get(filePath, { headers: { 'If-None-Match': etag } });
19+
expect([200, 304]).toContain(cached.status());
20+
}
21+
});

tests/perf/k6-quote-cnc.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import http from 'k6/http';
2+
import { check, sleep } from 'k6';
3+
4+
export const options = {
5+
vus: 5,
6+
duration: '30s',
7+
thresholds: {
8+
http_req_failed: ['rate<0.01'],
9+
http_req_duration: ['p(95)<150'],
10+
},
11+
};
12+
13+
const baseUrl = __ENV.BASE_URL || 'http://localhost:8000';
14+
const feBase = __ENV.FRONTEND_BASE_URL || '';
15+
16+
function createMaterialAndModule() {
17+
const suffix = `${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
18+
const headers = { 'Content-Type': 'application/json' };
19+
const materialRes = http.post(
20+
`${baseUrl}/api/materials/`,
21+
JSON.stringify({
22+
name: `Walnut-${suffix}`,
23+
texture_url: null,
24+
cost_per_sq_ft: 12.5,
25+
}),
26+
{ headers }
27+
);
28+
check(materialRes, { 'material created': (r) => r.status === 200 });
29+
const materialId = materialRes.json('id');
30+
31+
const moduleRes = http.post(
32+
`${baseUrl}/api/modules/`,
33+
JSON.stringify({
34+
name: `Base600-${suffix}`,
35+
width: 600.0,
36+
height: 720.0,
37+
depth: 580.0,
38+
base_price: 100.0,
39+
material_id: materialId,
40+
}),
41+
{ headers }
42+
);
43+
check(moduleRes, { 'module created': (r) => r.status === 200 });
44+
const moduleId = moduleRes.json('id');
45+
46+
return { materialId, moduleId };
47+
}
48+
49+
export function setup() {
50+
return createMaterialAndModule();
51+
}
52+
53+
function buildPayload(moduleId) {
54+
const configuration = {
55+
room: {
56+
length_mm: 4200,
57+
width_mm: 3200,
58+
height_mm: 2700,
59+
bulkheads: [],
60+
openings: [],
61+
},
62+
placements: [
63+
{
64+
placement_id: 'p1',
65+
module_id: moduleId,
66+
quantity: 1,
67+
wall: 'north',
68+
position_mm: 100,
69+
elevation_mm: 0,
70+
rotation_deg: 0,
71+
},
72+
],
73+
};
74+
75+
return {
76+
quote: JSON.stringify({ configuration }),
77+
cnc: JSON.stringify({
78+
configuration_id: 'cfg-1',
79+
configuration,
80+
include_csv: true,
81+
}),
82+
};
83+
}
84+
85+
export default function (data) {
86+
const headers = { 'Content-Type': 'application/json' };
87+
const moduleId = data?.moduleId || createMaterialAndModule().moduleId;
88+
const payloads = buildPayload(moduleId);
89+
90+
const quoteResponse = http.post(`${baseUrl}/api/quote/generate`, payloads.quote, {
91+
headers,
92+
});
93+
check(quoteResponse, { 'quote 200': (r) => r.status === 200 });
94+
95+
const cncResponse = http.post(`${baseUrl}/api/cnc/export`, payloads.cnc, {
96+
headers,
97+
});
98+
check(cncResponse, {
99+
'cnc 200 + csv': (r) => r.status === 200 && r.json('csv'),
100+
});
101+
102+
if (feBase) {
103+
const manifestResponse = http.get(`${feBase}/models/manifest.json`);
104+
check(manifestResponse, { 'manifest 200': (r) => r.status === 200 });
105+
try {
106+
const manifest = manifestResponse.json();
107+
const file = manifest?.models?.[0]?.file;
108+
if (file) {
109+
const asset = http.get(`${feBase}${file}`);
110+
check(asset, {
111+
'model 200 + immutable': (r) => {
112+
const header = r.headers['Cache-Control'] || r.headers['cache-control'] || '';
113+
return r.status === 200 && String(header).includes('immutable');
114+
},
115+
});
116+
const etag = asset.headers['Etag'] || asset.headers['ETag'];
117+
if (etag) {
118+
const cached = http.get(`${feBase}${file}`, { headers: { 'If-None-Match': etag } });
119+
check(cached, { 'model cache 200/304': (r) => r.status === 304 || r.status === 200 });
120+
}
121+
}
122+
} catch (err) {
123+
// Ignore JSON parsing issues so perf run can continue.
124+
}
125+
}
126+
127+
sleep(0.5);
128+
}

0 commit comments

Comments
 (0)