fix(desktop): 修复 EdgeOne 轮询和发布恢复并补齐回归测试
This commit is contained in:
1 parent
8e89080ba5
commit
ad5dfe7658
16 files changed
+554
-91
No files matched your search
@@ -267,6 +267,7 @@ External packages **directly declared** for development, tests, types, or toolin
|
||||
| [`selfsigned`](https://github.com/jfromaniello/selfsigned) | MIT |
|
||||
| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause |
|
||||
| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT |
|
||||
| [`tencentcloud-sdk-nodejs-teo`](https://github.com/tencentcloud/tencentcloud-sdk-nodejs) | Apache-2.0 |
|
||||
| [`tsdown`](https://github.com/rolldown/tsdown) | MIT |
|
||||
| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 |
|
||||
| [`vite`](https://github.com/vitejs/vite) | MIT |
|
||||
|
||||
@@ -18,14 +18,14 @@
|
||||
en: b407de63118c9bf9
|
||||
zh: de19f1ba9feacbbf
|
||||
/desktop-publication/deployment-and-test-release-identity:
|
||||
en: 405588e021bcfb60
|
||||
zh: d9f9700b0b6db6f8
|
||||
en: eed7c4ed0d4e2758
|
||||
zh: 026fe6076e11018e
|
||||
/desktop-publication/publish-installers-and-update-feeds:
|
||||
en: 3c3e3a09573c4386
|
||||
zh: c851ae6a94f2284a
|
||||
/desktop-publication/fixed-installer-downloads:
|
||||
en: 2a4c72e884502909
|
||||
zh: 29b112db69971cc7
|
||||
en: fc2d126835d00f12
|
||||
zh: fceda915f9ce5558
|
||||
/desktop-publication/upload-credentials:
|
||||
en: 3a14a53c7eca80f7
|
||||
zh: bb122276f6648f74
|
||||
|
||||
@@ -33,7 +33,7 @@ Publication requires completed release artifacts and explicit operator authoriza
|
||||
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds/<target>/` and binaries use `dsh-desk/bin/<target>/`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk/<release-id>/feeds/<target>/` and `dsh-desk/<release-id>/bin/<target>/`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`.
|
||||
|
||||
The update destination and upload credentials follow the selected deployment:
|
||||
The update destination and upload credentials follow the selected deployment; production fixed downloads also require the [EdgeOne settings](#fixed-downloads):
|
||||
|
||||
| Environment | Public origin | COS bucket | COS credentials |
|
||||
|---|---|---|---|
|
||||
@@ -71,9 +71,9 @@ Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the
|
||||
|
||||
Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit an EdgeOne URL purge and wait for that task to succeed. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance.
|
||||
|
||||
Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv. The uploader reuses `DOWNLOAD_PROD_COS_SECRET_ID` and `DOWNLOAD_PROD_COS_SECRET_KEY`; their identity needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks` for that zone. No additional CI Secret is required. Optional `DOWNLOAD_PROD_EO_TIMEOUT_MS` and `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` default to 300000 and 5000 milliseconds; both must be positive timer durations, and the interval must not exceed the timeout. Required settings are resolved before uploading.
|
||||
Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv. The uploader reuses `DOWNLOAD_PROD_COS_SECRET_ID` and `DOWNLOAD_PROD_COS_SECRET_KEY`; their identity needs `teo:CreatePurgeTask` and `teo:DescribePurgeTasks` for that zone. Existing key permissions are not verified by this script change; before enabling production fixed uploads, the release operator must confirm both EO actions for the selected identity. No additional CI Secret is required. Optional `DOWNLOAD_PROD_EO_TIMEOUT_MS` and `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` default to 300000 and 5000 milliseconds; both must be positive timer durations, and the interval must not exceed the timeout. Required settings are resolved before uploading.
|
||||
|
||||
An uploaded installer stays published if the purge fails or times out; the command exits unsuccessfully and reports that cache refresh is incomplete. Purges retain separate `production-<target>-purge-*` directories under the upload-record root, including URL, task ID, status events and final result. An upload's successful `result.json` confirms only its COS writes. Resume an accepted task with `pnpm run purge:latest <target> --job-id <id>`, or submit a new URL purge with `pnpm run purge:latest <target>` after resolving an unknown submission outcome in EO. These commands require production settings and credentials but no local installer. They never upload or roll back bytes; EOF, timeout or access denial does not imply EO canceled the task. Serialize uploads and recovery per target. The CI publisher does not publish these fixed downloads or run this refresh.
|
||||
An uploaded installer stays published if the purge fails or times out; the command exits unsuccessfully and reports that cache refresh is incomplete. Purges retain separate `production-<target>-purge-*` directories under the upload-record root, including versioned plans/results, script fingerprints, URL, task ID and status events. An upload's successful `result.json` confirms only its COS writes. Resume an accepted task with `pnpm run purge:latest <target> --job-id <id>`, or submit a new URL purge with `pnpm run purge:latest <target>` after resolving an unknown submission outcome in EO. These commands require production settings and credentials but no local installer. They never upload or roll back bytes; connection loss, timeout or access denial does not imply EO canceled the task. Transient query failures retry within the deadline; permission and parameter errors stop immediately. A task never found by the deadline is reported separately from an unfinished task. DPAPI recovery must reuse the original credential launcher with the same credential file, deployment, bucket and target, adding `-PurgeOnly` and the accepted `-JobId`. Serialize uploads and recovery per target. The CI publisher does not publish these fixed downloads or run this refresh.
|
||||
|
||||
<a id="credentials"></a>
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ description: "将完整 Desktop 安装包与更新清单发布到选定下载服
|
||||
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds/<target>/`,安装包位于 `dsh-desk/bin/<target>/`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk/<release-id>/feeds/<target>/` 和 `dsh-desk/<release-id>/bin/<target>/`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。
|
||||
|
||||
更新目标与上传凭据都与所选环境对应:
|
||||
更新目标与上传凭据都与所选环境对应;生产固定下载还需配置 [EdgeOne 设置](#fixed-downloads):
|
||||
|
||||
| 环境 | 公开 origin | COS bucket | COS 凭据 |
|
||||
|---|---|---|---|
|
||||
@@ -71,9 +71,9 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo
|
||||
|
||||
固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交 EdgeOne URL 缓存清除任务,并等待该任务成功。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。
|
||||
|
||||
生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`。上传器复用 `DOWNLOAD_PROD_COS_SECRET_ID` 和 `DOWNLOAD_PROD_COS_SECRET_KEY`;该身份需要对应站点的 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限,无需新增 CI Secret。可选的 `DOWNLOAD_PROD_EO_TIMEOUT_MS` 和 `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` 默认分别为 300000 和 5000 毫秒,均须为正数计时器时长,轮询间隔不能大于超时时长。上传前解析必需配置。
|
||||
生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`。上传器复用 `DOWNLOAD_PROD_COS_SECRET_ID` 和 `DOWNLOAD_PROD_COS_SECRET_KEY`;该身份需要对应站点的 `teo:CreatePurgeTask` 和 `teo:DescribePurgeTasks` 权限。本次脚本修改未验证现有密钥权限;启用生产固定上传前,发布操作人员必须确认所选身份具备这两个 EO 操作的权限。无需新增 CI Secret。可选的 `DOWNLOAD_PROD_EO_TIMEOUT_MS` 和 `DOWNLOAD_PROD_EO_POLL_INTERVAL_MS` 默认分别为 300000 和 5000 毫秒,均须为正数计时器时长,轮询间隔不能大于超时时长。上传前解析必需配置。
|
||||
|
||||
缓存清除失败或超时时,已上传的安装包仍保持发布,命令以失败状态退出并提示缓存刷新未完成。缓存清除在上传记录根目录下独立保留 `production-<target>-purge-*` 目录,包含 URL、任务 ID、状态事件和最终结果。上传记录中成功的 `result.json` 只确认 COS 写入。使用 `pnpm run purge:latest <target> --job-id <id>` 继续等待已接受的任务;提交结果未知时,先在 EO 确认状态,再用 `pnpm run purge:latest <target>` 提交新任务。这些命令需要生产配置和凭据,无需本地安装包,不上传或回滚文件;连接结束、超时或权限拒绝不代表 EO 已取消任务。按目标串行执行上传和恢复。CI 发布器不发布这些固定下载,也不执行此刷新。
|
||||
缓存清除失败或超时时,已上传的安装包仍保持发布,命令以失败状态退出并提示缓存刷新未完成。缓存清除在上传记录根目录下独立保留 `production-<target>-purge-*` 目录,包含带版本号的计划和结果、脚本指纹、URL、任务 ID 和状态事件。上传记录中成功的 `result.json` 只确认 COS 写入。使用 `pnpm run purge:latest <target> --job-id <id>` 继续等待已接受的任务;提交结果未知时,先在 EO 确认状态,再用 `pnpm run purge:latest <target>` 提交新任务。这些命令需要生产配置和凭据,无需本地安装包,不上传或回滚文件;连接中断、超时或权限拒绝不代表 EO 已取消任务。临时查询错误在截止时间内重试;权限和参数错误立即停止。截止时间前始终未找到任务时,单独报告该情况,与任务尚未完成区分。DPAPI 恢复必须复用原凭据启动器以及相同的凭据文件、部署环境、bucket 和目标,添加 `-PurgeOnly` 和已接受的 `-JobId`。按目标串行执行上传和恢复。CI 发布器不发布这些固定下载,也不执行此刷新。
|
||||
|
||||
<a id="credentials"></a>
|
||||
|
||||
|
||||
@@ -105,3 +105,11 @@ export function resolveDesktopUploadConfig(
|
||||
* @returns Validated channel.
|
||||
*/
|
||||
export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest'
|
||||
|
||||
/**
|
||||
* Read a required nonempty deployment setting.
|
||||
* @param env Packaging or upload settings.
|
||||
* @param name Setting name included in missing-value diagnostics.
|
||||
* @returns Trimmed setting value.
|
||||
*/
|
||||
export function requiredEnvironmentValue(env: NodeJS.ProcessEnv, name: string): string
|
||||
@@ -110,7 +110,7 @@ export function desktopUpdateMetadataFilename(version, platform, channel = 'nigh
|
||||
* @param {string} name - Environment variable to read.
|
||||
* @returns {string} Trimmed setting.
|
||||
*/
|
||||
function requiredEnvironmentValue(env, name) {
|
||||
export function requiredEnvironmentValue(env, name) {
|
||||
const value = env[name]?.trim()
|
||||
if (value === undefined || value === '') {
|
||||
throw new Error(`desktop auto-update: ${name} must be set to a non-empty value`)
|
||||
|
||||
@@ -75,10 +75,12 @@ export function desktopTargetBuildPaths(target, environment = process.env) {
|
||||
*/
|
||||
export function desktopTargetPlatform(target) {
|
||||
assertSupportedTarget(target)
|
||||
return {
|
||||
platform: /** @type {'darwin' | 'win32'} */ (target === 'win-x64' ? 'win32' : 'darwin'),
|
||||
arch: /** @type {'arm64' | 'x64'} */ (target === 'mac-arm64' ? 'arm64' : 'x64'),
|
||||
}
|
||||
const targets = /** @type {const} */ ({
|
||||
'mac-arm64': { platform: 'darwin', arch: 'arm64' },
|
||||
'mac-x64': { platform: 'darwin', arch: 'x64' },
|
||||
'win-x64': { platform: 'win32', arch: 'x64' },
|
||||
})
|
||||
return targets[target]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/** Refresh production fixed-installer URLs through EdgeOne with resumable task tracking. */
|
||||
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { setTimeout as delay } from 'node:timers/promises'
|
||||
import { teo } from 'tencentcloud-sdk-nodejs-teo'
|
||||
import type { teo } from 'tencentcloud-sdk-nodejs-teo'
|
||||
import type { DesktopPackageTargetName } from './package-target.ts'
|
||||
import { desktopLatestInstallerFilename, resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
|
||||
import { desktopLatestInstallerFilename, requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
|
||||
import { desktopTargetPlatform } from './desktop-build-paths.mjs'
|
||||
import { recordPackagingEvent } from './packaging-run.mjs'
|
||||
|
||||
/** Validated production destination and credentials; never serialize this value. */
|
||||
@@ -18,10 +20,14 @@ export interface DesktopPurgeConfig {
|
||||
readonly pollIntervalMs: number
|
||||
}
|
||||
|
||||
function required(env: NodeJS.ProcessEnv, name: string): string {
|
||||
const value = env[name]?.trim()
|
||||
if (!value) throw new DesktopPurgeError(`desktop purge: ${name} must be set in the target dotenv`)
|
||||
return value
|
||||
/** SDK operations used by one purge; callers may supply an isolated client. */
|
||||
export type DesktopPurgeClient = Pick<InstanceType<typeof teo.v20220901.Client>, 'profile' | 'CreatePurgeTask' | 'DescribePurgeTasks'>
|
||||
|
||||
/** Optional existing task, credential-launcher context, and caller-owned SDK client. */
|
||||
export interface DesktopPurgeOptions {
|
||||
readonly jobId?: string
|
||||
readonly credentialLauncher?: boolean
|
||||
readonly client?: DesktopPurgeClient
|
||||
}
|
||||
|
||||
function milliseconds(env: NodeJS.ProcessEnv, name: string, fallback: number): number {
|
||||
@@ -37,19 +43,23 @@ function milliseconds(env: NodeJS.ProcessEnv, name: string, fallback: number): n
|
||||
* Resolve the production URL, EO zone and existing COS credentials before any upload.
|
||||
* @param env Target dotenv settings, with any credential-launcher replacement already applied.
|
||||
* @param target Fixed installer selected by the operator.
|
||||
* @param publicUrl Uploaded object URL; omitted derives the fixed URL for cache-only recovery.
|
||||
* @returns Required EO settings; test deployments and invalid settings throw before network writes.
|
||||
*/
|
||||
export function resolveDesktopPurgeConfig(env: NodeJS.ProcessEnv, target: DesktopPackageTargetName): DesktopPurgeConfig {
|
||||
if (env.DSH_DESKTOP_AUTO_UPDATE_ENV?.trim() !== 'production') throw new DesktopPurgeError('desktop purge: production is required')
|
||||
const update = resolveDesktopAutoUpdateConfig(env, target === 'win-x64' ? 'win32' : 'darwin', target === 'mac-arm64' ? 'arm64' : 'x64')
|
||||
const zoneId = required(env, 'DOWNLOAD_PROD_EO_ZONE_ID')
|
||||
export function resolveDesktopPurgeConfig(env: NodeJS.ProcessEnv, target: DesktopPackageTargetName,
|
||||
publicUrl?: string,
|
||||
): DesktopPurgeConfig {
|
||||
if (resolveDesktopAutoUpdateEnvironment(env) !== 'production') throw new DesktopPurgeError('desktop purge: production is required')
|
||||
const { platform, arch } = desktopTargetPlatform(target)
|
||||
const update = resolveDesktopUploadConfig(env, platform, arch)
|
||||
const zoneId = requiredEnvironmentValue(env, 'DOWNLOAD_PROD_EO_ZONE_ID')
|
||||
if (!/^zone-[a-z0-9]+$/u.test(zoneId)) throw new DesktopPurgeError('desktop purge: DOWNLOAD_PROD_EO_ZONE_ID must name one EO zone')
|
||||
const timeoutMs = milliseconds(env, 'DOWNLOAD_PROD_EO_TIMEOUT_MS', 300_000)
|
||||
const pollIntervalMs = milliseconds(env, 'DOWNLOAD_PROD_EO_POLL_INTERVAL_MS', 5_000)
|
||||
if (pollIntervalMs > timeoutMs) throw new DesktopPurgeError('desktop purge: poll interval must not exceed timeout')
|
||||
return {
|
||||
target, zoneId, publicUrl: `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`,
|
||||
secretId: required(env, 'DOWNLOAD_PROD_COS_SECRET_ID'), secretKey: required(env, 'DOWNLOAD_PROD_COS_SECRET_KEY'),
|
||||
target, zoneId, publicUrl: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`,
|
||||
secretId: requiredEnvironmentValue(env, update.secretIdEnvName), secretKey: requiredEnvironmentValue(env, update.secretKeyEnvName),
|
||||
timeoutMs, pollIntervalMs,
|
||||
}
|
||||
}
|
||||
@@ -68,15 +78,33 @@ function safeField(value: unknown, config: DesktopPurgeConfig): string | undefin
|
||||
return value
|
||||
}
|
||||
|
||||
function errorCode(error: unknown, config: DesktopPurgeConfig): string | undefined {
|
||||
return safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config)
|
||||
}
|
||||
|
||||
function transientQueryError(error: unknown, config: DesktopPurgeConfig): boolean {
|
||||
const code = errorCode(error, config)
|
||||
// The pinned SDK wraps transport failures without preserving their original code.
|
||||
if (code === undefined && error instanceof Error && 'getRequestId' in error && 'getTraceId' in error) {
|
||||
return !('httpCode' in error) || (typeof error.httpCode === 'number' && (error.httpCode === 429 || error.httpCode >= 500))
|
||||
}
|
||||
return code !== undefined && (['RequestLimitExceeded', 'InternalError'].includes(code.split('.')[0]!)
|
||||
|| ['ClientNetworkError', 'ClientNetworkSocketTimeoutError', 'ClientNetworkConnectTimeoutError',
|
||||
'RequestTimeout', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN'].includes(code))
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit one URL purge, or wait for an existing task without submitting again.
|
||||
* @param config Validated production destination and credentials.
|
||||
* @param recordsRoot Parent directory for separate purge records; upload records remain intact.
|
||||
* @param existingJobId EO task to resume; omitted submits once, without automatic write retries.
|
||||
* @param options Existing task, launcher context, and optional SDK client; submission is never retried.
|
||||
* @returns Resolves only after EO reports success for the exact target URL and task ID.
|
||||
* @throws DesktopPurgeError on failure, timeout or unknown submission outcome; uploaded bytes remain unchanged.
|
||||
*/
|
||||
export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsRoot: string, existingJobId?: string): Promise<void> {
|
||||
export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsRoot: string,
|
||||
options: DesktopPurgeOptions = {},
|
||||
): Promise<void> {
|
||||
const existingJobId = options.jobId
|
||||
if (existingJobId !== undefined && !/^[a-zA-Z0-9-]{1,128}$/u.test(existingJobId)) {
|
||||
throw new DesktopPurgeError('desktop purge: invalid --job-id')
|
||||
}
|
||||
@@ -84,6 +112,8 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR
|
||||
let jobId = existingJobId
|
||||
let stage = 'prepare'
|
||||
let success = false
|
||||
let observedTask = false
|
||||
let lastQueryFailed = false
|
||||
let failure: string | undefined
|
||||
let rejected = false
|
||||
let report: DesktopPurgeError | undefined
|
||||
@@ -92,11 +122,12 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR
|
||||
directory = await mkdtemp(join(recordsRoot, `production-${config.target}-purge-`))
|
||||
process.stdout.write(`desktop purge: record ${directory}\n`)
|
||||
await writeFile(join(directory, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true })
|
||||
await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ zoneId: config.zoneId, publicUrl: config.publicUrl,
|
||||
const sourceSha256 = { 'desktop-edgeone.ts': createHash('sha256').update(await readFile(import.meta.filename)).digest('hex') }
|
||||
await writeFile(join(directory, 'plan.json'), `${JSON.stringify({ schemaVersion: 1, sourceSha256, zoneId: config.zoneId, publicUrl: config.publicUrl,
|
||||
target: config.target, jobId, timeoutMs: config.timeoutMs, pollIntervalMs: config.pollIntervalMs })}\n`,
|
||||
{ flag: 'wx', mode: 0o600, flush: true })
|
||||
const httpProfile = { endpoint: 'teo.tencentcloudapi.com', reqTimeout: Math.min(30, config.timeoutMs / 1000) }
|
||||
const client = new teo.v20220901.Client({
|
||||
const client = options.client ?? new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({
|
||||
credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '',
|
||||
profile: { httpProfile },
|
||||
})
|
||||
@@ -116,12 +147,28 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR
|
||||
}
|
||||
stage = 'DescribePurgeTasks'
|
||||
while (performance.now() < deadline) {
|
||||
client.profile.httpProfile = { ...httpProfile, reqTimeout: Math.min(30, Math.max(0.001, (deadline - performance.now()) / 1000)) }
|
||||
const response = await client.DescribePurgeTasks({ ZoneId: config.zoneId, Limit: 1000,
|
||||
Filters: [{ Name: 'job-id', Values: [jobId] }, { Name: 'target', Values: [config.publicUrl] }] })
|
||||
client.profile.httpProfile!.reqTimeout = Math.min(30, Math.max(0.001, (deadline - performance.now()) / 1000))
|
||||
let response: Awaited<ReturnType<DesktopPurgeClient['DescribePurgeTasks']>>
|
||||
try {
|
||||
response = await client.DescribePurgeTasks({ ZoneId: config.zoneId, Limit: 1000,
|
||||
Filters: [{ Name: 'job-id', Values: [jobId] }] })
|
||||
} catch (error) {
|
||||
const code = errorCode(error, config)
|
||||
if (!transientQueryError(error, config)) throw error
|
||||
lastQueryFailed = true
|
||||
recordPackagingEvent(directory, { type: 'purge-query-retry', jobId, code: code ?? 'SDKTransportError' })
|
||||
const remaining = deadline - performance.now()
|
||||
if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining))
|
||||
continue
|
||||
}
|
||||
lastQueryFailed = false
|
||||
if ((response.TotalCount ?? 0) > 1000) throw new DesktopPurgeError('EO task query exceeded its page limit')
|
||||
const tasks = response.Tasks?.filter(task => task.JobId === jobId && task.Target === config.publicUrl && task.Type === 'purge_url') ?? []
|
||||
if ((response.Tasks?.length ?? 0) > 0 && tasks.length === 0) {
|
||||
throw new DesktopPurgeError('EO job does not contain the expected URL purge; check the zone, job ID and target')
|
||||
}
|
||||
const task = tasks[0]
|
||||
observedTask ||= task !== undefined
|
||||
if (tasks.length > 1) throw new DesktopPurgeError('EO returned multiple entries for the URL and task ID')
|
||||
const status = task?.Status
|
||||
recordPackagingEvent(directory, { type: 'purge-status', jobId, status: safeField(status, config), requestId: safeField(response.RequestId, config) })
|
||||
@@ -135,13 +182,18 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR
|
||||
const remaining = deadline - performance.now()
|
||||
if (remaining > 0) await delay(Math.min(config.pollIntervalMs, remaining))
|
||||
}
|
||||
if (!success) throw new DesktopPurgeError('timed out waiting for EO; task completion is unknown')
|
||||
if (!success) throw new DesktopPurgeError(observedTask || lastQueryFailed
|
||||
? 'timed out waiting for EO; task completion is unknown'
|
||||
: 'EO task was not found before the deadline; check the zone, job ID and EO task history')
|
||||
} catch (error) {
|
||||
const code = safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config)
|
||||
const code = errorCode(error, config)
|
||||
failure = error instanceof DesktopPurgeError ? error.message : `${stage} failed (${code ?? 'unclassified error'})`
|
||||
const command = options.credentialLauncher
|
||||
? `rerun upload-with-credentials.ps1 with the original -CredentialFile, -Environment production, -Target ${config.target} and -Bucket; include -Upload -Latest -PurgeOnly${!rejected && jobId ? ` -JobId ${jobId}` : ''}`
|
||||
: `pnpm run purge:latest ${config.target}${!rejected && jobId ? ` --job-id ${jobId}` : ''}`
|
||||
const recovery = rejected
|
||||
? `Resolve the EO failure, then retry with pnpm run purge:latest ${config.target}.`
|
||||
: jobId ? `Resume with pnpm run purge:latest ${config.target} --job-id ${jobId}.`
|
||||
? `Resolve the EO failure, then ${command}.`
|
||||
: jobId ? `Resume with ${command}.`
|
||||
: 'Inspect EO task history before submitting another purge.'
|
||||
const permission = code?.startsWith('AuthFailure') || code?.startsWith('UnauthorizedOperation')
|
||||
? ' Check teo:CreatePurgeTask and teo:DescribePurgeTasks permissions.' : ''
|
||||
@@ -149,8 +201,8 @@ export async function purgeDesktopInstaller(config: DesktopPurgeConfig, recordsR
|
||||
} finally {
|
||||
if (directory !== undefined) {
|
||||
try {
|
||||
await writeFile(join(directory, 'result.json'), `${JSON.stringify({ success, stage, jobId, failure, publicUrl: config.publicUrl })}\n`,
|
||||
{ flag: 'wx', mode: 0o600, flush: true })
|
||||
await writeFile(join(directory, 'result.json'), `${JSON.stringify({ schemaVersion: 1, success, stage, jobId, failure, publicUrl: config.publicUrl })}\n`,
|
||||
{ flag: 'wx', mode: 0o600, flush: true })
|
||||
} catch (error) {
|
||||
// An unavailable journal cannot establish completion for a subsequent process.
|
||||
report = new DesktopPurgeError(`desktop purge: cannot save result; inspect EO task ${jobId ?? 'history'} and ${directory} before recovery`)
|
||||
|
||||
@@ -13,7 +13,7 @@ const FAILURE_CODES = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket',
|
||||
'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT'])
|
||||
|
||||
/** Object keys fingerprinted into every upload record to bind evidence to this uploader. */
|
||||
const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts']
|
||||
const UPLOADER_SOURCES = ['desktop-upload-run.ts', 'upload-target.ts', 'desktop-upload-plan.ts', 'desktop-cos.ts', 'desktop-edgeone.ts']
|
||||
|
||||
async function fingerprint(artifact: DesktopUploadArtifact) {
|
||||
const sha512 = createHash('sha512')
|
||||
|
||||
@@ -4,7 +4,8 @@ import { resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import type { DesktopPackageTargetName } from './package-target.ts'
|
||||
import { createDesktopCos } from './desktop-cos.ts'
|
||||
import { resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
|
||||
import { requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
|
||||
import { desktopTargetPlatform } from './desktop-build-paths.mjs'
|
||||
import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
|
||||
import { createDesktopUploadPlan, type DesktopUploadPlan } from './desktop-upload-plan.ts'
|
||||
import { desktopReleaseTag, tagDesktopRelease } from './desktop-release-tag.ts'
|
||||
@@ -15,18 +16,13 @@ const SUPPORTED_TARGETS = new Set<DesktopPackageTargetName>(['mac-arm64', 'mac-x
|
||||
|
||||
function targetName(value: string): DesktopPackageTargetName {
|
||||
if (!SUPPORTED_TARGETS.has(value as DesktopPackageTargetName)) {
|
||||
throw new Error(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`)
|
||||
throw new DesktopUploadUsageError(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`)
|
||||
}
|
||||
return value as DesktopPackageTargetName
|
||||
}
|
||||
|
||||
function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, name: string): string {
|
||||
const value = environment[name]?.trim()
|
||||
if (value === undefined || value === '') {
|
||||
throw new Error(`desktop upload: ${name} must be set to a non-empty value`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
/** Invalid command arguments or release configuration, reported before network writes. */
|
||||
class DesktopUploadUsageError extends Error {}
|
||||
|
||||
/**
|
||||
* Use the credential launcher's selected deployment only when it matches the packaged release destination.
|
||||
@@ -42,15 +38,14 @@ export function resolveCredentialUploadEnvironment(
|
||||
selected: 'test' | 'production', bucket: string,
|
||||
target: DesktopPackageTargetName,
|
||||
): NodeJS.ProcessEnv {
|
||||
const platform = target === 'win-x64' ? 'win32' : 'darwin'
|
||||
const arch = target === 'mac-arm64' ? 'arm64' : 'x64'
|
||||
const { platform, arch } = desktopTargetPlatform(target)
|
||||
const destination = resolveDesktopUploadConfig(fileEnvironment, platform, arch)
|
||||
if (destination.environment !== selected || destination.bucket !== bucket) {
|
||||
throw new Error('desktop upload: credential launcher deployment or bucket differs from the packaged release destination')
|
||||
throw new DesktopUploadUsageError('desktop upload: credential launcher deployment or bucket differs from the packaged release destination')
|
||||
}
|
||||
if (injectedEnvironment.DSH_DESKTOP_AUTO_UPDATE_ENV !== selected
|
||||
|| injectedEnvironment[`${selected === 'test' ? 'DOWNLOAD_TEST' : 'DOWNLOAD_PROD'}_COS_BUCKET`] !== bucket) {
|
||||
throw new Error('desktop upload: credential launcher environment differs from its explicit arguments')
|
||||
throw new DesktopUploadUsageError('desktop upload: credential launcher environment differs from its explicit arguments')
|
||||
}
|
||||
return {
|
||||
...fileEnvironment,
|
||||
@@ -59,6 +54,15 @@ export function resolveCredentialUploadEnvironment(
|
||||
}
|
||||
}
|
||||
|
||||
function purgeConfig(environment: NodeJS.ProcessEnv, target: DesktopPackageTargetName, publicUrl?: string) {
|
||||
try {
|
||||
return resolveDesktopPurgeConfig(environment, target, publicUrl)
|
||||
} catch (error) {
|
||||
if (error instanceof DesktopPurgeError) throw error
|
||||
throw new DesktopUploadUsageError('Invalid production purge settings; check DOWNLOAD_PROD_COS_BUCKET, DOWNLOAD_PROD_COS_SECRET_ID, DOWNLOAD_PROD_COS_SECRET_KEY and DOWNLOAD_PROD_EO_ZONE_ID in the target dotenv.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the selected target, refresh production fixed downloads, or resume only their cache refresh.
|
||||
* @param args Target and upload options from the command line.
|
||||
@@ -66,38 +70,48 @@ export function resolveCredentialUploadEnvironment(
|
||||
* @throws DesktopPurgeError when refresh is incomplete; a completed upload is not repeated or rolled back.
|
||||
*/
|
||||
export async function uploadDesktopTarget(args: string[]): Promise<void> {
|
||||
const { positionals, values } = parseArgs({ args: args.filter(arg => arg !== '--'), allowPositionals: true, options: {
|
||||
const { positionals, values } = parseArgs({ args, allowPositionals: true, options: {
|
||||
latest: { type: 'boolean' },
|
||||
'purge-only': { type: 'boolean' }, 'job-id': { type: 'string' },
|
||||
'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' },
|
||||
} })
|
||||
const target = positionals[0]
|
||||
if (target === undefined || positionals.length !== 1) {
|
||||
throw new Error('desktop upload: expected exactly one target')
|
||||
throw new DesktopUploadUsageError('desktop upload: expected exactly one target')
|
||||
}
|
||||
const name = targetName(target)
|
||||
if ((values['purge-only'] && !values.latest) || (values['job-id'] !== undefined && !values['purge-only'])) {
|
||||
throw new Error('desktop upload: --purge-only requires --latest; --job-id requires --purge-only')
|
||||
throw new DesktopUploadUsageError('desktop upload: --purge-only requires --latest; --job-id requires --purge-only')
|
||||
}
|
||||
let fileEnvironment: NodeJS.ProcessEnv
|
||||
try {
|
||||
fileEnvironment = loadDesktopPackageEnvironment(desktopTargetPlatform(name).platform)
|
||||
} catch (error) {
|
||||
throw new DesktopUploadUsageError('Cannot load target dotenv; check the target dotenv file and release settings.')
|
||||
}
|
||||
const fileEnvironment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin')
|
||||
const launcher = values['credential-launcher'] === true
|
||||
if (launcher ? values.environment === undefined || values.bucket === undefined
|
||||
: values.environment !== undefined || values.bucket !== undefined) {
|
||||
throw new Error('desktop upload: credential launcher requires an explicit environment and bucket')
|
||||
throw new DesktopUploadUsageError('desktop upload: credential launcher requires an explicit environment and bucket')
|
||||
}
|
||||
if (values.environment !== undefined && values.environment !== 'test' && values.environment !== 'production') {
|
||||
throw new Error('desktop upload: credential launcher environment must be test or production')
|
||||
throw new DesktopUploadUsageError('desktop upload: credential launcher environment must be test or production')
|
||||
}
|
||||
const environment = launcher
|
||||
? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name)
|
||||
: fileEnvironment
|
||||
try {
|
||||
resolveDesktopAutoUpdateEnvironment(environment)
|
||||
} catch (error) {
|
||||
throw new DesktopUploadUsageError('DSH_DESKTOP_AUTO_UPDATE_ENV must be "test" or "production".')
|
||||
}
|
||||
const recordsRoot = resolve(import.meta.dirname, '../.desktop-build/upload-records')
|
||||
if (values['purge-only']) {
|
||||
await purgeDesktopInstaller(resolveDesktopPurgeConfig(environment, name), recordsRoot, values['job-id'])
|
||||
await purgeDesktopInstaller(purgeConfig(environment, name), recordsRoot, { ...values['job-id'] !== undefined ? { jobId: values['job-id'] } : {}, credentialLauncher: launcher })
|
||||
return
|
||||
}
|
||||
const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true })
|
||||
const purge = plan.environment === 'production' && values.latest ? resolveDesktopPurgeConfig(environment, name) : undefined
|
||||
const purge = plan.environment === 'production' && values.latest ? purgeConfig(environment, name, plan.publicUrl) : undefined
|
||||
const cos = createDesktopCos({
|
||||
secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName),
|
||||
secretKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName),
|
||||
@@ -106,7 +120,7 @@ export async function uploadDesktopTarget(args: string[]): Promise<void> {
|
||||
await uploadDesktopRelease(plan, cos, recordsRoot)
|
||||
if (purge !== undefined) {
|
||||
try {
|
||||
await purgeDesktopInstaller(purge, recordsRoot)
|
||||
await purgeDesktopInstaller(purge, recordsRoot, { credentialLauncher: launcher })
|
||||
} catch (error) {
|
||||
throw new DesktopPurgeError(`desktop upload: installer uploaded; cache refresh incomplete. ${error instanceof DesktopPurgeError ? error.message : 'Inspect the purge record before recovery.'}`)
|
||||
}
|
||||
@@ -140,13 +154,27 @@ function recordProductionRelease(plan: DesktopUploadPlan): void {
|
||||
process.stdout.write(`desktop upload: ${result.tag} ${result.status === 'created' ? 'records' : 'already recorded'} ${plan.commit}\n`)
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
|
||||
uploadDesktopTarget(process.argv.slice(2)).catch((error) => {
|
||||
/**
|
||||
* Report command failures without exposing raw storage or SDK exceptions.
|
||||
* @param args Target and options forwarded by the package command or credential launcher.
|
||||
* @returns Resolves after reporting any failure and setting the process exit status.
|
||||
*/
|
||||
export async function runDesktopUploadCommand(args: string[]): Promise<void> {
|
||||
try {
|
||||
await uploadDesktopTarget(args)
|
||||
} catch (error) {
|
||||
const usage = error instanceof DesktopUploadUsageError
|
||||
|| (error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS_'))
|
||||
const message = error instanceof DesktopPurgeError ? error.message
|
||||
: 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.'
|
||||
// The DPAPI launcher suppresses raw stderr; this error contains only public purge diagnostics.
|
||||
const output = error instanceof DesktopPurgeError && process.argv.includes('--credential-launcher') ? process.stdout : process.stderr
|
||||
: usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts <target> [--latest [--purge-only [--job-id <id>]]]; targets: mac-arm64, mac-x64, win-x64.`
|
||||
: 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.'
|
||||
// The DPAPI launcher suppresses raw stderr; these diagnostics contain no SDK details.
|
||||
const output = args.includes('--credential-launcher') ? process.stdout : process.stderr
|
||||
output.write(`${message}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
|
||||
void runDesktopUploadCommand(process.argv.slice(2))
|
||||
}
|
||||
@@ -58,10 +58,12 @@ $credentials = $null
|
||||
$startInfo = $null
|
||||
$stage = 'decrypt-file'
|
||||
|
||||
if (($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) -or ($JobId -and -not $PurgeOnly)) {
|
||||
Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production; JobId requires PurgeOnly.'
|
||||
exit 1
|
||||
}
|
||||
|
||||
try {
|
||||
if (($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) -or ($JobId -and -not $PurgeOnly)) {
|
||||
throw 'PurgeOnly requires Latest and production; JobId requires PurgeOnly.'
|
||||
}
|
||||
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
|
||||
throw 'Windows DPAPI is required.'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { teo } from 'tencentcloud-sdk-nodejs-teo'
|
||||
import { purgeDesktopInstaller, resolveDesktopPurgeConfig, type DesktopPurgeClient } from '../scripts/desktop-edgeone.ts'
|
||||
|
||||
const environment = {
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket',
|
||||
DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-secret-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-secret-key',
|
||||
DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', DOWNLOAD_PROD_EO_TIMEOUT_MS: '30', DOWNLOAD_PROD_EO_POLL_INTERVAL_MS: '10',
|
||||
}
|
||||
const config = resolveDesktopPurgeConfig(environment, 'mac-arm64')
|
||||
const task = { JobId: 'job-1', Target: config.publicUrl, Type: 'purge_url', Status: 'success' }
|
||||
const roots: string[] = []
|
||||
let time = 0
|
||||
|
||||
vi.mock('node:timers/promises', () => ({ setTimeout: vi.fn(async (ms: number) => { time += ms }) }))
|
||||
|
||||
beforeEach(() => {
|
||||
time = 0
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => time)
|
||||
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
})
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'desktop-purge-'))
|
||||
roots.push(root)
|
||||
// Real SDK initialization, with both network operations replaced before use.
|
||||
const client = new teo.v20220901.Client({ credential: { secretId: config.secretId, secretKey: config.secretKey } })
|
||||
const create = vi.spyOn(client, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1', RequestId: 'request-1' })
|
||||
const describe = vi.spyOn(client, 'DescribePurgeTasks').mockResolvedValue({ TotalCount: 1, Tasks: [task] })
|
||||
const run = (options: { jobId?: string; credentialLauncher?: boolean } = {}) =>
|
||||
purgeDesktopInstaller(config, root, { ...options, client })
|
||||
const directory = async () => join(root, (await readdir(root))[0]!)
|
||||
const result = async (): Promise<unknown> => JSON.parse(await readFile(join(await directory(), 'result.json'), 'utf8'))
|
||||
return { root, client, create, describe, run, directory, result }
|
||||
}
|
||||
|
||||
function sdkError(code: string) {
|
||||
return Object.assign(new Error('private service details fixture-secret-key'), { code })
|
||||
}
|
||||
|
||||
describe('production EdgeOne configuration', () => {
|
||||
it.each([
|
||||
[{ DSH_DESKTOP_AUTO_UPDATE_ENV: 'test' }, /production is required/u],
|
||||
[{ DSH_DESKTOP_AUTO_UPDATE_ENV: 'prod' }, /must be "test" or "production"/u],
|
||||
[{ DOWNLOAD_PROD_EO_ZONE_ID: '' }, /DOWNLOAD_PROD_EO_ZONE_ID/u],
|
||||
[{ DOWNLOAD_PROD_EO_ZONE_ID: 'invalid' }, /must name one EO zone/u],
|
||||
[{ DOWNLOAD_PROD_COS_SECRET_KEY: '' }, /DOWNLOAD_PROD_COS_SECRET_KEY/u],
|
||||
[{ DOWNLOAD_PROD_EO_TIMEOUT_MS: '0' }, /positive timer/u],
|
||||
[{ DOWNLOAD_PROD_EO_POLL_INTERVAL_MS: '31' }, /must not exceed timeout/u],
|
||||
] as const)('rejects invalid settings %j', (patch, message) => {
|
||||
expect(() => resolveDesktopPurgeConfig({ ...environment, ...patch }, 'mac-arm64')).toThrow(message)
|
||||
})
|
||||
|
||||
it('uses the upload plan URL verbatim and derives only cache-only destinations', () => {
|
||||
const url = 'https://download.deepseek.com/changed/installer.dmg'
|
||||
expect(resolveDesktopPurgeConfig(environment, 'mac-arm64', url).publicUrl).toBe(url)
|
||||
expect(resolveDesktopPurgeConfig(environment, 'win-x64').publicUrl).toBe('https://download.deepseek.com/desktop/dsh-latest-windows-x64.exe')
|
||||
expect(resolveDesktopPurgeConfig(environment, 'mac-x64').publicUrl).toBe('https://download.deepseek.com/desktop/dsh-latest-macos-x64.dmg')
|
||||
})
|
||||
})
|
||||
|
||||
describe('EdgeOne task tracking', () => {
|
||||
it('preserves SDK HTTP defaults through delayed visibility and processing, and records only safe evidence', async () => {
|
||||
const f = await fixture()
|
||||
const profile = f.client.profile.httpProfile
|
||||
f.describe.mockResolvedValueOnce({ Tasks: [] }).mockResolvedValueOnce({ Tasks: [{ ...task, Status: 'processing' }] })
|
||||
await f.run()
|
||||
expect(f.create).toHaveBeenCalledExactlyOnceWith({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] })
|
||||
expect(f.describe).toHaveBeenCalledTimes(3)
|
||||
expect(f.describe).toHaveBeenLastCalledWith({ ZoneId: config.zoneId, Limit: 1000, Filters: [{ Name: 'job-id', Values: ['job-1'] }] })
|
||||
expect(f.client.profile.httpProfile).toBe(profile)
|
||||
expect(profile).toMatchObject({ reqMethod: 'POST', protocol: 'https://', reqTimeout: 0.01 })
|
||||
expect(await f.result()).toMatchObject({ schemaVersion: 1, success: true, stage: 'complete', jobId: 'job-1' })
|
||||
const plan = await readFile(join(await f.directory(), 'plan.json'), 'utf8')
|
||||
const hash = createHash('sha256').update(await readFile(new URL('../scripts/desktop-edgeone.ts', import.meta.url))).digest('hex')
|
||||
expect(JSON.parse(plan)).toMatchObject({ schemaVersion: 1, sourceSha256: { 'desktop-edgeone.ts': hash } })
|
||||
expect(plan).not.toContain(config.secretId)
|
||||
expect(plan).not.toContain(config.secretKey)
|
||||
})
|
||||
|
||||
it('resumes an existing multi-URL job without resubmission', async () => {
|
||||
const f = await fixture()
|
||||
f.describe.mockResolvedValue({ Tasks: [{ ...task, Target: 'https://example.com/other' }, task] })
|
||||
await f.run({ jobId: 'job-1' })
|
||||
expect(f.create).not.toHaveBeenCalled()
|
||||
expect(await f.result()).toMatchObject({ success: true })
|
||||
})
|
||||
|
||||
it.each(['RequestLimitExceeded', 'InternalError.Backend', 'ECONNRESET'])('retries %s queries within the deadline', async (code) => {
|
||||
const f = await fixture()
|
||||
f.describe.mockRejectedValueOnce(sdkError(code))
|
||||
await f.run()
|
||||
expect(f.create).toHaveBeenCalledOnce()
|
||||
expect(f.describe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([undefined, 503])('bounds code-less SDK transport failures with HTTP status %s', async (httpCode) => {
|
||||
const f = await fixture()
|
||||
const error = Object.assign(new Error('private network details'), { getRequestId: () => '', getTraceId: () => '',
|
||||
...httpCode === undefined ? {} : { httpCode } })
|
||||
f.describe.mockRejectedValue(error)
|
||||
await expect(f.run()).rejects.toThrow(/timed out.*--job-id job-1/u)
|
||||
expect(f.describe).toHaveBeenCalledTimes(3)
|
||||
expect(await f.result()).toMatchObject({ success: false })
|
||||
})
|
||||
|
||||
it.each(['AuthFailure', 'UnauthorizedOperation', 'InvalidParameter', 'InvalidParameterValue', 'MissingParameter'])('stops immediately on %s', async (code) => {
|
||||
const f = await fixture()
|
||||
f.describe.mockRejectedValue(sdkError(code))
|
||||
await expect(f.run()).rejects.toThrow(new RegExp(`${code}.*--job-id job-1`, 'u'))
|
||||
expect(f.describe).toHaveBeenCalledOnce()
|
||||
expect(await f.result()).toMatchObject({ success: false })
|
||||
const evidence = await readFile(join(await f.directory(), 'result.json'), 'utf8')
|
||||
expect(evidence).not.toContain('private service details')
|
||||
expect(evidence).not.toContain(config.secretKey)
|
||||
})
|
||||
|
||||
it.each(['failed', 'timeout', 'canceled'])('reports terminal %s with a fresh-purge recovery command', async (status) => {
|
||||
const f = await fixture()
|
||||
f.describe.mockResolvedValue({ Tasks: [{ ...task, Status: status }] })
|
||||
await expect(f.run()).rejects.toThrow('Resolve the EO failure, then pnpm run purge:latest mac-arm64.')
|
||||
expect(f.describe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not poll a rejected submission', async () => {
|
||||
const f = await fixture()
|
||||
f.create.mockResolvedValue({ FailedList: [{ Targets: [config.publicUrl], Reason: 'rejected' }] })
|
||||
await expect(f.run()).rejects.toThrow(/EO rejected.*Resolve the EO failure/u)
|
||||
expect(f.describe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([{}, { RequestId: config.secretKey }])('reports unknown submission outcome when no job ID is returned', async (response) => {
|
||||
const f = await fixture()
|
||||
f.create.mockResolvedValue(response)
|
||||
await expect(f.run()).rejects.toThrow(/no task ID.*Inspect EO task history/u)
|
||||
expect(f.create).toHaveBeenCalledOnce()
|
||||
expect(f.describe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never retries a submission with an unknown outcome', async () => {
|
||||
const f = await fixture()
|
||||
f.create.mockRejectedValue(sdkError('ECONNRESET'))
|
||||
await expect(f.run()).rejects.toThrow(/Inspect EO task history/u)
|
||||
expect(f.create).toHaveBeenCalledOnce()
|
||||
expect(f.describe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ Tasks: [] }, /task was not found/u],
|
||||
[{ Tasks: [{ ...task, Status: 'processing' }] }, /task completion is unknown/u],
|
||||
])('distinguishes invisible tasks from unfinished tasks: %j', async (response, message) => {
|
||||
const f = await fixture()
|
||||
f.describe.mockResolvedValue(response)
|
||||
await expect(f.run({ jobId: 'job-1' })).rejects.toThrow(message)
|
||||
expect(f.describe).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ TotalCount: 1001 }, /page limit/u],
|
||||
[{ Tasks: [task, task] }, /multiple entries/u],
|
||||
[{ Tasks: [{ ...task, Target: 'https://example.com/wrong' }] }, /does not contain the expected URL/u],
|
||||
[{ Tasks: [{ ...task, Status: 'unexpected' }] }, /did not succeed/u],
|
||||
])('refuses ambiguous or mismatched task responses: %j', async (response, message) => {
|
||||
const f = await fixture()
|
||||
f.describe.mockResolvedValue(response)
|
||||
await expect(f.run()).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('retains the DPAPI launcher and original identity in recovery instructions', async () => {
|
||||
const f = await fixture()
|
||||
f.describe.mockRejectedValue(sdkError('UnauthorizedOperation'))
|
||||
await expect(f.run({ credentialLauncher: true })).rejects
|
||||
.toThrow(/original -CredentialFile.*-Bucket.*-Upload -Latest -PurgeOnly -JobId job-1/u)
|
||||
})
|
||||
|
||||
it('does not report completion when the final record cannot be saved', async () => {
|
||||
const f = await fixture()
|
||||
f.describe.mockImplementation(async () => {
|
||||
await writeFile(join(await f.directory(), 'result.json'), 'occupied')
|
||||
return { Tasks: [task] }
|
||||
})
|
||||
await expect(f.run()).rejects.toThrow(/cannot save result; inspect EO task job-1/u)
|
||||
const output = vi.spyOn(process.stdout, 'write')
|
||||
expect(output).not.toHaveBeenCalledWith(expect.stringContaining('desktop purge: completed'))
|
||||
})
|
||||
|
||||
it('rejects malformed recovery IDs before allocating a record or contacting EO', async () => {
|
||||
const f = await fixture()
|
||||
await expect(f.run({ jobId: 'unsafe value' })).rejects.toThrow(/invalid --job-id/u)
|
||||
expect(await readdir(f.root)).toEqual([])
|
||||
expect(f.create).not.toHaveBeenCalled()
|
||||
expect(f.describe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lazily initializes the pinned SDK when no client is supplied', async () => {
|
||||
const f = await fixture()
|
||||
vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockResolvedValue({ JobId: 'job-1' })
|
||||
vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockImplementation(async function (this: InstanceType<typeof teo.v20220901.Client>) {
|
||||
expect(this.profile.httpProfile).toMatchObject({ reqMethod: 'POST', protocol: 'https://' })
|
||||
return { Tasks: [task] }
|
||||
})
|
||||
await purgeDesktopInstaller(config, f.root)
|
||||
expect(await f.result()).toMatchObject({ success: true })
|
||||
})
|
||||
|
||||
it('accepts the minimal caller-owned client', async () => {
|
||||
const f = await fixture()
|
||||
const client: DesktopPurgeClient = { profile: f.client.profile, CreatePurgeTask: f.create, DescribePurgeTasks: f.describe }
|
||||
await purgeDesktopInstaller(config, f.root, { client })
|
||||
expect(await f.result()).toMatchObject({ success: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { promisify } from 'node:util'
|
||||
import { z } from 'zod'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
|
||||
const execute = promisify(execFile)
|
||||
const root = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
const roots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
it('forwards the documented pnpm purge command through both package scripts to the real argument parser', async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'desktop-purge-command-'))
|
||||
roots.push(directory)
|
||||
const scripts = join(directory, 'apps/desktop/scripts')
|
||||
await mkdir(scripts, { recursive: true })
|
||||
const manifest = z.object({ name: z.string(), scripts: z.object({ 'purge:latest': z.string() }) })
|
||||
const repository = manifest.parse(JSON.parse(await readFile(join(root, 'package.json'), 'utf8')))
|
||||
const desktop = manifest.parse(JSON.parse(await readFile(join(root, 'apps/desktop/package.json'), 'utf8')))
|
||||
await writeFile(join(directory, 'package.json'), JSON.stringify({ private: true, type: 'module', scripts: { 'purge:latest': repository.scripts['purge:latest'] } }))
|
||||
await writeFile(join(directory, 'pnpm-workspace.yaml'), 'packages:\n - apps/desktop\n')
|
||||
await writeFile(join(directory, 'apps/desktop/package.json'), JSON.stringify({ name: desktop.name, type: 'module', scripts: { 'purge:latest': desktop.scripts['purge:latest'] } }))
|
||||
await mkdir(join(directory, 'node_modules'))
|
||||
for (const dependency of ['.bin', 'pnpm', 'tsx', 'semver']) {
|
||||
await symlink(join(root, 'node_modules', dependency), join(directory, 'node_modules', dependency), 'junction')
|
||||
}
|
||||
await copyFile(join(root, 'apps/desktop/scripts/upload-target.ts'), join(scripts, 'upload-target.ts'))
|
||||
for (const filename of ['desktop-auto-update-environment.mjs', 'desktop-build-paths.mjs']) {
|
||||
await copyFile(join(root, 'apps/desktop/scripts', filename), join(scripts, filename))
|
||||
}
|
||||
// Only command forwarding and parsing are under test; all external work fails closed.
|
||||
const stubs = {
|
||||
'desktop-package-environment.mjs': "export const loadDesktopPackageEnvironment = () => ({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production' })",
|
||||
'desktop-cos.ts': "export const createDesktopCos = () => { throw new Error('unexpected COS') }",
|
||||
'desktop-upload-plan.ts': "export const createDesktopUploadPlan = () => { throw new Error('unexpected installer read') }",
|
||||
'desktop-upload-run.ts': "export const uploadDesktopRelease = () => { throw new Error('unexpected upload') }",
|
||||
'desktop-release-tag.ts': "export const desktopReleaseTag = () => { throw new Error('unexpected tag') }; export const tagDesktopRelease = desktopReleaseTag",
|
||||
'desktop-edgeone.ts': `
|
||||
import assert from 'node:assert/strict'
|
||||
export class DesktopPurgeError extends Error {}
|
||||
export const resolveDesktopPurgeConfig = (_env, target) => ({ target })
|
||||
export const purgeDesktopInstaller = async (config, _root, options) => {
|
||||
assert.equal(config.target, 'mac-arm64')
|
||||
assert.deepEqual(options, { jobId: 'job-1', credentialLauncher: false })
|
||||
console.log('purge-resume: mac-arm64 job-1')
|
||||
}
|
||||
`,
|
||||
}
|
||||
await Promise.all(Object.entries(stubs).map(([name, contents]) => writeFile(join(scripts, name), contents)))
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) =>
|
||||
!/KEY|SECRET|TOKEN|PASSWORD|^NODE_OPTIONS$|^DSH_|^DOWNLOAD_|^PACKAGE_/iu.test(name)))
|
||||
const args = [join(root, 'node_modules/pnpm/bin/pnpm.mjs'), 'run', 'purge:latest', 'mac-arm64', '--job-id', 'job-1']
|
||||
const result = await execute(process.execPath, args, { cwd: directory, env, timeout: 30_000 }).catch((error: unknown) => {
|
||||
expect(error).not.toMatchObject({ killed: true })
|
||||
throw error
|
||||
})
|
||||
expect(result.stdout).toContain('purge-resume: mac-arm64 job-1')
|
||||
})
|
||||
@@ -1,9 +1,15 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DesktopPurgeError, purgeDesktopInstaller } from '../scripts/desktop-edgeone.ts'
|
||||
import { createDesktopUploadPlan, type DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
|
||||
import { loadDesktopPackageEnvironment } from '../scripts/desktop-package-environment.mjs'
|
||||
import { tagDesktopRelease } from '../scripts/desktop-release-tag.ts'
|
||||
import { uploadDesktopRelease } from '../scripts/desktop-upload-run.ts'
|
||||
import { uploadDesktopTarget } from '../scripts/upload-target.ts'
|
||||
import { runDesktopUploadCommand, uploadDesktopTarget } from '../scripts/upload-target.ts'
|
||||
|
||||
vi.mock('../scripts/desktop-edgeone.ts', async importOriginal => ({
|
||||
...await importOriginal<typeof import('../scripts/desktop-edgeone.ts')>(),
|
||||
purgeDesktopInstaller: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../scripts/desktop-package-environment.mjs', () => ({ loadDesktopPackageEnvironment: vi.fn() }))
|
||||
vi.mock('../scripts/desktop-upload-plan.ts', () => ({ createDesktopUploadPlan: vi.fn() }))
|
||||
@@ -27,7 +33,7 @@ describe('desktop upload command', () => {
|
||||
{ environment: 'test', latest: false, tagged: false },
|
||||
] as const)('uploads $environment latest=$latest with tagged=$tagged', async ({ environment, latest, tagged }) => {
|
||||
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
const credentials = { DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' }
|
||||
const credentials = { ...productionEnvironment, DSH_DESKTOP_AUTO_UPDATE_ENV: environment, DOWNLOAD_TEST_COS_SECRET_ID: 'fixture-id', DOWNLOAD_TEST_COS_SECRET_KEY: 'fixture-key' }
|
||||
const plan: DesktopUploadPlan = {
|
||||
environment, target: 'win-x64', version: '1.2.3-alpha.4',
|
||||
publicUrl: 'https://desktop-updates.example.com/desktop/dsh-latest-windows-x64.exe',
|
||||
@@ -44,6 +50,15 @@ describe('desktop upload command', () => {
|
||||
expect(createDesktopUploadPlan).toHaveBeenCalledWith('win-x64', { environment: credentials, latest })
|
||||
expect(uploadDesktopRelease).toHaveBeenCalledOnce()
|
||||
expect(vi.mocked(uploadDesktopRelease).mock.calls[0]![0]).toBe(plan)
|
||||
if (environment === 'production' && latest) {
|
||||
expect(purgeDesktopInstaller).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ publicUrl: plan.publicUrl }), expect.any(String), { credentialLauncher: false },
|
||||
)
|
||||
expect(vi.mocked(purgeDesktopInstaller).mock.invocationCallOrder[0])
|
||||
.toBeGreaterThan(vi.mocked(uploadDesktopRelease).mock.invocationCallOrder[0]!)
|
||||
} else {
|
||||
expect(purgeDesktopInstaller).not.toHaveBeenCalled()
|
||||
}
|
||||
if (tagged) {
|
||||
expect(tagDesktopRelease).toHaveBeenCalledWith(expect.objectContaining({ version: plan.version, commit: plan.commit }))
|
||||
expect(vi.mocked(tagDesktopRelease).mock.invocationCallOrder[0])
|
||||
@@ -54,3 +69,74 @@ describe('desktop upload command', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const productionEnvironment = {
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket',
|
||||
DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-key', DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture',
|
||||
}
|
||||
|
||||
describe('cache-only recovery command', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(loadDesktopPackageEnvironment).mockReturnValue(productionEnvironment)
|
||||
})
|
||||
|
||||
it.each([undefined, 'job-1'])('refreshes without reading or uploading an installer, job=%s', async (jobId) => {
|
||||
await uploadDesktopTarget(['--latest', '--purge-only', 'mac-arm64', ...jobId ? ['--job-id', jobId] : []])
|
||||
expect(createDesktopUploadPlan).not.toHaveBeenCalled()
|
||||
expect(uploadDesktopRelease).not.toHaveBeenCalled()
|
||||
expect(tagDesktopRelease).not.toHaveBeenCalled()
|
||||
expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ target: 'mac-arm64' }), expect.any(String), { ...jobId !== undefined ? { jobId } : {}, credentialLauncher: false })
|
||||
})
|
||||
|
||||
it.each([[], ['win-x64', '--purge-only'], ['win-x64', '--job-id', 'job-1'], ['win-x64', '--latest', '--', '--purge-only']])('rejects invalid arguments %j before uploading', async (...args) => {
|
||||
await expect(uploadDesktopTarget(args)).rejects.toThrow()
|
||||
expect(uploadDesktopRelease).not.toHaveBeenCalled()
|
||||
expect(purgeDesktopInstaller).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a completed upload when refresh fails, without tagging or repeating the upload', async () => {
|
||||
vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
vi.mocked(createDesktopUploadPlan).mockResolvedValue({
|
||||
environment: 'production', target: 'mac-arm64', version: '1.2.3', publicUrl: 'https://example.com/new-prefix/installer.dmg',
|
||||
bucket: 'fixture-bucket', secretIdEnvName: 'DOWNLOAD_PROD_COS_SECRET_ID', secretKeyEnvName: 'DOWNLOAD_PROD_COS_SECRET_KEY', artifacts: [],
|
||||
})
|
||||
vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: task incomplete; resume --job-id job-1'))
|
||||
await expect(uploadDesktopTarget(['mac-arm64', '--latest'])).rejects.toThrow(/installer uploaded; cache refresh incomplete.*job-1/u)
|
||||
expect(uploadDesktopRelease).toHaveBeenCalledOnce()
|
||||
expect(purgeDesktopInstaller).toHaveBeenCalledOnce()
|
||||
expect(tagDesktopRelease).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([false, true])('reports safe purge diagnostics to the launcher-visible stream: launcher=%s', async (launcher) => {
|
||||
const previousExitCode = process.exitCode
|
||||
const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
vi.stubEnv('DSH_DESKTOP_AUTO_UPDATE_ENV', 'production')
|
||||
vi.stubEnv('DOWNLOAD_PROD_COS_BUCKET', 'fixture-bucket')
|
||||
vi.stubEnv('DOWNLOAD_PROD_COS_SECRET_ID', 'injected-id')
|
||||
vi.stubEnv('DOWNLOAD_PROD_COS_SECRET_KEY', 'injected-key')
|
||||
try {
|
||||
vi.mocked(purgeDesktopInstaller).mockRejectedValue(new DesktopPurgeError('desktop purge: safe recovery message'))
|
||||
await runDesktopUploadCommand(['mac-arm64', '--latest', '--purge-only', ...launcher ? ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'] : []])
|
||||
expect(launcher ? stdout : stderr).toHaveBeenCalledExactlyOnceWith('desktop purge: safe recovery message\n')
|
||||
expect(launcher ? stderr : stdout).not.toHaveBeenCalled()
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(purgeDesktopInstaller).toHaveBeenCalledWith(expect.objectContaining({ secretId: launcher ? 'injected-id' : 'fixture-id' }), expect.any(String), expect.objectContaining({ credentialLauncher: launcher }))
|
||||
} finally {
|
||||
process.exitCode = previousExitCode
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows usage without referring to a nonexistent record for invalid arguments', async () => {
|
||||
const previousExitCode = process.exitCode
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
await runDesktopUploadCommand([])
|
||||
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('Usage:'))
|
||||
expect(stderr).not.toHaveBeenCalledWith(expect.stringContaining('record directory'))
|
||||
} finally {
|
||||
process.exitCode = previousExitCode
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -12,13 +12,13 @@ const launcher = fileURLToPath(new URL('../scripts/upload-with-credentials.ps1',
|
||||
const roots: string[] = []
|
||||
const quote = (value: string): string => `'${value.replaceAll("'", "''")}'`
|
||||
|
||||
async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure', deployment = 'production') {
|
||||
async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload' | 'upload-latest' | 'upload-failure' | 'upload-purge-resume' | 'invalid-purge', deployment = 'production') {
|
||||
const root = await mkdtemp(join(tmpdir(), 'desktop-credentials-'))
|
||||
roots.push(root)
|
||||
const credentialPath = join(root, 'credential.clixml')
|
||||
let entry = launcher
|
||||
let uploadArguments = ''
|
||||
if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure') {
|
||||
if (mode === 'upload' || mode === 'upload-latest' || mode === 'upload-failure' || mode === 'upload-purge-resume' || mode === 'invalid-purge') {
|
||||
const scripts = join(root, 'apps/desktop/scripts')
|
||||
await mkdir(scripts, { recursive: true })
|
||||
entry = join(scripts, 'upload-with-credentials.ps1')
|
||||
@@ -34,12 +34,12 @@ async function check(mode: 'valid' | 'plaintext' | 'blank' | 'missing' | 'upload
|
||||
assert.equal(process.env.DSH_DESKTOP_WINDOWS_TOKEN_PIN, undefined)
|
||||
assert.equal(process.env.NODE_OPTIONS, undefined)
|
||||
assert.equal(process.argv[2], 'win-x64')
|
||||
assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-latest' ? ", '--latest'" : ''}])
|
||||
assert.deepEqual(process.argv.slice(3), ['--credential-launcher', '--environment', 'production', '--bucket', 'fixture-bucket'${mode === 'upload-purge-resume' ? ", '--latest', '--purge-only', '--job-id', 'job-1'" : mode === 'upload-latest' ? ", '--latest'" : ''}])
|
||||
console.log('fixture-id fixture-secret')
|
||||
console.error('private service details fixture-secret')
|
||||
process.exit(${mode === 'upload-failure' ? 17 : 0})
|
||||
`)
|
||||
uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-latest' ? ' -Latest' : ''}`
|
||||
uploadArguments = ` -Upload -Target win-x64 -Bucket fixture-bucket${mode === 'upload-purge-resume' ? ' -Latest -PurgeOnly -JobId job-1' : mode === 'invalid-purge' ? ' -PurgeOnly' : mode === 'upload-latest' ? ' -Latest' : ''}`
|
||||
}
|
||||
const setup = mode === 'missing' ? '' : `
|
||||
[pscustomobject]@{
|
||||
@@ -134,7 +134,14 @@ describe.skipIf(process.platform !== 'win32')('Windows upload credentials', () =
|
||||
expect(result.output).not.toContain('child environment verified')
|
||||
})
|
||||
|
||||
it.each(['upload', 'upload-latest', 'upload-failure'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => {
|
||||
it('reports invalid purge arguments before decrypting credentials', async () => {
|
||||
const result = await check('invalid-purge')
|
||||
expect(result.code).toBe(1)
|
||||
expect(result.output).toContain('invalid arguments; PurgeOnly requires Latest and production')
|
||||
expect(result.output).not.toContain('stage=decrypt-file')
|
||||
})
|
||||
|
||||
it.each(['upload', 'upload-latest', 'upload-failure', 'upload-purge-resume'] as const)('isolates the %s child and sanitizes both output streams', async (mode) => {
|
||||
const result = await check(mode)
|
||||
expect(result.code, result.output).toBe(mode === 'upload-failure' ? 1 : 0)
|
||||
expect(result.output).toContain('[REDACTED] [REDACTED]')
|
||||
|
||||
Generated
+3
-10
@@ -747,12 +747,12 @@ importers:
|
||||
'@deepseek-ai/dsh-home-paths':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/util/home-paths
|
||||
'@deepseek-ai/dsh-plugin-manager':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/boot/plugin-manager
|
||||
'@deepseek-ai/dsh-official-bundles':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/util/official-bundles
|
||||
'@deepseek-ai/dsh-plugin-manager':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/boot/plugin-manager
|
||||
'@deepseek-ai/dsh-util-file-drag':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/util/file-drag
|
||||
@@ -22404,13 +22404,6 @@ packages:
|
||||
engines: {node: '>= 0.12'}
|
||||
|
||||
[email protected]:
|
||||
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
|
||||
|
||||
resolution: {integrity: sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
|
||||
Reference in new issue
Block a user