smoke-cloud-functions.mjs 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import { fileURLToPath } from 'node:url';
  4. const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions';
  5. const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
  6. const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
  7. const STRICT_PROTECTED = process.env.SMOKE_STRICT_PROTECTED === '1';
  8. const __dirname = path.dirname(fileURLToPath(import.meta.url));
  9. const rootDir = path.resolve(__dirname, '..');
  10. const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts');
  11. const fn = readCloudFunctionIds(cloudFunctionsPath);
  12. const checks = [
  13. protectedCheck('manifest.list', 'manifest', { action: 'list' }, validateArrayData),
  14. protectedCheck('task.list', 'task', { action: 'list' }, validateArrayData),
  15. protectedCheck('history.list', 'history', { action: 'list' }, validateArrayData),
  16. protectedCheck('result.list', 'result', { action: 'list' }, validateArrayData),
  17. protectedCheck('remix.listAll', 'remix', { action: 'listAll' }, validateRemixListAllData),
  18. protectedCheck('voice.listProfiles', 'voice', { action: 'listProfiles' }, validateArrayData),
  19. check('jimeng.unknownAction', 'jimeng', { action: 'unknown' }, 'failure'),
  20. check('douyin.unknownRoute', 'douyin', { action: 'call', route: 'unknown' }, 'failure'),
  21. check('douyinInsight.authGuard', 'douyinInsight', { action: 'listTopics' }, 'failure'),
  22. check('proxy.unknownAction', 'proxy', { action: 'foo' }, 'failure'),
  23. check('quickly.configured', 'quickly', { action: 'query', taskId: 'probe' }, 'any'),
  24. protectedCheck(
  25. 'upload.token',
  26. 'upload',
  27. { action: 'createUploadToken', filename: 'probe.txt', mimeType: 'text/plain', kind: 'probe', bizId: 'smoke' },
  28. validateUploadToken
  29. ),
  30. protectedCheck('authCredit.balance', 'authCredit', { action: 'balance' }, validateAuthCreditBalance),
  31. protectedCheck('systemStorage.stats', 'systemStorage', { action: 'stats' }, validateSystemStorageStats),
  32. protectedCheck('fileAsset.stats', 'fileAsset', { action: 'stats' }, validateFileAssetStats),
  33. ];
  34. function check(name, key, body, expect) {
  35. return { name, key, body, expect, validate: null };
  36. }
  37. function protectedCheck(name, key, body, validate) {
  38. const expect = SESSION_TOKEN ? 'success' : STRICT_PROTECTED ? 'failure' : 'any';
  39. return { name, key, body, expect, validate };
  40. }
  41. async function call(id, body) {
  42. const payload = { id, _ApplicationId: APP_ID, ...body };
  43. if (SESSION_TOKEN) payload.sessionToken = SESSION_TOKEN;
  44. let lastError = null;
  45. for (let attempt = 1; attempt <= 3; attempt += 1) {
  46. try {
  47. const response = await fetch(FN_URL, {
  48. method: 'POST',
  49. headers: {
  50. 'Content-Type': 'application/json',
  51. 'X-Parse-Application-Id': APP_ID,
  52. },
  53. body: JSON.stringify(payload),
  54. });
  55. const text = await response.text();
  56. try {
  57. const data = JSON.parse(text);
  58. if (isRetryableGatewayResult(data) && attempt < 3) {
  59. await sleep(500 * attempt);
  60. continue;
  61. }
  62. return data;
  63. } catch {
  64. return { code: response.status, success: false, error: text };
  65. }
  66. } catch (error) {
  67. lastError = error;
  68. await sleep(500 * attempt);
  69. }
  70. }
  71. throw lastError || new Error('fetch failed');
  72. }
  73. let failed = 0;
  74. for (const item of checks) {
  75. const id = fn[item.key] || '';
  76. if (!id) {
  77. console.log(`SKIP ${item.name}: ${item.key} function id is empty`);
  78. continue;
  79. }
  80. try {
  81. const result = await call(id, item.body);
  82. const ok = matchesExpectation(result, item.expect) && runValidator(item, result);
  83. if (!ok) failed += 1;
  84. console.log(`${ok ? 'OK' : 'FAIL'} ${item.name}: ${summarize(result)}`);
  85. } catch (error) {
  86. failed += 1;
  87. console.log(`FAIL ${item.name}: ${error?.message || error}`);
  88. }
  89. }
  90. if (failed > 0) {
  91. console.error(`Cloud function smoke failed: ${failed}`);
  92. process.exit(1);
  93. }
  94. console.log('Cloud function smoke passed');
  95. function matchesExpectation(result, expect) {
  96. if (expect === 'any') return true;
  97. const code = Number(result?.code || 0);
  98. const success = result?.success !== false && code === 200;
  99. if (expect === 'success') return success;
  100. return code >= 400 || result?.success === false;
  101. }
  102. function runValidator(item, result) {
  103. if (item.expect !== 'success' || !item.validate) return true;
  104. try {
  105. return item.validate(result);
  106. } catch {
  107. return false;
  108. }
  109. }
  110. function validateUploadToken(result) {
  111. const key = String(result?.data?.key || '');
  112. return !!result?.data?.token && key.startsWith('users/');
  113. }
  114. function validateArrayData(result) {
  115. return Array.isArray(result?.data);
  116. }
  117. function validateRemixListAllData(result) {
  118. return Array.isArray(result?.data) || (result?.data && typeof result.data === 'object' && !Array.isArray(result.data));
  119. }
  120. function validateAuthCreditBalance(result) {
  121. return Number.isFinite(Number(result?.data?.balance));
  122. }
  123. function validateSystemStorageStats(result) {
  124. return Number.isFinite(Number(result?.data?.entities?.total ?? result?.data?.totalEntities ?? 0));
  125. }
  126. function validateFileAssetStats(result) {
  127. return Number.isFinite(Number(result?.data?.total ?? 0));
  128. }
  129. function summarize(result) {
  130. return JSON.stringify(result).slice(0, 700);
  131. }
  132. function readCloudFunctionIds(filePath) {
  133. const text = fs.readFileSync(filePath, 'utf8');
  134. const ids = {};
  135. const pattern = /^\s*([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*'([^']*)'/gm;
  136. let match;
  137. while ((match = pattern.exec(text))) {
  138. ids[match[1]] = match[2];
  139. }
  140. return ids;
  141. }
  142. function isRetryableGatewayResult(result) {
  143. const message = `${result?.error || ''} ${result?.message || ''}`;
  144. return /必须提供\s*id|fetch failed|Failed to fetch|NetworkError|Load failed/i.test(message);
  145. }
  146. function sleep(ms) {
  147. return new Promise((resolve) => setTimeout(resolve, ms));
  148. }