feat(install): 安裝完成等於驗過能用
install 寫完轉接檔後,把叫用鏈真的走一遍:轉接檔 → PATH 上的 tea-sdlc → 流程正本。
最脆弱的是中間那一環。套件裝在某個 Node 版本底下,換個版本就找不到了,而轉接檔本身
看起來完全正常——沒有這道驗證,使用者要到第一次打 /sdlc-plan 才發現,那時他已經離開
安裝的心智狀態很久了。所以不是查檔案在不在,而是真的到 PATH 上把 tea-sdlc 找出來執行
一次,再把取回的正本跟套件裡的那一份逐字比對:找不到、叫不動、或叫到的是另一份安裝,
三種都驗得出來。轉接檔則逐一回磁碟讀,比對存在且內容含正確的叫用行。
驗證不碰網路,也與 Gitea 登入、時間追蹤無關,所以無條件執行。
驗不過回 ok:false,但已經寫好的轉接檔一份都不刪。回滾在升級情境下是淨損失:原本有一組
能用的舊轉接檔,覆蓋後驗證失敗再刪掉,使用者就從「有點舊但能用」變成什麼都沒有;何況
最可能的病灶是「PATH 上找不到 tea-sdlc」,那不是轉接檔的問題。
為此 lib 多一個 Failure:有一種失敗是事情做完了、檔案也寫出去了,只是驗不過,那時最該
交出去的正是「已經寫了哪些、哪一段不通」。envelope 形狀不變,只是 {ok:false, error}
旁邊多一個 data,只讀 error.code 的呼叫端照常運作。
--dry-run 不寫入,也就沒有東西可驗,verify 標成 skipped。
Closes #59
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
* 過去,回推就自然指向假根,跑的仍是真正的程式碼;順便把「plugin 目錄不完整」
|
||||
* 那條路徑一起測得到——少給哪個目錄由測試自己決定。
|
||||
*/
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { repoRoot, runBin, tmpRoot } from './run-script.js';
|
||||
|
||||
@@ -20,7 +20,8 @@ export const fakePrompt = (name) =>
|
||||
* prompts 要放進 prompts/ 的正本,鍵為指令名;
|
||||
* omit 故意不建立的目錄,用來造出「plugin 目錄不完整」;
|
||||
* version 覆寫假根的套件版本
|
||||
* @returns {{root: string, run: (args: string[], opts?: object) => Promise<object>}}
|
||||
* @returns {{root: string, shim: string, run: (args: string[], opts?: object) => Promise<object>}}
|
||||
* shim 是放著這份假 plugin 的 tea-sdlc 的目錄,預設已經加進 run 的 PATH
|
||||
*/
|
||||
export function makeFakePlugin(t, { prompts = {}, omit = [], version } = {}) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
@@ -44,5 +45,36 @@ export function makeFakePlugin(t, { prompts = {}, omit = [], version } = {}) {
|
||||
writeFileSync(join(root, 'prompts', `${name}.md`), text);
|
||||
}
|
||||
|
||||
return { root, run: (args, opts = {}) => runBin(args, { ...opts, root }) };
|
||||
const shim = makeShim(root);
|
||||
|
||||
return {
|
||||
root,
|
||||
shim,
|
||||
// 預設把這份假 plugin 的 tea-sdlc 放進 PATH:真實使用者是 npm i -g 裝的,
|
||||
// 叫用鏈上本來就有這一環。要測「PATH 上找不到」的那條路徑就傳 shim: false。
|
||||
run: (args, { shim: onPath = true, path, ...opts } = {}) =>
|
||||
runBin(args, {
|
||||
...opts,
|
||||
root,
|
||||
path: onPath ? [shim, path ?? process.env.PATH].join(':') : path,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 替一份假 plugin 根造出可以從 PATH 叫到的 `tea-sdlc`。
|
||||
*
|
||||
* install 的驗證會真的去 PATH 上把 tea-sdlc 找出來執行——那正是它要驗的那一環。
|
||||
* 測試裡若沒有這個殼,驗到的就只是「測試環境沒有裝 tea-sdlc」,而不是待驗的東西。
|
||||
* @param {string} root 假 plugin 根
|
||||
* @returns {string} 殼所在的目錄,加進 PATH 就能叫到
|
||||
*/
|
||||
function makeShim(root) {
|
||||
const dir = join(root, 'shim');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const path = join(dir, 'tea-sdlc');
|
||||
writeFileSync(path, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} ${JSON.stringify(join(root, 'bin', 'tea-sdlc.js'))} "$@"\n`);
|
||||
chmodSync(path, 0o755);
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 安裝完成等於驗過能用:install 寫完轉接檔之後,真的把那條叫用鏈走一遍。
|
||||
*
|
||||
* 為什麼要驗這條鏈,見 scripts/install-verify.js 開頭。這裡只交代測法:一律在臨時家目錄上
|
||||
* 真的寫檔、真的把 tea-sdlc 放上 PATH、真的執行它,再斷言結果。只驗「有沒有呼叫某個函式」
|
||||
* 的話,正好驗不到唯一會壞的那一環。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { manifest, tmpRoot } from './helpers/run-script.js';
|
||||
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
|
||||
import { startStubGitea } from './helpers/stub-gitea.js';
|
||||
|
||||
const PROMPTS = { 'sdlc-plan': fakePrompt('sdlc-plan'), 'sdlc-feat': fakePrompt('sdlc-feat') };
|
||||
|
||||
/** 一個只「裝了」claude 與 kiro 的臨時家目錄 */
|
||||
function makeHome(t) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const home = mkdtempSync(join(tmpRoot, 'home-'));
|
||||
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
for (const dir of ['.claude', '.kiro', 'work']) mkdirSync(join(home, dir), { recursive: true });
|
||||
return home;
|
||||
}
|
||||
|
||||
const inHome = (plugin, home) => (args, opts = {}) =>
|
||||
plugin.run(args, { env: { HOME: home }, cwd: join(home, 'work'), ...opts });
|
||||
|
||||
/** 這次安裝實際寫出去的每一份轉接檔 */
|
||||
const adaptersOf = (json) => json.data.platforms.flatMap((platform) => platform.adapters);
|
||||
|
||||
const byName = (verify) => Object.fromEntries(verify.platforms.map((p) => [p.name, p]));
|
||||
|
||||
/**
|
||||
* 把這份假 plugin 的 tea-sdlc 放上本行程的 PATH,測試結束後還原。
|
||||
*
|
||||
* 直接叫 verifyInstall 的測試才需要這個:它跟 install 不一樣,走的是本行程的 PATH。
|
||||
* 叫用鏈那一環要是通的,那些測試的 fail 才只可能來自轉接檔。
|
||||
*/
|
||||
function 把tea_sdlc放上PATH(plugin, t) {
|
||||
const 原本的 = process.env.PATH;
|
||||
process.env.PATH = [plugin.shim, 原本的].join(':');
|
||||
t.after(() => { process.env.PATH = 原本的; });
|
||||
}
|
||||
|
||||
/**
|
||||
* 組出 install 剛寫完轉接檔、正要交給驗證的那個樣子。純粹組資料,不碰環境。
|
||||
* @param {string} home 臨時家目錄
|
||||
* @param {object} plugin 假 plugin,取它的版本
|
||||
*/
|
||||
function 裝好的樣子(home, plugin) {
|
||||
const version = JSON.parse(readFileSync(join(plugin.root, 'package.json'), 'utf8')).version;
|
||||
const names = Object.keys(PROMPTS).sort();
|
||||
|
||||
return {
|
||||
version,
|
||||
prompt: { name: names[0], text: PROMPTS[names[0]] },
|
||||
platforms: [
|
||||
{
|
||||
name: 'claude',
|
||||
adapters: names.map((name) => ({ name, path: join(home, '.claude', 'commands', `${name}.md`) })),
|
||||
},
|
||||
{
|
||||
name: 'kiro',
|
||||
adapters: names.map((name) => ({ name, path: join(home, '.kiro', 'skills', name, 'SKILL.md') })),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ── 全部通過 ───────────────────────────────────────────────────────
|
||||
|
||||
test('轉接檔寫完就驗一次真實的叫用鏈,逐平台回報 pass', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.data.verify.ok, true);
|
||||
// 逐平台 pass/fail,而不是只有一個總結
|
||||
assert.deepEqual(byName(json.data.verify).claude, { name: 'claude', ok: true, checked: 2, failures: [] });
|
||||
assert.deepEqual(byName(json.data.verify).kiro, { name: 'kiro', ok: true, checked: 2, failures: [] });
|
||||
});
|
||||
|
||||
test('驗證是真的把 PATH 上的 tea-sdlc 找出來執行,不是查有沒有這個檔', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
|
||||
const { json } = await inHome(plugin, home)(['install']);
|
||||
const { chain } = json.data.verify;
|
||||
|
||||
assert.equal(chain.ok, true);
|
||||
assert.equal(chain.command, 'tea-sdlc');
|
||||
assert.equal(chain.resolved, join(plugin.shim, 'tea-sdlc'));
|
||||
assert.ok(json.data.commands.includes(chain.prompt), `取回的是 ${chain.prompt}`);
|
||||
});
|
||||
|
||||
|
||||
// ── 中間那一環斷掉 ─────────────────────────────────────────────────
|
||||
|
||||
test('PATH 上找不到 tea-sdlc 時整體 ok:false,並指出病灶在 PATH 而不是轉接檔', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install'], { shim: false });
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.ok, false);
|
||||
assert.equal(json.data.verify.chain.ok, false);
|
||||
assert.equal(json.data.verify.chain.resolved, null);
|
||||
assert.match(json.data.verify.chain.病灶, /PATH/);
|
||||
assert.match(json.data.verify.chain.修復, /npm/);
|
||||
// 轉接檔本身沒有問題,刪掉它一點幫助也沒有——這裡要分得開
|
||||
assert.equal(byName(json.data.verify).claude.ok, true);
|
||||
});
|
||||
|
||||
test('PATH 上的 tea-sdlc 是另一份安裝時驗得出來——取回的正本跟這一份不一樣', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const 另一份 = makeFakePlugin(t, {
|
||||
prompts: Object.fromEntries(
|
||||
Object.entries(PROMPTS).map(([name, text]) => [name, `${text}\n舊版多出來的一段。\n`]),
|
||||
),
|
||||
});
|
||||
const home = makeHome(t);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install'], {
|
||||
shim: false,
|
||||
path: [另一份.shim, process.env.PATH].join(':'),
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.data.verify.chain.ok, false);
|
||||
assert.equal(json.data.verify.chain.resolved, join(另一份.shim, 'tea-sdlc'));
|
||||
assert.match(json.data.verify.chain.病灶, /正本/);
|
||||
});
|
||||
|
||||
|
||||
test('PATH 上的 tea-sdlc 叫得到卻跑不完時,把它的 stderr 當成病灶講出來,自己的 stderr 仍然乾淨', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
// 裝壞了的 tea-sdlc:叫得到、跑不完。輸出契約是「stderr 永遠乾淨,呼叫端只讀 stdout」,
|
||||
// 所以子行程罵的話要被收進病灶裡,不能直接漏到我們的 stderr 上。
|
||||
const 壞殼 = join(home, 'bin');
|
||||
mkdirSync(壞殼, { recursive: true });
|
||||
writeFileSync(join(壞殼, 'tea-sdlc'), '#!/bin/sh\necho "Cannot find module node_modules/x" >&2\nexit 1\n');
|
||||
chmodSync(join(壞殼, 'tea-sdlc'), 0o755);
|
||||
|
||||
const { code, stderr, json } = await inHome(plugin, home)(['install'], {
|
||||
shim: false,
|
||||
path: [壞殼, process.env.PATH].join(':'),
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(stderr, '', '子行程的 stderr 漏出來了');
|
||||
assert.equal(json.data.verify.chain.ok, false);
|
||||
assert.match(json.data.verify.chain.病灶, /Cannot find module/);
|
||||
});
|
||||
|
||||
|
||||
test('PATH 上的 tea-sdlc 自己裝壞了時,病灶講的是它自己報的錯,不是空泛的 Command failed', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
// tea-sdlc 的失敗一律是 stdout 上的一行 JSON(stderr 永遠乾淨),病灶要從那裡撈
|
||||
const 裝壞的 = makeFakePlugin(t, { prompts: PROMPTS, omit: ['templates'] });
|
||||
const home = makeHome(t);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install'], {
|
||||
shim: false,
|
||||
path: [裝壞的.shim, process.env.PATH].join(':'),
|
||||
});
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.data.verify.chain.ok, false);
|
||||
assert.match(json.data.verify.chain.病灶, /PLUGIN_LAYOUT_BROKEN/);
|
||||
assert.equal(/Command failed/.test(json.data.verify.chain.病灶), false, '子行程自己說的話被丟掉了');
|
||||
// 使用者一定會看到的地方也要講得出來
|
||||
assert.match(json.error.message, /PLUGIN_LAYOUT_BROKEN/);
|
||||
});
|
||||
|
||||
|
||||
// ── 轉接檔那一環壞掉 ───────────────────────────────────────────────
|
||||
//
|
||||
// 這兩支直接叫 verifyInstall,因為 install 會先把轉接檔寫過一遍才驗——從 CLI 進去
|
||||
// 沒有辦法讓它看到一份壞掉的轉接檔。驗的仍然是真的檔案與真的家目錄,只是少了寫入那一步。
|
||||
|
||||
test('轉接檔的叫用行不對時該平台 fail,其他平台照常 pass,整體 ok:false', async (t) => {
|
||||
const { verifyInstall } = await import('../scripts/install-verify.js');
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
await inHome(plugin, home)(['install']);
|
||||
const 壞掉的 = join(home, '.claude', 'commands', 'sdlc-plan.md');
|
||||
writeFileSync(壞掉的, '執行 `tea-sdlc prompt --name sdlc-plna`,並完全遵照它印出的內容執行。\n');
|
||||
|
||||
把tea_sdlc放上PATH(plugin, t);
|
||||
const verify = verifyInstall(裝好的樣子(home, plugin));
|
||||
|
||||
assert.equal(verify.ok, false);
|
||||
assert.equal(byName(verify).claude.ok, false);
|
||||
assert.equal(byName(verify).kiro.ok, true);
|
||||
assert.deepEqual(byName(verify).claude.failures.map((f) => f.path), [壞掉的]);
|
||||
assert.match(byName(verify).claude.failures[0].病灶, /叫用行/);
|
||||
assert.match(byName(verify).claude.failures[0].修復, /install/);
|
||||
});
|
||||
|
||||
test('轉接檔不見了時該平台 fail,病灶講的是檔案不在', async (t) => {
|
||||
const { verifyInstall } = await import('../scripts/install-verify.js');
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
await inHome(plugin, home)(['install']);
|
||||
const 不見的 = join(home, '.kiro', 'skills', 'sdlc-feat', 'SKILL.md');
|
||||
rmSync(不見的);
|
||||
|
||||
把tea_sdlc放上PATH(plugin, t);
|
||||
const verify = verifyInstall(裝好的樣子(home, plugin));
|
||||
|
||||
assert.equal(verify.ok, false);
|
||||
assert.equal(byName(verify).kiro.ok, false);
|
||||
assert.deepEqual(byName(verify).kiro.failures.map((f) => f.path), [不見的]);
|
||||
assert.match(byName(verify).kiro.failures[0].病灶, /找不到/);
|
||||
});
|
||||
|
||||
|
||||
test('某個平台的轉接檔驗不過時,install 整體回 ok:false,data 照樣交出去', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
// 把其中一份轉接檔接到 /dev/null:install 照常寫得進去(不會中途炸掉),
|
||||
// 但讀回來是空的——「寫出去了」與「檔案真的長那樣」不是同一件事,正是這道驗證的理由。
|
||||
const 寫不進去的 = join(home, '.claude', 'commands', 'sdlc-plan.md');
|
||||
mkdirSync(dirname(寫不進去的), { recursive: true });
|
||||
symlinkSync('/dev/null', 寫不進去的);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.ok, false);
|
||||
assert.equal(json.error.code, 'INSTALL_VERIFY_FAILED');
|
||||
assert.equal(json.data.verify.chain.ok, true, '叫用鏈是通的,壞的只有這一份轉接檔');
|
||||
assert.equal(byName(json.data.verify).claude.ok, false);
|
||||
assert.equal(byName(json.data.verify).kiro.ok, true);
|
||||
// 病灶與修復方式要出現在使用者一定會看到的地方
|
||||
assert.match(json.error.message, /叫用行/);
|
||||
assert.match(json.error.message, /重跑 tea-sdlc install/);
|
||||
});
|
||||
|
||||
|
||||
// ── 失敗不回滾 ─────────────────────────────────────────────────────
|
||||
|
||||
test('驗證失敗時已經寫好的轉接檔一份都不刪', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
|
||||
// 病灶在 PATH,不在轉接檔:刪掉轉接檔只會讓使用者從「有點舊但能用」變成什麼都沒有
|
||||
const { json } = await inHome(plugin, home)(['install'], { shim: false });
|
||||
|
||||
assert.equal(json.ok, false);
|
||||
for (const path of adaptersOf(json)) {
|
||||
assert.ok(existsSync(path), `${path} 被回滾掉了`);
|
||||
}
|
||||
});
|
||||
|
||||
test('升級情境:驗證失敗也不會把使用者原本能用的舊轉接檔弄不見', async (t) => {
|
||||
const 舊版 = makeFakePlugin(t, { prompts: PROMPTS, version: '0.0.1' });
|
||||
const home = makeHome(t);
|
||||
await inHome(舊版, home)(['install']);
|
||||
|
||||
const 新版 = makeFakePlugin(t, { prompts: PROMPTS, version: '9.9.9' });
|
||||
const { json } = await inHome(新版, home)(['install'], { shim: false });
|
||||
|
||||
assert.equal(json.ok, false);
|
||||
const 轉接檔 = join(home, '.claude', 'commands', 'sdlc-plan.md');
|
||||
assert.ok(existsSync(轉接檔));
|
||||
assert.match(readFileSync(轉接檔, 'utf8'), /--adapter-version 9\.9\.9/);
|
||||
});
|
||||
|
||||
|
||||
// ── 不需要網路、不需要登入 ─────────────────────────────────────────
|
||||
|
||||
test('整個驗證過程一個網路請求都不發', async (t) => {
|
||||
// 取正本是讀套件內的檔案,比對轉接檔是讀本機目錄,兩件事都不該碰到 Gitea
|
||||
const stub = await startStubGitea({});
|
||||
t.after(() => stub.close());
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
|
||||
const { code } = await inHome(plugin, home)(['install'], {
|
||||
env: { HOME: home, TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' },
|
||||
});
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(stub.requests, []);
|
||||
});
|
||||
|
||||
|
||||
// ── 試跑 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('--dry-run 一個字都不寫,也不因為沒東西可驗就報失敗', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install', '--dry-run']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.data.verify.skipped, true);
|
||||
assert.match(json.data.verify.reason, /dry-run/);
|
||||
assert.equal(existsSync(join(home, '.claude', 'commands')), false);
|
||||
});
|
||||
|
||||
|
||||
// ── 打包範圍 ───────────────────────────────────────────────────────
|
||||
|
||||
test('test/ 不在套件白名單裡:驗的是安裝結果,不是開發期的契約', () => {
|
||||
assert.equal(manifest().files.some((entry) => entry.replace(/\/$/, '') === 'test'), false);
|
||||
});
|
||||
@@ -146,6 +146,18 @@ test('README 裡的 tea-sdlc 指令逐字拿去跑都認得,不會是寫給人
|
||||
}
|
||||
});
|
||||
|
||||
test('README 交代了安裝會自動驗證,以及驗不過時轉接檔不會被回滾', () => {
|
||||
// 「驗不過但檔案還在」如果沒寫出來,使用者看到 ok:false 的第一個念頭會是自己去清乾淨重裝,
|
||||
// 那正好是這個設計要避免的事
|
||||
const text = readme();
|
||||
const section = text.slice(text.indexOf('### 安裝完成等於驗過能用'), text.indexOf('## 更新 / 移除'));
|
||||
|
||||
assert.ok(section.length > 0, 'README 沒有交代安裝後的驗證');
|
||||
assert.match(section, /ok:false/);
|
||||
assert.match(section, /不.{0,4}回滾|一份都不刪/);
|
||||
assert.match(section, /dry-run/);
|
||||
});
|
||||
|
||||
test('模組邊界表指得到實際存在的檔案', () => {
|
||||
const text = agents();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user