实现网站游戏评分与评价
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m6s
Project CI / Backend tests (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m51s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m15s
Project CI / Native shell tests (pull_request) Successful in 5m37s
Project CI / Repository checks (pull_request) Successful in 2m48s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m6s
Project CI / Backend tests (pull_request) Successful in 4m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m47s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m51s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m15s
Project CI / Native shell tests (pull_request) Successful in 5m37s
Project CI / Repository checks (pull_request) Successful in 2m48s
新增游戏唯一评分评价私有表、事务保存与公共分页统计 接入个人评价读取保存接口并同步前后端契约及生成绑定 在网站详情页实现评分评论、编辑取消、字数统计和请求隔离 补齐定向测试、真实数据库接口验收脚本及文档证据
This commit is contained in:
@@ -33,6 +33,12 @@ const PAIRS = [
|
||||
['GameDistributionOrientation', 'GameDistributionOrientation'],
|
||||
['GameDistributionDeviceSupport', 'GameDistributionDeviceSupport'],
|
||||
['GameDistributionAuthor', 'GameDistributionAuthor'],
|
||||
['GameDistributionReview', 'GameDistributionReview'],
|
||||
['GameDistributionRatingSummary', 'GameDistributionRatingSummary'],
|
||||
['GameDistributionReviewsResponse', 'GameDistributionReviewsResponse'],
|
||||
['GameDistributionMyReviewResponse', 'GameDistributionMyReviewResponse'],
|
||||
['GameDistributionSaveReviewRequest', 'GameDistributionSaveReviewRequest'],
|
||||
['GameDistributionSaveReviewResponse', 'GameDistributionSaveReviewResponse'],
|
||||
['GameDistributionVersionSummary', 'GameDistributionVersionSummary'],
|
||||
['GameDistributionGameSummary', 'GameDistributionGame'],
|
||||
['GameDistributionListResponse', 'GameDistributionListResponse'],
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
// 网站游戏评价真实 HTTP/SpacetimeDB 验收,仅允许显式指定本机隔离数据库。
|
||||
// 先运行 npm run dev backend -- --database genarrative-reviews-e2e
|
||||
// --spacetime-data-dir server-rs/.spacetimedb/reviews-e2e/data --no-interactive
|
||||
// 再运行 E2E_REVIEWS_DATABASE=genarrative-reviews-e2e node scripts/check-game-distribution-ratings-e2e.mjs
|
||||
// 地址与本地 runtime writer 凭据从当前 .app/dev-stack.json/data-dir 读取;不会输出凭据。
|
||||
// fixture 只调用已有数据库 procedure,不上传 OSS,也不验证发行包或真实游玩。
|
||||
// 创建 21 个临时账号、两个公开游戏及一个未公开游戏;记录留在隔离库供浏览器验收。
|
||||
// 清理由停止本次栈后删除明确指定的隔离 data-dir 完成,不删除或修改其它数据库。
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const state = JSON.parse(readFileSync(resolve('.app/dev-stack.json'), 'utf8'));
|
||||
const database = process.env.E2E_REVIEWS_DATABASE;
|
||||
assert(
|
||||
database && database === state.database,
|
||||
'请显式指定与当前 dev 栈一致的 E2E_REVIEWS_DATABASE',
|
||||
);
|
||||
assert(database.includes('reviews-e2e'), '本验收仅允许 reviews-e2e 隔离数据库');
|
||||
const apiBase = process.env.E2E_API_BASE ?? state.services['api-server'].url;
|
||||
const spacetimeBase = state.services.spacetime.url;
|
||||
for (const value of [apiBase, spacetimeBase]) {
|
||||
const url = new URL(value);
|
||||
assert(
|
||||
url.protocol === 'http:' &&
|
||||
['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname),
|
||||
'仅允许本机 HTTP 栈',
|
||||
);
|
||||
}
|
||||
const identity = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(state.spacetimeDataDir, 'dev-api-identities/local-node.json'),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
assert(identity.token, '本地 runtime writer 凭据缺失');
|
||||
let checks = 0;
|
||||
function check(label, condition) {
|
||||
assert(condition, label);
|
||||
checks += 1;
|
||||
console.log(`PASS ${label}`);
|
||||
}
|
||||
async function api(path, { token, body, method = 'GET' } = {}) {
|
||||
const headers = { 'x-genarrative-response-envelope': 'v1' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
const json = await response.json();
|
||||
return {
|
||||
status: response.status,
|
||||
data: json.data,
|
||||
headers: response.headers,
|
||||
};
|
||||
}
|
||||
let operation = 0;
|
||||
function receipt() {
|
||||
const key = `reviews-e2e-${randomUUID()}-${operation++}`;
|
||||
return {
|
||||
idempotency_key: key,
|
||||
request_digest: key,
|
||||
now_micros: Date.now() * 1000,
|
||||
};
|
||||
}
|
||||
async function procedure(name, input) {
|
||||
const response = await fetch(
|
||||
`${spacetimeBase}/v1/database/${encodeURIComponent(database)}/call/${name}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${identity.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify([input]),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const diagnostic = await response.text();
|
||||
throw new Error(
|
||||
`${name}: HTTP ${response.status} ${diagnostic.slice(0, 400)}`,
|
||||
);
|
||||
}
|
||||
const result = await response.json();
|
||||
assert(
|
||||
(result.success ?? result[0]) === true,
|
||||
`${name}: fixture procedure 失败`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
const pathFor = (gameId) => `/api/game-distribution/games/${gameId}`;
|
||||
const list = (gameId, query = '') => api(`${pathFor(gameId)}/reviews${query}`);
|
||||
const mine = (gameId, token) => api(`${pathFor(gameId)}/my-review`, { token });
|
||||
const save = (gameId, token, body) =>
|
||||
api(`${pathFor(gameId)}/my-review`, { method: 'PUT', token, body });
|
||||
async function account(index, stamp) {
|
||||
const phone = `139${String(stamp + index).slice(-8)}`;
|
||||
const response = await api('/api/auth/entry', {
|
||||
method: 'POST',
|
||||
body: { purePhoneNumber: phone, password: 'ReviewsE2e123!' },
|
||||
});
|
||||
assert(
|
||||
response.status === 200 && response.data?.token && response.data?.user?.id,
|
||||
`账号 ${index} 登录失败 ${response.status}`,
|
||||
);
|
||||
return { token: response.data.token, user: response.data.user, phone };
|
||||
}
|
||||
async function createGame(owner, title) {
|
||||
const gameId = `game_${randomUUID().replaceAll('-', '')}`;
|
||||
await procedure('create_game_distribution_game_and_return', {
|
||||
game_id: gameId,
|
||||
owner_user_id: owner.user.id,
|
||||
title,
|
||||
summary: '评价验收本地 fixture',
|
||||
description: null,
|
||||
category: '休闲',
|
||||
tags_json: '["e2e"]',
|
||||
cover_asset_id: null,
|
||||
author_name: [0, owner.user.displayName],
|
||||
author_avatar_url: null,
|
||||
device_support_desktop: true,
|
||||
device_support_mobile: true,
|
||||
device_support_touch: true,
|
||||
input_modes_json: '["keyboard","touch"]',
|
||||
orientation: 'landscape',
|
||||
local_project_id: null,
|
||||
...receipt(),
|
||||
});
|
||||
return gameId;
|
||||
}
|
||||
async function publish(gameId, owner, revision) {
|
||||
const versionId = `version_${randomUUID().replaceAll('-', '')}`;
|
||||
const metadata = {
|
||||
title: '网站评价验收',
|
||||
summary: '本地评价 fixture,发行资源不上传',
|
||||
category: '休闲',
|
||||
coverAssetId: 'reviews-e2e-cover',
|
||||
coverObjectKey: 'reviews-e2e/cover.png',
|
||||
tags: ['e2e'],
|
||||
deviceSupport: { desktop: true, mobile: true, touch: true },
|
||||
inputModes: ['keyboard', 'touch'],
|
||||
orientation: 'landscape',
|
||||
};
|
||||
const packageFields = {
|
||||
package_sha_256: 'a'.repeat(64),
|
||||
package_bytes: 1,
|
||||
package_file_count: 1,
|
||||
package_entry_path: 'index.html',
|
||||
};
|
||||
await procedure('create_game_distribution_version_and_return', {
|
||||
game_id: gameId,
|
||||
owner_user_id: owner.user.id,
|
||||
version_id: versionId,
|
||||
metadata_json: JSON.stringify(metadata),
|
||||
...packageFields,
|
||||
local_project_id: null,
|
||||
...receipt(),
|
||||
});
|
||||
const confirmReceipt = receipt();
|
||||
const { now_micros, ...confirmKeys } = confirmReceipt;
|
||||
await procedure('confirm_game_distribution_package_and_return', {
|
||||
version_id: versionId,
|
||||
owner_user_id: owner.user.id,
|
||||
...packageFields,
|
||||
package_object_key: 'reviews-e2e/package.zip',
|
||||
package_manifest_json: '{}',
|
||||
...confirmKeys,
|
||||
updated_at_micros: now_micros,
|
||||
});
|
||||
await procedure('submit_game_distribution_version_for_review_and_return', {
|
||||
version_id: versionId,
|
||||
owner_user_id: owner.user.id,
|
||||
expected_publication_revision: revision,
|
||||
...receipt(),
|
||||
});
|
||||
await procedure('approve_game_distribution_version_and_return', {
|
||||
version_id: versionId,
|
||||
admin_user_id: 'reviews-e2e-fixture',
|
||||
expected_publication_revision: revision,
|
||||
entry_url: `http://127.0.0.1/games/${gameId}/index.html`,
|
||||
...receipt(),
|
||||
});
|
||||
}
|
||||
async function invisible(gameId, token, label) {
|
||||
const responses = await Promise.all([
|
||||
list(gameId),
|
||||
mine(gameId, token),
|
||||
save(gameId, token, { score: 5 }),
|
||||
]);
|
||||
check(
|
||||
`${label}:公共/个人/保存均 404`,
|
||||
responses.every((row) => row.status === 404),
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const health = await fetch(`${apiBase}/healthz`);
|
||||
check('真实 API 健康', health.ok);
|
||||
const users = [];
|
||||
const stamp = Date.now();
|
||||
for (let index = 0; index < 21; index += 1)
|
||||
users.push(await account(index, stamp));
|
||||
const [author, other] = users;
|
||||
const gameId = await createGame(author, '评价验收主游戏');
|
||||
const secondGame = await createGame(author, '评价验收第二游戏');
|
||||
const privateGame = await createGame(author, '评价验收未公开游戏');
|
||||
await publish(gameId, author, 0);
|
||||
await publish(secondGame, author, 0);
|
||||
const empty = await list(gameId);
|
||||
check(
|
||||
'匿名空列表与 null/0 统计',
|
||||
empty.status === 200 &&
|
||||
empty.data.reviews.length === 0 &&
|
||||
empty.data.totalPages === 0 &&
|
||||
empty.data.ratingSummary.averageScore === null &&
|
||||
empty.data.ratingSummary.ratingCount === 0,
|
||||
);
|
||||
check('公开响应 no-store', empty.headers.get('cache-control') === 'no-store');
|
||||
check(
|
||||
'已登录未评价返回 null',
|
||||
(await mine(gameId, author.token)).data.review === null,
|
||||
);
|
||||
check('匿名个人读取 401', (await mine(gameId)).status === 401);
|
||||
check(
|
||||
'匿名写入 401',
|
||||
(await save(gameId, undefined, { score: 5 })).status === 401,
|
||||
);
|
||||
check(
|
||||
'失效认证 401',
|
||||
(await save(gameId, 'invalid-token', { score: 5 })).status === 401,
|
||||
);
|
||||
await invisible(privateGame, author.token, '未公开游戏');
|
||||
for (const [label, body, status] of [
|
||||
['未选择评分', { comment: '仅文字' }, 400],
|
||||
['非整数', { score: 1.5 }, 400],
|
||||
['字符串评分', { score: '5' }, 400],
|
||||
['评论类型', { score: 5, comment: 42 }, 400],
|
||||
['0 分', { score: 0 }, 422],
|
||||
['11 分', { score: 11 }, 422],
|
||||
['4001 emoji', { score: 5, comment: '😀'.repeat(4001) }, 422],
|
||||
['4001 空白先校验', { score: 5, comment: ' '.repeat(4001) }, 422],
|
||||
])
|
||||
check(
|
||||
`${label}拒绝 ${status}`,
|
||||
(await save(gameId, author.token, body)).status === status,
|
||||
);
|
||||
check('无效输入没有写入', (await list(gameId)).data.total === 0);
|
||||
const concurrent = await Promise.all(
|
||||
Array.from({ length: 6 }, () => save(gameId, author.token, { score: 1 })),
|
||||
);
|
||||
check(
|
||||
'并发首次保存均成功且同一 ID',
|
||||
concurrent.every(
|
||||
(row) =>
|
||||
row.status === 200 &&
|
||||
row.data.review.id === concurrent[0].data.review.id,
|
||||
),
|
||||
);
|
||||
const original = concurrent[0].data.review;
|
||||
check('1 分与省略评论成功', original.score === 1 && original.comment === '');
|
||||
check('并发只有一人', (await list(gameId)).data.total === 1);
|
||||
const repeated = await save(gameId, author.token, { score: 1, comment: '' });
|
||||
check(
|
||||
'相同内容重试不制造修改时间',
|
||||
repeated.data.review.updatedAt === original.updatedAt &&
|
||||
repeated.data.review.createdAt === original.createdAt,
|
||||
);
|
||||
const ten = await save(gameId, other.token, { score: 10, comment: '' });
|
||||
check(
|
||||
'10 分与空评论成功,全量均分 5.5',
|
||||
ten.status === 200 &&
|
||||
ten.data.ratingSummary.averageScore === 5.5 &&
|
||||
ten.data.ratingSummary.ratingCount === 2,
|
||||
);
|
||||
const long = await save(gameId, author.token, {
|
||||
score: 1,
|
||||
comment: '😀'.repeat(4000),
|
||||
});
|
||||
check(
|
||||
'4000 Unicode 码点保存完整',
|
||||
long.status === 200 && [...long.data.review.comment].length === 4000,
|
||||
);
|
||||
const text = ' 中文 English 😀 \r\n第二行\r末尾 ';
|
||||
const normal = await save(gameId, author.token, { score: 1, comment: text });
|
||||
check(
|
||||
'保留空格并归一化 CRLF/CR',
|
||||
normal.data.review.comment === text.replace(/\r\n?/gu, '\n'),
|
||||
);
|
||||
const blank = await save(gameId, author.token, {
|
||||
score: 1,
|
||||
comment: '\r\n \t',
|
||||
});
|
||||
check('纯空白保存为空', blank.data.review.comment === '');
|
||||
const forged = await save(gameId, author.token, {
|
||||
score: 3,
|
||||
comment: '作者更新',
|
||||
userId: other.user.id,
|
||||
reviewId: ten.data.review.id,
|
||||
author: { id: other.user.id, name: '伪造昵称' },
|
||||
});
|
||||
check(
|
||||
'伪造身份不修改他人',
|
||||
[200, 400].includes(forged.status) &&
|
||||
(await mine(gameId, other.token)).data.review.score === 10,
|
||||
);
|
||||
const second = await save(secondGame, author.token, { score: 8 });
|
||||
check(
|
||||
'同账号不同游戏独立',
|
||||
second.status === 200 &&
|
||||
second.data.ratingSummary.ratingCount === 1 &&
|
||||
second.data.review.id !== original.id,
|
||||
);
|
||||
for (let index = 2; index < users.length; index += 1) {
|
||||
const saved = await save(gameId, users[index].token, {
|
||||
score: (index % 10) + 1,
|
||||
comment: index % 2 ? '' : `评价 ${index}`,
|
||||
});
|
||||
assert(saved.status === 200, `评价 ${index} 保存失败 ${saved.status}`);
|
||||
}
|
||||
const firstPage = await list(gameId);
|
||||
const lastPage = await list(gameId, '?page=2');
|
||||
const rows = [...firstPage.data.reviews, ...lastPage.data.reviews];
|
||||
check(
|
||||
'21 条真实评价,两页 20/1',
|
||||
firstPage.data.total === 21 &&
|
||||
firstPage.data.pageSize === 20 &&
|
||||
firstPage.data.reviews.length === 20 &&
|
||||
lastPage.data.reviews.length === 1 &&
|
||||
firstPage.data.totalPages === 2,
|
||||
);
|
||||
check(
|
||||
'公共列表无重复并包含自己的评价',
|
||||
new Set(rows.map((row) => row.id)).size === 21 &&
|
||||
rows.filter((row) => row.id === original.id).length === 1,
|
||||
);
|
||||
const sorted = [...rows].sort(
|
||||
(a, b) =>
|
||||
Date.parse(b.createdAt) - Date.parse(a.createdAt) ||
|
||||
b.id.localeCompare(a.id),
|
||||
);
|
||||
check(
|
||||
'创建时间倒序与稳定 ID 次序',
|
||||
rows.every((row, index) => row.id === sorted[index].id),
|
||||
);
|
||||
const average =
|
||||
Math.round(
|
||||
(rows.reduce((sum, row) => sum + row.score, 0) / rows.length) * 10,
|
||||
) / 10;
|
||||
check(
|
||||
'均分覆盖全部页',
|
||||
firstPage.data.ratingSummary.averageScore === average &&
|
||||
lastPage.data.ratingSummary.averageScore === average &&
|
||||
firstPage.data.ratingSummary.ratingCount === 21,
|
||||
);
|
||||
const before = (await mine(gameId, author.token)).data.review;
|
||||
const edited = await save(gameId, author.token, {
|
||||
score: 9,
|
||||
comment: '改分',
|
||||
});
|
||||
check(
|
||||
'修改稳定 ID/创建时间/人数',
|
||||
edited.data.review.id === before.id &&
|
||||
edited.data.review.createdAt === before.createdAt &&
|
||||
edited.data.ratingSummary.ratingCount === 21 &&
|
||||
edited.data.review.updatedAt !== before.updatedAt,
|
||||
);
|
||||
const expectedAverage =
|
||||
Math.round(
|
||||
((rows.reduce((sum, row) => sum + row.score, 0) - before.score + 9) /
|
||||
21) *
|
||||
10,
|
||||
) / 10;
|
||||
check(
|
||||
'改分重新计算均分',
|
||||
edited.data.ratingSummary.averageScore === expectedAverage,
|
||||
);
|
||||
const commentEdit = await save(gameId, author.token, {
|
||||
score: 9,
|
||||
comment: '<script>alert(1)</script>\n**纯文本**',
|
||||
});
|
||||
check(
|
||||
'只改评论不改变均分并保持纯文本',
|
||||
commentEdit.data.ratingSummary.averageScore === expectedAverage &&
|
||||
commentEdit.data.review.comment.startsWith('<script>'),
|
||||
);
|
||||
const afterRows = [
|
||||
...(await list(gameId)).data.reviews,
|
||||
...(await list(gameId, '?page=2')).data.reviews,
|
||||
];
|
||||
check(
|
||||
'编辑不改变排序位置',
|
||||
afterRows.every((row, index) => row.id === rows[index].id),
|
||||
);
|
||||
check(
|
||||
'超出末页是空列表',
|
||||
(await list(gameId, '?page=3')).data.reviews.length === 0,
|
||||
);
|
||||
check(
|
||||
'pageSize=1/50 边界',
|
||||
(await list(gameId, '?pageSize=1')).data.reviews.length === 1 &&
|
||||
(await list(gameId, '?pageSize=50')).data.reviews.length === 21,
|
||||
);
|
||||
for (const query of [
|
||||
'?page=0',
|
||||
'?page=-1',
|
||||
'?page=1.5',
|
||||
'?page=abc',
|
||||
'?pageSize=0',
|
||||
'?pageSize=51',
|
||||
]) {
|
||||
check(
|
||||
`非法分页 ${query} 返回 400`,
|
||||
(await list(gameId, query)).status === 400,
|
||||
);
|
||||
}
|
||||
const personal = await mine(gameId, author.token);
|
||||
check(
|
||||
'个人响应 no-store 与公共同一形状',
|
||||
personal.headers.get('cache-control') === 'no-store' &&
|
||||
personal.data.review.id === original.id &&
|
||||
personal.data.review.author.id === author.user.id,
|
||||
);
|
||||
await publish(gameId, author, 1);
|
||||
check(
|
||||
'发布新版本保留评价',
|
||||
(await list(gameId)).data.total === 21 &&
|
||||
(await mine(gameId, author.token)).data.review.id === original.id,
|
||||
);
|
||||
await procedure('unpublish_game_distribution_game_and_return', {
|
||||
game_id: gameId,
|
||||
owner_user_id: author.user.id,
|
||||
expected_publication_revision: 2,
|
||||
...receipt(),
|
||||
});
|
||||
await invisible(gameId, author.token, '作者下架');
|
||||
await publish(gameId, author, 3);
|
||||
check('重新公开恢复已有评价', (await list(gameId)).data.total === 21);
|
||||
await procedure('suspend_game_distribution_game_and_return', {
|
||||
game_id: gameId,
|
||||
admin_user_id: 'reviews-e2e-fixture',
|
||||
expected_publication_revision: 4,
|
||||
reason: [0, '评价验收'],
|
||||
...receipt(),
|
||||
});
|
||||
await invisible(gameId, author.token, '管理员封禁');
|
||||
await procedure('restore_game_distribution_game_and_return', {
|
||||
game_id: gameId,
|
||||
admin_user_id: 'reviews-e2e-fixture',
|
||||
expected_publication_revision: 5,
|
||||
...receipt(),
|
||||
});
|
||||
check(
|
||||
'解除封禁保留评价并可编辑',
|
||||
(await list(gameId)).data.total === 21 &&
|
||||
(await save(gameId, author.token, { score: 9, comment: '恢复后编辑' }))
|
||||
.status === 200,
|
||||
);
|
||||
writeFileSync(
|
||||
resolve('.app/reviews-e2e-browser.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
database,
|
||||
apiBase,
|
||||
gameId,
|
||||
secondGame,
|
||||
accounts: [author, other].map(({ phone }) => ({
|
||||
phone,
|
||||
password: 'ReviewsE2e123!',
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
console.log(
|
||||
`全部 ${checks} 项通过;数据库=${database};主游戏=${gameId};第二游戏=${secondGame}。fixture 保留在隔离库,未上传 OSS。`,
|
||||
);
|
||||
}
|
||||
await main().catch((error) => {
|
||||
console.error(`FAIL ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user