chore: 将维护脚本迁移到 Bash
This commit is contained in:
parent
e2be439d17
commit
22d99d1a85
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
20
API.md
20
API.md
@ -387,12 +387,12 @@ NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `Nap
|
||||
|
||||
API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。
|
||||
|
||||
```powershell
|
||||
node scripts/napcat-desktop-cn-stage-build.mjs `
|
||||
--napcat-root D:\MyFiles\KT\GitHub\NapCatQQ `
|
||||
--upstream-release-tag v4.8.0 `
|
||||
--upstream-release-commit 0000000000000000000000000000000000000000 `
|
||||
--napcat-base-image-digest mlikiowa/napcat-docker@sha256:0000000000000000000000000000000000000000000000000000000000000000 `
|
||||
```bash
|
||||
node scripts/napcat-desktop-cn-stage-build.mjs \
|
||||
--napcat-root /home/yemu2/KT/GitHub/NapCatQQ \
|
||||
--upstream-release-tag v4.8.0 \
|
||||
--upstream-release-commit 0000000000000000000000000000000000000000 \
|
||||
--napcat-base-image-digest mlikiowa/napcat-docker@sha256:0000000000000000000000000000000000000000000000000000000000000000 \
|
||||
--jenkins-build-url https://jenkins.kwitsukasa.top/job/KT-NapCatQQ-Runtime-Release/1/
|
||||
```
|
||||
|
||||
@ -404,7 +404,7 @@ Gateway 只改写 NapCat HTML/JS/CSS 中需要浏览器直连的绝对根路径
|
||||
|
||||
必需环境变量:`NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL`、`NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL`、`NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET`、`NAPCAT_WEBUI_GATEWAY_REDIS_HOST`、`NAPCAT_WEBUI_GATEWAY_REDIS_PORT`、`NAPCAT_WEBUI_GATEWAY_SESSION_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_TICKET_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS`。生产 `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET` 只来自 Jenkins 私有 `.env.production` 生成的 `kt-template-online-api-env` Secret,不写入 Git 或 manifest 字面量。
|
||||
|
||||
部署验收使用:`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`Test-Path .\dist\apps\napcat-webui-gateway\main.js`、`git diff --check`。安全验收要求浏览器永远不接收 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。
|
||||
部署验收使用:`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`test -f dist/apps/napcat-webui-gateway/main.js`、`git diff --check`。安全验收要求浏览器永远不接收 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。
|
||||
|
||||
`napcat_login_event` 实体和表仅作为历史 schema 兼容保留;watchdog 不再写入 quick/password 恢复事件,也不再依赖该表判断是否恢复登录。
|
||||
|
||||
@ -630,9 +630,9 @@ pnpm test
|
||||
|
||||
BangDream 图片 smoke:
|
||||
|
||||
```powershell
|
||||
.\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.song.search -Text "夏祭り" -OutFile ".kt-workspace/bangdream-smoke/song.jpg"
|
||||
.\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.event.stage -Text "310" -OutFile ".kt-workspace/bangdream-smoke/stage.jpg" -ExpectedImageCount 5
|
||||
```bash
|
||||
bash scripts/bangdream-render-smoke.sh --operation-key bangdream.song.search --text "夏祭り" --out-file ".kt-workspace/bangdream-smoke/song.jpg"
|
||||
bash scripts/bangdream-render-smoke.sh --operation-key bangdream.event.stage --text "310" --out-file ".kt-workspace/bangdream-smoke/stage.jpg" --expected-image-count 5
|
||||
```
|
||||
|
||||
Jenkins/K8s 发布后还需要观察 rollout、新 Pod 日志,并跑真实运行态 smoke;推送成功不等于发布完成。
|
||||
|
||||
20
README.md
20
README.md
@ -83,16 +83,16 @@ NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布
|
||||
|
||||
运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。
|
||||
|
||||
```powershell
|
||||
node scripts/napcat-desktop-cn-stage-build.mjs `
|
||||
--napcat-root D:\MyFiles\KT\GitHub\NapCatQQ `
|
||||
--upstream-release-tag v4.8.0 `
|
||||
--upstream-release-commit 0000000000000000000000000000000000000000 `
|
||||
--napcat-base-image-digest mlikiowa/napcat-docker@sha256:0000000000000000000000000000000000000000000000000000000000000000 `
|
||||
```bash
|
||||
node scripts/napcat-desktop-cn-stage-build.mjs \
|
||||
--napcat-root /home/yemu2/KT/GitHub/NapCatQQ \
|
||||
--upstream-release-tag v4.8.0 \
|
||||
--upstream-release-commit 0000000000000000000000000000000000000000 \
|
||||
--napcat-base-image-digest mlikiowa/napcat-docker@sha256:0000000000000000000000000000000000000000000000000000000000000000 \
|
||||
--jenkins-build-url https://jenkins.kwitsukasa.top/job/KT-NapCatQQ-Runtime-Release/1/
|
||||
```
|
||||
|
||||
NapCat WebUI Gateway 是独立运行的 NestJS 入口,生产镜像使用 `dockerfile.gateway` 打包 `dist/apps/napcat-webui-gateway/main.js` 并监听 `48086`。API 通过内部地址 `NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL=http://kt-napcat-webui-gateway:48086` 创建/续期/撤销 WebUI 会话,Admin 浏览器只访问公开前缀 `NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL=/napcat-webui`。Gateway 运行时需要 `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET`、`NAPCAT_WEBUI_GATEWAY_REDIS_HOST`、`NAPCAT_WEBUI_GATEWAY_REDIS_PORT`、`NAPCAT_WEBUI_GATEWAY_SESSION_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_TICKET_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS`;生产 secret 由 Jenkins 从私有 `.env.production` 重建到 `kt-template-online-api-env`,不得写入 Git。验收命令:`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`Test-Path .\dist\apps\napcat-webui-gateway\main.js`、`git diff --check`。安全边界:浏览器不得收到 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。
|
||||
NapCat WebUI Gateway 是独立运行的 NestJS 入口,生产镜像使用 `dockerfile.gateway` 打包 `dist/apps/napcat-webui-gateway/main.js` 并监听 `48086`。API 通过内部地址 `NAPCAT_WEBUI_GATEWAY_INTERNAL_BASE_URL=http://kt-napcat-webui-gateway:48086` 创建/续期/撤销 WebUI 会话,Admin 浏览器只访问公开前缀 `NAPCAT_WEBUI_GATEWAY_PUBLIC_BASE_URL=/napcat-webui`。Gateway 运行时需要 `NAPCAT_WEBUI_GATEWAY_INTERNAL_SECRET`、`NAPCAT_WEBUI_GATEWAY_REDIS_HOST`、`NAPCAT_WEBUI_GATEWAY_REDIS_PORT`、`NAPCAT_WEBUI_GATEWAY_SESSION_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_TICKET_TTL_MS`、`NAPCAT_WEBUI_GATEWAY_UPSTREAM_TIMEOUT_MS`;生产 secret 由 Jenkins 从私有 `.env.production` 重建到 `kt-template-online-api-env`,不得写入 Git。验收命令:`pnpm exec jest --runTestsByPath test/modules/qqbot/napcat-webui-gateway/gateway-deployment.spec.ts --runInBand`、`pnpm run typecheck`、`pnpm run build`、`test -f dist/apps/napcat-webui-gateway/main.js`、`git diff --check`。安全边界:浏览器不得收到 WebUI token、Credential、上游 URL/端口、Docker 拓扑、Redis 地址或内部 secret。
|
||||
|
||||
## 启动
|
||||
|
||||
@ -208,9 +208,9 @@ pnpm test
|
||||
|
||||
BangDream 图片能力改动:
|
||||
|
||||
```powershell
|
||||
.\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.song.search -Text "夏祭り" -OutFile ".kt-workspace/bangdream-smoke/song.jpg"
|
||||
.\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.event.stage -Text "310" -OutFile ".kt-workspace/bangdream-smoke/stage.jpg" -ExpectedImageCount 5
|
||||
```bash
|
||||
bash scripts/bangdream-render-smoke.sh --operation-key bangdream.song.search --text "夏祭り" --out-file ".kt-workspace/bangdream-smoke/song.jpg"
|
||||
bash scripts/bangdream-render-smoke.sh --operation-key bangdream.event.stage --text "310" --out-file ".kt-workspace/bangdream-smoke/stage.jpg" --expected-image-count 5
|
||||
```
|
||||
|
||||
接口改动必须启动或复用本地服务,并真实调用一次对应接口。
|
||||
|
||||
@ -7,12 +7,12 @@
|
||||
3. Apply `sql/refactor-v3/01-seed-core.sql`.
|
||||
4. Run `sql/refactor-v3/99-verify.sql`.
|
||||
5. Start API against the dry-run database.
|
||||
6. Run `scripts/refactor-v3/local-smoke.ps1`.
|
||||
6. Run `bash scripts/refactor-v3/local-smoke.sh`.
|
||||
|
||||
## Online Backup
|
||||
|
||||
1. Confirm the exact API image tag and current database name.
|
||||
2. Run `scripts/refactor-v3/db-backup-online.ps1`.
|
||||
2. Run `bash scripts/refactor-v3/db-backup-online.sh --database <name> --output-directory <path>`.
|
||||
3. Record backup path, timestamp, source database, and restore command.
|
||||
|
||||
## Online Rebuild
|
||||
|
||||
341
scripts/bangdream-render-smoke-matrix.cjs
Normal file
341
scripts/bangdream-render-smoke-matrix.cjs
Normal file
@ -0,0 +1,341 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { setTimeout: delay } = require('timers/promises');
|
||||
const XLSX = require('xlsx');
|
||||
const { loadImage } = require('skia-canvas');
|
||||
const { createPlugin } = require('../src/modules/qqbot/plugins/bangdream/src');
|
||||
|
||||
const cases = [
|
||||
{ name: 'song-search-detail', operationKey: 'bangdream.song.search', text: '136', expectedImageCount: 1 },
|
||||
{ name: 'song-search-list', operationKey: 'bangdream.song.search', text: 'FIRE BIRD', expectedImageCount: 1 },
|
||||
{ name: 'song-chart', operationKey: 'bangdream.song.chart', text: '136 expert', expectedImageCount: 1 },
|
||||
{ name: 'song-random', operationKey: 'bangdream.song.random', text: 'FIRE BIRD', expectedImageCount: 1 },
|
||||
{ name: 'song-meta', operationKey: 'bangdream.song.meta', text: 'cn', expectedImageCount: 1 },
|
||||
{ name: 'card-search-detail', operationKey: 'bangdream.card.search', text: '472', expectedImageCount: 1 },
|
||||
{ name: 'card-search-list', operationKey: 'bangdream.card.search', text: '香澄', expectedImageCount: 1 },
|
||||
{ name: 'card-illustration', operationKey: 'bangdream.card.illustration', text: '472', expectedImageCountMin: 1 },
|
||||
{ name: 'character-search-detail', operationKey: 'bangdream.character.search', text: '1', expectedImageCount: 1 },
|
||||
{ name: 'character-search-list', operationKey: 'bangdream.character.search', text: '香澄', expectedImageCount: 1 },
|
||||
{ name: 'event-search-detail', operationKey: 'bangdream.event.search', text: '50', expectedImageCount: 1 },
|
||||
{ name: 'event-search-list', operationKey: 'bangdream.event.search', text: 'summer', expectedImageCount: 1 },
|
||||
{ name: 'event-stage', operationKey: 'bangdream.event.stage', text: '310 cn -m', expectedImageCount: 5 },
|
||||
{ name: 'player-search', operationKey: 'bangdream.player.search', text: '26591455 jp', expectedImageCount: 1, external: true },
|
||||
{ name: 'gacha-search', operationKey: 'bangdream.gacha.search', text: '259', expectedImageCount: 1 },
|
||||
{ name: 'gacha-simulate', operationKey: 'bangdream.gacha.simulate', text: '10 259', expectedImageCount: 1 },
|
||||
{ name: 'cutoff-detail', operationKey: 'bangdream.cutoff.detail', text: 'ycx 1000 50 cn', expectedImageCount: 1 },
|
||||
{ name: 'cutoff-detail-top10', operationKey: 'bangdream.cutoff.detail', text: 'ycx 10 100 cn', expectedImageCount: 1 },
|
||||
{ name: 'cutoff-all', operationKey: 'bangdream.cutoff.all', text: '50 cn', expectedImageCount: 1 },
|
||||
{ name: 'cutoff-recent', operationKey: 'bangdream.cutoff.recent', text: '1000 50 cn', expectedImageCount: 1 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Reads the Bash-validated matrix payload from environment variables.
|
||||
* @returns {{outDir: string, skipExternalPlayer: boolean}} Matrix payload.
|
||||
*/
|
||||
function readPayload() {
|
||||
const outDir = process.env.BANGDREAM_MATRIX_OUT_DIR || '';
|
||||
if (!outDir) {
|
||||
throw new Error('BANGDREAM_MATRIX_OUT_DIR is required');
|
||||
}
|
||||
return {
|
||||
outDir,
|
||||
skipExternalPlayer:
|
||||
process.env.BANGDREAM_MATRIX_SKIP_EXTERNAL_PLAYER === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an HTTP-shaped error compatible with the BangDream adapters.
|
||||
* @param {string} message Error message.
|
||||
* @param {number} statusCode HTTP response status.
|
||||
* @returns {Error & {response: {status: number}, statusCode: number}} Compatible error.
|
||||
*/
|
||||
function createHttpError(message, statusCode) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
error.response = { status: statusCode };
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one resource with an adapter-level timeout.
|
||||
* @param {string | URL} url Resource URL.
|
||||
* @param {{headers?: object, timeoutMs?: number}} [options] Request options.
|
||||
* @returns {Promise<Response>} Fetch response.
|
||||
*/
|
||||
async function fetchWithTimeout(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = options.timeoutMs || 30000;
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
headers: options.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses a JSON file synchronously.
|
||||
* @param {string} filePath JSON file path.
|
||||
* @returns {unknown} Parsed JSON value.
|
||||
*/
|
||||
function readJsonFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the first worksheet from an Excel file as row objects.
|
||||
* @param {string} filePath Excel file path.
|
||||
* @returns {object[]} Worksheet rows.
|
||||
*/
|
||||
function readExcelRows(filePath) {
|
||||
const workbook = XLSX.readFile(filePath);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a binary resource for the BangDream plugin.
|
||||
* @param {string | URL} url Resource URL.
|
||||
* @param {{headers?: object, timeoutMs?: number}} [options] Request options.
|
||||
* @returns {Promise<{body: Buffer, statusCode: number}>} Binary response body and status.
|
||||
*/
|
||||
async function requestBuffer(url, options) {
|
||||
const response = await fetchWithTimeout(url, options);
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
if (!response.ok) {
|
||||
throw createHttpError(
|
||||
`BangDream resource request failed: ${response.status}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return { body, statusCode: response.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests and parses a JSON resource for the BangDream plugin.
|
||||
* @param {string | URL} url Resource URL.
|
||||
* @param {{headers?: object, timeoutMs?: number}} [options] Request options.
|
||||
* @returns {Promise<{body: unknown, statusCode: number}>} Parsed response body and status.
|
||||
*/
|
||||
async function requestJson(url, options) {
|
||||
const response = await fetchWithTimeout(url, options);
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw createHttpError(
|
||||
`BangDream JSON request failed: ${response.status}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return { body: JSON.parse(responseText), statusCode: response.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the filesystem and network adapter expected by the BangDream plugin.
|
||||
* @returns {object} Plugin IO adapter.
|
||||
*/
|
||||
function createIoAdapter() {
|
||||
return {
|
||||
/** @param {string} key Environment key. @returns {string | undefined} Config value. */
|
||||
getConfig(key) {
|
||||
return process.env[key];
|
||||
},
|
||||
/** @param {string} filePath Asset path. @returns {Promise<Buffer>} Asset bytes. */
|
||||
async readAssetFile(filePath) {
|
||||
return fs.promises.readFile(filePath);
|
||||
},
|
||||
/** @param {string} filePath Excel path. @returns {Promise<object[]>} Worksheet rows. */
|
||||
async readExcelRows(filePath) {
|
||||
return readExcelRows(filePath);
|
||||
},
|
||||
/** @param {string} filePath JSON path. @returns {Promise<unknown>} Parsed value. */
|
||||
async readJsonFile(filePath) {
|
||||
return readJsonFile(filePath);
|
||||
},
|
||||
/** @param {string} filePath JSON path. @returns {unknown} Parsed value. */
|
||||
readJsonFileSync(filePath) {
|
||||
return readJsonFile(filePath);
|
||||
},
|
||||
requestArrayBuffer: requestBuffer,
|
||||
requestJson,
|
||||
/** @param {number} milliseconds Delay length. @returns {Promise<void>} Delay completion. */
|
||||
async sleep(milliseconds) {
|
||||
await delay(milliseconds);
|
||||
},
|
||||
/** @param {string} filePath Output path. @param {unknown} data JSON value. @returns {Promise<void>} Write completion. */
|
||||
async writeJsonFile(filePath, data) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(data));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts plugin errors to their stable human-readable representation.
|
||||
* @param {unknown} error Error-like value.
|
||||
* @returns {string} Normalized message.
|
||||
*/
|
||||
function normalizeError(error) {
|
||||
return String(error?.message || error || 'BangDream command failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a case name into a safe output filename component.
|
||||
* @param {string} name Matrix case name.
|
||||
* @returns {string} Filesystem-safe name.
|
||||
*/
|
||||
function safeName(name) {
|
||||
return name.replace(/[^a-zA-Z0-9._-]+/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes and decodes all images returned by one matrix case.
|
||||
* @param {object} testCase Matrix case contract.
|
||||
* @param {object} result Plugin operation result.
|
||||
* @param {string} outDir Matrix output directory.
|
||||
* @returns {Promise<object[]>} Image file metadata.
|
||||
*/
|
||||
async function writeCaseImages(testCase, result, outDir) {
|
||||
const matches = [...result.replyText.matchAll(/base64:\/\/([A-Za-z0-9+/=]+)/g)];
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`${testCase.name}: no CQ image payload`);
|
||||
}
|
||||
if (
|
||||
testCase.expectedImageCount &&
|
||||
matches.length !== testCase.expectedImageCount
|
||||
) {
|
||||
throw new Error(
|
||||
`${testCase.name}: expected imageCount=${testCase.expectedImageCount}, got ${matches.length}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
testCase.expectedImageCountMin &&
|
||||
matches.length < testCase.expectedImageCountMin
|
||||
) {
|
||||
throw new Error(
|
||||
`${testCase.name}: expected imageCount>=${testCase.expectedImageCountMin}, got ${matches.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const files = [];
|
||||
for (let index = 0; index < matches.length; index += 1) {
|
||||
const output = path.join(
|
||||
outDir,
|
||||
`${safeName(testCase.name)}${index === 0 ? '' : `-${index + 1}`}.jpg`,
|
||||
);
|
||||
const buffer = Buffer.from(matches[index][1], 'base64');
|
||||
fs.writeFileSync(output, buffer);
|
||||
const image = await loadImage(output);
|
||||
files.push({
|
||||
bytes: buffer.length,
|
||||
height: image.height,
|
||||
out: output,
|
||||
width: image.width,
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the full render matrix and writes its machine-readable summary.
|
||||
* @returns {Promise<void>} Resolves after the summary has been written.
|
||||
*/
|
||||
async function main() {
|
||||
const payload = readPayload();
|
||||
fs.mkdirSync(payload.outDir, { recursive: true });
|
||||
const manifest = readJsonFile(
|
||||
path.join(process.cwd(), 'src/modules/qqbot/plugins/bangdream/plugin.json'),
|
||||
);
|
||||
const operations = manifest.operations.map((operation) => ({
|
||||
handlerName: operation.handlerName,
|
||||
key: operation.key,
|
||||
}));
|
||||
const plugin = createPlugin({
|
||||
io: createIoAdapter(),
|
||||
normalizeError,
|
||||
operations,
|
||||
});
|
||||
await plugin.activate();
|
||||
|
||||
const summaries = [];
|
||||
const executedKeys = new Set();
|
||||
for (const testCase of cases) {
|
||||
if (testCase.external && payload.skipExternalPlayer) {
|
||||
summaries.push({
|
||||
name: testCase.name,
|
||||
operationKey: testCase.operationKey,
|
||||
reason: 'external-player',
|
||||
skipped: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const result = await plugin.executeOperation(testCase.operationKey, {
|
||||
compress: true,
|
||||
displayedServerList: '',
|
||||
text: testCase.text,
|
||||
});
|
||||
const files = await writeCaseImages(testCase, result, payload.outDir);
|
||||
executedKeys.add(testCase.operationKey);
|
||||
summaries.push({
|
||||
durationMs: Date.now() - startedAt,
|
||||
files,
|
||||
imageCount: result.imageCount,
|
||||
name: testCase.name,
|
||||
operationKey: testCase.operationKey,
|
||||
query: result.query,
|
||||
});
|
||||
process.stdout.write(
|
||||
`[matrix] ${testCase.name} imageCount=${result.imageCount} first=${files[0].width}x${files[0].height}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const manifestKeys = manifest.operations
|
||||
.map((operation) => operation.key)
|
||||
.sort();
|
||||
const missingKeys = manifestKeys.filter(
|
||||
(key) => !cases.some((item) => item.operationKey === key),
|
||||
);
|
||||
if (missingKeys.length > 0) {
|
||||
throw new Error(`Smoke matrix missing operation keys: ${missingKeys.join(', ')}`);
|
||||
}
|
||||
|
||||
const summary = {
|
||||
caseCount: cases.length,
|
||||
executedOperationKeys: [...executedKeys].sort(),
|
||||
manifestOperationKeys: manifestKeys,
|
||||
outDir: payload.outDir,
|
||||
skipped: summaries.filter((item) => item.skipped),
|
||||
summaries,
|
||||
};
|
||||
const summaryFile = path.join(payload.outDir, 'matrix-summary.json');
|
||||
fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2));
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
caseCount: summary.caseCount,
|
||||
executedOperationKeyCount: summary.executedOperationKeys.length,
|
||||
skippedCount: summary.skipped.length,
|
||||
summaryFile,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an uncaught matrix error to stderr and fails the process.
|
||||
* @param {unknown} error Error-like value.
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleFatalError(error) {
|
||||
process.stderr.write(`${String(error?.stack || error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch(handleFatalError);
|
||||
@ -1,257 +0,0 @@
|
||||
param(
|
||||
[string]$OutDir = ".kt-workspace/bangdream-smoke/matrix",
|
||||
[int]$TimeoutSeconds = 240,
|
||||
[switch]$SkipExternalPlayer
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ProjectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$ResolvedOutDir = if ([System.IO.Path]::IsPathRooted($OutDir)) {
|
||||
$OutDir
|
||||
} else {
|
||||
Join-Path $ProjectRoot $OutDir
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $ResolvedOutDir | Out-Null
|
||||
|
||||
$StdoutLog = Join-Path $ResolvedOutDir "matrix.out.log"
|
||||
$StderrLog = Join-Path $ResolvedOutDir "matrix.err.log"
|
||||
$SummaryFile = Join-Path $ResolvedOutDir "matrix-summary.json"
|
||||
Remove-Item -LiteralPath $StdoutLog,$StderrLog,$SummaryFile -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$Payload = @{
|
||||
outDir = $ResolvedOutDir
|
||||
skipExternalPlayer = [bool]$SkipExternalPlayer
|
||||
}
|
||||
$PayloadJson = $Payload | ConvertTo-Json -Compress -Depth 5
|
||||
$PayloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($PayloadJson))
|
||||
|
||||
$NodeCode = @"
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const XLSX = require("xlsx");
|
||||
const { loadImage } = require("skia-canvas");
|
||||
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
|
||||
const { createPlugin } = require("./src/modules/qqbot/plugins/bangdream/src");
|
||||
|
||||
const cases = [
|
||||
{ name: "song-search-detail", operationKey: "bangdream.song.search", text: "136", expectedImageCount: 1 },
|
||||
{ name: "song-search-list", operationKey: "bangdream.song.search", text: "FIRE BIRD", expectedImageCount: 1 },
|
||||
{ name: "song-chart", operationKey: "bangdream.song.chart", text: "136 expert", expectedImageCount: 1 },
|
||||
{ name: "song-random", operationKey: "bangdream.song.random", text: "FIRE BIRD", expectedImageCount: 1 },
|
||||
{ name: "song-meta", operationKey: "bangdream.song.meta", text: "cn", expectedImageCount: 1 },
|
||||
{ name: "card-search-detail", operationKey: "bangdream.card.search", text: "472", expectedImageCount: 1 },
|
||||
{ name: "card-search-list", operationKey: "bangdream.card.search", text: "香澄", expectedImageCount: 1 },
|
||||
{ name: "card-illustration", operationKey: "bangdream.card.illustration", text: "472", expectedImageCountMin: 1 },
|
||||
{ name: "character-search-detail", operationKey: "bangdream.character.search", text: "1", expectedImageCount: 1 },
|
||||
{ name: "character-search-list", operationKey: "bangdream.character.search", text: "香澄", expectedImageCount: 1 },
|
||||
{ name: "event-search-detail", operationKey: "bangdream.event.search", text: "50", expectedImageCount: 1 },
|
||||
{ name: "event-search-list", operationKey: "bangdream.event.search", text: "summer", expectedImageCount: 1 },
|
||||
{ name: "event-stage", operationKey: "bangdream.event.stage", text: "310 cn -m", expectedImageCount: 5 },
|
||||
{ name: "player-search", operationKey: "bangdream.player.search", text: "26591455 jp", expectedImageCount: 1, external: true },
|
||||
{ name: "gacha-search", operationKey: "bangdream.gacha.search", text: "259", expectedImageCount: 1 },
|
||||
{ name: "gacha-simulate", operationKey: "bangdream.gacha.simulate", text: "10 259", expectedImageCount: 1 },
|
||||
{ name: "cutoff-detail", operationKey: "bangdream.cutoff.detail", text: "ycx 1000 50 cn", expectedImageCount: 1 },
|
||||
{ name: "cutoff-detail-top10", operationKey: "bangdream.cutoff.detail", text: "ycx 10 100 cn", expectedImageCount: 1 },
|
||||
{ name: "cutoff-all", operationKey: "bangdream.cutoff.all", text: "50 cn", expectedImageCount: 1 },
|
||||
{ name: "cutoff-recent", operationKey: "bangdream.cutoff.recent", text: "1000 50 cn", expectedImageCount: 1 },
|
||||
];
|
||||
|
||||
function createHttpError(message, statusCode) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
error.response = { status: statusCode };
|
||||
return error;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = options.timeoutMs || 30000;
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
headers: options.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function readExcelRows(filePath) {
|
||||
const workbook = XLSX.readFile(filePath);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]);
|
||||
}
|
||||
|
||||
async function requestBuffer(url, options) {
|
||||
const response = await fetchWithTimeout(url, options);
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
if (!response.ok) {
|
||||
throw createHttpError("BangDream resource request failed: " + response.status, response.status);
|
||||
}
|
||||
return { body, statusCode: response.status };
|
||||
}
|
||||
|
||||
async function requestJson(url, options) {
|
||||
const response = await fetchWithTimeout(url, options);
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw createHttpError("BangDream JSON request failed: " + response.status, response.status);
|
||||
}
|
||||
return { body: JSON.parse(text), statusCode: response.status };
|
||||
}
|
||||
|
||||
function safeName(name) {
|
||||
return name.replace(/[^a-zA-Z0-9._-]+/g, "-");
|
||||
}
|
||||
|
||||
async function writeCaseImages(testCase, result) {
|
||||
const matches = [...result.replyText.matchAll(/base64:\/\/([A-Za-z0-9+/=]+)/g)];
|
||||
if (matches.length === 0) throw new Error(testCase.name + ": no CQ image payload");
|
||||
if (testCase.expectedImageCount && matches.length !== testCase.expectedImageCount) {
|
||||
throw new Error(testCase.name + ": expected imageCount=" + testCase.expectedImageCount + ", got " + matches.length);
|
||||
}
|
||||
if (testCase.expectedImageCountMin && matches.length < testCase.expectedImageCountMin) {
|
||||
throw new Error(testCase.name + ": expected imageCount>=" + testCase.expectedImageCountMin + ", got " + matches.length);
|
||||
}
|
||||
|
||||
const files = [];
|
||||
for (let index = 0; index < matches.length; index++) {
|
||||
const output = path.join(payload.outDir, safeName(testCase.name) + (index === 0 ? "" : "-" + (index + 1)) + ".jpg");
|
||||
const buffer = Buffer.from(matches[index][1], "base64");
|
||||
fs.writeFileSync(output, buffer);
|
||||
const image = await loadImage(output);
|
||||
files.push({
|
||||
bytes: buffer.length,
|
||||
height: image.height,
|
||||
out: output,
|
||||
width: image.width,
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
fs.mkdirSync(payload.outDir, { recursive: true });
|
||||
const manifest = readJsonFile(path.join(process.cwd(), "src/modules/qqbot/plugins/bangdream/plugin.json"));
|
||||
const operations = manifest.operations.map((operation) => ({
|
||||
handlerName: operation.handlerName,
|
||||
key: operation.key,
|
||||
}));
|
||||
const plugin = createPlugin({
|
||||
io: {
|
||||
getConfig: (key) => process.env[key],
|
||||
readAssetFile: async (filePath) => fs.promises.readFile(filePath),
|
||||
readExcelRows: async (filePath) => readExcelRows(filePath),
|
||||
readJsonFile: async (filePath) => readJsonFile(filePath),
|
||||
readJsonFileSync: (filePath) => readJsonFile(filePath),
|
||||
requestArrayBuffer: requestBuffer,
|
||||
requestJson,
|
||||
sleep: async (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
writeJsonFile: async (filePath, data) => {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(data));
|
||||
},
|
||||
},
|
||||
normalizeError: (error) => String(error?.message || error || "BangDream command failed"),
|
||||
operations,
|
||||
});
|
||||
await plugin.activate();
|
||||
|
||||
const summaries = [];
|
||||
const executedKeys = new Set();
|
||||
for (const testCase of cases) {
|
||||
if (testCase.external && payload.skipExternalPlayer) {
|
||||
summaries.push({
|
||||
name: testCase.name,
|
||||
operationKey: testCase.operationKey,
|
||||
skipped: true,
|
||||
reason: "external-player",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
const result = await plugin.executeOperation(testCase.operationKey, {
|
||||
compress: true,
|
||||
displayedServerList: "",
|
||||
text: testCase.text,
|
||||
});
|
||||
const files = await writeCaseImages(testCase, result);
|
||||
executedKeys.add(testCase.operationKey);
|
||||
summaries.push({
|
||||
durationMs: Date.now() - startedAt,
|
||||
files,
|
||||
imageCount: result.imageCount,
|
||||
name: testCase.name,
|
||||
operationKey: testCase.operationKey,
|
||||
query: result.query,
|
||||
});
|
||||
process.stdout.write("[matrix] " + testCase.name + " imageCount=" + result.imageCount + " first=" + files[0].width + "x" + files[0].height + "\n");
|
||||
}
|
||||
|
||||
const manifestKeys = manifest.operations.map((operation) => operation.key).sort();
|
||||
const missingKeys = manifestKeys.filter((key) => !cases.some((item) => item.operationKey === key));
|
||||
if (missingKeys.length > 0) {
|
||||
throw new Error("Smoke matrix missing operation keys: " + missingKeys.join(", "));
|
||||
}
|
||||
|
||||
const summary = {
|
||||
caseCount: cases.length,
|
||||
executedOperationKeys: [...executedKeys].sort(),
|
||||
manifestOperationKeys: manifestKeys,
|
||||
outDir: payload.outDir,
|
||||
skipped: summaries.filter((item) => item.skipped),
|
||||
summaries,
|
||||
};
|
||||
const summaryFile = path.join(payload.outDir, "matrix-summary.json");
|
||||
fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2));
|
||||
process.stdout.write(JSON.stringify({
|
||||
caseCount: summary.caseCount,
|
||||
executedOperationKeyCount: summary.executedOperationKeys.length,
|
||||
skippedCount: summary.skipped.length,
|
||||
summaryFile,
|
||||
}, null, 2) + "\n");
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error?.stack || error) + "\n");
|
||||
process.exit(1);
|
||||
});
|
||||
"@
|
||||
$NodeCodeBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($NodeCode))
|
||||
$EvalCode = "eval(Buffer.from('$NodeCodeBase64','base64').toString('utf8'))"
|
||||
|
||||
$env:TS_NODE_TRANSPILE_ONLY = "true"
|
||||
$Process = Start-Process `
|
||||
-FilePath "node" `
|
||||
-ArgumentList @("-r", "ts-node/register", "-r", "tsconfig-paths/register", "-e", $EvalCode) `
|
||||
-WorkingDirectory $ProjectRoot `
|
||||
-RedirectStandardOutput $StdoutLog `
|
||||
-RedirectStandardError $StderrLog `
|
||||
-PassThru `
|
||||
-WindowStyle Hidden
|
||||
|
||||
$Deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
while (-not $Process.HasExited) {
|
||||
if ((Get-Date) -ge $Deadline) {
|
||||
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
|
||||
Write-Output "BangDream smoke matrix timed out and process $($Process.Id) was killed."
|
||||
if (Test-Path $StdoutLog) { Get-Content $StdoutLog }
|
||||
if (Test-Path $StderrLog) { Get-Content $StderrLog }
|
||||
exit 124
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
$Process.Refresh()
|
||||
}
|
||||
|
||||
if (Test-Path $StdoutLog) { Get-Content $StdoutLog }
|
||||
if (Test-Path $StderrLog) { Get-Content $StderrLog }
|
||||
if (-not (Test-Path $SummaryFile)) {
|
||||
Write-Output "BangDream smoke matrix did not produce a summary file."
|
||||
exit 1
|
||||
}
|
||||
exit $Process.ExitCode
|
||||
167
scripts/bangdream-render-smoke-matrix.sh
Executable file
167
scripts/bangdream-render-smoke-matrix.sh
Executable file
@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
||||
PROJECT_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd -P)
|
||||
NODE_RUNNER="$SCRIPT_DIR/bangdream-render-smoke-matrix.cjs"
|
||||
|
||||
out_dir=".kt-workspace/bangdream-smoke/matrix"
|
||||
timeout_seconds=240
|
||||
skip_external_player=false
|
||||
child_pid=""
|
||||
|
||||
# Prints command usage without performing render or network work.
|
||||
print_help() {
|
||||
cat <<'EOF'
|
||||
Usage: bangdream-render-smoke-matrix.sh [options]
|
||||
|
||||
Run the bounded BangDream render matrix and write its JSON summary.
|
||||
|
||||
Options:
|
||||
--out-dir PATH Matrix output directory
|
||||
--timeout-seconds SECONDS Whole-process timeout (default: 240)
|
||||
--skip-external-player Skip the external player lookup case
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
# Reports an invalid invocation and exits with the standard usage status.
|
||||
usage_error() {
|
||||
printf 'Error: %s\n\n' "$1" >&2
|
||||
print_help >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Returns success when a value is an integer greater than zero.
|
||||
is_positive_integer() {
|
||||
[[ $1 =~ ^[1-9][0-9]*$ ]]
|
||||
}
|
||||
|
||||
# Stops the matrix child started by this script and reaps it.
|
||||
cleanup_child() {
|
||||
if [[ -n ${child_pid:-} ]] && kill -0 "$child_pid" 2>/dev/null; then
|
||||
kill -TERM "$child_pid" 2>/dev/null || true
|
||||
for _ in {1..20}; do
|
||||
if ! kill -0 "$child_pid" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if kill -0 "$child_pid" 2>/dev/null; then
|
||||
kill -KILL "$child_pid" 2>/dev/null || true
|
||||
fi
|
||||
wait "$child_pid" 2>/dev/null || true
|
||||
fi
|
||||
child_pid=""
|
||||
}
|
||||
|
||||
# Converts an interrupt signal into a deterministic exit after cleanup.
|
||||
# shellcheck disable=SC2317 # Invoked indirectly by trap.
|
||||
handle_interrupt() {
|
||||
cleanup_child
|
||||
exit 130
|
||||
}
|
||||
|
||||
# Converts a termination signal into a deterministic exit after cleanup.
|
||||
# shellcheck disable=SC2317 # Invoked indirectly by trap.
|
||||
handle_termination() {
|
||||
cleanup_child
|
||||
exit 143
|
||||
}
|
||||
|
||||
# Replays captured output while preserving stdout and stderr separation.
|
||||
print_logs() {
|
||||
[[ -f $stdout_log ]] && cat -- "$stdout_log"
|
||||
[[ -f $stderr_log ]] && cat -- "$stderr_log" >&2
|
||||
}
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--out-dir)
|
||||
(($# >= 2)) || usage_error "--out-dir requires a value"
|
||||
out_dir=$2
|
||||
shift 2
|
||||
;;
|
||||
--timeout-seconds)
|
||||
(($# >= 2)) || usage_error "--timeout-seconds requires a value"
|
||||
timeout_seconds=$2
|
||||
shift 2
|
||||
;;
|
||||
--skip-external-player)
|
||||
skip_external_player=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
print_help
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
(($# == 0)) || usage_error "unexpected positional argument: $1"
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n $out_dir ]] || usage_error "--out-dir cannot be empty"
|
||||
is_positive_integer "$timeout_seconds" || usage_error "--timeout-seconds must be a positive integer"
|
||||
|
||||
if [[ $out_dir = /* ]]; then
|
||||
resolved_out_dir=$out_dir
|
||||
else
|
||||
resolved_out_dir="$PROJECT_ROOT/$out_dir"
|
||||
fi
|
||||
mkdir -p -- "$resolved_out_dir"
|
||||
|
||||
stdout_log="$resolved_out_dir/matrix.out.log"
|
||||
stderr_log="$resolved_out_dir/matrix.err.log"
|
||||
summary_file="$resolved_out_dir/matrix-summary.json"
|
||||
rm -f -- "$stdout_log" "$stderr_log" "$summary_file"
|
||||
|
||||
trap cleanup_child EXIT
|
||||
trap handle_interrupt INT
|
||||
trap handle_termination TERM
|
||||
|
||||
cd -- "$PROJECT_ROOT"
|
||||
TS_NODE_TRANSPILE_ONLY=true \
|
||||
BANGDREAM_MATRIX_OUT_DIR=$resolved_out_dir \
|
||||
BANGDREAM_MATRIX_SKIP_EXTERNAL_PLAYER=$skip_external_player \
|
||||
node -r ts-node/register -r tsconfig-paths/register "$NODE_RUNNER" \
|
||||
>"$stdout_log" 2>"$stderr_log" &
|
||||
child_pid=$!
|
||||
|
||||
deadline=$((SECONDS + timeout_seconds))
|
||||
timed_out=false
|
||||
while kill -0 "$child_pid" 2>/dev/null; do
|
||||
if ((SECONDS >= deadline)); then
|
||||
timed_out=true
|
||||
cleanup_child
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if [[ $timed_out == true ]]; then
|
||||
printf 'BangDream smoke matrix timed out after %s seconds and its process was killed.\n' "$timeout_seconds"
|
||||
print_logs
|
||||
exit 124
|
||||
fi
|
||||
|
||||
process_status=0
|
||||
if [[ -n $child_pid ]]; then
|
||||
if wait "$child_pid"; then
|
||||
process_status=0
|
||||
else
|
||||
process_status=$?
|
||||
fi
|
||||
child_pid=""
|
||||
fi
|
||||
|
||||
print_logs
|
||||
if [[ ! -f $summary_file ]]; then
|
||||
printf 'BangDream smoke matrix did not produce a summary file.\n'
|
||||
exit 1
|
||||
fi
|
||||
exit "$process_status"
|
||||
253
scripts/bangdream-render-smoke.cjs
Normal file
253
scripts/bangdream-render-smoke.cjs
Normal file
@ -0,0 +1,253 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { setTimeout: delay } = require('timers/promises');
|
||||
const XLSX = require('xlsx');
|
||||
const { createPlugin } = require('../src/modules/qqbot/plugins/bangdream/src');
|
||||
|
||||
/**
|
||||
* Reads the Bash-validated render payload from environment variables.
|
||||
* @returns {{expectedImageCount: number, input: object, operationKey: string, outFile: string}} Render payload.
|
||||
*/
|
||||
function readPayload() {
|
||||
const expectedImageCount = Number.parseInt(
|
||||
process.env.BANGDREAM_EXPECTED_IMAGE_COUNT || '0',
|
||||
10,
|
||||
);
|
||||
if (!Number.isInteger(expectedImageCount) || expectedImageCount < 0) {
|
||||
throw new Error('BANGDREAM_EXPECTED_IMAGE_COUNT must be a non-negative integer');
|
||||
}
|
||||
|
||||
const operationKey = process.env.BANGDREAM_OPERATION_KEY || '';
|
||||
const outFile = process.env.BANGDREAM_OUT_FILE || '';
|
||||
if (!operationKey || !outFile) {
|
||||
throw new Error('BANGDREAM_OPERATION_KEY and BANGDREAM_OUT_FILE are required');
|
||||
}
|
||||
|
||||
return {
|
||||
expectedImageCount,
|
||||
input: {
|
||||
compress: true,
|
||||
displayedServerList: process.env.BANGDREAM_DISPLAYED_SERVER_LIST || '',
|
||||
text: process.env.BANGDREAM_TEXT || '',
|
||||
useEasyBG: process.env.BANGDREAM_USE_EASY_BG === 'true',
|
||||
},
|
||||
operationKey,
|
||||
outFile,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an HTTP-shaped error compatible with the BangDream adapters.
|
||||
* @param {string} message Error message.
|
||||
* @param {number} statusCode HTTP response status.
|
||||
* @returns {Error & {response: {status: number}, statusCode: number}} Compatible error.
|
||||
*/
|
||||
function createHttpError(message, statusCode) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
error.response = { status: statusCode };
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one resource with an adapter-level timeout.
|
||||
* @param {string | URL} url Resource URL.
|
||||
* @param {{headers?: object, timeoutMs?: number}} [options] Request options.
|
||||
* @returns {Promise<Response>} Fetch response.
|
||||
*/
|
||||
async function fetchWithTimeout(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = options.timeoutMs || 30000;
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
headers: options.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses a JSON file synchronously.
|
||||
* @param {string} filePath JSON file path.
|
||||
* @returns {unknown} Parsed JSON value.
|
||||
*/
|
||||
function readJsonFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the first worksheet from an Excel file as row objects.
|
||||
* @param {string} filePath Excel file path.
|
||||
* @returns {object[]} Worksheet rows.
|
||||
*/
|
||||
function readExcelRows(filePath) {
|
||||
const workbook = XLSX.readFile(filePath);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a binary resource for the BangDream plugin.
|
||||
* @param {string | URL} url Resource URL.
|
||||
* @param {{headers?: object, timeoutMs?: number}} [options] Request options.
|
||||
* @returns {Promise<{body: Buffer, statusCode: number}>} Binary response body and status.
|
||||
*/
|
||||
async function requestBuffer(url, options) {
|
||||
const response = await fetchWithTimeout(url, options);
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
if (!response.ok) {
|
||||
throw createHttpError(
|
||||
`BangDream resource request failed: ${response.status}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return { body, statusCode: response.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests and parses a JSON resource for the BangDream plugin.
|
||||
* @param {string | URL} url Resource URL.
|
||||
* @param {{headers?: object, timeoutMs?: number}} [options] Request options.
|
||||
* @returns {Promise<{body: unknown, statusCode: number}>} Parsed response body and status.
|
||||
*/
|
||||
async function requestJson(url, options) {
|
||||
const response = await fetchWithTimeout(url, options);
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw createHttpError(
|
||||
`BangDream JSON request failed: ${response.status}`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return { body: JSON.parse(responseText), statusCode: response.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the filesystem and network adapter expected by the BangDream plugin.
|
||||
* @returns {object} Plugin IO adapter.
|
||||
*/
|
||||
function createIoAdapter() {
|
||||
return {
|
||||
/** @param {string} key Environment key. @returns {string | undefined} Config value. */
|
||||
getConfig(key) {
|
||||
return process.env[key];
|
||||
},
|
||||
/** @param {string} filePath Asset path. @returns {Promise<Buffer>} Asset bytes. */
|
||||
async readAssetFile(filePath) {
|
||||
return fs.promises.readFile(filePath);
|
||||
},
|
||||
/** @param {string} filePath Excel path. @returns {Promise<object[]>} Worksheet rows. */
|
||||
async readExcelRows(filePath) {
|
||||
return readExcelRows(filePath);
|
||||
},
|
||||
/** @param {string} filePath JSON path. @returns {Promise<unknown>} Parsed value. */
|
||||
async readJsonFile(filePath) {
|
||||
return readJsonFile(filePath);
|
||||
},
|
||||
/** @param {string} filePath JSON path. @returns {unknown} Parsed value. */
|
||||
readJsonFileSync(filePath) {
|
||||
return readJsonFile(filePath);
|
||||
},
|
||||
requestArrayBuffer: requestBuffer,
|
||||
requestJson,
|
||||
/** @param {number} milliseconds Delay length. @returns {Promise<void>} Delay completion. */
|
||||
async sleep(milliseconds) {
|
||||
await delay(milliseconds);
|
||||
},
|
||||
/** @param {string} filePath Output path. @param {unknown} data JSON value. @returns {Promise<void>} Write completion. */
|
||||
async writeJsonFile(filePath, data) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(data));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts plugin errors to their stable human-readable representation.
|
||||
* @param {unknown} error Error-like value.
|
||||
* @returns {string} Normalized message.
|
||||
*/
|
||||
function normalizeError(error) {
|
||||
return String(error?.message || error || 'BangDream command failed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the requested operation and writes every CQ base64 image to disk.
|
||||
* @returns {Promise<void>} Resolves after output metadata has been written to stdout.
|
||||
*/
|
||||
async function main() {
|
||||
const payload = readPayload();
|
||||
const manifest = readJsonFile(
|
||||
path.join(process.cwd(), 'src/modules/qqbot/plugins/bangdream/plugin.json'),
|
||||
);
|
||||
const operations = manifest.operations.map((operation) => ({
|
||||
handlerName: operation.handlerName,
|
||||
key: operation.key,
|
||||
}));
|
||||
const plugin = createPlugin({
|
||||
io: createIoAdapter(),
|
||||
normalizeError,
|
||||
operations,
|
||||
});
|
||||
await plugin.activate();
|
||||
const result = await plugin.executeOperation(payload.operationKey, payload.input);
|
||||
if (
|
||||
payload.expectedImageCount > 0 &&
|
||||
result.imageCount !== payload.expectedImageCount
|
||||
) {
|
||||
throw new Error(
|
||||
`Expected imageCount=${payload.expectedImageCount}, got ${result.imageCount}`,
|
||||
);
|
||||
}
|
||||
|
||||
const matches = [...result.replyText.matchAll(/base64:\/\/([A-Za-z0-9+/=]+)/g)];
|
||||
if (matches.length === 0) {
|
||||
throw new Error('No image CQ payload');
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(payload.outFile), { recursive: true });
|
||||
const parsedOutput = path.parse(payload.outFile);
|
||||
const files = matches.map((match, index) => {
|
||||
const outputFile =
|
||||
index === 0
|
||||
? payload.outFile
|
||||
: path.join(
|
||||
parsedOutput.dir,
|
||||
`${parsedOutput.name}-${index + 1}${parsedOutput.ext || '.jpg'}`,
|
||||
);
|
||||
fs.writeFileSync(outputFile, Buffer.from(match[1], 'base64'));
|
||||
return {
|
||||
bytes: fs.statSync(outputFile).size,
|
||||
out: outputFile,
|
||||
};
|
||||
});
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
bytes: files[0].bytes,
|
||||
files,
|
||||
imageCount: result.imageCount,
|
||||
out: payload.outFile,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes an uncaught render error to stderr and fails the process.
|
||||
* @param {unknown} error Error-like value.
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleFatalError(error) {
|
||||
process.stderr.write(`${String(error?.stack || error)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch(handleFatalError);
|
||||
@ -1,224 +0,0 @@
|
||||
param(
|
||||
[string]$OperationKey = "bangdream.event.search",
|
||||
[string]$Text = "50",
|
||||
[string]$DisplayedServerList = "cn jp",
|
||||
[string]$OutFile = ".kt-workspace/bangdream-smoke/bangdream-smoke.jpg",
|
||||
[int]$TimeoutSeconds = 45,
|
||||
[int]$ExpectedImageCount = 0,
|
||||
[switch]$UseEasyBg
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$ProjectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$ResolvedOutFile = if ([System.IO.Path]::IsPathRooted($OutFile)) {
|
||||
$OutFile
|
||||
} else {
|
||||
Join-Path $ProjectRoot $OutFile
|
||||
}
|
||||
$OutDir = Split-Path -Parent $ResolvedOutFile
|
||||
New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
|
||||
|
||||
$LogDir = Join-Path $ProjectRoot ".kt-workspace/bangdream-smoke"
|
||||
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
|
||||
$LogName = [System.IO.Path]::GetFileNameWithoutExtension($ResolvedOutFile)
|
||||
$StdoutLog = Join-Path $LogDir "$LogName.out.log"
|
||||
$StderrLog = Join-Path $LogDir "$LogName.err.log"
|
||||
Remove-Item -LiteralPath $StdoutLog,$StderrLog -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $ResolvedOutFile -Force -ErrorAction SilentlyContinue
|
||||
$OutExtension = [System.IO.Path]::GetExtension($ResolvedOutFile)
|
||||
Get-ChildItem -LiteralPath $OutDir -Filter "$LogName-*${OutExtension}" -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$Payload = @{
|
||||
input = @{
|
||||
compress = $true
|
||||
displayedServerList = $DisplayedServerList
|
||||
text = $Text
|
||||
useEasyBG = [bool]$UseEasyBg
|
||||
}
|
||||
operationKey = $OperationKey
|
||||
outFile = $ResolvedOutFile
|
||||
expectedImageCount = $ExpectedImageCount
|
||||
}
|
||||
$PayloadJson = $Payload | ConvertTo-Json -Compress -Depth 5
|
||||
$PayloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($PayloadJson))
|
||||
|
||||
$NodeCode = @"
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const XLSX = require("xlsx");
|
||||
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
|
||||
const { createPlugin } = require("./src/modules/qqbot/plugins/bangdream/src");
|
||||
|
||||
function createHttpError(message, statusCode) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = statusCode;
|
||||
error.response = { status: statusCode };
|
||||
return error;
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = options.timeoutMs || 30000;
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, {
|
||||
headers: options.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
}
|
||||
|
||||
function readExcelRows(filePath) {
|
||||
const workbook = XLSX.readFile(filePath);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]);
|
||||
}
|
||||
|
||||
async function requestBuffer(url, options) {
|
||||
const response = await fetchWithTimeout(url, options);
|
||||
const body = Buffer.from(await response.arrayBuffer());
|
||||
if (!response.ok) {
|
||||
throw createHttpError("BangDream resource request failed: " + response.status, response.status);
|
||||
}
|
||||
return {
|
||||
body,
|
||||
statusCode: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestJson(url, options) {
|
||||
const response = await fetchWithTimeout(url, options);
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw createHttpError("BangDream JSON request failed: " + response.status, response.status);
|
||||
}
|
||||
return {
|
||||
body: JSON.parse(text),
|
||||
statusCode: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const manifest = readJsonFile(path.join(process.cwd(), "src/modules/qqbot/plugins/bangdream/plugin.json"));
|
||||
const operations = manifest.operations.map((operation) => ({
|
||||
handlerName: operation.handlerName,
|
||||
key: operation.key,
|
||||
}));
|
||||
const plugin = createPlugin({
|
||||
io: {
|
||||
getConfig: (key) => process.env[key],
|
||||
readAssetFile: async (filePath) => fs.promises.readFile(filePath),
|
||||
readExcelRows: async (filePath) => readExcelRows(filePath),
|
||||
readJsonFile: async (filePath) => readJsonFile(filePath),
|
||||
readJsonFileSync: (filePath) => readJsonFile(filePath),
|
||||
requestArrayBuffer: requestBuffer,
|
||||
requestJson,
|
||||
sleep: async (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
writeJsonFile: async (filePath, data) => {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(data));
|
||||
},
|
||||
},
|
||||
normalizeError: (error) => String(error?.message || error || "BangDream command failed"),
|
||||
operations,
|
||||
});
|
||||
await plugin.activate();
|
||||
const result = await plugin.executeOperation(payload.operationKey, payload.input);
|
||||
if (payload.expectedImageCount > 0 && result.imageCount !== payload.expectedImageCount) {
|
||||
throw new Error(
|
||||
"Expected imageCount=" + payload.expectedImageCount + ", got " + result.imageCount,
|
||||
);
|
||||
}
|
||||
const matches = [...result.replyText.matchAll(/base64:\/\/([A-Za-z0-9+/=]+)/g)];
|
||||
if (matches.length === 0) throw new Error("No image CQ payload");
|
||||
fs.mkdirSync(path.dirname(payload.outFile), { recursive: true });
|
||||
const parsed = path.parse(payload.outFile);
|
||||
const files = matches.map((match, index) => {
|
||||
const outFile =
|
||||
index === 0
|
||||
? payload.outFile
|
||||
: path.join(
|
||||
parsed.dir,
|
||||
parsed.name + "-" + (index + 1) + (parsed.ext || ".jpg"),
|
||||
);
|
||||
fs.writeFileSync(outFile, Buffer.from(match[1], "base64"));
|
||||
return {
|
||||
bytes: fs.statSync(outFile).size,
|
||||
out: outFile,
|
||||
};
|
||||
});
|
||||
process.stdout.write(JSON.stringify({
|
||||
bytes: files[0].bytes,
|
||||
files,
|
||||
imageCount: result.imageCount,
|
||||
out: payload.outFile,
|
||||
}, null, 2) + "\n");
|
||||
process.exit(0);
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error?.stack || error) + "\n");
|
||||
process.exit(1);
|
||||
});
|
||||
"@
|
||||
$NodeCodeBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($NodeCode))
|
||||
$EvalCode = "eval(Buffer.from('$NodeCodeBase64','base64').toString('utf8'))"
|
||||
|
||||
$env:TS_NODE_TRANSPILE_ONLY = "true"
|
||||
$Process = Start-Process `
|
||||
-FilePath "node" `
|
||||
-ArgumentList @("-r", "ts-node/register", "-r", "tsconfig-paths/register", "-e", $EvalCode) `
|
||||
-WorkingDirectory $ProjectRoot `
|
||||
-RedirectStandardOutput $StdoutLog `
|
||||
-RedirectStandardError $StderrLog `
|
||||
-PassThru `
|
||||
-WindowStyle Hidden
|
||||
|
||||
function Test-SmokeCompleted {
|
||||
if (-not (Test-Path -LiteralPath $ResolvedOutFile)) { return $false }
|
||||
if ((Get-Item -LiteralPath $ResolvedOutFile).Length -le 0) { return $false }
|
||||
if (-not (Test-Path -LiteralPath $StdoutLog)) { return $false }
|
||||
return [bool](Select-String -Path $StdoutLog -Pattern '"bytes"' -Quiet)
|
||||
}
|
||||
|
||||
$Deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$CompletedByOutput = $false
|
||||
while (-not $Process.HasExited) {
|
||||
if (Test-SmokeCompleted) {
|
||||
$CompletedByOutput = $true
|
||||
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
|
||||
$Process.WaitForExit(5000) | Out-Null
|
||||
break
|
||||
}
|
||||
if ((Get-Date) -ge $Deadline) {
|
||||
break
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
$Process.Refresh()
|
||||
}
|
||||
|
||||
if ((-not $Process.HasExited) -and (-not $CompletedByOutput)) {
|
||||
Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue
|
||||
Write-Output "BangDream smoke timed out and process $($Process.Id) was killed."
|
||||
if (Test-Path $StdoutLog) { Get-Content $StdoutLog }
|
||||
if (Test-Path $StderrLog) { Get-Content $StderrLog }
|
||||
exit 124
|
||||
}
|
||||
|
||||
if (Test-Path $StdoutLog) { Get-Content $StdoutLog }
|
||||
if (Test-Path $StderrLog) { Get-Content $StderrLog }
|
||||
if ($CompletedByOutput) {
|
||||
Write-Output "BangDream smoke output completed; lingering process $($Process.Id) was cleaned up."
|
||||
exit 0
|
||||
}
|
||||
if (-not (Test-SmokeCompleted)) {
|
||||
Write-Output "BangDream smoke did not produce an image file."
|
||||
exit 1
|
||||
}
|
||||
exit $Process.ExitCode
|
||||
237
scripts/bangdream-render-smoke.sh
Executable file
237
scripts/bangdream-render-smoke.sh
Executable file
@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
||||
PROJECT_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd -P)
|
||||
NODE_RUNNER="$SCRIPT_DIR/bangdream-render-smoke.cjs"
|
||||
|
||||
operation_key="bangdream.event.search"
|
||||
text="50"
|
||||
displayed_server_list="cn jp"
|
||||
out_file=".kt-workspace/bangdream-smoke/bangdream-smoke.jpg"
|
||||
timeout_seconds=45
|
||||
expected_image_count=0
|
||||
use_easy_bg=false
|
||||
child_pid=""
|
||||
|
||||
# Prints command usage without performing render or network work.
|
||||
print_help() {
|
||||
cat <<'EOF'
|
||||
Usage: bangdream-render-smoke.sh [options]
|
||||
|
||||
Run one bounded BangDream render smoke and write decoded image output.
|
||||
|
||||
Options:
|
||||
--operation-key KEY Operation key (default: bangdream.event.search)
|
||||
--text TEXT Operation input text (default: 50)
|
||||
--displayed-server-list LIST
|
||||
Displayed servers (default: "cn jp")
|
||||
--out-file PATH Image output path
|
||||
--timeout-seconds SECONDS Whole-process timeout (default: 45)
|
||||
--expected-image-count N Require this imageCount when greater than zero
|
||||
--use-easy-bg Enable the easy background renderer
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
# Reports an invalid invocation and exits with the standard usage status.
|
||||
usage_error() {
|
||||
printf 'Error: %s\n\n' "$1" >&2
|
||||
print_help >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Returns success when a value is an integer greater than zero.
|
||||
is_positive_integer() {
|
||||
[[ $1 =~ ^[1-9][0-9]*$ ]]
|
||||
}
|
||||
|
||||
# Returns success when a value is a non-negative integer.
|
||||
is_non_negative_integer() {
|
||||
[[ $1 =~ ^[0-9]+$ ]]
|
||||
}
|
||||
|
||||
# Stops the render child started by this script and reaps it.
|
||||
cleanup_child() {
|
||||
if [[ -n ${child_pid:-} ]] && kill -0 "$child_pid" 2>/dev/null; then
|
||||
kill -TERM "$child_pid" 2>/dev/null || true
|
||||
for _ in {1..20}; do
|
||||
if ! kill -0 "$child_pid" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if kill -0 "$child_pid" 2>/dev/null; then
|
||||
kill -KILL "$child_pid" 2>/dev/null || true
|
||||
fi
|
||||
wait "$child_pid" 2>/dev/null || true
|
||||
fi
|
||||
child_pid=""
|
||||
}
|
||||
|
||||
# Converts an interrupt signal into a deterministic exit after cleanup.
|
||||
# shellcheck disable=SC2317 # Invoked indirectly by trap.
|
||||
handle_interrupt() {
|
||||
cleanup_child
|
||||
exit 130
|
||||
}
|
||||
|
||||
# Converts a termination signal into a deterministic exit after cleanup.
|
||||
# shellcheck disable=SC2317 # Invoked indirectly by trap.
|
||||
handle_termination() {
|
||||
cleanup_child
|
||||
exit 143
|
||||
}
|
||||
|
||||
# Returns success after the primary image and completion JSON are durable.
|
||||
smoke_completed() {
|
||||
[[ -s $resolved_out_file && -f $stdout_log ]] && grep -Fq '"bytes"' "$stdout_log"
|
||||
}
|
||||
|
||||
# Replays captured output while preserving stdout and stderr separation.
|
||||
print_logs() {
|
||||
[[ -f $stdout_log ]] && cat -- "$stdout_log"
|
||||
[[ -f $stderr_log ]] && cat -- "$stderr_log" >&2
|
||||
}
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--operation-key)
|
||||
(($# >= 2)) || usage_error "--operation-key requires a value"
|
||||
operation_key=$2
|
||||
shift 2
|
||||
;;
|
||||
--text)
|
||||
(($# >= 2)) || usage_error "--text requires a value"
|
||||
text=$2
|
||||
shift 2
|
||||
;;
|
||||
--displayed-server-list)
|
||||
(($# >= 2)) || usage_error "--displayed-server-list requires a value"
|
||||
displayed_server_list=$2
|
||||
shift 2
|
||||
;;
|
||||
--out-file)
|
||||
(($# >= 2)) || usage_error "--out-file requires a value"
|
||||
out_file=$2
|
||||
shift 2
|
||||
;;
|
||||
--timeout-seconds)
|
||||
(($# >= 2)) || usage_error "--timeout-seconds requires a value"
|
||||
timeout_seconds=$2
|
||||
shift 2
|
||||
;;
|
||||
--expected-image-count)
|
||||
(($# >= 2)) || usage_error "--expected-image-count requires a value"
|
||||
expected_image_count=$2
|
||||
shift 2
|
||||
;;
|
||||
--use-easy-bg)
|
||||
use_easy_bg=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
print_help
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
(($# == 0)) || usage_error "unexpected positional argument: $1"
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n $operation_key ]] || usage_error "--operation-key cannot be empty"
|
||||
[[ -n $out_file ]] || usage_error "--out-file cannot be empty"
|
||||
is_positive_integer "$timeout_seconds" || usage_error "--timeout-seconds must be a positive integer"
|
||||
is_non_negative_integer "$expected_image_count" || usage_error "--expected-image-count must be a non-negative integer"
|
||||
|
||||
if [[ $out_file = /* ]]; then
|
||||
resolved_out_file=$out_file
|
||||
else
|
||||
resolved_out_file="$PROJECT_ROOT/$out_file"
|
||||
fi
|
||||
out_dir=$(dirname -- "$resolved_out_file")
|
||||
mkdir -p -- "$out_dir"
|
||||
|
||||
log_dir="$PROJECT_ROOT/.kt-workspace/bangdream-smoke"
|
||||
mkdir -p -- "$log_dir"
|
||||
output_filename=$(basename -- "$resolved_out_file")
|
||||
log_name=${output_filename%.*}
|
||||
if [[ $output_filename == *.* ]]; then
|
||||
out_extension=.${output_filename##*.}
|
||||
else
|
||||
out_extension=""
|
||||
fi
|
||||
stdout_log="$log_dir/$log_name.out.log"
|
||||
stderr_log="$log_dir/$log_name.err.log"
|
||||
rm -f -- "$stdout_log" "$stderr_log" "$resolved_out_file"
|
||||
shopt -s nullglob
|
||||
generated_images=("$out_dir"/"$log_name"-*"$out_extension")
|
||||
shopt -u nullglob
|
||||
if ((${#generated_images[@]} > 0)); then
|
||||
rm -f -- "${generated_images[@]}"
|
||||
fi
|
||||
|
||||
trap cleanup_child EXIT
|
||||
trap handle_interrupt INT
|
||||
trap handle_termination TERM
|
||||
|
||||
cd -- "$PROJECT_ROOT"
|
||||
TS_NODE_TRANSPILE_ONLY=true \
|
||||
BANGDREAM_OPERATION_KEY=$operation_key \
|
||||
BANGDREAM_TEXT=$text \
|
||||
BANGDREAM_DISPLAYED_SERVER_LIST=$displayed_server_list \
|
||||
BANGDREAM_OUT_FILE=$resolved_out_file \
|
||||
BANGDREAM_EXPECTED_IMAGE_COUNT=$expected_image_count \
|
||||
BANGDREAM_USE_EASY_BG=$use_easy_bg \
|
||||
node -r ts-node/register -r tsconfig-paths/register "$NODE_RUNNER" \
|
||||
>"$stdout_log" 2>"$stderr_log" &
|
||||
child_pid=$!
|
||||
|
||||
deadline=$((SECONDS + timeout_seconds))
|
||||
completed_by_output=false
|
||||
timed_out=false
|
||||
while kill -0 "$child_pid" 2>/dev/null; do
|
||||
if smoke_completed; then
|
||||
completed_by_output=true
|
||||
cleanup_child
|
||||
break
|
||||
fi
|
||||
if ((SECONDS >= deadline)); then
|
||||
timed_out=true
|
||||
cleanup_child
|
||||
break
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
if [[ $timed_out == true ]]; then
|
||||
printf 'BangDream smoke timed out after %s seconds and its process was killed.\n' "$timeout_seconds"
|
||||
print_logs
|
||||
exit 124
|
||||
fi
|
||||
|
||||
process_status=0
|
||||
if [[ -n $child_pid ]]; then
|
||||
if wait "$child_pid"; then
|
||||
process_status=0
|
||||
else
|
||||
process_status=$?
|
||||
fi
|
||||
child_pid=""
|
||||
fi
|
||||
|
||||
print_logs
|
||||
if [[ $completed_by_output == true ]]; then
|
||||
printf 'BangDream smoke output completed; its lingering process was cleaned up.\n'
|
||||
exit 0
|
||||
fi
|
||||
if ! smoke_completed; then
|
||||
printf 'BangDream smoke did not produce an image file.\n'
|
||||
exit 1
|
||||
fi
|
||||
exit "$process_status"
|
||||
@ -1,14 +0,0 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Database,
|
||||
[Parameter(Mandatory = $true)][string]$OutputDirectory
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($Database -notmatch '^[A-Za-z0-9_]+$') {
|
||||
throw "Database must match ^[A-Za-z0-9_]+$"
|
||||
}
|
||||
|
||||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$target = Join-Path $OutputDirectory "$Database-refactor-v3-$stamp.sql"
|
||||
mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers --default-character-set=utf8mb4 "--result-file=$target" $Database
|
||||
Write-Output $target
|
||||
90
scripts/refactor-v3/db-backup-online.sh
Executable file
90
scripts/refactor-v3/db-backup-online.sh
Executable file
@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
database=""
|
||||
output_directory=""
|
||||
timeout_seconds=300
|
||||
|
||||
# Prints command usage without accessing the database.
|
||||
print_help() {
|
||||
cat <<'EOF'
|
||||
Usage: db-backup-online.sh --database NAME --output-directory PATH [options]
|
||||
|
||||
Create a bounded logical MySQL backup and print its absolute output path.
|
||||
MySQL credentials are resolved by the mysql client and are never printed.
|
||||
|
||||
Required:
|
||||
--database NAME Database name (letters, digits, and underscores)
|
||||
--output-directory PATH Directory for the timestamped SQL backup
|
||||
|
||||
Options:
|
||||
--timeout-seconds SECONDS mysqldump timeout (default: 300)
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
# Reports an invalid invocation and exits with the standard usage status.
|
||||
usage_error() {
|
||||
printf 'Error: %s\n\n' "$1" >&2
|
||||
print_help >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Returns success when a value is an integer greater than zero.
|
||||
is_positive_integer() {
|
||||
[[ $1 =~ ^[1-9][0-9]*$ ]]
|
||||
}
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--database)
|
||||
(($# >= 2)) || usage_error "--database requires a value"
|
||||
database=$2
|
||||
shift 2
|
||||
;;
|
||||
--output-directory)
|
||||
(($# >= 2)) || usage_error "--output-directory requires a value"
|
||||
output_directory=$2
|
||||
shift 2
|
||||
;;
|
||||
--timeout-seconds)
|
||||
(($# >= 2)) || usage_error "--timeout-seconds requires a value"
|
||||
timeout_seconds=$2
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
print_help
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
(($# == 0)) || usage_error "unexpected positional argument: $1"
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $database =~ ^[A-Za-z0-9_]+$ ]] || usage_error "--database must match ^[A-Za-z0-9_]+$"
|
||||
[[ -n $output_directory ]] || usage_error "--output-directory is required"
|
||||
is_positive_integer "$timeout_seconds" || usage_error "--timeout-seconds must be a positive integer"
|
||||
command -v timeout >/dev/null 2>&1 || usage_error "GNU timeout is required"
|
||||
command -v mysqldump >/dev/null 2>&1 || usage_error "mysqldump is required"
|
||||
|
||||
mkdir -p -- "$output_directory"
|
||||
output_directory=$(cd -- "$output_directory" && pwd -P)
|
||||
stamp=$(date +%Y%m%d-%H%M%S)
|
||||
target="$output_directory/$database-refactor-v3-$stamp.sql"
|
||||
|
||||
timeout --foreground --kill-after=5s "${timeout_seconds}s" \
|
||||
mysqldump \
|
||||
--set-gtid-purged=OFF \
|
||||
--single-transaction \
|
||||
--routines \
|
||||
--triggers \
|
||||
--default-character-set=utf8mb4 \
|
||||
"--result-file=$target" \
|
||||
"$database"
|
||||
|
||||
printf '%s\n' "$target"
|
||||
@ -1,41 +0,0 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Database,
|
||||
[string]$HostName = "127.0.0.1",
|
||||
[int]$Port = 3306,
|
||||
[string]$User = "root"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($Database -notmatch '^[A-Za-z0-9_]+$') {
|
||||
throw "Database must match ^[A-Za-z0-9_]+$"
|
||||
}
|
||||
|
||||
$root = Resolve-Path (Join-Path $PSScriptRoot "..\..")
|
||||
$schema = Join-Path $root "sql\refactor-v3\00-full-schema.sql"
|
||||
$seed = Join-Path $root "sql\refactor-v3\01-seed-core.sql"
|
||||
$verify = Join-Path $root "sql\refactor-v3\99-verify.sql"
|
||||
|
||||
function Invoke-Mysql {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string[]]$Arguments
|
||||
)
|
||||
|
||||
mysql --default-character-set=utf8mb4 @Arguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "mysql failed with exit code $LASTEXITCODE"
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-MysqlSource {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path
|
||||
)
|
||||
|
||||
$sourcePath = (Resolve-Path -LiteralPath $Path).Path.Replace("\", "/")
|
||||
Invoke-Mysql -Arguments @("-h", $HostName, "-P", "$Port", "-u", $User, $Database, "--execute=source $sourcePath")
|
||||
}
|
||||
|
||||
Invoke-Mysql -Arguments @("-h", $HostName, "-P", "$Port", "-u", $User, "-e", "DROP DATABASE IF EXISTS ``$Database``; CREATE DATABASE ``$Database`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;")
|
||||
Invoke-MysqlSource -Path $schema
|
||||
Invoke-MysqlSource -Path $seed
|
||||
Invoke-MysqlSource -Path $verify
|
||||
150
scripts/refactor-v3/db-dry-run.sh
Executable file
150
scripts/refactor-v3/db-dry-run.sh
Executable file
@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
|
||||
PROJECT_ROOT=$(cd -- "$SCRIPT_DIR/../.." && pwd -P)
|
||||
|
||||
database=""
|
||||
host_name="127.0.0.1"
|
||||
port=3306
|
||||
user_name="root"
|
||||
timeout_seconds=120
|
||||
execute=false
|
||||
|
||||
# Prints command usage without accessing the database.
|
||||
print_help() {
|
||||
cat <<'EOF'
|
||||
Usage: db-dry-run.sh --database NAME [options]
|
||||
|
||||
Plan a disposable refactor-v3 database rebuild. The default is plan-only;
|
||||
pass --execute explicitly to drop/recreate the database and load SQL files.
|
||||
|
||||
Required:
|
||||
--database NAME Disposable target database
|
||||
|
||||
Options:
|
||||
--host HOST MySQL host (default: 127.0.0.1)
|
||||
--port PORT MySQL port (default: 3306)
|
||||
--user USER MySQL user (default: root)
|
||||
--timeout-seconds SECONDS Timeout for each mysql command (default: 120)
|
||||
--execute Apply the destructive rebuild
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
# Reports an invalid invocation and exits with the standard usage status.
|
||||
usage_error() {
|
||||
printf 'Error: %s\n\n' "$1" >&2
|
||||
print_help >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Returns success when a value is an integer greater than zero.
|
||||
is_positive_integer() {
|
||||
[[ $1 =~ ^[1-9][0-9]*$ ]]
|
||||
}
|
||||
|
||||
# Prints the destructive scope and rollback/verification intent before writes.
|
||||
print_plan() {
|
||||
printf 'mode=%s\n' "$([[ $execute == true ]] && printf execute || printf plan-only)"
|
||||
printf 'source.schema=%s\n' "$schema_file"
|
||||
printf 'source.seed=%s\n' "$seed_file"
|
||||
printf 'target=mysql://%s:%s/%s (user=%s)\n' "$host_name" "$port" "$database" "$user_name"
|
||||
printf 'rollback.backup=not-created (disposable dry-run target; recreate or restore separately)\n'
|
||||
printf 'verification.intent=execute %s after schema and seed load\n' "$verify_file"
|
||||
}
|
||||
|
||||
# Runs one bounded mysql command without exposing credentials.
|
||||
run_mysql_command() {
|
||||
timeout --foreground --kill-after=5s "${timeout_seconds}s" \
|
||||
"${mysql_base[@]}" "$@"
|
||||
}
|
||||
|
||||
# Loads one SQL file into the target database with a bounded mysql process.
|
||||
run_mysql_file() {
|
||||
local sql_file=$1
|
||||
timeout --foreground --kill-after=5s "${timeout_seconds}s" \
|
||||
"${mysql_base[@]}" "$database" <"$sql_file"
|
||||
}
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--database)
|
||||
(($# >= 2)) || usage_error "--database requires a value"
|
||||
database=$2
|
||||
shift 2
|
||||
;;
|
||||
--host)
|
||||
(($# >= 2)) || usage_error "--host requires a value"
|
||||
host_name=$2
|
||||
shift 2
|
||||
;;
|
||||
--port)
|
||||
(($# >= 2)) || usage_error "--port requires a value"
|
||||
port=$2
|
||||
shift 2
|
||||
;;
|
||||
--user)
|
||||
(($# >= 2)) || usage_error "--user requires a value"
|
||||
user_name=$2
|
||||
shift 2
|
||||
;;
|
||||
--timeout-seconds)
|
||||
(($# >= 2)) || usage_error "--timeout-seconds requires a value"
|
||||
timeout_seconds=$2
|
||||
shift 2
|
||||
;;
|
||||
--execute)
|
||||
execute=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
print_help
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
(($# == 0)) || usage_error "unexpected positional argument: $1"
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $database =~ ^[A-Za-z0-9_]+$ ]] || usage_error "--database must match ^[A-Za-z0-9_]+$"
|
||||
[[ -n $host_name ]] || usage_error "--host cannot be empty"
|
||||
[[ -n $user_name ]] || usage_error "--user cannot be empty"
|
||||
if ! is_positive_integer "$port" || ((port > 65535)); then
|
||||
usage_error "--port must be between 1 and 65535"
|
||||
fi
|
||||
is_positive_integer "$timeout_seconds" || usage_error "--timeout-seconds must be a positive integer"
|
||||
|
||||
schema_file="$PROJECT_ROOT/sql/refactor-v3/00-full-schema.sql"
|
||||
seed_file="$PROJECT_ROOT/sql/refactor-v3/01-seed-core.sql"
|
||||
verify_file="$PROJECT_ROOT/sql/refactor-v3/99-verify.sql"
|
||||
[[ -f $schema_file ]] || usage_error "schema file does not exist: $schema_file"
|
||||
[[ -f $seed_file ]] || usage_error "seed file does not exist: $seed_file"
|
||||
[[ -f $verify_file ]] || usage_error "verification file does not exist: $verify_file"
|
||||
|
||||
print_plan
|
||||
if [[ $execute != true ]]; then
|
||||
printf 'execution=skipped (pass --execute to apply)\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command -v timeout >/dev/null 2>&1 || usage_error "GNU timeout is required"
|
||||
command -v mysql >/dev/null 2>&1 || usage_error "mysql is required"
|
||||
mysql_base=(
|
||||
mysql
|
||||
--default-character-set=utf8mb4
|
||||
-h "$host_name"
|
||||
-P "$port"
|
||||
-u "$user_name"
|
||||
)
|
||||
create_sql="DROP DATABASE IF EXISTS \`$database\`; CREATE DATABASE \`$database\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
|
||||
run_mysql_command --execute="$create_sql"
|
||||
run_mysql_file "$schema_file"
|
||||
run_mysql_file "$seed_file"
|
||||
run_mysql_file "$verify_file"
|
||||
@ -1,16 +0,0 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Database,
|
||||
[Parameter(Mandatory = $true)][string]$BackupFile
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ($Database -notmatch '^[A-Za-z0-9_]+$') {
|
||||
throw "Database must match ^[A-Za-z0-9_]+$"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $BackupFile -PathType Leaf)) {
|
||||
throw "BackupFile does not exist"
|
||||
}
|
||||
|
||||
$sourcePath = (Resolve-Path -LiteralPath $BackupFile).Path.Replace("\", "/")
|
||||
mysql -e "DROP DATABASE IF EXISTS ``$Database``; CREATE DATABASE ``$Database`` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
mysql --default-character-set=utf8mb4 $Database --execute="source $sourcePath"
|
||||
114
scripts/refactor-v3/db-restore-online.sh
Executable file
114
scripts/refactor-v3/db-restore-online.sh
Executable file
@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
database=""
|
||||
backup_file=""
|
||||
timeout_seconds=300
|
||||
execute=false
|
||||
|
||||
# Prints command usage without accessing the database.
|
||||
print_help() {
|
||||
cat <<'EOF'
|
||||
Usage: db-restore-online.sh --database NAME --backup-file PATH [options]
|
||||
|
||||
Plan an online database restore. The default is plan-only; pass --execute
|
||||
explicitly to drop/recreate the database and load the selected backup.
|
||||
MySQL connection and credentials come from mysql client defaults.
|
||||
|
||||
Required:
|
||||
--database NAME Target database
|
||||
--backup-file PATH Existing SQL backup to restore
|
||||
|
||||
Options:
|
||||
--timeout-seconds SECONDS Timeout for each mysql command (default: 300)
|
||||
--execute Apply the destructive restore
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
# Reports an invalid invocation and exits with the standard usage status.
|
||||
usage_error() {
|
||||
printf 'Error: %s\n\n' "$1" >&2
|
||||
print_help >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Returns success when a value is an integer greater than zero.
|
||||
is_positive_integer() {
|
||||
[[ $1 =~ ^[1-9][0-9]*$ ]]
|
||||
}
|
||||
|
||||
# Prints the destructive scope and rollback/verification intent before writes.
|
||||
print_plan() {
|
||||
printf 'mode=%s\n' "$([[ $execute == true ]] && printf execute || printf plan-only)"
|
||||
printf 'source.backup=%s\n' "$resolved_backup_file"
|
||||
printf 'target.database=%s (connection=mysql client defaults)\n' "$database"
|
||||
printf 'rollback.backup=%s\n' "$resolved_backup_file"
|
||||
printf 'verification.intent=confirm restore command success, then run the project post-restore verification queries\n'
|
||||
}
|
||||
|
||||
# Runs one bounded mysql command without exposing credentials.
|
||||
run_mysql_command() {
|
||||
timeout --foreground --kill-after=5s "${timeout_seconds}s" mysql "$@"
|
||||
}
|
||||
|
||||
# Loads the selected backup with a bounded mysql process.
|
||||
run_mysql_restore() {
|
||||
timeout --foreground --kill-after=5s "${timeout_seconds}s" \
|
||||
mysql --default-character-set=utf8mb4 "$database" <"$resolved_backup_file"
|
||||
}
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--database)
|
||||
(($# >= 2)) || usage_error "--database requires a value"
|
||||
database=$2
|
||||
shift 2
|
||||
;;
|
||||
--backup-file)
|
||||
(($# >= 2)) || usage_error "--backup-file requires a value"
|
||||
backup_file=$2
|
||||
shift 2
|
||||
;;
|
||||
--timeout-seconds)
|
||||
(($# >= 2)) || usage_error "--timeout-seconds requires a value"
|
||||
timeout_seconds=$2
|
||||
shift 2
|
||||
;;
|
||||
--execute)
|
||||
execute=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
print_help
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
(($# == 0)) || usage_error "unexpected positional argument: $1"
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $database =~ ^[A-Za-z0-9_]+$ ]] || usage_error "--database must match ^[A-Za-z0-9_]+$"
|
||||
[[ -n $backup_file ]] || usage_error "--backup-file is required"
|
||||
is_positive_integer "$timeout_seconds" || usage_error "--timeout-seconds must be a positive integer"
|
||||
[[ -f $backup_file ]] || usage_error "backup file does not exist: $backup_file"
|
||||
backup_directory=$(cd -- "$(dirname -- "$backup_file")" && pwd -P)
|
||||
resolved_backup_file="$backup_directory/$(basename -- "$backup_file")"
|
||||
|
||||
print_plan
|
||||
if [[ $execute != true ]]; then
|
||||
printf 'execution=skipped (pass --execute to apply)\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
command -v timeout >/dev/null 2>&1 || usage_error "GNU timeout is required"
|
||||
command -v mysql >/dev/null 2>&1 || usage_error "mysql is required"
|
||||
create_sql="DROP DATABASE IF EXISTS \`$database\`; CREATE DATABASE \`$database\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
|
||||
|
||||
run_mysql_command --execute="$create_sql"
|
||||
run_mysql_restore
|
||||
@ -1,10 +0,0 @@
|
||||
param(
|
||||
[string]$BaseUrl = "http://127.0.0.1:48085"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$runtime = Invoke-RestMethod -Method Get -Uri "$BaseUrl/health/runtime" -TimeoutSec 10
|
||||
if (-not $runtime.service) {
|
||||
throw "Runtime health response did not include service"
|
||||
}
|
||||
Write-Output "runtime.service=$($runtime.service)"
|
||||
82
scripts/refactor-v3/local-smoke.sh
Executable file
82
scripts/refactor-v3/local-smoke.sh
Executable file
@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
base_url="http://127.0.0.1:48085"
|
||||
timeout_seconds=10
|
||||
|
||||
# Prints command usage without performing an HTTP request.
|
||||
print_help() {
|
||||
cat <<'EOF'
|
||||
Usage: local-smoke.sh [options]
|
||||
|
||||
Read the bounded local runtime health endpoint and print runtime.service.
|
||||
|
||||
Options:
|
||||
--base-url URL API base URL (default: http://127.0.0.1:48085)
|
||||
--timeout-seconds SECONDS Timeout for curl and jq (default: 10)
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
# Reports an invalid invocation and exits with the standard usage status.
|
||||
usage_error() {
|
||||
printf 'Error: %s\n\n' "$1" >&2
|
||||
print_help >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# Returns success when a value is an integer greater than zero.
|
||||
is_positive_integer() {
|
||||
[[ $1 =~ ^[1-9][0-9]*$ ]]
|
||||
}
|
||||
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--base-url)
|
||||
(($# >= 2)) || usage_error "--base-url requires a value"
|
||||
base_url=$2
|
||||
shift 2
|
||||
;;
|
||||
--timeout-seconds)
|
||||
(($# >= 2)) || usage_error "--timeout-seconds requires a value"
|
||||
timeout_seconds=$2
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
print_help
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
(($# == 0)) || usage_error "unexpected positional argument: $1"
|
||||
;;
|
||||
*)
|
||||
usage_error "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -n $base_url ]] || usage_error "--base-url cannot be empty"
|
||||
is_positive_integer "$timeout_seconds" || usage_error "--timeout-seconds must be a positive integer"
|
||||
command -v timeout >/dev/null 2>&1 || usage_error "GNU timeout is required"
|
||||
command -v curl >/dev/null 2>&1 || usage_error "curl is required"
|
||||
command -v jq >/dev/null 2>&1 || usage_error "jq is required"
|
||||
|
||||
base_url=${base_url%/}
|
||||
runtime_json=$(
|
||||
timeout --foreground --kill-after=2s "${timeout_seconds}s" \
|
||||
curl \
|
||||
--fail \
|
||||
--silent \
|
||||
--show-error \
|
||||
--connect-timeout "$timeout_seconds" \
|
||||
--max-time "$timeout_seconds" \
|
||||
"$base_url/health/runtime"
|
||||
)
|
||||
service=$(
|
||||
printf '%s\n' "$runtime_json" |
|
||||
timeout --foreground --kill-after=2s "${timeout_seconds}s" \
|
||||
jq -er '.service | select(. != null) | tostring | select(length > 0)'
|
||||
)
|
||||
|
||||
printf 'runtime.service=%s\n' "$service"
|
||||
@ -161,20 +161,21 @@ describe('refactor v3 schema skeleton', () => {
|
||||
expect(seed).toContain('INSERT IGNORE INTO admin_role_menu');
|
||||
});
|
||||
|
||||
it('runs dry-run SQL sources with utf8mb4 and fails on native mysql errors', () => {
|
||||
it('runs dry-run SQL sources with utf8mb4 and bounded native mysql errors', () => {
|
||||
const dryRunScript = readFileSync(
|
||||
join(root, 'scripts/refactor-v3/db-dry-run.ps1'),
|
||||
join(root, 'scripts/refactor-v3/db-dry-run.sh'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
expect(dryRunScript).toContain('set -Eeuo pipefail');
|
||||
expect(dryRunScript).toContain('--default-character-set=utf8mb4');
|
||||
expect(dryRunScript).toContain('$LASTEXITCODE');
|
||||
expect(dryRunScript).toContain('throw');
|
||||
expect(dryRunScript).toContain('timeout --foreground');
|
||||
expect(dryRunScript).toContain('if [[ $execute != true ]]');
|
||||
});
|
||||
|
||||
it('uses the Nest listen port as the local smoke default base URL', () => {
|
||||
const localSmokeScript = readFileSync(
|
||||
join(root, 'scripts/refactor-v3/local-smoke.ps1'),
|
||||
join(root, 'scripts/refactor-v3/local-smoke.sh'),
|
||||
'utf8',
|
||||
);
|
||||
const main = readFileSync(join(root, 'src/main.ts'), 'utf8');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user