197 行
5.7 KiB
JavaScript
197 行
5.7 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const DEFAULT_BASE_URL = "https://oa.hyinda.com";
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
baseUrl: process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL,
|
|
json: false,
|
|
expectations: [],
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--json") {
|
|
options.json = true;
|
|
} else if (arg === "--expect") {
|
|
const value = argv[index + 1];
|
|
if (!value) {
|
|
throw new Error("--expect requires key=value");
|
|
}
|
|
options.expectations.push(value);
|
|
index += 1;
|
|
} else if (arg.startsWith("--expect=")) {
|
|
options.expectations.push(arg.slice("--expect=".length));
|
|
} else if (arg.startsWith("--base-url=")) {
|
|
options.baseUrl = arg.slice("--base-url=".length);
|
|
}
|
|
}
|
|
|
|
options.baseUrl = options.baseUrl.trim().replace(/\/$/, "");
|
|
return options;
|
|
}
|
|
|
|
function parseExpectation(value) {
|
|
const [flagKey, expectedState] = value.split("=");
|
|
if (!flagKey || !expectedState) {
|
|
throw new Error(`invalid expectation: ${value}`);
|
|
}
|
|
const allowed = new Set(["force_on", "force_off", "enabled", "disabled"]);
|
|
if (!allowed.has(expectedState)) {
|
|
throw new Error(`invalid expected state for ${flagKey}: ${expectedState}`);
|
|
}
|
|
return { flagKey, expectedState };
|
|
}
|
|
|
|
function getAdminCredential() {
|
|
const identifier = process.env.LIVE_SMOKE_ADMIN_IDENTIFIER?.trim();
|
|
const password = process.env.LIVE_SMOKE_ADMIN_PASSWORD?.trim();
|
|
if (!identifier || !password) {
|
|
return null;
|
|
}
|
|
return { identifier, password };
|
|
}
|
|
|
|
async function fetchJson(url, init = {}) {
|
|
const response = await fetch(url, {
|
|
...init,
|
|
signal: AbortSignal.timeout(20_000),
|
|
});
|
|
const text = await response.text();
|
|
let json = null;
|
|
try {
|
|
json = text ? JSON.parse(text) : null;
|
|
} catch {
|
|
json = null;
|
|
}
|
|
return { ok: response.ok, status: response.status, json };
|
|
}
|
|
|
|
function summarizeError(json) {
|
|
const error = json?.error ?? {};
|
|
return {
|
|
code: typeof error.code === "string" ? error.code : null,
|
|
message: typeof error.message === "string" ? error.message.slice(0, 120) : null,
|
|
};
|
|
}
|
|
|
|
async function login(baseUrl, credential) {
|
|
const result = await fetchJson(`${baseUrl}/api/auth/login`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"x-device-fingerprint": "launch-auto-feature-flags",
|
|
},
|
|
body: JSON.stringify({
|
|
identifier: credential.identifier,
|
|
password: credential.password,
|
|
}),
|
|
});
|
|
const accessToken = result.json?.data?.accessToken;
|
|
if (!result.ok || typeof accessToken !== "string" || !accessToken) {
|
|
throw new Error(`admin login failed: HTTP ${result.status} ${summarizeError(result.json).code ?? ""}`.trim());
|
|
}
|
|
return accessToken;
|
|
}
|
|
|
|
function checkExpectation(item, expectedState) {
|
|
if (!item) {
|
|
return false;
|
|
}
|
|
if (expectedState === "force_on") {
|
|
return item.overrideMode === "force_on" && item.effectiveEnabled === true;
|
|
}
|
|
if (expectedState === "force_off") {
|
|
return item.overrideMode === "force_off" && item.effectiveEnabled === false;
|
|
}
|
|
if (expectedState === "enabled") {
|
|
return item.effectiveEnabled === true;
|
|
}
|
|
return item.effectiveEnabled === false;
|
|
}
|
|
|
|
function buildFlagSummary(item) {
|
|
if (!item) {
|
|
return null;
|
|
}
|
|
return {
|
|
flagKey: item.flagKey,
|
|
overrideMode: item.overrideMode,
|
|
rolloutType: item.rolloutType,
|
|
version: item.version,
|
|
envEnabled: item.envEnabled,
|
|
effectiveEnabled: item.effectiveEnabled,
|
|
effectiveReason: item.effectiveReason,
|
|
};
|
|
}
|
|
|
|
function printHuman(summary) {
|
|
console.log(`[live-feature-flags] status=${summary.status} base=${summary.baseUrl}`);
|
|
for (const result of summary.results) {
|
|
const item = result.item;
|
|
const detail = item
|
|
? `override=${item.overrideMode} effective=${item.effectiveEnabled} reason=${item.effectiveReason}`
|
|
: "missing";
|
|
console.log(
|
|
`[live-feature-flags] ${result.flagKey} expected=${result.expectedState} status=${result.status} ${detail}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const options = parseArgs(process.argv.slice(2));
|
|
const expectations = options.expectations.map(parseExpectation);
|
|
if (!expectations.length) {
|
|
throw new Error("at least one --expect key=value is required");
|
|
}
|
|
const credential = getAdminCredential();
|
|
if (!credential) {
|
|
throw new Error("missing LIVE_SMOKE_ADMIN_* credentials");
|
|
}
|
|
|
|
const accessToken = await login(options.baseUrl, credential);
|
|
const flags = await fetchJson(`${options.baseUrl}/api/system/feature-flags`, {
|
|
headers: {
|
|
authorization: `Bearer ${accessToken}`,
|
|
accept: "application/json",
|
|
},
|
|
});
|
|
if (!flags.ok) {
|
|
throw new Error(`feature flag list failed: HTTP ${flags.status} ${summarizeError(flags.json).code ?? ""}`.trim());
|
|
}
|
|
|
|
const items = flags.json?.data?.items ?? [];
|
|
const results = expectations.map((expectation) => {
|
|
const item = items.find((candidate) => candidate.flagKey === expectation.flagKey);
|
|
const passed = checkExpectation(item, expectation.expectedState);
|
|
return {
|
|
...expectation,
|
|
status: passed ? "PASS" : "FAIL",
|
|
item: buildFlagSummary(item),
|
|
};
|
|
});
|
|
const status = results.every((result) => result.status === "PASS") ? "PASS" : "FAIL";
|
|
const summary = {
|
|
checkedAt: new Date().toISOString(),
|
|
baseUrl: options.baseUrl,
|
|
status,
|
|
readOnly: true,
|
|
results,
|
|
};
|
|
|
|
if (options.json) {
|
|
console.log(JSON.stringify(summary, null, 2));
|
|
} else {
|
|
printHuman(summary);
|
|
}
|
|
|
|
if (status !== "PASS") {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(`[live-feature-flags][ERROR] ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exit(1);
|
|
});
|