import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions'; const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster'; const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || ''; const STRICT_PROTECTED = process.env.SMOKE_STRICT_PROTECTED === '1'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.resolve(__dirname, '..'); const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts'); const fn = readCloudFunctionIds(cloudFunctionsPath); const checks = [ protectedCheck('manifest.list', 'manifest', { action: 'list' }, validateArrayData), protectedCheck('task.list', 'task', { action: 'list' }, validateArrayData), protectedCheck('history.list', 'history', { action: 'list' }, validateArrayData), protectedCheck('result.list', 'result', { action: 'list' }, validateArrayData), protectedCheck('remix.listAll', 'remix', { action: 'listAll' }, validateRemixListAllData), protectedCheck('voice.listProfiles', 'voice', { action: 'listProfiles' }, validateArrayData), check('jimeng.unknownAction', 'jimeng', { action: 'unknown' }, 'failure'), check('douyin.unknownRoute', 'douyin', { action: 'call', route: 'unknown' }, 'failure'), check('douyinInsight.authGuard', 'douyinInsight', { action: 'listTopics' }, 'failure'), check('proxy.unknownAction', 'proxy', { action: 'foo' }, 'failure'), check('quickly.configured', 'quickly', { action: 'query', taskId: 'probe' }, 'any'), protectedCheck( 'upload.token', 'upload', { action: 'createUploadToken', filename: 'probe.txt', mimeType: 'text/plain', kind: 'probe', bizId: 'smoke' }, validateUploadToken ), protectedCheck('authCredit.balance', 'authCredit', { action: 'balance' }, validateAuthCreditBalance), protectedCheck('systemStorage.stats', 'systemStorage', { action: 'stats' }, validateSystemStorageStats), protectedCheck('fileAsset.stats', 'fileAsset', { action: 'stats' }, validateFileAssetStats), ]; function check(name, key, body, expect) { return { name, key, body, expect, validate: null }; } function protectedCheck(name, key, body, validate) { const expect = SESSION_TOKEN ? 'success' : STRICT_PROTECTED ? 'failure' : 'any'; return { name, key, body, expect, validate }; } async function call(id, body) { const payload = { id, _ApplicationId: APP_ID, ...body }; if (SESSION_TOKEN) payload.sessionToken = SESSION_TOKEN; let lastError = null; for (let attempt = 1; attempt <= 3; attempt += 1) { try { const response = await fetch(FN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': APP_ID, }, body: JSON.stringify(payload), }); const text = await response.text(); try { const data = JSON.parse(text); if (isRetryableGatewayResult(data) && attempt < 3) { await sleep(500 * attempt); continue; } return data; } catch { return { code: response.status, success: false, error: text }; } } catch (error) { lastError = error; await sleep(500 * attempt); } } throw lastError || new Error('fetch failed'); } let failed = 0; for (const item of checks) { const id = fn[item.key] || ''; if (!id) { console.log(`SKIP ${item.name}: ${item.key} function id is empty`); continue; } try { const result = await call(id, item.body); const ok = matchesExpectation(result, item.expect) && runValidator(item, result); if (!ok) failed += 1; console.log(`${ok ? 'OK' : 'FAIL'} ${item.name}: ${summarize(result)}`); } catch (error) { failed += 1; console.log(`FAIL ${item.name}: ${error?.message || error}`); } } if (failed > 0) { console.error(`Cloud function smoke failed: ${failed}`); process.exit(1); } console.log('Cloud function smoke passed'); function matchesExpectation(result, expect) { if (expect === 'any') return true; const code = Number(result?.code || 0); const success = result?.success !== false && code === 200; if (expect === 'success') return success; return code >= 400 || result?.success === false; } function runValidator(item, result) { if (item.expect !== 'success' || !item.validate) return true; try { return item.validate(result); } catch { return false; } } function validateUploadToken(result) { const key = String(result?.data?.key || ''); return !!result?.data?.token && key.startsWith('users/'); } function validateArrayData(result) { return Array.isArray(result?.data); } function validateRemixListAllData(result) { return Array.isArray(result?.data) || (result?.data && typeof result.data === 'object' && !Array.isArray(result.data)); } function validateAuthCreditBalance(result) { return Number.isFinite(Number(result?.data?.balance)); } function validateSystemStorageStats(result) { return Number.isFinite(Number(result?.data?.entities?.total ?? result?.data?.totalEntities ?? 0)); } function validateFileAssetStats(result) { return Number.isFinite(Number(result?.data?.total ?? 0)); } function summarize(result) { return JSON.stringify(result).slice(0, 700); } function readCloudFunctionIds(filePath) { const text = fs.readFileSync(filePath, 'utf8'); const ids = {}; const pattern = /^\s*([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*'([^']*)'/gm; let match; while ((match = pattern.exec(text))) { ids[match[1]] = match[2]; } return ids; } function isRetryableGatewayResult(result) { const message = `${result?.error || ''} ${result?.message || ''}`; return /必须提供\s*id|fetch failed|Failed to fetch|NetworkError|Load failed/i.test(message); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }