diff options
Diffstat (limited to 'qa')
| -rw-r--r-- | qa/README.md | 60 | ||||
| -rw-r--r-- | qa/driver/browser-test.js | 280 | ||||
| -rw-r--r-- | qa/driver/package-lock.json | 28 | ||||
| -rw-r--r-- | qa/driver/package.json | 9 | ||||
| -rwxr-xr-x | qa/lifecycle-test.sh | 64 | ||||
| -rwxr-xr-x | qa/run.sh | 133 | ||||
| -rwxr-xr-x | qa/stub/fund-lab-python | 32 |
7 files changed, 606 insertions, 0 deletions
diff --git a/qa/README.md b/qa/README.md new file mode 100644 index 0000000..96aea72 --- /dev/null +++ b/qa/README.md @@ -0,0 +1,60 @@ +# fund-lab QA(3c-1) + +仓库内可复现的端到端浏览器 QA 入口。一条命令启动一次性 Postgres、API(桩行情)与 Vite,然后用无头 Chromium 驱动页面断言 3c-1 行为。 + +## 运行 + +```bash +bash qa/run.sh +``` + +前提:`docker`(含本地 `postgres:16-alpine` 镜像)、`dotnet`、仓库内 `.tools/node-v22.23.2`(`node`/`npm`)。首次运行会在 `qa/driver/` 安装 `playwright-core`(node_modules 已被 .gitignore 覆盖)。 + +`run.sh` 的行为约定: + +- 端口均为非默认:API `5095`、Vite `5175`、Postgres `55434`;容器名 `fund-lab-qa-pg-3c1`。 +- 启动前预检端口/容器占用,被占用即中止,绝不复用或杀掉既有进程(拒绝路径有 `qa/lifecycle-test.sh` 回归保障)。 +- 每次运行使用 `mktemp -d /tmp/opencode/fund-lab-qa-*` 专属临时目录,日志、截图、env 互不覆盖,失败现场完整保留。 +- Postgres 密码随机生成,仅写入本次临时目录的 `pg-env`(0600),不回显、不进仓库。 +- API 环境变量:`FUND_LAB_AUTH_TOKEN=qa-token`、`FUND_LAB_AKSHARE_PYTHON=<repo>/qa/stub/fund-lab-python`。 +- 退出时清理:杀掉自身启动的 API/Vite PID;仅删除本次 `docker run` 创建的容器(按容器 ID 与名称双重归属校验,拒绝路径绝不触碰既有容器);删除临时 env 文件。日志与截图保留在临时目录中供排查。 + +## 生命周期回归(qa/lifecycle-test.sh) + +```bash +bash qa/lifecycle-test.sh +``` + +验证 `run.sh` 的拒绝路径不破坏既有资源:预置同名容器或占用端口时 `run.sh` 必须拒绝退出,且既有容器与端口占用进程原样保留。脚本自身也只管理它创建的资源。 + +## 行情桩(stub) + +`qa/stub/fund-lab-python` 是一个假解释器:`MarketDataService` 以 `FUND_LAB_AKSHARE_PYTHON` 调用它并传 `akshare_collector.py --operation search|nav ...`,它按 collector JSON 契约向 stdout 输出 envelope: + +- `schema_version="fund-lab.akshare.v1"`、`operation`、`source="stub-synthetic"`、`source_revision`、`collected_at` +- 每个载荷都带 `_synthetic:true`,来源标记为 stub(合成数据不做真实披露) + +## 浏览器场景(qa/driver/browser-test.js) + +- A 初始渲染:创建面板、名称/现金输入框、按钮文案。 +- B 校验:空名称、非法金额(`abc`、`-5.00`)被拒绝且不发出任何 POST。 +- C 创建成功:双击仅 1 次 POST;`Idempotency-Key` 为 32 位十六进制;请求体金额为 decimal 字符串(`initialCash="12345.67"`、`initialUnitNav="1.00000000"`,不经 JS float);`isSynthetic=false`;档案展示服务端返回值,份额诚实显示 0。 +- D 重新读取:恰好 1 次 GET,金额持久化复核。 +- E token 失效:换 token 立即清空档案;拦截并延迟的旧 GET 响应落地后被忽略(stale 防护)。 +- F 幂等重试:首 POST 被掐断显示错误横幅,重试复用同一 `Idempotency-Key` 成功。 +- G 全程无浏览器 console/page 错误。 + +## 真实行情模式 + +QA 默认走合成桩。需要真实 AKShare 时,不运行 `run.sh`,而是自行启动 Postgres 并把 API 的 `FUND_LAB_AKSHARE_PYTHON` 指向装有 akshare 的解释器,例如本机已验证可用: + +```bash +PYTHONPATH=/tmp/opencode/fund-lab-akshare-site /usr/bin/python3 -c "import akshare" # 1.18.96 +``` + +真实模式输出的 envelope 不含 `_synthetic`,来源为真实数据源;前端“数据来源”行相应显示“真实建档”。 + +## 不变量 + +- 金额永远以 decimal 字符串提交/展示;浏览器端不经过任何 float 运算(见 `qa/driver/browser-test.js` 场景 C3 对请求体的断言)。 +- 网络失败后的重试复用同一 `Idempotency-Key`;输入变更或创建成功后才换新 key。 diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js new file mode 100644 index 0000000..7a45441 --- /dev/null +++ b/qa/driver/browser-test.js @@ -0,0 +1,280 @@ +const { chromium } = require("playwright-core"); + +const BASE = process.env.QA_BASE_URL || "http://127.0.0.1:5175/"; +const SHOTS = process.env.QA_SHOTS || "/tmp/opencode/fund-lab-qa-screens"; +const EXE = "/home/somhairle/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome"; + +const results = []; +const consoleErrors = []; +const fundPosts = []; +let fundPostResponses = 0; +let fundGets = 0; +let createdFund = null; +let createdFundResponse = null; + +function check(name, ok, detail) { + results.push({ name, ok, detail: detail || "" }); + console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " -- " + detail : ""}`); +} + +async function expectText(page, selector, expected, name, timeout = 10000) { + try { + await page.waitForSelector(selector, { timeout }); + await page.waitForFunction( + ({ sel, want }) => { + const el = document.querySelector(sel); + return el !== null && (el.textContent || "").trim() === want; + }, + { sel: selector, want: expected }, + { timeout } + ); + check(name, true); + } catch (e) { + let got = ""; + try { + got = ((await page.textContent(selector)) || "").trim(); + } catch {} + check(name, false, `got="${got}" want="${expected}"`); + } +} + +async function summaryValue(page, label, expected, name) { + try { + await page.waitForFunction( + ({ want }) => { + const rows = Array.from(document.querySelectorAll(".fund-detail-row")); + return rows.some((r) => { + const l = r.querySelector(".fund-detail-label"); + const v = r.querySelector(".fund-detail-value"); + return l && v && (l.textContent || "").trim() === want.label && (v.textContent || "").trim() === want.value; + }); + }, + { want: { label, value: expected } }, + { timeout: 10000 } + ); + check(name, true); + } catch (e) { + check(name, false, `summary row ${label} != "${expected}"`); + } +} + +async function summaryLabelExists(page, label, name) { + try { + await page.waitForFunction( + (want) => { + const rows = Array.from(document.querySelectorAll(".fund-detail-row")); + return rows.some((r) => (r.querySelector(".fund-detail-label")?.textContent || "").trim() === want); + }, + label, + { timeout: 10000 } + ); + check(name, true); + } catch (e) { + check(name, false, `summary row ${label} missing`); + } +} + +(async () => { + const browser = await chromium.launch({ + executablePath: EXE, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], + }); + const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); + page.on("pageerror", (e) => consoleErrors.push("pageerror: " + e.message)); + page.on("response", async (r) => { + if (r.url().includes("/api/funds") && r.request().method() === "POST" && r.status() < 400) { + fundPostResponses++; + try { + if (!createdFundResponse) createdFundResponse = await r.json(); + } catch {} + } + if (r.url().includes("/api/funds/") && r.request().method() === "GET" && r.status() < 400) fundGets++; + if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url())) { + consoleErrors.push("resource " + r.status() + ": " + r.url()); + } + }); + page.on("request", (r) => { + if (r.url().includes("/api/funds") && r.method() === "POST") { + fundPosts.push({ url: r.url(), key: r.headers()["idempotency-key"] || "", body: r.postData() || "" }); + } + }); + page.on("console", (m) => { + if (m.type() !== "error") return; + const text = m.text(); + if (/favicon/i.test(text)) return; + if (/401 \(Unauthorized\)/.test(text)) return; + if (/Failed to load resource/.test(text) && !text.includes("api/")) return; + consoleErrors.push("console: " + text); + }); + + try { + await page.goto(BASE, { waitUntil: "networkidle", timeout: 30000 }); + + await expectVisibleFundPanel(page); + await validationScenario(page); + await createScenario(page); + await rereadScenario(page); + await staleTokenScenario(page); + await idempotentRetryScenario(page); + } finally { + check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); + await browser.close(); + } + + const failed = results.filter((r) => !r.ok); + console.log(`\n==== ${results.length - failed.length}/${results.length} passed ====`); + process.exit(failed.length > 0 ? 1 : 0); +})().catch((e) => { + console.error("QA driver crashed:", e); + process.exit(2); +}); + +async function expectVisibleFundPanel(page) { + await page.waitForSelector(".fund-panel", { timeout: 15000 }); + await page.waitForSelector(".fund-name-input", { timeout: 10000 }); + await page.waitForSelector(".fund-cash-input", { timeout: 10000 }); + check("A1 初始渲染: 创建面板/名称/现金输入框", true); + const eyebrow = ((await page.textContent(".fund-panel .eyebrow")) || "").trim(); + check("A2 面板 eyebrow 为 03 / CREATE", eyebrow === "03 / CREATE", eyebrow); + const btn = ((await page.textContent(".fund-create-action")) || "").trim(); + check("A3 创建按钮初始为 创建基金", btn === "创建基金", btn); + const summaryCount = await page.locator(".fund-summary").count(); + check("A4 初始无基金档案", summaryCount === 0, `count=${summaryCount}`); + await page.screenshot({ path: SHOTS + "/01-initial.png" }); +} + +async function validationScenario(page) { + await page.fill(".token-input", "qa-token"); + await page.fill(".fund-name-input", ""); + await page.fill(".fund-cash-input", ""); + await page.click(".fund-create-action"); + await expectText(page, ".error-banner", "请输入基金名称", "B1 空名称校验提示"); + + await page.fill(".fund-name-input", "稳健一号"); + await page.fill(".fund-cash-input", "abc"); + await page.click(".fund-create-action"); + await expectText( + page, + ".error-banner", + "初始现金必须是合法金额,例如 10000.00", + "B2 非法金额校验提示" + ); + + await page.fill(".fund-cash-input", "-5.00"); + await page.click(".fund-create-action"); + const negativeBlocked = await page + .waitForSelector(".error-banner", { timeout: 5000 }) + .then(() => true) + .catch(() => false); + check("B3 负数金额被拒绝", negativeBlocked, "banner presence"); + check("B4 校验阶段无 POST /api/funds", fundPosts.length === 0, `posts=${fundPosts.length}`); + await page.screenshot({ path: SHOTS + "/02-validation.png" }); +} + +async function createScenario(page) { + await page.fill(".fund-name-input", "稳健一号"); + await page.fill(".fund-cash-input", "12345.67"); + await page.evaluate(() => { + const btn = document.querySelector(".fund-create-action"); + btn.click(); + btn.click(); + }); + await page.waitForSelector(".fund-summary", { timeout: 15000 }); + + check("C1 创建阶段恰好 1 次 POST(双击防护)", fundPosts.length === 1, `posts=${fundPosts.length}`); + const post = fundPosts[0] || { key: "", body: "" }; + check("C2 幂等键为 32 位十六进制", /^[0-9a-f]{32}$/.test(post.key), post.key); + let body = {}; + try { + body = JSON.parse(post.body); + } catch {} + check( + "C3 请求体金额为 decimal 字符串", + body.initialCash === "12345.67" && body.initialUnitNav === "1.00000000", + post.body.slice(0, 200) + ); + check("C4 请求体 isSynthetic=false", body.isSynthetic === false, String(body.isSynthetic)); + check("C5 请求体名称为 稳健一号", body.name === "稳健一号", String(body.name)); + + await summaryLabelExists(page, "基金ID", "C6 档案含基金ID"); + await summaryValue(page, "初始现金", "12345.67", "C7 初始现金 12345.67"); + await summaryValue(page, "初始单位净值", "1.00000000", "C8 初始单位净值 1.00000000"); + await summaryValue(page, "份额", "0(新基金尚未买入持仓)", "C9 份额诚实显示 0"); + await summaryValue(page, "数据来源", "真实建档", "C10 数据来源为 真实建档"); + + createdFund = { id: body.id || "", name: "稳健一号" }; + try { + const idRow = await page.locator(".fund-detail-row", { hasText: "基金ID" }).first().textContent(); + const m = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i.exec(idRow || ""); + if (m) createdFund.id = m[0]; + } catch {} + check( + "C11 已捕获完整档案响应载荷", + !!(createdFundResponse && createdFundResponse.id && createdFundResponse.name && createdFundResponse.availableCash), + JSON.stringify(createdFundResponse || {}).slice(0, 160) + ); + await page.screenshot({ path: SHOTS + "/03-created.png" }); +} + +async function rereadScenario(page) { + await page.click(".fund-read-action"); + await expectText(page, ".fund-read-action", "重新读取", "D1 重新读取完成按钮恢复", 15000); + check("D2 重新读取恰好 1 次 GET", fundGets === 1, `gets=${fundGets}`); + await summaryValue(page, "可用现金", "12345.67", "D3 重读后可用现金 12345.67"); + await page.screenshot({ path: SHOTS + "/04-reread.png" }); +} + +async function staleTokenScenario(page) { + check( + "E0 延迟响应使用完整档案载荷", + !!(createdFundResponse && createdFundResponse.id && createdFundResponse.name && createdFundResponse.availableCash), + JSON.stringify(createdFundResponse || {}).slice(0, 160) + ); + await page.route("**/api/funds/*", async (route) => { + await new Promise((r) => setTimeout(r, 1200)); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(createdFundResponse || createdFund || {}), + }); + }); + await page.click(".fund-read-action"); + await page.fill(".token-input", "qa-token-rotated"); + await page.waitForSelector(".fund-summary", { state: "detached", timeout: 10000 }); + check("E1 换 token 立即清空基金档案", true); + await page.waitForTimeout(1800); + const summaryAfter = await page.locator(".fund-summary").count(); + check("E2 延迟响应落地后档案仍被清除(stale 忽略)", summaryAfter === 0, `count=${summaryAfter}`); + await page.unroute("**/api/funds/*"); + await page.fill(".token-input", "qa-token"); + await page.screenshot({ path: SHOTS + "/05-stale-token.png" }); +} + +async function idempotentRetryScenario(page) { + let aborted = false; + await page.route("**/api/funds", async (route) => { + if (!aborted) { + aborted = true; + await route.abort("failed"); + } else { + await route.continue(); + } + }); + + await page.fill(".fund-name-input", "稳健二号"); + await page.fill(".fund-cash-input", "20000.00"); + await page.click(".fund-create-action"); + await page.waitForSelector(".error-banner", { timeout: 15000 }); + const failedKey = fundPosts[fundPosts.length - 1]?.key || ""; + check("F1 首次 POST 失败显示错误横幅", true); + + await page.click(".fund-create-action"); + await page.waitForSelector(".fund-summary", { timeout: 15000 }); + const retryKey = fundPosts[fundPosts.length - 1]?.key || ""; + check("F2 重试复用同一 Idempotency-Key", failedKey !== "" && failedKey === retryKey, `${failedKey} vs ${retryKey}`); + check("F3 失败后恰好 2 次 POST", fundPosts.length === 3, `posts=${fundPosts.length}`); + await summaryValue(page, "名称", "稳健二号", "F4 重试成功档案为 稳健二号"); + await page.unroute("**/api/funds"); + await page.screenshot({ path: SHOTS + "/06-idempotent-retry.png" }); +} diff --git a/qa/driver/package-lock.json b/qa/driver/package-lock.json new file mode 100644 index 0000000..9592f5d --- /dev/null +++ b/qa/driver/package-lock.json @@ -0,0 +1,28 @@ +{ + "name": "fund-lab-qa-driver", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fund-lab-qa-driver", + "version": "1.0.0", + "license": "UNLICENSED", + "dependencies": { + "playwright-core": "^1.63.0" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/qa/driver/package.json b/qa/driver/package.json new file mode 100644 index 0000000..74ac02a --- /dev/null +++ b/qa/driver/package.json @@ -0,0 +1,9 @@ +{ + "name": "fund-lab-qa-driver", + "version": "1.0.0", + "private": true, + "license": "UNLICENSED", + "dependencies": { + "playwright-core": "^1.63.0" + } +} diff --git a/qa/lifecycle-test.sh b/qa/lifecycle-test.sh new file mode 100755 index 0000000..ace6a08 --- /dev/null +++ b/qa/lifecycle-test.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Lifecycle regression for qa/run.sh refusal paths. +# Verifies that run.sh refuses to start when a container name or port is +# already taken, and that the refusal leaves the pre-existing resources +# untouched (no container deletion, no killing of the port holder). +# The script only manages resources it created itself. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PG_CONTAINER=fund-lab-qa-pg-3c1 +PG_IMAGE=postgres:16-alpine +API_PORT=5095 +TMP="$(mktemp -d /tmp/opencode/fund-lab-qa-lifecycle-XXXXXX)" +HOLDER_PID="" + +cleanup() { + local status=$? + trap - EXIT INT TERM + [ -n "$HOLDER_PID" ] && kill "$HOLDER_PID" 2>/dev/null || true + [ -n "$HOLDER_PID" ] && wait "$HOLDER_PID" 2>/dev/null || true + if [ -n "$DUMMY_ID" ]; then + owned="$(docker ps -a --filter "id=$DUMMY_ID" --format '{{.Names}}' 2>/dev/null || true)" + [ "$owned" = "$PG_CONTAINER" ] && docker rm -f "$DUMMY_ID" >/dev/null 2>&1 || true + fi + rm -rf "$TMP" + exit "$status" +} +DUMMY_ID="" +trap cleanup EXIT INT TERM + +log() { printf '[lifecycle] %s\n' "$*"; } +fail() { printf '[lifecycle] FAIL: %s\n' "$*" >&2; exit 1; } +pass() { printf '[lifecycle] PASS: %s\n' "$*"; } + +command -v docker >/dev/null || fail "docker is required" +docker info >/dev/null 2>&1 || fail "docker daemon is not reachable" +docker image inspect "$PG_IMAGE" >/dev/null 2>&1 || fail "$PG_IMAGE is not available locally" +docker ps -a --format '{{.Names}}' | grep -qx "$PG_CONTAINER" && fail "container $PG_CONTAINER already exists; refusing to proceed" + +log "scenario 1: pre-existing container name is refused and preserved" +DUMMY_ID="$(docker run -d --name "$PG_CONTAINER" "$PG_IMAGE" sleep 120)" +if bash "$ROOT/qa/run.sh" >"$TMP/refuse-container.log" 2>&1; then + fail "run.sh should refuse when $PG_CONTAINER already exists" +fi +grep -q "already exists" "$TMP/refuse-container.log" || fail "refusal reason missing from log" +[ "$(docker ps -a --filter "id=$DUMMY_ID" --format '{{.Names}}' 2>/dev/null)" = "$PG_CONTAINER" ] \ + || fail "pre-existing container was removed or modified by the refused run" +pass "refusal preserved the pre-existing container" +docker rm -f "$DUMMY_ID" >/dev/null +DUMMY_ID="" + +log "scenario 2: occupied API port is refused and the holder is untouched" +python3 -m http.server "$API_PORT" --bind 127.0.0.1 >/dev/null 2>&1 & +HOLDER_PID=$! +sleep 0.5 +kill -0 "$HOLDER_PID" 2>/dev/null || fail "port holder failed to start" +if bash "$ROOT/qa/run.sh" >"$TMP/refuse-port.log" 2>&1; then + fail "run.sh should refuse when port $API_PORT is occupied" +fi +grep -q "port $API_PORT is already in use" "$TMP/refuse-port.log" || fail "refusal reason missing from log" +kill -0 "$HOLDER_PID" 2>/dev/null || fail "port holder was killed by the refused run" +pass "refusal preserved the port holder" + +log "all lifecycle scenarios passed" diff --git a/qa/run.sh b/qa/run.sh new file mode 100755 index 0000000..bdfba3f --- /dev/null +++ b/qa/run.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# End-to-end browser QA for fund-lab (work order 3c-1 scope and regressions). +# Starts: disposable Postgres (port 55434), API (5095), Vite dev server (5175), +# then drives the UI with headless Chromium via qa/driver/browser-test.js. +# All ports and the container name are non-default and cleaned up on exit. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +QA_DIR="$ROOT/qa" +API_PORT=5095 +WEB_PORT=5175 +PG_PORT=55434 +PG_CONTAINER=fund-lab-qa-pg-3c1 +PG_IMAGE=postgres:16-alpine +PG_DB=fund_lab_qa +PG_USER=fund_qa +AUTH_TOKEN=qa-token +TMP_DIR="$(mktemp -d /tmp/opencode/fund-lab-qa-XXXXXX)" +ENV_FILE="$TMP_DIR/pg-env" +API_LOG="$TMP_DIR/api.log" +WEB_LOG="$TMP_DIR/web.log" +API_PID="" +WEB_PID="" +CREATED_CONTAINER_ID="" + +log() { printf '[qa] %s\n' "$*"; } +die() { printf '[qa] FATAL: %s\n' "$*" >&2; exit 1; } + +cleanup() { + local status=$? + trap - EXIT INT TERM + [ -n "$WEB_PID" ] && kill "$WEB_PID" 2>/dev/null || true + [ -n "$API_PID" ] && kill "$API_PID" 2>/dev/null || true + [ -n "$WEB_PID" ] && wait "$WEB_PID" 2>/dev/null || true + [ -n "$API_PID" ] && wait "$API_PID" 2>/dev/null || true + if [ -n "$CREATED_CONTAINER_ID" ]; then + owned="$(docker ps -a --filter "id=$CREATED_CONTAINER_ID" --format '{{.Names}}' 2>/dev/null || true)" + [ "$owned" = "$PG_CONTAINER" ] && docker rm -f "$CREATED_CONTAINER_ID" >/dev/null 2>&1 || true + fi + rm -f "$ENV_FILE" + log "cleanup done (exit=$status)" + log "artifacts preserved in $TMP_DIR" + exit "$status" +} +trap cleanup EXIT INT TERM + +port_in_use() { + (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && { exec 3>&- 3<&-; return 0; } || return 1 +} + +command -v docker >/dev/null || die "docker is required" +command -v openssl >/dev/null || die "openssl is required" +docker info >/dev/null 2>&1 || die "docker daemon is not reachable" +docker image inspect "$PG_IMAGE" >/dev/null 2>&1 || die "$PG_IMAGE is not available locally; pull it first" +docker ps -a --format '{{.Names}}' | grep -qx "$PG_CONTAINER" && die "container $PG_CONTAINER already exists; refusing to touch it" +for port in "$API_PORT" "$WEB_PORT" "$PG_PORT"; do + port_in_use "$port" && die "port $port is already in use; refusing to continue" +done +[ -x "$QA_DIR/stub/fund-lab-python" ] || chmod +x "$QA_DIR/stub/fund-lab-python" +export PATH="$ROOT/.tools/node-v22.23.2/bin:$PATH" +command -v node >/dev/null || die "node not found in .tools" +command -v dotnet >/dev/null || die "dotnet not found on PATH" + +mkdir -p "$TMP_DIR" +umask 077 +PG_PASSWORD="$(openssl rand -hex 24)" +printf 'PG_PASSWORD=%s\n' "$PG_PASSWORD" > "$ENV_FILE" + +log "starting $PG_IMAGE as $PG_CONTAINER on 127.0.0.1:$PG_PORT" +CREATED_CONTAINER_ID="$(docker run -d --name "$PG_CONTAINER" \ + -e POSTGRES_DB="$PG_DB" -e POSTGRES_USER="$PG_USER" -e POSTGRES_PASSWORD="$PG_PASSWORD" \ + -p "127.0.0.1:$PG_PORT:5432" "$PG_IMAGE")" + +pg_ready=0 +for _ in $(seq 1 60); do + if docker exec "$PG_CONTAINER" pg_isready -U "$PG_USER" -d "$PG_DB" >/dev/null 2>&1; then + pg_ready=1 + break + fi + sleep 0.5 +done +if [ "$pg_ready" != 1 ]; then + docker ps -a --format '{{.Names}} {{.Status}}' | grep "$PG_CONTAINER" >&2 || true + docker logs --tail 20 "$PG_CONTAINER" >&2 || true + die "postgres did not become ready" +fi + +log "building API" +dotnet build "$ROOT/src/FundLab.Api/FundLab.Api.fsproj" -v q --nologo >/dev/null + +log "starting API on 127.0.0.1:$API_PORT (stub interpreter, _synthetic payloads)" +FUND_LAB_DATABASE_URL="Host=127.0.0.1;Port=$PG_PORT;Database=$PG_DB;Username=$PG_USER;Password=$PG_PASSWORD;Pooling=false" \ +FUND_LAB_AUTH_TOKEN="$AUTH_TOKEN" \ +FUND_LAB_AKSHARE_PYTHON="$QA_DIR/stub/fund-lab-python" \ +ASPNETCORE_URLS="http://127.0.0.1:$API_PORT" \ + dotnet "$ROOT/src/FundLab.Api/bin/Debug/net8.0/FundLab.Api.dll" > "$API_LOG" 2>&1 & +API_PID=$! + +api_up=0 +for _ in $(seq 1 60); do + code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$API_PORT/api/instruments?query=x" || true)" + [ "$code" != "000" ] && { api_up=1; break; } + sleep 0.5 +done +[ "$api_up" = 1 ] || { cat "$API_LOG" >&2; die "API did not become ready"; } + +log "compiling F# to JS (fable)" +( cd "$ROOT/src/FundLab.Web" && dotnet fable FundLab.Web.fsproj --outDir dist --noCache ) > "$TMP_DIR/fable.log" 2>&1 + +log "starting Vite dev server on 127.0.0.1:$WEB_PORT" +( cd "$ROOT/src/FundLab.Web" && FUND_LAB_API_TARGET="http://127.0.0.1:$API_PORT" exec node node_modules/vite/bin/vite.js --host 127.0.0.1 --port "$WEB_PORT" --strictPort ) \ + > "$WEB_LOG" 2>&1 & +WEB_PID=$! + +web_up=0 +for _ in $(seq 1 60); do + code="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$WEB_PORT/" || true)" + [ "$code" = "200" ] && { web_up=1; break; } + sleep 0.5 +done +[ "$web_up" = 1 ] || { cat "$WEB_LOG" >&2; die "vite dev server did not become ready"; } + +if [ ! -d "$QA_DIR/driver/node_modules" ]; then + log "installing driver dependencies" + ( cd "$QA_DIR/driver" && npm install --no-audit --no-fund --loglevel=error >/dev/null ) +fi + +log "running browser scenarios" +mkdir -p "$TMP_DIR/screens" +QA_BASE_URL="http://127.0.0.1:$WEB_PORT/" \ +QA_SHOTS="$TMP_DIR/screens" \ +QA_TOKEN="$AUTH_TOKEN" \ + node "$QA_DIR/driver/browser-test.js" 2>&1 | tee "$TMP_DIR/driver.log" diff --git a/qa/stub/fund-lab-python b/qa/stub/fund-lab-python new file mode 100755 index 0000000..9cdfa6a --- /dev/null +++ b/qa/stub/fund-lab-python @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# QA stub interpreter: emulates the akshare_collector.py JSON contract +# (source=stub-synthetic, _synthetic=true on every payload). +# Invoked by MarketDataService as: fund-lab-python <script> --operation search --query X | --operation nav --code X +set -euo pipefail + +OP="" +QUERY="" +CODE="" +args=("$@") +for ((i=1; i<${#args[@]}; i++)); do + case "${args[$i]}" in + --operation) OP="${args[$((i+1))]:-}"; i=$((i+1)) ;; + --query) QUERY="${args[$((i+1))]:-}"; i=$((i+1)) ;; + --code) CODE="${args[$((i+1))]:-}"; i=$((i+1)) ;; + esac +done + +TS="$(date -u +%Y-%m-%dT%H:%M:%S+00:00)" + +case "$OP" in + search) + printf '{"schema_version":"fund-lab.akshare.v1","operation":"search","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instruments":[{"code":"000001","name":"桩测试基金一号","fund_type":"混合型"},{"code":"110011","name":"桩测试基金二号","fund_type":null}]}\n' "$TS" + ;; + nav) + printf '{"schema_version":"fund-lab.akshare.v1","operation":"nav","source":"stub-synthetic","source_revision":"stub-1","_synthetic":true,"collected_at":"%s","instrument":{"code":"%s"},"observations":[{"nav_date":"2026-09-15","published_at":null,"nav":"1.1000","accumulated_nav":null,"daily_return":null},{"nav_date":"2026-09-16","published_at":null,"nav":"1.1205","accumulated_nav":null,"daily_return":"1.86"},{"nav_date":"2026-09-17","published_at":null,"nav":"1.1180","accumulated_nav":null,"daily_return":"-0.22"},{"nav_date":"2026-09-18","published_at":null,"nav":"1.1500","accumulated_nav":null,"daily_return":"2.86"},{"nav_date":"2026-09-21","published_at":null,"nav":"1.1902","accumulated_nav":null,"daily_return":"3.49"}]}\n' "$TS" "$CODE" + ;; + *) + echo "unknown operation: $OP" >&2 + exit 2 + ;; +esac |
