gengxin
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
@@ -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'))
|
||||
})
|
||||
Reference in New Issue
Block a user