This commit is contained in:
大哥大哥的大哥哥
2026-09-09 14:47:29 +08:00
parent 4d9da40abd
commit 65755c9e96
832 changed files with 412085 additions and 0 deletions
@@ -0,0 +1,25 @@
// Uses the installed WeChat compiler binaries only. No DevTools UI, server,
// account, network, upload, media playback or files outside the build are used.
const fs = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const root = path.resolve(__dirname, '../dist/build/mp-weixin')
const bin = process.env.TANG_WECHAT_COMPILER_DIR || '/Applications/wechatwebdevtools.app/Contents/Resources/app.asar.unpacked/node_modules/wcc-exec'
const app = JSON.parse(fs.readFileSync(path.join(root, 'app.json'), 'utf8'))
const pages = [...app.pages, ...(app.subPackages || []).flatMap(pkg => pkg.pages.map(page => `${pkg.root}/${page}`))]
.filter(page => page.startsWith('tang-detective/'))
if (pages.length !== 24) throw new Error(`Expected 24 native pages, found ${pages.length}`)
const results = []
for (const [tool, args] of [
['wcc', pages.map(page => './' + page + '.wxml')],
['wcsc', ['-pc', String(pages.length), ...pages.map(page => './' + page + '.wxss'), './tang-detective/shared.wxss']],
]) {
if (!fs.existsSync(path.join(bin, tool))) throw new Error(`${tool} not available; set TANG_WECHAT_COMPILER_DIR`)
const run = spawnSync(path.join(bin, tool), args, { cwd: root, encoding: 'utf8', timeout: 30000, maxBuffer: 64 * 1024 * 1024 })
results.push({ tool, exitCode: run.status, passed: run.status === 0 && !run.error,
generatedOutputBytes: Buffer.byteLength(run.stdout || ''),
diagnostics: (run.stderr || '').slice(0, 3000), error: run.error ? run.error.message : null })
}
console.log(JSON.stringify({ nativePages: pages.length, results,
boundary: 'Installed compiler syntax check only, not simulator/device, networking or upload acceptance.' }, null, 2))
if (results.some(result => !result.passed)) process.exitCode = 1
+95
View File
@@ -0,0 +1,95 @@
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
export function dependency(name) {
const root = process.env.TANG_COS_TOOLS_DIR
return createRequire(root ? path.join(path.resolve(root), 'package.json') : import.meta.url)(name)
}
// Fail closed rather than evaluating PHP or guessing deployment settings.
export function configuredDatabase(serverDirectory, environment = process.env) {
const filename = path.join(serverDirectory, 'config/database.php')
const source = fs.readFileSync(filename, 'utf8')
if (fs.existsSync(path.join(serverDirectory, '.env'))) {
throw new Error('SERVER_ENV_REQUIRES_NATIVE_RUNTIME')
}
function value(key) {
const envKey = `DATABASE_${key.toUpperCase()}`
if (environment[envKey] !== undefined) return environment[envKey]
if (environment[`PHP_${envKey}`] !== undefined) return environment[`PHP_${envKey}`]
const match = source.match(new RegExp(`env\\('database\\.${key}',\\s*'((?:\\\\.|[^'\\\\])*)'\\)`))
if (!match) throw new Error('UNSUPPORTED_DATABASE_CONFIG')
return match[1].replace(/\\([\\'])/g, '$1')
}
const prefix = value('prefix')
const port = Number(value('hostport'))
if (!/^[a-zA-Z0-9_]+$/.test(prefix) || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('INVALID_DATABASE_CONFIG')
}
const options = {
host: value('hostname'), port, database: value('database'), user: value('username'), password: value('password'),
connectTimeout: 8000, multipleStatements: false,
ssl: { rejectUnauthorized: true, verifyIdentity: true },
}
// Supply only a trusted CA obtained from the server administrator; never fetch and trust a peer certificate.
if (environment.TANG_DB_CA_FILE) options.ssl.ca = fs.readFileSync(environment.TANG_DB_CA_FILE, 'utf8')
return { options, prefix }
}
export function validateCosConfig(config, driver) {
if (driver !== 'qcloud') throw new Error('CONFIGURED_DRIVER_IS_NOT_COS')
if (!/^[a-z0-9][a-z0-9-]*-\d+$/.test(config.bucket || '') || !/^[a-z][a-z0-9-]+$/.test(config.region || '')) {
throw new Error('INVALID_COS_DESTINATION')
}
if (typeof config.access_key !== 'string' || !config.access_key || typeof config.secret_key !== 'string' || !config.secret_key) {
throw new Error('COS_CREDENTIALS_MISSING')
}
const rawDomain = String(config.domain || '').trim()
const base = new URL(rawDomain ? (/^https?:\/\//i.test(rawDomain) ? rawDomain : `https://${rawDomain}`)
: `https://${config.bucket}.cos.${config.region}.myqcloud.com`)
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash) {
throw new Error('COS_REQUIRES_UNSIGNED_HTTPS_BASE_URL')
}
return { ...config, baseUrl: base.href.replace(/\/+$/, '') }
}
export async function withDeadline(operation, milliseconds, abort, code) {
let timer
try {
return await Promise.race([operation, new Promise((_, reject) => {
timer = setTimeout(() => {
try { abort() } catch {}
reject(new Error(code))
}, milliseconds)
})])
} finally {
clearTimeout(timer)
}
}
export async function readCosConfig(serverDirectory) {
const mysql = dependency('mysql2')
const { options, prefix } = configuredDatabase(serverDirectory)
let connection
let destroyed = false
const abort = () => { destroyed = true; try { connection?.destroy() } catch {} }
try {
connection = mysql.createConnection(options).promise()
await withDeadline(connection.connect(), 8000, abort, 'DATABASE_CONNECT_TIMEOUT')
const [rows] = await withDeadline(connection.execute(`SELECT name,value FROM \`${prefix}config\` WHERE type=? AND name IN (?,?)`,
['storage', 'default', 'qcloud']), 8000, abort, 'DATABASE_QUERY_TIMEOUT')
const config = JSON.parse(rows.find(row => row.name === 'qcloud')?.value || '{}')
return validateCosConfig(config, rows.find(row => row.name === 'default')?.value)
} finally {
if (connection && !destroyed) {
try { await withDeadline(connection.end(), 2000, abort, 'DATABASE_CLOSE_TIMEOUT') } catch { abort() }
}
}
}
// Never print raw MySQL/COS errors: they may contain connection values or signed request URLs.
export function safeError(error) {
const code = String(error?.code || error?.message || '')
return /^[A-Z][A-Z0-9_]{2,80}$/.test(code) ? code : 'REDACTED_OPERATION_ERROR'
}
+909
View File
@@ -0,0 +1,909 @@
{
"name": "tang-detective-cos-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tang-detective-cos-tools",
"dependencies": {
"cos-nodejs-sdk-v5": "3.0.0",
"mysql2": "3.24.4"
}
},
"node_modules/@types/node": {
"version": "26.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz",
"integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
"integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==",
"license": "Apache-2.0",
"engines": {
"node": "*"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/aws4": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
"integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==",
"license": "MIT"
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
"license": "Apache-2.0"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"license": "MIT"
},
"node_modules/cos-fast-xml-parser": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/cos-fast-xml-parser/-/cos-fast-xml-parser-1.0.0.tgz",
"integrity": "sha512-kOPJb1cuj+gc5E8jN5ekZn4rgQHaxYTLcQT+jmbHtlTNcNjv7wnOBEYH2uRA0pG3h8giot4z9h4WFLDuXATZwg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.0.5"
},
"bin": {
"fxparser": "src/cli/cli.js"
},
"engines": {
"node": ">= 9"
}
},
"node_modules/cos-nodejs-sdk-v5": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cos-nodejs-sdk-v5/-/cos-nodejs-sdk-v5-3.0.0.tgz",
"integrity": "sha512-xUqiDdUxfEjfaWoyBA3qsSnlQVHPz13DRFErL+NliadBVUeeZhAPHGVa2inGpUvPTOs52ALIKkMbzL+inSqvXg==",
"license": "ISC",
"dependencies": {
"cos-fast-xml-parser": "^1.0.0",
"cos-request": "^1.3.0",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 9"
}
},
"node_modules/cos-request": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/cos-request/-/cos-request-1.3.3.tgz",
"integrity": "sha512-zD7fKMAIMfJNHssx8VZE5mUQ67hs3QDi7S2BX7JuD8MzT1XYqEOAN42PJ0BtRhgutnn+8HiUF+Fup+j5zORasQ==",
"license": "Apache-2.0",
"dependencies": {
"aws-sign2": "~0.7.0",
"aws4": "^1.8.0",
"caseless": "~0.12.0",
"combined-stream": "~1.0.6",
"extend": "~3.0.2",
"forever-agent": "~0.6.1",
"form-data": "~2.5.6",
"http-signature": "~1.2.0",
"is-typedarray": "~1.0.0",
"isstream": "~0.1.2",
"json-stringify-safe": "~5.0.1",
"mime-types": "~2.1.19",
"oauth-sign": "~0.9.0",
"performance-now": "^2.1.0",
"qs": "^6.15.2",
"safe-buffer": "^5.1.2",
"tough-cookie": "~4.1.4",
"tunnel-agent": "^0.6.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/dashdash": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
"integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==",
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0"
},
"engines": {
"node": ">=0.10"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ecc-jsbn": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
"integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==",
"license": "MIT",
"dependencies": {
"jsbn": "~0.1.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
"integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==",
"engines": [
"node >=0.6.0"
],
"license": "MIT"
},
"node_modules/forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==",
"license": "Apache-2.0",
"engines": {
"node": "*"
}
},
"node_modules/form-data": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz",
"integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.4",
"mime-types": "^2.1.35",
"safe-buffer": "^5.2.1"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/getpass": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
"integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==",
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-signature": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
"integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==",
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0",
"jsprim": "^1.2.2",
"sshpk": "^1.7.0"
},
"engines": {
"node": ">=0.8",
"npm": ">=1.3.7"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
"license": "MIT"
},
"node_modules/isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==",
"license": "MIT"
},
"node_modules/jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==",
"license": "MIT"
},
"node_modules/json-schema": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
"license": "(AFL-2.1 OR BSD-3-Clause)"
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
"license": "ISC"
},
"node_modules/jsprim": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
"integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
"license": "MIT",
"dependencies": {
"assert-plus": "1.0.0",
"extsprintf": "1.3.0",
"json-schema": "0.4.0",
"verror": "1.10.0"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lru.min": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.5.tgz",
"integrity": "sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mysql2": {
"version": "3.24.4",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
"integrity": "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.3",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.5.1"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
"integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==",
"license": "Apache-2.0",
"engines": {
"node": "*"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"license": "MIT"
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sql-escaper": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
"integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
"integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==",
"license": "MIT",
"dependencies": {
"asn1": "~0.2.3",
"assert-plus": "^1.0.0",
"bcrypt-pbkdf": "^1.0.0",
"dashdash": "^1.12.0",
"ecc-jsbn": "~0.1.1",
"getpass": "^0.1.1",
"jsbn": "~0.1.0",
"safer-buffer": "^2.0.2",
"tweetnacl": "~0.14.0"
},
"bin": {
"sshpk-conv": "bin/sshpk-conv",
"sshpk-sign": "bin/sshpk-sign",
"sshpk-verify": "bin/sshpk-verify"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"license": "MIT",
"peer": true
},
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
"integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
"engines": [
"node >=0.6.0"
],
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
}
}
}
}
@@ -0,0 +1,9 @@
{
"name": "tang-detective-cos-tools",
"private": true,
"type": "module",
"dependencies": {
"cos-nodejs-sdk-v5": "3.0.0",
"mysql2": "3.24.4"
}
}
@@ -0,0 +1,33 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath } from 'node:url'
import { inventory, hash } from './upload.mjs'
const project = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const stage = process.argv[2]
if (!stage || !path.isAbsolute(stage) || fs.existsSync(stage)) throw new Error('NEW_ABSOLUTE_STAGING_DIRECTORY_REQUIRED')
const source = inventory(project)
const unique = [...new Map(source.entries.map(entry => [entry.objectKey, entry])).values()]
const shared = source.entries.find(entry => entry.sourcePath === 'assets/share/guixiang-story-share-preview-v1.jpg')
if (!shared) throw new Error('FIRST_VERIFIED_SAMPLE_NOT_FOUND')
const sampleUrl = 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com/uploads/images/20260908/202609081718480ee488780.jpg'
const runId = crypto.randomUUID()
const counters = { image: 0, audio: 0 }
fs.mkdirSync(stage, { recursive: false })
const entries = unique.map(entry => {
const sample = entry.sha256 === shared.sha256
const stagedName = sample ? null : `tang-20260908-1714-${entry.kind}-${String(++counters[entry.kind]).padStart(3, '0')}${path.extname(entry.sourcePath)}`
const stagedPath = stagedName ? path.join(stage, stagedName) : null
if (stagedPath) {
const original = path.join(source.sourceDirectory, entry.sourcePath)
fs.copyFileSync(original, stagedPath, fs.constants.COPYFILE_EXCL)
if (hash(fs.readFileSync(stagedPath)) !== entry.sha256) throw new Error('STAGING_BYTES_DIFFER')
}
return { ...entry, stagedName, stagedPath, observedUrl: sample ? sampleUrl : null }
})
const result = { schemaVersion: 1, uploadRunId: runId, sourceManifestSha256: source.sourceManifestSha256,
sourceDirectory: source.sourceDirectory, stagingDirectory: stage, createdAt: new Date().toISOString(), entries }
const output = path.join(project, 'build/tang-detective-admin-upload-plan.json')
fs.writeFileSync(output, JSON.stringify(result, null, 2) + '\n', { flag: 'wx' })
console.log(JSON.stringify({ uploadRunId: runId, entries: entries.length, staged: counters, stage, output, originalsChanged: false }, null, 2))
@@ -0,0 +1,108 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { PROJECT, inventory, hash } from './upload.mjs'
import { loadCosMediaManifest, validateCosMediaManifest } from '../../build/tang-detective-cos-media.mjs'
import { validatePruneReceipt, readSourceManifest } from '../../build/tang-detective-source-validation.mjs'
// Deliberately task-scoped, recoverable cleanup. Never removes a cloud object or recurses over a deletion target.
const mode = process.argv[2]
if (!['check', 'apply'].includes(mode)) throw new Error('USE_CHECK_OR_APPLY')
const backupDirectory = '/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE'
const archive = path.join(backupDirectory, 'original-media.tar.gz')
const recoveryDirectory = path.join(backupDirectory, 'pruned-originals')
const expectedArchiveSha256 = '656afc8d82ccb2343487dca5b931c69de4108c0ebc21116a9fae4f62dfd27b61'
const unused = ['static/background.svg', 'static/calling-logo.png', 'static/check.png',
'static/user/home.png', 'static/user/home_no.png', 'static/wjw.png', 'static/ys.png',
'static/yy.png', 'static/zs.jpg', 'training/static/footprint.svg']
const sourceDirectory = path.join(PROJECT, 'native/tang-detective')
const receiptPath = path.join(PROJECT, 'build/tang-detective-media-prune-receipt.json')
if (fs.existsSync(receiptPath) || fs.existsSync(recoveryDirectory)) throw new Error('CLEANUP_ALREADY_STARTED_OR_COMPLETED')
const source = inventory(PROJECT)
const manifest = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-cos-manifest.json')))
const media = loadCosMediaManifest({ sourceDirectory })
const uploaded = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-admin-upload-receipt.json')))
if (!media || uploaded.complete !== true || uploaded.runId !== media.uploadRunId
|| uploaded.mediaManifestSha256 !== media.manifestSha256 || uploaded.objects.length !== media.objectCount) {
throw new Error('REAL_COMPLETE_UPLOAD_RECEIPT_REQUIRED')
}
for (const entry of media.entries.values()) {
const proof = uploaded.objects.find(item => item.url === entry.url)
if (!proof || proof.sha256 !== entry.sha256 || proof.bytes !== entry.bytes || proof.remoteVerifiedSha256 !== entry.sha256
|| proof.publicReadVerified !== true || proof.uploaded !== true
|| (entry.kind === 'audio' && proof.rangeVerified !== true)) throw new Error('UPLOAD_PROOF_MISMATCH')
}
const archiveStat = fs.lstatSync(archive)
if (!archiveStat.isFile() || archiveStat.isSymbolicLink()) throw new Error('BACKUP_NOT_REGULAR_FILE')
const archiveBytes = fs.readFileSync(archive)
if (hash(archiveBytes) !== expectedArchiveSha256) throw new Error('BACKUP_ARCHIVE_CHANGED')
const targets = [...source.entries.map(entry => ({ projectPath: `native/tang-detective/${entry.sourcePath}`,
bytes: entry.bytes, sha256: entry.sha256, reason: 'verified-cos-replacement' })),
...unused.map(projectPath => {
const bytes = fs.readFileSync(path.join(PROJECT, projectPath))
return { projectPath, bytes: bytes.length, sha256: hash(bytes), reason: 'no-runtime-reference' }
})]
if (targets.length !== 214 || new Set(targets.map(e => e.projectPath)).size !== 214) throw new Error('UNEXPECTED_CLEANUP_SET')
function verifyLocal(entry) {
let current = PROJECT
for (const part of entry.projectPath.split('/')) {
current = path.join(current, part)
if (fs.lstatSync(current).isSymbolicLink()) throw new Error('CLEANUP_SYMLINK_NOT_ALLOWED')
}
const stat = fs.lstatSync(current)
const bytes = fs.readFileSync(current)
if (!stat.isFile() || bytes.length !== entry.bytes || hash(bytes) !== entry.sha256) throw new Error('CLEANUP_SOURCE_CHANGED')
}
for (const entry of targets) {
verifyLocal(entry)
const result = spawnSync('tar', ['-xOf', archive, entry.projectPath], { maxBuffer: entry.bytes + 65536 })
if (result.status !== 0 || result.stdout.length !== entry.bytes || hash(result.stdout) !== entry.sha256) {
throw new Error(`BACKUP_ENTRY_NOT_IDENTICAL: ${entry.projectPath}`)
}
}
const receipt = { schemaVersion: 1, status: 'completed', uploadRunId: media.uploadRunId,
sourceManifestSha256: media.sourceManifestSha256, mediaManifestSha256: media.manifestSha256,
backup: { sha256: expectedArchiveSha256, bytes: archiveBytes.length, format: 'tar.gz' },
entries: [...media.entries.values()].map(({ sourcePath, bytes, sha256, objectKey, url }) => ({
sourcePath, bytes, sha256, objectKey, url, backupSha256: expectedArchiveSha256 })),
additionalUnusedFiles: targets.filter(entry => entry.reason === 'no-runtime-reference'),
sourceMediaBytes: source.entries.reduce((n, e) => n + e.bytes, 0),
removedProjectBytes: targets.reduce((n, e) => n + e.bytes, 0),
method: 'moved-byte-identical-originals-outside-project-with-verified-archive',
contentRegenerated: false, cloudObjectsDeleted: false }
validatePruneReceipt(receipt, { source: readSourceManifest(), media })
if (mode === 'check') {
console.log(JSON.stringify({ ready: true, files: targets.length, bytes: receipt.removedProjectBytes,
backupEntriesByteVerified: targets.length, originalsChanged: false }))
} else {
// Preflight every target again before the first move. On a failure, roll back only this task's exact paths.
targets.forEach(verifyLocal)
fs.mkdirSync(recoveryDirectory, { recursive: false })
const moved = []
try {
for (const entry of targets) {
verifyLocal(entry)
const to = path.join(recoveryDirectory, entry.projectPath)
fs.mkdirSync(path.dirname(to), { recursive: true })
if (fs.existsSync(to)) throw new Error('RECOVERY_TARGET_EXISTS')
fs.renameSync(path.join(PROJECT, entry.projectPath), to)
moved.push(entry)
}
validateCosMediaManifest(manifest, { sourceDirectory, pruneReceipt: receipt })
receipt.completedAt = new Date().toISOString()
fs.writeFileSync(receiptPath, JSON.stringify(receipt, null, 2) + '\n', { flag: 'wx' })
console.log(JSON.stringify({ completed: true, filesRemovedFromProject: moved.length,
bytesRemovedFromProject: receipt.removedProjectBytes, recoveryDirectory, archive, receiptPath }))
} catch (error) {
const recoveryFailures = []
for (const entry of moved.reverse()) {
try {
const original = path.join(PROJECT, entry.projectPath)
if (fs.existsSync(original)) throw new Error('ORIGINAL_PATH_NOW_OCCUPIED')
fs.renameSync(path.join(recoveryDirectory, entry.projectPath), original)
} catch { recoveryFailures.push(entry.projectPath) }
}
if (recoveryFailures.length) console.error(JSON.stringify({ recoveryFailures, recoveryDirectory, archive }))
throw error
}
}
+228
View File
@@ -0,0 +1,228 @@
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { dependency, readCosConfig, safeError } from './config.mjs'
export const PROJECT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const MEDIA = {
'.jpg': ['image', 'image/jpeg'], '.jpeg': ['image', 'image/jpeg'], '.png': ['image', 'image/png'],
'.webp': ['image', 'image/webp'], '.gif': ['image', 'image/gif'], '.svg': ['image', 'image/svg+xml'],
'.mp3': ['audio', 'audio/mpeg'], '.wav': ['audio', 'audio/wav'], '.m4a': ['audio', 'audio/mp4'],
'.aac': ['audio', 'audio/aac'], '.ogg': ['audio', 'audio/ogg'], '.mp4': ['video', 'video/mp4'],
'.webm': ['video', 'video/webm'], '.mov': ['video', 'video/quicktime'],
}
export const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex')
function walk(directory, prefix = '') {
return fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap(entry => {
const relative = prefix ? `${prefix}/${entry.name}` : entry.name
if (entry.isSymbolicLink()) throw new Error('SOURCE_SYMLINK_NOT_ALLOWED')
return entry.isDirectory() ? walk(path.join(directory, entry.name), relative) : [relative]
})
}
export function inventory(project = PROJECT) {
const sourceDirectory = path.join(project, 'native/tang-detective')
const manifestBytes = fs.readFileSync(path.join(project, 'build/tang-detective-source-manifest.json'))
const source = JSON.parse(manifestBytes)
const files = walk(sourceDirectory)
const expected = new Map(source.files.map(entry => [entry.path, entry]))
if (files.length !== expected.size || files.some(file => !expected.has(file))) throw new Error('SOURCE_FILE_SET_CHANGED')
const entries = []
for (const relative of files) {
const bytes = fs.readFileSync(path.join(sourceDirectory, relative))
const sha256 = hash(bytes)
const original = expected.get(relative)
if (original.bytes !== bytes.length || original.sha256 !== sha256) throw new Error('SOURCE_BYTES_CHANGED')
const extension = path.extname(relative).toLowerCase()
if (!MEDIA[extension]) continue
const [kind, contentType] = MEDIA[extension]
entries.push({ sourcePath: relative, kind, contentType, bytes: bytes.length, sha256,
objectKey: `tang-detective/season-01/media-v1/${sha256}${extension}` })
}
if (!entries.length) throw new Error('NO_SOURCE_MEDIA')
return { sourceDirectory, sourceManifestSha256: hash(manifestBytes), entries }
}
function writeJson(filename, value) {
fs.mkdirSync(path.dirname(filename), { recursive: true })
const temp = `${filename}.${process.pid}.tmp`
fs.writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx' })
fs.renameSync(temp, filename)
}
export function objectUrl(baseUrl, objectKey) {
if (!/^tang-detective\/season-01\/media-v1\/[a-f0-9]{64}\.[a-z0-9]+$/.test(objectKey)) {
throw new Error('UNSAFE_OBJECT_KEY')
}
const url = new URL(`${baseUrl}/${objectKey}`)
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) throw new Error('UNSAFE_OBJECT_URL')
return url.href
}
async function boundedBody(response, limit) {
let length = 0
const chunks = []
for await (const chunk of response.body) {
length += chunk.length
if (length > limit) throw new Error('REMOTE_BODY_TOO_LARGE')
chunks.push(chunk)
}
return Buffer.concat(chunks)
}
export async function verifyPublicObject(entry, fetchImpl = fetch) {
const response = await fetchImpl(entry.url, { redirect: 'error', signal: AbortSignal.timeout(20000) })
if (response.status !== 200) { await response.body?.cancel(); throw new Error('PUBLIC_MEDIA_GET_FAILED') }
const bytes = await boundedBody(response, entry.bytes)
if (bytes.length !== entry.bytes || hash(bytes) !== entry.sha256) throw new Error('PUBLIC_MEDIA_HASH_MISMATCH')
if (response.headers.get('content-type')?.split(';')[0].trim() !== entry.contentType) throw new Error('PUBLIC_MEDIA_TYPE_MISMATCH')
let rangeVerified = null
if (entry.kind === 'audio' || entry.kind === 'video') {
const last = Math.min(1023, entry.bytes - 1)
const range = await fetchImpl(entry.url, { headers: { Range: `bytes=0-${last}` }, redirect: 'error', signal: AbortSignal.timeout(20000) })
if (range.status !== 206 || range.headers.get('content-range') !== `bytes 0-${last}/${entry.bytes}`) {
await range.body?.cancel(); throw new Error('PUBLIC_MEDIA_RANGE_FAILED')
}
const fragment = await boundedBody(range, last + 1)
if (!fragment.equals(bytes.subarray(0, last + 1))) throw new Error('PUBLIC_MEDIA_RANGE_HASH_MISMATCH')
rangeVerified = true
}
return { remoteVerifiedSha256: hash(bytes), rangeVerified, verifiedAt: new Date().toISOString() }
}
export async function uploadObject(cos, destination, entry, body, onState = () => {}) {
const params = { Bucket: destination.bucket, Region: destination.region, Key: entry.objectKey }
let exists = false
try {
const head = await cos.headObject(params)
if (Number(head.headers?.['content-length']) !== entry.bytes || head.headers?.['x-cos-meta-sha256'] !== entry.sha256) {
throw new Error('EXISTING_OBJECT_NOT_OWNED_OR_DIFFERENT')
}
exists = true
} catch (error) {
if (Number(error.statusCode) !== 404) throw error
}
if (!exists) {
onState({ uploaded: 'unknown', uploadStatus: 'put-started-outcome-unknown' })
await cos.putObject({ ...params, Body: body, ContentLength: body.length, ContentType: entry.contentType,
CacheControl: 'public, max-age=31536000, immutable', Headers: {
'Content-MD5': crypto.createHash('md5').update(body).digest('base64'),
'x-cos-meta-sha256': entry.sha256,
'x-cos-forbid-overwrite': 'true',
} })
}
return { uploaded: true, uploadStatus: 'confirmed', action: exists ? 'reused-identical' : 'uploaded' }
}
export async function main(mode, dependencies = {}) {
if (!['inventory', 'inspect', 'upload'].includes(mode)) throw new Error('USE_INVENTORY_INSPECT_OR_UPLOAD')
const project = dependencies.project || PROJECT
const readConfig = dependencies.readConfig || readCosConfig
const verify = dependencies.verify || verifyPublicObject
const log = dependencies.log || (value => console.log(JSON.stringify(value)))
if (mode === 'upload') return runUpload({ project, readConfig, verify, log, createCos: dependencies.createCos })
const input = inventory(project)
const summary = { files: input.entries.length, uniqueObjects: new Set(input.entries.map(e => e.objectKey)).size,
bytes: input.entries.reduce((sum, entry) => sum + entry.bytes, 0),
kinds: Object.fromEntries(['image', 'audio', 'video'].map(kind => [kind, input.entries.filter(e => e.kind === kind).length])) }
if (mode === 'inventory') { log(summary); return }
const config = await readConfig(path.resolve(project, '../server'))
const destination = { bucket: config.bucket, region: config.region, baseUrl: config.baseUrl }
log({ ...summary, destination, credentialsPresent: true, databaseReadOnly: true, tlsVerified: true })
}
async function runUpload({ project, readConfig, verify, log, createCos }) {
const runId = crypto.randomUUID()
const receipt = { schemaVersion: 1, runId, startedAt: new Date().toISOString(), phase: 'inventory',
complete: false, bucketPermissionsChanged: false, originalFilesChanged: false, objects: [] }
const receiptPath = path.join(project, 'build/tang-detective-cos-upload-receipt.json')
const historyDirectory = path.join(project, 'build/tang-detective-cos-upload-attempts')
fs.mkdirSync(historyDirectory, { recursive: true })
if (fs.existsSync(receiptPath)) {
const previous = fs.readFileSync(receiptPath)
const archive = path.join(historyDirectory, `previous-${hash(previous)}.json`)
if (!fs.existsSync(archive)) fs.writeFileSync(archive, previous, { flag: 'wx' })
}
const persist = () => {
writeJson(path.join(historyDirectory, `${runId}.json`), receipt)
writeJson(receiptPath, receipt)
}
persist()
const completed = new Map()
try {
const input = inventory(project)
const summary = { files: input.entries.length, uniqueObjects: new Set(input.entries.map(e => e.objectKey)).size,
bytes: input.entries.reduce((sum, entry) => sum + entry.bytes, 0) }
receipt.sourceManifestSha256 = input.sourceManifestSha256
receipt.phase = 'configuration'
persist()
const config = await readConfig(path.resolve(project, '../server'))
const destination = { bucket: config.bucket, region: config.region, baseUrl: config.baseUrl }
receipt.destination = destination
receipt.phase = 'sdk-initialization'
persist()
const options = { SecretId: config.access_key, SecretKey: config.secret_key, Protocol: 'https:',
Timeout: 20000, MaxRetryTimes: 0, UploadCheckContentMd5: true }
const cos = createCos ? createCos(options) : new (dependency('cos-nodejs-sdk-v5'))(options)
log({ ...summary, destination, credentialsPresent: true, databaseReadOnly: true, tlsVerified: true })
receipt.phase = 'upload-and-verify'
// Sequential first-object verification stops immediately if this bucket/domain isn't anonymously readable.
for (const item of input.entries) {
if (completed.has(item.objectKey)) continue
const entry = { ...item, url: objectUrl(config.baseUrl, item.objectKey) }
const body = fs.readFileSync(path.join(input.sourceDirectory, item.sourcePath))
if (body.length !== item.bytes || hash(body) !== item.sha256) throw new Error('SOURCE_CHANGED_DURING_UPLOAD')
const result = { ...entry, uploaded: false, uploadStatus: 'not-attempted' }
receipt.objects.push(result)
persist()
try {
Object.assign(result, await uploadObject(cos, destination, entry, body, state => {
Object.assign(result, state)
persist()
}))
persist()
Object.assign(result, await verify(entry))
completed.set(item.objectKey, result)
persist()
} catch (error) {
result.error = safeError(error)
// A lost PUT response is not proof that nothing was uploaded. Read-only reconciliation can establish it.
if (result.uploaded === 'unknown') {
try {
const head = await cos.headObject({ Bucket: destination.bucket, Region: destination.region, Key: entry.objectKey })
if (Number(head.headers?.['content-length']) === entry.bytes && head.headers?.['x-cos-meta-sha256'] === entry.sha256) {
Object.assign(result, { uploaded: true, uploadStatus: 'confirmed-by-readback' }, await verify(entry))
}
} catch { /* Keep unknown; retry checks the exact content-addressed key without overwriting. */ }
}
throw error
}
log({ verified: completed.size, total: summary.uniqueObjects })
}
// Recheck all source bytes immediately before publishing the build activation manifest.
if (inventory(project).sourceManifestSha256 !== input.sourceManifestSha256) throw new Error('SOURCE_MANIFEST_CHANGED')
const entries = input.entries.map(entry => ({ ...entry, url: completed.get(entry.objectKey).url, uploaded: true, publicReadVerified: true,
remoteVerifiedSha256: completed.get(entry.objectKey).remoteVerifiedSha256,
rangeVerified: completed.get(entry.objectKey).rangeVerified,
verifiedAt: completed.get(entry.objectKey).verifiedAt }))
writeJson(path.join(project, 'build/tang-detective-cos-manifest.json'), {
schemaVersion: 1, uploadRunId: runId, sourceManifestSha256: input.sourceManifestSha256, destination, entries,
})
receipt.complete = true
receipt.phase = 'complete'
receipt.completedAt = new Date().toISOString()
log({ complete: true, ...summary })
} catch (error) {
receipt.error = safeError(error)
receipt.failedAt = new Date().toISOString()
throw error
} finally {
persist()
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main(process.argv[2]).catch(error => { console.error(JSON.stringify({ complete: false, error: safeError(error) })); process.exitCode = 1 })
}
@@ -0,0 +1,203 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { inventory, hash, objectUrl, verifyPublicObject, uploadObject, main } from './upload.mjs'
import { configuredDatabase, validateCosConfig, safeError, withDeadline } from './config.mjs'
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-test-'))
t.after(() => fs.rmSync(directory, { recursive: true }))
fs.mkdirSync(path.join(directory, 'native/tang-detective'), { recursive: true })
fs.mkdirSync(path.join(directory, 'build'))
fs.writeFileSync(path.join(directory, 'native/tang-detective/image.jpg'), 'existing-image')
fs.writeFileSync(path.join(directory, 'build/tang-detective-source-manifest.json'), JSON.stringify({ files: [
{ path: 'image.jpg', bytes: 14, sha256: hash('existing-image') },
] }))
return directory
}
test('inventory creates content-addressed keys without changing source files', t => {
const project = fixture(t)
const input = inventory(project)
assert.equal(input.entries.length, 1)
assert.equal(input.entries[0].objectKey, `tang-detective/season-01/media-v1/${hash('existing-image')}.jpg`)
assert.equal(fs.readFileSync(path.join(project, 'native/tang-detective/image.jpg'), 'utf8'), 'existing-image')
})
test('inventory refuses changed bytes, unrecorded files, and symlinks', t => {
const project = fixture(t)
const source = path.join(project, 'native/tang-detective')
fs.writeFileSync(path.join(source, 'image.jpg'), 'different-data')
assert.throws(() => inventory(project), /SOURCE_BYTES_CHANGED/)
fs.writeFileSync(path.join(source, 'image.jpg'), 'existing-image')
fs.writeFileSync(path.join(source, 'extra.mp3'), 'audio')
assert.throws(() => inventory(project), /SOURCE_FILE_SET_CHANGED/)
fs.unlinkSync(path.join(source, 'extra.mp3'))
fs.symlinkSync(path.join(source, 'image.jpg'), path.join(source, 'linked.jpg'))
assert.throws(() => inventory(project), /SOURCE_SYMLINK_NOT_ALLOWED/)
})
test('database config never disables certificate or hostname verification', t => {
const server = fs.mkdtempSync(path.join(os.tmpdir(), 'tang-cos-db-test-'))
t.after(() => fs.rmSync(server, { recursive: true }))
fs.mkdirSync(path.join(server, 'config'))
const values = { hostname: 'db.example.invalid', hostport: '3306', database: 'test', username: 'test', password: 'fake-not-secret', prefix: 'zyt_' }
fs.writeFileSync(path.join(server, 'config/database.php'), Object.entries(values).map(([key, value]) => `env('database.${key}', '${value}')`).join('\n'))
const config = configuredDatabase(server, {})
assert.equal(config.options.ssl.rejectUnauthorized, true)
assert.equal(config.options.ssl.verifyIdentity, true)
assert.equal(config.options.multipleStatements, false)
fs.writeFileSync(path.join(server, '.env'), 'unparsed')
assert.throws(() => configuredDatabase(server, {}), /SERVER_ENV_REQUIRES_NATIVE_RUNTIME/)
})
test('destination rejects insecure/signed URLs and missing credentials', () => {
const config = { bucket: 'example-12345', region: 'ap-guangzhou', access_key: 'test', secret_key: 'test' }
assert.equal(validateCosConfig(config, 'qcloud').baseUrl, 'https://example-12345.cos.ap-guangzhou.myqcloud.com')
for (const domain of ['http://example.invalid', 'https://user:password@example.invalid', 'https://example.invalid/?token=test']) {
assert.throws(() => validateCosConfig({ ...config, domain }, 'qcloud'), /COS_REQUIRES_UNSIGNED_HTTPS_BASE_URL/)
}
assert.throws(() => validateCosConfig(config, 'local'), /CONFIGURED_DRIVER_IS_NOT_COS/)
assert.throws(() => validateCosConfig({ ...config, secret_key: '' }, 'qcloud'), /COS_CREDENTIALS_MISSING/)
})
test('object URLs only use namespaced content-addressed keys', () => {
const key = `tang-detective/season-01/media-v1/${hash('image')}.jpg`
assert.equal(objectUrl('https://example.invalid/prefix', key), `https://example.invalid/prefix/${key}`)
assert.throws(() => objectUrl('https://example.invalid', '../unrelated.jpg'), /UNSAFE_OBJECT_KEY/)
assert.throws(() => objectUrl('http://example.invalid', key), /UNSAFE_OBJECT_URL/)
})
test('public verification checks complete bytes, MIME, hash and audio Range', async () => {
const bytes = Buffer.from('original-audio')
const entry = { url: 'https://example.invalid/file.mp3', bytes: bytes.length, sha256: hash(bytes), kind: 'audio', contentType: 'audio/mpeg' }
const calls = []
const verified = await verifyPublicObject(entry, async (url, options) => {
calls.push(options)
assert.equal(options.redirect, 'error')
assert.equal(options.headers?.Authorization, undefined)
return new Response(bytes, options.headers?.Range ? { status: 206, headers: { 'content-range': `bytes 0-13/14` } }
: { status: 200, headers: { 'content-type': 'audio/mpeg' } })
})
assert.equal(verified.remoteVerifiedSha256, entry.sha256)
assert.equal(verified.rangeVerified, true)
assert.equal(calls.length, 2)
})
test('private objects, oversize bodies, corrupt data and missing Range cannot activate a manifest', async () => {
const entry = { url: 'https://example.invalid/file.mp3', bytes: 4, sha256: hash('good'), kind: 'audio', contentType: 'audio/mpeg' }
await assert.rejects(verifyPublicObject(entry, async () => new Response('denied', { status: 403 })), /PUBLIC_MEDIA_GET_FAILED/)
await assert.rejects(verifyPublicObject(entry, async () => new Response('too-large')), /REMOTE_BODY_TOO_LARGE/)
await assert.rejects(verifyPublicObject(entry, async () => new Response('oops')), /PUBLIC_MEDIA_HASH_MISMATCH/)
await assert.rejects(verifyPublicObject(entry, async () => new Response('good', { headers: { 'content-type': 'audio/mpeg' } })), /PUBLIC_MEDIA_RANGE_FAILED/)
})
test('safe errors never leak raw connection or signed-URL text', () => {
assert.equal(safeError({ message: 'mysql://user:password@database/' }), 'REDACTED_OPERATION_ERROR')
assert.equal(safeError({ code: 'HANDSHAKE_SSL_ERROR', message: 'secret' }), 'HANDSHAKE_SSL_ERROR')
assert.equal(safeError({ message: 'https://bucket/?secret=test' }), 'REDACTED_OPERATION_ERROR')
})
test('uploader only creates missing objects with checksums and never changes permissions', async () => {
const body = Buffer.from('good')
const entry = { bytes: body.length, sha256: hash(body), objectKey: `tang-detective/season-01/media-v1/${hash(body)}.jpg`, contentType: 'image/jpeg' }
const destination = { bucket: 'example-12345', region: 'ap-guangzhou' }
let put = null
const cos = { headObject: async () => { throw { statusCode: 404 } }, putObject: async params => { put = params } }
const result = await uploadObject(cos, destination, entry, body)
assert.equal(result.action, 'uploaded')
assert.equal(put.Headers['x-cos-forbid-overwrite'], 'true')
assert.equal(put.Headers['x-cos-meta-sha256'], entry.sha256)
assert.equal(put.Headers['Content-MD5'], 'dV+FwnI7s5OBxzeaYEFg2A==')
assert.equal(put.ACL, undefined)
assert.equal(put.Headers['x-cos-acl'], undefined)
assert.equal(put.ContentType, 'image/jpeg')
})
test('uploader reuses exact owned objects; mismatches and denied HEAD never trigger writes', async () => {
const entry = { bytes: 4, sha256: hash('good'), objectKey: 'test', contentType: 'image/jpeg' }
let writes = 0
const cos = { headObject: async () => ({ headers: { 'content-length': '4', 'x-cos-meta-sha256': hash('good') } }), putObject: async () => { writes++ } }
assert.equal((await uploadObject(cos, {}, entry, Buffer.from('good'))).action, 'reused-identical')
cos.headObject = async () => ({ headers: { 'content-length': '4', 'x-cos-meta-sha256': hash('evil') } })
await assert.rejects(uploadObject(cos, {}, entry, Buffer.from('good')), /EXISTING_OBJECT_NOT_OWNED_OR_DIFFERENT/)
cos.headObject = async () => { throw { statusCode: 403 } }
await assert.rejects(uploadObject(cos, {}, entry, Buffer.from('good')))
assert.equal(writes, 0)
})
test('database deadline aborts a stalled task-owned operation and clears its timer', async () => {
let aborts = 0
await assert.rejects(withDeadline(new Promise(() => {}), 5, () => { aborts++ }, 'DATABASE_QUERY_TIMEOUT'), /DATABASE_QUERY_TIMEOUT/)
assert.equal(aborts, 1)
assert.equal(await withDeadline(Promise.resolve('done'), 5, () => { aborts++ }, 'DATABASE_QUERY_TIMEOUT'), 'done')
assert.equal(aborts, 1)
})
const fakeConfig = () => ({ bucket: 'example-12345', region: 'ap-guangzhou', baseUrl: 'https://example.invalid',
access_key: 'FAKE_ACCESS_MUST_STAY_IN_MEMORY', secret_key: 'FAKE_SECRET_MUST_STAY_IN_MEMORY' })
const readReceipt = project => JSON.parse(fs.readFileSync(path.join(project, 'build/tang-detective-cos-upload-receipt.json')))
test('a configuration failure creates a fresh failed attempt and archives previous success', async t => {
const project = fixture(t)
const previous = { complete: true, runId: 'previous-run', objects: [] }
fs.writeFileSync(path.join(project, 'build/tang-detective-cos-upload-receipt.json'), JSON.stringify(previous))
await assert.rejects(main('upload', { project, log: () => {}, readConfig: async () => { throw { code: 'HANDSHAKE_SSL_ERROR' } } }))
const latest = readReceipt(project)
assert.equal(latest.complete, false)
assert.notEqual(latest.runId, previous.runId)
assert.equal(latest.phase, 'configuration')
assert.equal(latest.error, 'HANDSHAKE_SSL_ERROR')
assert.deepEqual(latest.objects, [])
const archived = fs.readdirSync(path.join(project, 'build/tang-detective-cos-upload-attempts'))
assert.equal(archived.length, 2)
assert.equal(fs.existsSync(path.join(project, 'build/tang-detective-cos-manifest.json')), false)
})
test('SDK initialization failure is recorded without exposing credentials', async t => {
const project = fixture(t)
await assert.rejects(main('upload', { project, readConfig: async () => fakeConfig(), log: () => {},
createCos: () => { throw new Error('signed-url-or-secret-detail') } }))
const latest = readReceipt(project)
assert.equal(latest.complete, false)
assert.equal(latest.phase, 'sdk-initialization')
assert.equal(latest.error, 'REDACTED_OPERATION_ERROR')
assert.equal(JSON.stringify(latest).includes('MUST_STAY_IN_MEMORY'), false)
})
test('lost PUT response persists unknown outcome and only performs read-only reconciliation', async t => {
const project = fixture(t)
let puts = 0
let heads = 0
await assert.rejects(main('upload', { project, readConfig: async () => fakeConfig(), log: () => {}, createCos: () => ({
headObject: async () => { heads++; throw { statusCode: heads === 1 ? 404 : 403 } },
putObject: async () => {
puts++
assert.equal(readReceipt(project).objects[0].uploaded, 'unknown')
throw { code: 'ETIMEDOUT' }
},
}) }))
const latest = readReceipt(project)
assert.equal(latest.complete, false)
assert.equal(latest.objects[0].uploaded, 'unknown')
assert.equal(heads, 2)
assert.equal(puts, 1)
assert.equal(fs.existsSync(path.join(project, 'build/tang-detective-cos-manifest.json')), false)
})
test('only a fully verified upload writes the activation manifest; no secret values reach artifacts', async t => {
const project = fixture(t)
await main('upload', { project, readConfig: async () => fakeConfig(), log: () => {}, createCos: () => ({
headObject: async () => { throw { statusCode: 404 } }, putObject: async () => {},
}), verify: async entry => ({ remoteVerifiedSha256: entry.sha256, rangeVerified: null, verifiedAt: 'test-only' }) })
const latest = readReceipt(project)
const manifest = JSON.parse(fs.readFileSync(path.join(project, 'build/tang-detective-cos-manifest.json')))
assert.equal(latest.complete, true)
assert.equal(manifest.entries.length, 1)
assert.equal(manifest.uploadRunId, latest.runId)
assert.equal(manifest.entries[0].publicReadVerified, true)
assert.equal(manifest.entries[0].remoteVerifiedSha256, hash('existing-image'))
assert.equal(JSON.stringify([latest, manifest]).includes('MUST_STAY_IN_MEMORY'), false)
})
@@ -0,0 +1,109 @@
import fs from 'node:fs'
import path from 'node:path'
import { inventory, hash, verifyPublicObject, PROJECT } from './upload.mjs'
import { validateCosMediaManifest, loadCosMediaManifest } from '../../build/tang-detective-cos-media.mjs'
const json = value => JSON.stringify(value, null, 2) + '\n'
const write = (file, value) => {
const temp = `${file}.${process.pid}.tmp`
fs.writeFileSync(temp, json(value), { flag: 'wx' })
fs.renameSync(temp, file)
}
const destination = { bucket: 'gz-1349751149', region: 'ap-guangzhou',
baseUrl: 'https://gz-1349751149.cos.ap-guangzhou.myqcloud.com' }
const mode = process.argv[2]
if (!['activate', 'audit'].includes(mode)) throw new Error('USE_ACTIVATE_OR_AUDIT')
const sourceDirectory = path.join(PROJECT, 'native/tang-detective')
const manifestPath = path.join(PROJECT, 'build/tang-detective-cos-manifest.json')
let manifest
if (mode === 'activate') {
if (fs.existsSync(manifestPath)) throw new Error('ACTIVATION_MANIFEST_EXISTS_USE_AUDIT')
const input = inventory(PROJECT) // No missing source or changed original can activate a new mapping.
const plan = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-admin-upload-plan.json')))
const observed = JSON.parse(fs.readFileSync(path.join(PROJECT, 'build/tang-detective-admin-upload-observations.json')))
if (plan.uploadRunId !== observed.uploadRunId || plan.sourceManifestSha256 !== input.sourceManifestSha256
|| observed.baseUrl !== destination.baseUrl || observed.images.length !== 135 || observed.audio.length !== 44
|| observed.imageDirectory !== 'uploads/images/20260908/' || observed.audioDirectory !== 'uploads/voice/20260908/') {
throw new Error('OBSERVATION_PLAN_BINDING_FAILED')
}
const byHash = new Map()
for (const entry of plan.entries) {
let filename, directory
if (!entry.stagedName) {
filename = observed.firstImage
directory = observed.imageDirectory
if (entry.observedUrl !== `${observed.baseUrl}/${directory}${filename}`) throw new Error('FIRST_SAMPLE_CHANGED')
} else {
const match = /^tang-20260908-1714-(image|audio)-(\d{3})\.(jpg|mp3)$/.exec(entry.stagedName)
if (!match || match[1] !== entry.kind) throw new Error('INVALID_STAGED_NAME')
const i = Number(match[2]) - 1
filename = (entry.kind === 'image' ? observed.images : observed.audio)[i]
directory = entry.kind === 'image' ? observed.imageDirectory : observed.audioDirectory
}
if (!/^[a-z0-9.-]+$/.test(filename || '')) throw new Error('UNSAFE_OBSERVED_FILENAME')
byHash.set(entry.sha256, { ...entry, objectKey: directory + filename, url: `${observed.baseUrl}/${directory}${filename}` })
}
if (byHash.size !== 180) throw new Error('INCOMPLETE_PLAN')
manifest = { schemaVersion: 2, uploadRunId: plan.uploadRunId, sourceManifestSha256: input.sourceManifestSha256,
destination, entries: input.entries.map(entry => {
const mapping = byHash.get(entry.sha256)
if (!mapping || mapping.bytes !== entry.bytes || mapping.contentType !== entry.contentType) throw new Error('PLAN_BYTES_CHANGED')
return { ...entry, objectKey: mapping.objectKey, url: mapping.url }
}) }
} else {
loadCosMediaManifest({ sourceDirectory }) // Validate source, receipt and complete existing binding first.
manifest = JSON.parse(fs.readFileSync(manifestPath))
}
const unique = [...new Map(manifest.entries.map(entry => [entry.url, entry])).values()]
const receipt = { schemaVersion: 1, runId: manifest.uploadRunId, mode, startedAt: new Date().toISOString(),
phase: 'public-readback', complete: false, uploadTransport: 'authenticated-existing-admin-ui',
destination, sourceManifestSha256: manifest.sourceManifestSha256, bucketPermissionsChanged: false,
originalFilesChanged: false, objects: [] }
const receiptPath = path.join(PROJECT, `build/tang-detective-admin-${mode === 'activate' ? 'upload' : 'audit'}-receipt.json`)
write(receiptPath, receipt)
try {
// Each unsigned GET is independent. Four workers, no access token, database connection or upload here.
let cursor = 0
const outcomes = await Promise.allSettled(Array.from({ length: 4 }, async () => {
for (;;) {
const i = cursor++
if (i >= unique.length) return
const entry = unique[i]
const url = new URL(entry.url)
if (url.origin !== destination.baseUrl || url.search || url.hash || url.username || url.password
|| !/^\/uploads\/(images|voice)\/20260908\/[a-z0-9.-]+$/.test(url.pathname)) throw new Error('UNSAFE_READBACK_URL')
const verified = await verifyPublicObject(entry)
receipt.objects.push({ ...entry, ...verified, uploaded: true, publicReadVerified: true })
write(receiptPath, receipt)
if (receipt.objects.length % 20 === 0) console.log(JSON.stringify({ verified: receipt.objects.length, total: unique.length }))
}
}))
const failure = outcomes.find(outcome => outcome.status === 'rejected')
if (failure) throw failure.reason // All task-owned requests have settled before recording failure.
const verified = new Map(receipt.objects.map(entry => [entry.url, entry]))
const completeManifest = { ...manifest, entries: manifest.entries.map(entry => {
const proof = verified.get(entry.url)
return { ...entry, uploaded: true, publicReadVerified: true, remoteVerifiedSha256: proof.remoteVerifiedSha256,
rangeVerified: proof.rangeVerified, verifiedAt: proof.verifiedAt }
}) }
// Canonical receipt binds the original activation manifest. Audit never changes it or prune hashes.
if (mode === 'activate') {
validateCosMediaManifest(completeManifest, { sourceDirectory })
inventory(PROJECT)
fs.writeFileSync(manifestPath, json(completeManifest), { flag: 'wx' })
}
receipt.complete = true
receipt.phase = 'complete'
receipt.mediaManifestSha256 = hash(fs.readFileSync(manifestPath))
receipt.completedAt = new Date().toISOString()
write(receiptPath, receipt)
console.log(JSON.stringify({ complete: true, uniqueObjects: unique.length, sourceMediaPaths: manifest.entries.length,
allBytesHashesTypesVerified: true, audioRangeVerified: unique.filter(e => e.kind === 'audio').length,
mode, manifestPath }))
} catch (error) {
receipt.phase = 'failed'
receipt.failedAt = new Date().toISOString()
receipt.errorCode = 'PUBLIC_READBACK_OR_MANIFEST_VALIDATION_FAILED'
write(receiptPath, receipt)
throw error
}
@@ -0,0 +1,59 @@
import fs from 'node:fs'
import path from 'node:path'
import { spawnSync } from 'node:child_process'
import { PROJECT, hash } from './upload.mjs'
import { loadCosMediaManifest, isMediaFile } from '../../build/tang-detective-cos-media.mjs'
import { validateNativeOutput } from '../../build/validate-tang-detective-output.mjs'
const sourceDirectory = path.join(PROJECT, 'native/tang-detective')
const media = loadCosMediaManifest({ sourceDirectory })
if (!media || media.sourceSnapshot.actual.size !== 269 || media.sourceSnapshot.missingMedia.length !== 204
|| !media.sourceSnapshot.prune) throw new Error('FULLY_CLEANED_CHECKOUT_REQUIRED')
const receiptFile = path.join(PROJECT, 'build/tang-detective-media-prune-receipt.json')
const receipt = JSON.parse(fs.readFileSync(receiptFile))
const recoveryDirectory = '/Users/dagedagededagege/Pictures/tang-media-backup-20260908.9hdgCE/pruned-originals'
const removed = [...receipt.entries.map(entry => ({ ...entry, projectPath: `native/tang-detective/${entry.sourcePath}` })),
...receipt.additionalUnusedFiles]
for (const entry of removed) {
if (fs.existsSync(path.join(PROJECT, entry.projectPath))) throw new Error('REMOVED_PROJECT_FILE_REAPPEARED')
const bytes = fs.readFileSync(path.join(recoveryDirectory, entry.projectPath))
if (bytes.length !== entry.bytes || hash(bytes) !== entry.sha256) throw new Error('RECOVERY_BYTES_CHANGED')
}
const report = { schemaVersion: 1, startedAt: new Date().toISOString(), passed: false,
uploadRunId: media.uploadRunId, sourceManifestSha256: media.sourceManifestSha256,
mediaManifestSha256: media.manifestSha256, pruneReceiptSha256: hash(fs.readFileSync(receiptFile)),
nonMediaSourceFilesByteVerified: 269, sourceMediaFilesRemaining: 0,
removedProjectFiles: removed.length, removedProjectBytes: receipt.removedProjectBytes,
recoveryFilesByteVerified: removed.length, commands: [],
boundary: 'Actual cleaned checkout, installed compiler and offline automated tests only. Not real-device, medical, backend deployment or release acceptance.' }
const outputFile = path.join(PROJECT, 'build/tang-detective-cleanup-verification.json')
const save = () => fs.writeFileSync(outputFile, JSON.stringify(report, null, 2) + '\n')
save()
for (const [command, args] of [
['npm', ['run', 'test:tang']],
['npm', ['run', 'build:mp-weixin']],
['npm', ['run', 'check:tang-output']],
[process.execPath, ['scripts/check-tang-native-compiler.cjs']],
]) {
const result = spawnSync(command, args, { cwd: PROJECT, encoding: 'utf8', timeout: 120000, maxBuffer: 16 * 1024 * 1024 })
report.commands.push({ command: [command, ...args], exitCode: result.status, signal: result.signal,
stdout: result.stdout, stderr: result.stderr, passed: result.status === 0 && !result.error })
save()
console.log(JSON.stringify({ command: [command, ...args], exitCode: result.status }))
if (!report.commands.at(-1).passed) throw new Error('POST_CLEANUP_COMMAND_FAILED')
}
report.output = validateNativeOutput(path.join(PROJECT, 'dist/build/mp-weixin'))
const walk = (dir, prefix = '') => fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
if (['node_modules', 'dist', 'unpackage', '.git'].includes(entry.name)) return []
const relative = prefix + entry.name
if (entry.isSymbolicLink()) throw new Error('UNEXPECTED_OUTPUT_SYMLINK')
return entry.isDirectory() ? walk(path.join(dir, entry.name), relative + '/') : [relative]
})
report.remainingHostMedia = walk(PROJECT).filter(file => !/^(node_modules|dist|unpackage|\.git)\//.test(file) && isMediaFile(file))
report.passed = report.output.passed && report.output.mediaMode === 'cos' && report.output.packagedMediaFiles === 0
report.completedAt = new Date().toISOString()
save()
console.log(JSON.stringify({ passed: report.passed, sourceMediaFilesRemaining: 0, packagedTangMediaFiles: report.output.packagedMediaFiles,
totalBuildBytes: report.output.outputSizes.totalFileBytes, mainPackageBytes: report.output.outputSizes.mainPackageFileBytes,
remainingHostMedia: report.remainingHostMedia, outputFile }))
if (!report.passed) process.exitCode = 1
@@ -0,0 +1,280 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const root = path.resolve(__dirname, '..')
const native = path.join(root, 'native/tang-detective')
const adapter = path.join(root, 'native-adapter/tang-detective')
const settle = () => new Promise(resolve => setImmediate(resolve))
function fixture(relative, customOptions) {
let page
let finishBoot
let timerId = 0
const state = { scope: 'account-A', events: [], audio: [], timers: new Map(), updates: 0, saves: [], resets: [], modals: [], redirects: [] }
const boot = new Promise(resolve => { finishBoot = resolve })
const bridge = { getScope: () => state.scope, open: () => boot, flush: () => state.events.push('flush') }
const storage = {
getProgress: () => ({ completedHotspots: {}, completedChapters: [], lastChapter: 1, collectedMemoryCards: [], comicReaderByChapter: {} }),
saveProgress: value => { state.saves.push({ kind: 'progress', scope: state.scope, value }); return true },
getSettings: () => ({ fontScale: 'large', sound: true }),
saveSettings: () => true,
getAudioProgress: () => ({}),
saveAudioProgress: value => { state.saves.push({ kind: 'audio', scope: state.scope, value }); return true },
resetStoryProgress: scope => { state.resets.push(scope); return scope === state.scope },
}
const beginHandlers = new Set()
const endHandlers = new Set()
const wx = {
env: { USER_DATA_PATH: '' },
getWindowInfo: () => ({ windowWidth: 812, windowHeight: 375, screenWidth: 812, screenHeight: 375, safeArea: { top: 0, left: 0, right: 812, bottom: 375 } }),
getMenuButtonBoundingClientRect: () => ({ top: 8, bottom: 40, left: 724, right: 804, width: 80, height: 32 }),
pageScrollTo() {},
reLaunch: value => state.redirects.push(value),
redirectTo: value => state.redirects.push(value),
navigateTo: value => state.redirects.push(value),
showToast: value => state.events.push(value.title),
showModal: value => state.modals.push(value),
onAudioInterruptionBegin: handler => beginHandlers.add(handler),
onAudioInterruptionEnd: handler => endHandlers.add(handler),
offAudioInterruptionBegin: handler => { beginHandlers.delete(handler); state.events.push('unbind-begin') },
offAudioInterruptionEnd: handler => { endHandlers.delete(handler); state.events.push('unbind-end') },
createInnerAudioContext() {
const audio = { handlers: {}, duration: 90, currentTime: 0, playbackRate: 1, plays: 0, pauses: 0, destroys: 0,
play() { this.plays += 1 }, pause() { this.pauses += 1 }, stop() {},
seek(value) { this.currentTime = value }, destroy() { this.destroys += 1 } }
for (const name of ['Canplay', 'Play', 'Pause', 'Stop', 'Waiting', 'TimeUpdate', 'Ended', 'Error', 'Seeked']) {
audio[`on${name}`] = handler => { audio.handlers[name] = handler }
}
state.audio.push(audio)
return audio
},
}
const cache = new Map()
function capture(options) {
page = { ...options, data: JSON.parse(JSON.stringify(options.data || {})), setData(values) {
state.updates += 1
for (const [key, value] of Object.entries(values)) {
const parts = key.split('.')
let target = this.data
for (const part of parts.slice(0, -1)) target = target[part] || (target[part] = {})
target[parts.at(-1)] = value
}
} }
}
let register
function load(filename, isPage = false) {
filename = path.resolve(filename)
if (filename.endsWith('/utils/storage.js')) return storage
if (filename.endsWith('/utils/platformBridge.js')) return bridge
if (cache.has(filename)) return cache.get(filename).exports
const module = { exports: {} }
cache.set(filename, module)
let source = fs.readFileSync(filename, 'utf8')
if (isPage) source = source.replace(/^Page\(\{/m, 'registerTangPage({')
vm.runInNewContext(source, {
module, exports: module.exports, Page: capture, registerTangPage: register, wx,
getCurrentPages: () => [page],
setTimeout: callback => { const id = ++timerId; state.timers.set(id, callback); return id },
clearTimeout: id => { state.timers.delete(id); state.events.push(`clear:${id}`) },
require(specifier) {
let dependency = path.resolve(path.dirname(filename), specifier)
if (!path.extname(dependency)) dependency += '.js'
// Catalog uses the final route adapter; all chapter/player helpers and
// data are real original modules, with only platform I/O mocked.
if (!fs.existsSync(dependency) && dependency.startsWith(adapter + path.sep)) {
dependency = path.join(native, path.relative(adapter, dependency))
}
return load(dependency)
},
}, { filename })
return module.exports
}
register = load(path.join(adapter, 'utils/tangPage.js'))
if (customOptions) register(customOptions(state))
else load(path.join(relative.startsWith('pages/catalog/') ? adapter : native, relative), true)
return { page, state, bridge, finishBoot, beginHandlers, endHandlers }
}
async function open(f, query = {}) {
f.page.onLoad(query)
f.page.onShow()
f.page.onReady()
f.finishBoot()
await settle()
}
test('deferred lifecycle delivers onLoad -> onShow -> onReady once and guards custom onX events', async () => {
const f = fixture(null, state => ({
onLoad() { state.events.push('load') }, onShow() { state.events.push('show') }, onReady() { state.events.push('ready') },
onAudioTimeUpdate() { state.events.push('audio-event') },
}))
f.page.onLoad({})
f.page.onShow()
f.page.onReady()
f.page.onAudioTimeUpdate()
f.page.onHide()
f.page.onShow()
f.finishBoot()
await settle()
assert.deepEqual(f.state.events, ['flush', 'load', 'show', 'ready'])
f.page.onReady()
f.page.onAudioTimeUpdate()
assert.equal(f.state.events.at(-1), 'audio-event')
f.page.onHide()
const count = f.state.events.length
f.page.onAudioTimeUpdate()
assert.equal(f.state.events.length, count)
f.page.onShow()
await settle()
assert.equal(f.state.events.filter(item => item === 'ready').length, 1)
f.state.scope = 'account-B'
f.page.onAudioTimeUpdate()
assert.equal(f.state.events.filter(item => item === 'audio-event').length, 1)
assert.equal(f.state.redirects.length, 1)
})
for (const [directory, pageId] of [['package-audio-c01-a', 'S01-C01-P01'], ['package-audio-c01-b', 'S01-C01-P05']]) {
test(`${directory}: real onReady waits for _page and unload clears both timers, context, and listeners`, async () => {
const f = fixture(`${directory}/pages/player/player.js`)
f.page.onLoad({ pageId })
f.page.onShow()
f.page.onReady()
assert.equal(f.state.audio.length, 0)
f.finishBoot()
await settle()
assert.equal(f.page.data.audioReady, true)
assert.equal(f.state.audio.length, 1)
const context = f.state.audio[0]
f.page.onReady()
assert.equal(f.state.audio.length, 1)
f.page.requestSeek(10)
f.page.beginPauseLock(context)
assert.equal(f.state.timers.size, 2)
const staleTimers = [...f.state.timers.values()]
const staleAudio = Object.values(context.handlers)
f.page.onUnload()
assert.equal(context.destroys, 1)
assert.equal(f.state.timers.size, 0)
assert.equal(f.beginHandlers.size, 0)
assert.equal(f.endHandlers.size, 0)
assert.equal(f.page.audioContext, null)
const updates = f.state.updates
for (const callback of [...staleTimers, ...staleAudio]) callback()
assert.equal(context.plays, 0)
assert.equal(f.state.updates, updates)
f.page.onUnload()
assert.equal(context.destroys, 1)
})
test(`${directory}: hidden boot never initializes audio until return; changed-account hide retires old audio`, async () => {
const f = fixture(`${directory}/pages/player/player.js`)
f.page.onLoad({ pageId })
f.page.onShow()
f.page.onReady()
f.page.onHide()
f.finishBoot()
await settle()
assert.equal(f.state.audio.length, 0)
f.page.onShow()
await settle()
assert.equal(f.page.data.audioReady, true)
const context = f.state.audio[0]
f.page.togglePlayback()
assert.equal(context.plays, 1)
f.state.scope = 'account-B'
f.page.onHide()
assert.ok(context.pauses >= 1)
assert.equal(context.destroys, 1)
assert.equal(f.state.timers.size, 0)
assert.equal(f.beginHandlers.size + f.endHandlers.size, 0)
const updates = f.state.updates
for (const callback of Object.values(context.handlers)) callback()
assert.equal(context.plays, 1)
assert.equal(f.state.updates, updates)
f.state.scope = 'account-A'
f.page.onShow()
await settle()
assert.equal(f.state.audio.length, 1)
assert.equal(f.state.redirects.length, 1)
f.page.onUnload()
assert.equal(context.destroys, 1)
})
}
test('real chapter unload destroys audio and stale callbacks cannot save or update', async () => {
const f = fixture('package-game/pages/chapter/chapter.js')
await open(f, { chapter: 1 })
f.page.createAudioContext({ src: '/example.mp3', progressKey: 'page:S01-C01-P01', durationSeconds: 40 })
const context = f.state.audio[0]
const staleAudio = Object.values(context.handlers)
f.page.onUnload()
assert.equal(context.destroys, 1)
assert.equal(f.page.audioContext, null)
assert.equal(f.state.saves.filter(item => item.kind === 'audio').length, 1)
const updates = f.state.updates
const saves = f.state.saves.length
for (const callback of staleAudio) callback()
assert.equal(f.state.updates, updates)
assert.equal(f.state.saves.length, saves)
assert.equal(context.plays, 0)
})
test('real chapter account-change hide cleans resources without writing previous audio progress into next account', async () => {
const f = fixture('package-game/pages/chapter/chapter.js')
await open(f, { chapter: 1 })
f.page.createAudioContext({ src: '/example.mp3', progressKey: 'page:S01-C01-P01', durationSeconds: 40 })
const context = f.state.audio[0]
f.state.scope = 'account-B'
f.page.onHide()
assert.equal(context.destroys, 1)
assert.equal(f.page.audioContext, null)
assert.equal(f.state.saves.filter(item => item.scope === 'account-B').length, 0)
context.handlers.Ended()
assert.equal(f.state.saves.filter(item => item.scope === 'account-B').length, 0)
})
test('unload before hydration prevents late onLoad/onShow/onReady and playback', async () => {
const f = fixture('package-audio-c01-a/pages/player/player.js')
f.page.onLoad({ pageId: 'S01-C01-P01' })
f.page.onShow()
f.page.onReady()
f.page.onUnload()
f.finishBoot()
await settle()
assert.equal(f.state.audio.length, 0)
assert.equal(f.beginHandlers.size, 0)
})
test('catalog reset requires unchanged account and the same visible page lifetime', async () => {
for (const transition of ['none', 'account', 'hidden', 'returned', 'unloaded']) {
const f = fixture('pages/catalog/catalog.js')
await open(f)
f.page.restartStory()
assert.equal(f.state.modals.length, 1)
if (transition === 'account') f.state.scope = 'account-B'
if (transition === 'hidden' || transition === 'returned') f.page.onHide()
if (transition === 'returned') { f.page.onShow(); await settle() }
if (transition === 'unloaded') f.page.onUnload()
f.state.modals[0].success({ confirm: true })
assert.deepEqual(f.state.resets, transition === 'none' ? ['account-A'] : [], transition)
assert.equal(f.state.redirects.length, transition === 'none' ? 1 : 0, transition)
}
})
test('cleanup permission ends synchronously even when the original unload throws', async () => {
const f = fixture(null, state => ({
onUnload() { this.clearTimers(); throw new Error('cleanup failed') },
clearTimers() { state.events.push('clear') },
onAudioTimeUpdate() { state.events.push('late-event') },
}))
await open(f)
assert.throws(() => f.page.onUnload(), /cleanup failed/)
assert.equal(f.page.__tangCleaning, false)
assert.deepEqual(f.state.events, ['clear', 'flush'])
f.page.clearTimers()
f.page.onAudioTimeUpdate()
assert.deepEqual(f.state.events, ['clear', 'flush'])
})
+401
View File
@@ -0,0 +1,401 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const crypto = require('node:crypto')
const fs = require('node:fs')
const path = require('node:path')
const vm = require('node:vm')
const { createPlatformBridge } = require('../native-adapter/tang-detective/utils/platformCore')
const { emptyProgress, projectProgress } = require('../native-adapter/tang-detective/utils/progressContract')
const { sha256Hex } = require('../native-adapter/tang-detective/utils/identityHash')
const clone = value => JSON.parse(JSON.stringify(value))
function fixture(t, initialToken = 'test-account-A') {
const storage = new Map([['token', initialToken]])
const calls = []
const servers = new Map()
const state = { offline: false, expired: false, failWrite: false, loseAck: false, beforeAck: null, saveError: '' }
function server(token) {
if (!servers.has(token)) servers.set(token, { user_id: token.endsWith('B') ? 2 : 1,
schema_version: 1, content_version: 'season-01', revision: 0, story_generation: 0,
progress: emptyProgress() })
return servers.get(token)
}
const platform = {
getStorageSync: key => clone(storage.get(key) || ''),
setStorageSync(key, value) { if (state.failWrite) throw new Error('quota'); storage.set(key, clone(value)) },
request(options) {
calls.push(clone({ url: options.url, method: options.method, header: options.header, data: options.data || null }))
queueMicrotask(() => {
if (state.offline) return options.fail({})
if (state.expired) return options.success({ statusCode: 200, data: { code: -1 } })
let remote = server(options.header.token)
const reply = (code, data) => options.success({ statusCode: 200, data: { code, data: clone(data) } })
if (options.url.endsWith('/catalog')) return reply(1, { schema_version: 1, content_version: 'season-01' })
if (options.url.endsWith('/progress')) return reply(1, remote)
assert.equal(options.method, 'POST')
if (state.saveError) return reply(0, { error_code: state.saveError })
const body = options.data
assert.deepEqual(Object.keys(body).sort(), ['schema_version', 'content_version', 'base_revision', 'story_generation', 'request_id', 'operation', 'progress'].sort())
assert.deepEqual(body.progress, projectProgress(body.progress))
if (remote.lastRequest === body.request_id) return reply(1, remote)
if (remote.revision !== body.base_revision || remote.story_generation !== body.story_generation) return reply(0, { error_code: 'PROGRESS_CONFLICT' })
remote.progress = clone(body.progress)
remote.revision++
if (body.operation === 'reset_story') remote.story_generation++
remote.lastRequest = body.request_id
if (state.beforeAck) { const callback = state.beforeAck; state.beforeAck = null; callback() }
if (state.loseAck) { state.loseAck = false; return options.fail({}) }
reply(1, remote)
})
},
}
const bridge = createPlatformBridge(platform, { apiBaseUrl: 'https://example.invalid/' })
t.after(() => bridge.dispose())
return { bridge, platform, storage, calls, state, server }
}
function advance(bridge, page = 'S01-C01-P02') {
return { ...bridge.getProgress(), lastChapter: 1,
comicReaderByChapter: { 'S01-C01': { currentPageId: page, completedEventIds: [], chapterFinished: false } },
completedHotspots: { 'S01-C01': [] }, lastPageId: page }
}
test('SHA-256 uses the original portable implementation', () => {
for (const value of ['', 'abc', '测试-token']) assert.equal(sha256Hex(value), crypto.createHash('sha256').update(value).digest('hex'))
})
test('wire projection excludes story text, health answers, credentials and invalid IDs', () => {
const projected = projectProgress({ ...emptyProgress(), healthAnswer: 'secret', token: 'secret',
memoryCardSnapshots: { secret: 'story text' }, lastChapter: 100,
collectedMemoryCards: ['S01-C01-MC01', 'illegal'],
comicReaderByChapter: { 'S01-C01': { currentPageId: 'S01-C01-P08', completedEventIds: ['S01-H02'], chapterFinished: true } } })
assert.equal(projected.lastChapter, 1)
assert.equal(projected.comicReaderByChapter['S01-C01'].currentPageId, 'S01-C01-P03')
assert.equal(projected.comicReaderByChapter['S01-C01'].chapterFinished, false)
assert.deepEqual(projected.collectedMemoryCards, ['S01-C01-MC01'])
assert.ok(!JSON.stringify(projected).includes('secret'))
})
test('guest can read locally without any API calls, and guest state is not uploaded after login', async t => {
const f = fixture(t, '')
await f.bridge.open()
assert.equal(f.bridge.saveProgress(advance(f.bridge)), true)
await f.bridge.flush()
assert.equal(f.calls.length, 0)
f.storage.set('token', 'test-account-A')
await f.bridge.open()
assert.equal(f.bridge.getProgress().lastPageId, '')
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0)
})
test('authenticated save uses existing token header, JSON, whitelist and own revision', async t => {
const f = fixture(t)
await f.bridge.open()
f.bridge.saveProgress({ ...advance(f.bridge), memoryCardSnapshots: { a: 'stay local' }, answer: 'stay local' })
await f.bridge.flush()
const post = f.calls.find(c => c.method === 'POST')
assert.equal(post.header.token, 'test-account-A')
assert.equal(post.header['content-type'], 'application/json')
assert.equal(post.data.base_revision, 0)
assert.equal(post.data.progress.lastPageId, 'S01-C01-P02')
assert.ok(!JSON.stringify(post.data).includes('stay local'))
assert.equal(f.bridge.getStatus(), 'synced')
assert.equal(f.bridge.getProgress().answer, 'stay local')
for (const [key, value] of f.storage) if (key !== 'token') assert.ok(!JSON.stringify([key, value]).includes('test-account-A'))
})
test('offline edits survive and retry when API becomes available', async t => {
const f = fixture(t)
f.state.offline = true
await f.bridge.open()
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0)
f.state.offline = false
await f.bridge.open(true)
assert.equal(f.bridge.getStatus(), 'synced')
assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P02')
})
test('login expiration never silently claims synchronization', async t => {
const f = fixture(t)
f.state.expired = true
await f.bridge.open()
assert.equal(f.bridge.getStatus(), 'auth-expired')
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0)
})
test('account changes isolate progress and reject delayed writes from the previous page', async t => {
const f = fixture(t)
await f.bridge.open()
const old = advance(f.bridge)
f.bridge.saveProgress(old)
await f.bridge.flush()
f.storage.set('token', 'test-account-B')
await f.bridge.open()
assert.equal(f.bridge.getProgress().lastPageId, '')
assert.equal(f.bridge.saveProgress(old), false)
assert.equal(f.server('test-account-B').revision, 0)
})
test('late HTTP response after account switch does not hydrate the new account', async t => {
const f = fixture(t)
const first = f.bridge.open()
f.storage.set('token', 'test-account-B')
const second = f.bridge.open()
await Promise.all([first, second])
assert.equal(f.bridge.getProgress().lastPageId, '')
assert.equal(f.bridge.getStatus(), 'synced')
assert.ok(f.bridge.getScope().endsWith(sha256Hex('test-account-B')))
})
test('conflict keeps both versions; explicit cloud choice takes a recoverable backup', async t => {
const f = fixture(t)
await f.bridge.open()
f.server('test-account-A').revision = 4
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'conflict')
assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02')
assert.equal(await f.bridge.resolveConflict('cloud', f.bridge.getConflictContext()), true)
assert.equal(f.bridge.getProgress().lastPageId, '')
assert.ok([...f.storage.keys()].some(key => key.endsWith(':conflict-backup')))
})
test('explicit local conflict resolution uses the new server revision', async t => {
const f = fixture(t)
await f.bridge.open()
f.server('test-account-A').revision = 2
f.server('test-account-A').story_generation = 1
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(await f.bridge.resolveConflict('local', f.bridge.getConflictContext()), true)
const last = f.calls.filter(c => c.method === 'POST').at(-1)
assert.equal(last.data.base_revision, 2)
assert.equal(last.data.story_generation, 1)
})
test('lost response retries the same request ID and does not double increment', async t => {
const f = fixture(t)
await f.bridge.open()
f.state.loseAck = true
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'offline')
await f.bridge.open(true)
const posts = f.calls.filter(c => c.method === 'POST')
assert.equal(posts.length, 2)
assert.equal(posts[0].data.request_id, posts[1].data.request_id)
assert.equal(f.server('test-account-A').revision, 1)
assert.equal(f.bridge.getStatus(), 'synced')
})
test('new edits made while saving are queued without being overwritten by an old response', async t => {
const f = fixture(t)
await f.bridge.open()
f.state.beforeAck = () => f.bridge.saveProgress(advance(f.bridge, 'S01-C01-P03'))
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P03')
await f.bridge.flush()
assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P03')
})
test('reset uses an empty story and preserves valid unsynced card IDs', async t => {
const f = fixture(t)
await f.bridge.open()
f.bridge.saveProgress({ ...f.bridge.getProgress(), ...emptyProgress(), collectedMemoryCards: ['S01-C01-MC01'] }, true)
await f.bridge.flush()
const post = f.calls.find(c => c.method === 'POST')
assert.equal(post.data.operation, 'reset_story')
assert.deepEqual(post.data.progress.collectedMemoryCards, ['S01-C01-MC01'])
assert.deepEqual(post.data.progress.comicReaderByChapter, {})
assert.equal(f.server('test-account-A').story_generation, 1)
})
test('reset requested during an in-flight replace is not dropped', async t => {
const f = fixture(t)
await f.bridge.open()
f.state.beforeAck = () => f.bridge.saveProgress({ ...f.bridge.getProgress(), ...emptyProgress() }, true)
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
await f.bridge.flush()
assert.equal(f.calls.filter(c => c.method === 'POST').at(-1).data.operation, 'reset_story')
})
test('storage errors stop cloud writes and are reported truthfully', async t => {
const f = fixture(t)
await f.bridge.open()
f.state.failWrite = true
assert.equal(f.bridge.saveProgress(advance(f.bridge)), false)
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'storage-error')
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0)
})
test('settings/audio remain local and scoped', async t => {
const f = fixture(t)
await f.bridge.open()
f.bridge.writeLocal('settings', { fontScale: 'xlarge' })
f.bridge.writeLocal('audio', { page: 200 })
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0)
f.storage.set('token', 'test-account-B')
await f.bridge.open()
assert.deepEqual(f.bridge.readLocal('audio', {}), {})
})
test('a renewed token recovers the authenticated user local unsynced queue', async t => {
const f = fixture(t)
await f.bridge.open()
f.state.offline = true
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
f.storage.set('token', 'renewed-test-account-A')
f.state.offline = false
await f.bridge.open()
assert.equal(f.bridge.getStatus(), 'synced')
assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02')
assert.equal(f.server('renewed-test-account-A').revision, 1)
})
test('cloud hydration does not claim local persistence when storage is full', async t => {
const f = fixture(t)
f.state.failWrite = true
await f.bridge.open()
assert.equal(f.bridge.getStatus(), 'storage-error')
})
test('permanent contract failure is not automatically resubmitted by page hide or new edits', async t => {
const f = fixture(t)
await f.bridge.open()
f.state.saveError = 'INVALID_REQUEST'
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'sync-error')
f.bridge.saveProgress(advance(f.bridge, 'S01-C01-P03'))
await f.bridge.flush()
await f.bridge.open()
assert.equal(f.calls.filter(c => c.method === 'POST').length, 1)
})
test('review regression: offline reset then new reading sends reset followed by the new progress', async t => {
const f = fixture(t)
f.state.offline = true
await f.bridge.open()
f.bridge.saveProgress({ ...f.bridge.getProgress(), ...emptyProgress() }, true)
f.bridge.saveProgress(advance(f.bridge))
f.state.offline = false
await f.bridge.open(true)
assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02')
await f.bridge.flush()
const posts = f.calls.filter(c => c.method === 'POST')
assert.deepEqual(posts.map(c => c.data.operation), ['reset_story', 'replace'])
assert.equal(posts[1].data.story_generation, 1)
assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P02')
assert.equal(f.bridge.getStatus(), 'synced')
})
test('review regression: queue persistence failure releases in-flight lock and can recover', async t => {
const f = fixture(t)
await f.bridge.open()
assert.equal(f.bridge.saveProgress(advance(f.bridge)), true)
f.state.failWrite = true
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'storage-error')
assert.equal(f.calls.filter(c => c.method === 'POST').length, 0)
f.state.failWrite = false
await f.bridge.open(true)
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'synced')
assert.equal(f.server('test-account-A').progress.lastPageId, 'S01-C01-P02')
})
test('review regression: conflict confirmation is bound to account and exact local/conflict revision', async t => {
const f = fixture(t)
await f.bridge.open()
f.server('test-account-A').revision = 2
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
const oldContext = f.bridge.getConflictContext()
f.bridge.saveProgress(advance(f.bridge, 'S01-C01-P03'))
assert.equal(await f.bridge.resolveConflict('cloud', oldContext), false)
assert.equal(await f.bridge.resolveConflict('cloud'), false)
f.storage.set('token', 'test-account-B')
await f.bridge.open()
f.server('test-account-B').revision = 2
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(f.bridge.getStatus(), 'conflict')
assert.equal(await f.bridge.resolveConflict('cloud', oldContext), false)
assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02')
})
test('review regression: reset helper rejects missing or stale confirmation scope', async t => {
const f = fixture(t)
await f.bridge.open()
const oldScope = f.bridge.getScope()
const sandbox = { module: { exports: {} }, require: name => name === './platformBridge' ? f.bridge : { emptyProgress } }
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../native-adapter/tang-detective/utils/storage.js'), 'utf8'), sandbox)
const reset = sandbox.module.exports.resetStoryProgress
f.storage.set('token', 'test-account-B')
await f.bridge.open()
f.bridge.saveProgress(advance(f.bridge))
await f.bridge.flush()
assert.equal(reset(), false)
assert.equal(reset(oldScope), false)
assert.equal(f.bridge.getProgress().lastPageId, 'S01-C01-P02')
assert.equal(f.server('test-account-B').story_generation, 0)
assert.equal(reset(f.bridge.getScope()), true)
await f.bridge.flush()
assert.equal(f.server('test-account-B').story_generation, 1)
})
function pageFixture() {
let finishBoot
let scope = 'account-A'
let page
const events = []
const boot = new Promise(resolve => { finishBoot = resolve })
const bridge = { getScope: () => scope, open: () => boot, flush: () => events.push('flush') }
const sandbox = { module: { exports: {} }, require: () => bridge,
Page: options => { page = { ...options, data: { ...options.data }, setData(data) { Object.assign(this.data, data) } } },
wx: { reLaunch: () => events.push('reLaunch'), showToast: () => events.push('error') } }
vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../native-adapter/tang-detective/utils/tangPage.js'), 'utf8'), sandbox)
sandbox.module.exports({ data: { value: 1 }, onLoad() { events.push('load') }, onShow() { events.push('show') },
onHide() { events.push('hide') }, onUnload() { events.push('unload') }, click() { events.push('click') } })
return { page, events, finishBoot, changeAccount: () => { scope = 'account-B' } }
}
const settlePage = () => new Promise(resolve => setImmediate(resolve))
test('native lifecycle waits for account hydration and blocks pre-boot interaction', async () => {
const f = pageFixture()
f.page.onLoad({})
f.page.onShow()
f.page.click()
assert.deepEqual(f.events, [])
assert.equal(f.page.data.tangBootPending, true)
f.finishBoot()
await settlePage()
assert.deepEqual(f.events, ['load', 'show'])
assert.equal(f.page.data.tangBootPending, false)
f.page.click()
assert.equal(f.events.at(-1), 'click')
})
test('native page hidden during boot initializes only on return, then cleans up', async () => {
const f = pageFixture()
f.page.onLoad({})
f.page.onShow()
f.page.onHide()
f.finishBoot()
await settlePage()
assert.deepEqual(f.events, ['flush'])
f.page.onShow()
await settlePage()
assert.deepEqual(f.events, ['flush', 'load', 'show'])
f.page.onUnload()
f.page.click()
assert.deepEqual(f.events.slice(-2), ['unload', 'flush'])
})
test('native page unloaded before HTTP completion cannot start late playback/initialization', async () => {
const f = pageFixture()
f.page.onLoad({})
f.page.onShow()
f.page.onUnload()
f.finishBoot()
await settlePage()
assert.deepEqual(f.events, ['flush'])
})
test('native event after host account changes returns home before old game mutation', async () => {
const f = pageFixture()
f.page.onLoad({})
f.page.onShow()
f.finishBoot()
await settlePage()
f.changeAccount()
f.page.click()
assert.equal(f.events.at(-1), 'reLaunch')
assert.ok(!f.events.includes('click'))
})