diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e372387..06800b7 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -2,20 +2,41 @@ ARG NODE_VERSION FROM node:${NODE_VERSION}-bookworm-slim SHELL ["/bin/bash", "-o", "pipefail", "-c"] -ARG PACKAGE_MANAGER_PIN ENV COREPACK_HOME="/corepack" ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME/bin:$PNPM_HOME:$PATH" +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates \ + && apt-get install -y --no-install-recommends \ + bubblewrap \ + ca-certificates \ + curl \ + file \ + git \ + iproute2 \ + jq \ + openssh-client \ + procps \ + python-is-python3 \ + python3 \ + ripgrep \ + unzip \ && rm -rf /var/lib/apt/lists/* \ + && git config --system init.defaultBranch main \ && install -d -m 0755 "$COREPACK_HOME" "$PNPM_HOME" "$PNPM_HOME/bin" \ && corepack enable --install-directory "$PNPM_HOME" \ - && corepack prepare "${PACKAGE_MANAGER_PIN}" --activate \ && chmod -R a+rX "$COREPACK_HOME" "$PNPM_HOME" ARG PLAYWRIGHT_CLI_PACKAGE -RUN npx --yes --package "${PLAYWRIGHT_CLI_PACKAGE}" playwright install-deps chromium +RUN npx --yes --package "${PLAYWRIGHT_CLI_PACKAGE}" playwright install --with-deps chromium + +RUN install -m 0755 -d /etc/apt/keyrings \ + && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends gh \ + && rm -rf /var/lib/apt/lists/* RUN apt-get update \ && apt-get install -y --no-install-recommends shellcheck \ diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..05b2ad3 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { + "version": "1.10.1", + "resolved": "ghcr.io/devcontainers/features/docker-outside-of-docker@sha256:698f5900fac471e046c6e9d4333fec88c0e8d952c713494fa0712dce80baeca0", + "integrity": "sha256:698f5900fac471e046c6e9d4333fec88c0e8d952c713494fa0712dce80baeca0" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 076c47f..5ac4d03 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,16 +1,33 @@ { "name": "digital", + "init": true, "build": { "dockerfile": "Dockerfile", "args": { - "NODE_VERSION": "24", - "PACKAGE_MANAGER_PIN": "pnpm@11.17.0", - "PLAYWRIGHT_CLI_PACKAGE": "@playwright/test@^1.61.1" + "NODE_VERSION": "24.21.0", + "PLAYWRIGHT_CLI_PACKAGE": "@playwright/test@1.62.0" } }, + "features": { + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} + }, + "containerEnv": { + "PNPM_CONFIG_STORE_DIR": "/pnpm/store", + "DIGITAL_DEPLOYMENT_BUILD_NETWORK": "host" + }, + "mounts": [ + { + "source": "${devcontainerId}-pnpm-store", + "target": "/pnpm/store", + "type": "volume" + } + ], "customizations": { "vscode": { "extensions": ["oxc.oxc-vscode"] } - } + }, + "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind", + "workspaceFolder": "${localWorkspaceFolder}", + "runArgs": ["--network=host"] } diff --git a/.dockerignore b/.dockerignore index 0f67c16..4331fb4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,12 @@ .git .scratch +.digital +.env +.env.local +.env.*.local +*.env +credentials/ +secrets/ **/node_modules **/dist **/*.tsbuildinfo diff --git a/.github/README.md b/.github/README.md index 5df47b1..9291ae1 100644 --- a/.github/README.md +++ b/.github/README.md @@ -1,22 +1,21 @@ # CI 运行与证据 -默认 `Check` 工作流在三个独立的 GitHub 托管工作区并发运行:根检查仍为 `pnpm run check`;CLI 仍调用本地 `test:e2e` 的受控旅程;Web 仍调用真实 HTTP 浏览器入口。默认范围不需要个人来源凭据、私有语料、开发监听或 Docker。 +每次 PR 和推送到 main,`Check` 在独占检出中调用同一个根治理实现,先运行轻检查,再运行全部默认重检查。默认范围包括受控默认资料、正式服务 HTTP/SQLite、安装客户端和 standalone/Compose 部署;不需要个人来源凭据或私有素材。Docker daemon、挂载权限及安装客户端验收所需的浏览器是受支持运行平台的前置条件,缺失时检查失败。 -本地可按相同阶段复现(先冻结安装,CLI/Web 先按正式 Turbo 图构建依赖,Web 安装 Chromium): +本地完整验收使用 `pnpm check:full`。CI 收集与上传交接可在独占的新检出中按同一阶段复现: ```sh pnpm install --frozen-lockfile -pnpm exec turbo run build --filter=@digital/web --filter=@digital/cli --filter=@digital/db-migrations -node --conditions=digital-source scripts/ci-environment.ts run cli -node --conditions=digital-source scripts/ci-environment.ts collect cli +TURBO_CONCURRENCY=2 DIGITAL_AUTOMATION_WORKSPACE="$PWD" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts run +DIGITAL_AUTOMATION_WORKSPACE="$PWD" pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts collect ``` -将 `cli` 换为 `web` 可运行浏览器链;`root` 需要独占的新工作区,运行前会登记环境目录为空。证据默认保存到 `.scratch/ci/`,可用 `DIGITAL_CI_EVIDENCE` 选择本次独立目录。每次重跑应使用新目录,避免覆盖前次证据。 +`run` 直接调用公共门禁的治理实现;包内原生准备任务负责客户端和正式镜像,不在工作流复制构建逻辑。Turbo 并发限制为 2,镜像构建继续使用 Docker/BuildKit 的层缓存。每次重跑使用新的独占检出和证据目录。 -CI 根检查按托管运行器的资源预算将 Turbo 任务并发限制为 2,CLI/Web 保持默认 10;本地相同条件用 `TURBO_CONCURRENCY=2 node --conditions=digital-source scripts/ci-environment.ts run root` 复现,内部仍执行 `pnpm run check`,测试和作业期限保持不变。 +证据默认保存到 `.scratch/ci/full`,可用 `DIGITAL_CI_EVIDENCE` 指定目录。相同变量传到服务、环境与部署验收入口:它们保留实际产品数据、归属和场景结果,成功后把环境交给 CI 收尾;本地未启用 CI 交接时,成功场景按正式生命周期回收,失败现场保留。 -CLI 的 `--retain-on-success` 保持成功结局,停止并确认退出后暂不删除产品数据;不提供该选项时仍按原本行为回收。Web 使用已有 `--keep-on-success true`,成功后服务仍运行,后续 `collect` 会经公共 stop/inspect 停止并独立确认退出,然后收集整个真实产品数据根和停止后的完整日志。适用的浏览器诊断随场景结果保留。收集失败不会阻止其他明确自有环境的停止。 +`collect` 独立停止并检查本次自有服务和容器,收集整个真实产品数据根及停止后的日志。部署通过公共容器管理接口保存数据,客户端诊断和验收结果一并进入本次证据目录。执行失败或取消后仍运行停止、收集和上传;一个环境收集失败不阻止其他自有环境停止。 -CI 在执行失败、超时或取消后仍进入独立停止、收集与上传步骤。上传要求有真实文件;只有实际 upload-artifact 成功,并通过 GitHub API 核实产物 ID、名称、运行归属和有效字节后,才调用公共 delete。保存或上传失败不执行删除;未知退出状态保留载体,执行、保存和清理结果分别报告。删除后的结果另写入实际作业日志,避免只留在已经上传之后的本地文件里。本地 `collect` 成功仅证明收集完成,不能代替真实上传。 +只有实际上传成功,并通过 GitHub API 核实产物 ID、名称、运行归属及有效字节后,`delete` 才调用正式删除。保存或上传失败保留原载体,未知退出状态不删除。执行、保存、上传和清理结果分别报告,删除后的结果也写入实际作业日志。本地 `collect` 成功不能替代真实上传核验。 -Docker 部署只在手工触发时显式选择 `deployment`。该作业先使用现有 Dockerfile 构建 standalone/runtime 两目标,向原 `check:deployment` 传入两份明确镜像引用,再运行既有部署断言。未选择或未执行该作业不表示部署通过。完整真实资料与来源验收继续使用各自显式入口和既有条件,不纳入默认 CI。 +真实资料、真实来源和 provided 服务继续使用所属包的显式条件验收入口;普通门禁报告这些任务未执行。完整受控验收通过不代表真实来源验收通过。 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 19400d7..e91eaca 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,10 @@ version: 2 updates: + - package-ecosystem: devcontainers + directory: / + schedule: + interval: weekly - package-ecosystem: npm directory: / schedule: @@ -31,7 +35,3 @@ updates: - dependency-name: mcr.microsoft.com/devcontainers/typescript-node update-types: - version-update:semver-major - - package-ecosystem: docker - directory: /apps/web - schedule: - interval: weekly diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 4464693..9391ff7 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -5,12 +5,6 @@ on: push: branches: - main - workflow_dispatch: - inputs: - deployment: - description: 显式构建镜像并执行已有 Docker 部署入口 - type: boolean - default: false permissions: contents: read @@ -22,15 +16,12 @@ concurrency: jobs: check: - name: ${{ matrix.capability }} + name: 完整轻重验收 runs-on: ubuntu-latest - timeout-minutes: 45 - strategy: - fail-fast: false - matrix: - capability: [root, cli, web] + timeout-minutes: 150 env: - DIGITAL_CI_EVIDENCE: ${{ github.workspace }}/.scratch/ci/${{ matrix.capability }} + DIGITAL_AUTOMATION_WORKSPACE: ${{ github.workspace }} + DIGITAL_CI_EVIDENCE: ${{ github.workspace }}/.scratch/ci/full steps: - name: 检出独立工作区 uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 @@ -48,90 +39,58 @@ jobs: cache: true - name: 准备本作业临时目录 run: | - ci_tmp="$RUNNER_TEMP/d18-${{ matrix.capability }}" - mkdir -m 700 -p "$ci_tmp" + # 私有控制通道使用 Unix socket,作业临时根保持短路径。 + ci_tmp="$(mktemp -d /tmp/digital-ci.XXXXXXXX)" printf 'TMPDIR=%s\n' "$ci_tmp" >> "$GITHUB_ENV" - name: 安装锁定依赖 run: pnpm install --frozen-lockfile - - name: 安装 Chromium - run: pnpm --filter @digital/web exec playwright install --with-deps chromium - - name: 构建场景正式依赖 - if: matrix.capability != 'root' - run: pnpm exec turbo run build --filter=@digital/web --filter=@digital/cli --filter=@digital/db-migrations - name: 运行相同本地入口 id: execution - timeout-minutes: 30 + timeout-minutes: 120 env: - TURBO_CONCURRENCY: ${{ matrix.capability == 'root' && '2' || '10' }} - run: node --conditions=digital-source scripts/ci-environment.ts run ${{ matrix.capability }} + TURBO_CONCURRENCY: "2" + run: pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts run - name: 停止自有环境并收集真实数据及完整日志 id: collection if: always() - run: node --conditions=digital-source scripts/ci-environment.ts collect ${{ matrix.capability }} + run: pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts collect - name: 上传场景证据 id: upload if: always() uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4 with: - name: ci-${{ matrix.capability }}-${{ github.run_id }}-${{ github.run_attempt }} + name: ci-full-${{ github.run_id }}-${{ github.run_attempt }} path: ${{ env.DIGITAL_CI_EVIDENCE }} + include-hidden-files: true + if-no-files-found: error + retention-days: 7 + - name: 上传可独立下载的产品数据与完整日志 + id: product_upload + if: always() + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4 + with: + name: ci-product-data-and-logs-${{ github.run_id }}-${{ github.run_attempt }} + # 完整包保留容器文件系统快照;此包只排除这些大快照。 + path: | + ${{ env.DIGITAL_CI_EVIDENCE }} + !${{ env.DIGITAL_CI_EVIDENCE }}/**/*-stopped-container.tar + !${{ env.DIGITAL_CI_EVIDENCE }}/**/*-stopped.tar + include-hidden-files: true if-no-files-found: error retention-days: 7 - name: 核实上传产物后显式删除自有环境 id: deletion - if: always() && steps.collection.outcome == 'success' && steps.upload.outcome == 'success' + if: always() && steps.collection.outcome == 'success' && steps.upload.outcome == 'success' && steps.product_upload.outcome == 'success' env: GITHUB_TOKEN: ${{ github.token }} DIGITAL_CI_ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }} - DIGITAL_CI_ARTIFACT_NAME: ci-${{ matrix.capability }}-${{ github.run_id }}-${{ github.run_attempt }} - run: node --conditions=digital-source scripts/ci-environment.ts delete ${{ matrix.capability }} + DIGITAL_CI_ARTIFACT_NAME: ci-full-${{ github.run_id }}-${{ github.run_attempt }} + run: pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts delete - name: 分别报告执行、保存、上传与清理结果 if: always() env: DIGITAL_CI_EXECUTION_OUTCOME: ${{ steps.execution.outcome }} DIGITAL_CI_COLLECTION_OUTCOME: ${{ steps.collection.outcome }} - DIGITAL_CI_UPLOAD_OUTCOME: ${{ steps.upload.outcome }} + DIGITAL_CI_UPLOAD_OUTCOME: ${{ steps.upload.outcome == 'success' && steps.product_upload.outcome == 'success' && 'success' || 'failure' }} DIGITAL_CI_DELETION_OUTCOME: ${{ steps.deletion.outcome }} - run: node --conditions=digital-source scripts/ci-environment.ts result ${{ matrix.capability }} - - deployment: - name: 显式 Docker 部署 - if: github.event_name == 'workflow_dispatch' && inputs.deployment - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - with: - persist-credentials: false - - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 - with: - node-version-file: package.json - package-manager-cache: false - - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - with: - cache: true - - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - name: 安装依赖与浏览器 - run: | - pnpm install --frozen-lockfile - pnpm --filter @digital/web exec playwright install --with-deps chromium - - name: 构建正式部署镜像与 checker 依赖 - run: | - pnpm exec turbo run build --filter=@digital/web --filter=@digital/cli --filter=@digital/db-migrations - docker build -f apps/web/Dockerfile --target standalone -t completion-ci-standalone:${{ github.run_id }} . - docker build -f apps/web/Dockerfile --target runtime -t completion-ci-runtime:${{ github.run_id }} . - - name: 调用原部署入口 - env: - DIGITAL_DEPLOYMENT_STANDALONE_IMAGE: completion-ci-standalone:${{ github.run_id }} - DIGITAL_DEPLOYMENT_RUNTIME_IMAGE: completion-ci-runtime:${{ github.run_id }} - DIGITAL_DEPLOYMENT_ARTIFACTS: ${{ github.workspace }}/.scratch/ci/deployment/standalone - DIGITAL_COMPOSE_CHECK_ARTIFACTS: ${{ github.workspace }}/.scratch/ci/deployment/compose - run: pnpm run check:deployment - - name: 上传部署诊断 - if: always() - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4 - with: - name: deployment-${{ github.run_id }}-${{ github.run_attempt }} - path: .scratch/ci/deployment - if-no-files-found: error - retention-days: 7 + run: pnpm --filter @digital/workspace-automation exec node --conditions=digital-source scripts/ci-environment.ts result diff --git a/apps/cli/package.json b/apps/cli/package.json deleted file mode 100644 index 4f87738..0000000 --- a/apps/cli/package.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "name": "@digital/cli", - "version": "0.0.0", - "bin": { - "cli": "./dist/cli.js" - }, - "files": [ - "dist" - ], - "type": "module", - "imports": { - "#main": { - "digital-source": "./src/main.ts", - "types": "./src/main.ts", - "default": "./dist/main.js" - } - }, - "exports": { - ".": { - "digital-source": "./src/main.ts", - "types": "./dist/main.d.ts", - "default": "./dist/main.js" - } - }, - "publishConfig": { - "access": "public" - }, - "scripts": { - "build": "tsc -p tsconfig.build.json --pretty false", - "format:check": "oxfmt --list-different --config ../../oxfmt.config.ts .", - "format:write": "oxfmt --write --config ../../oxfmt.config.ts .", - "lint": "oxlint --quiet --format=unix --config ../../oxlint.config.ts --ignore-pattern node_modules .", - "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", - "postbuild": "node -e \"if (process.platform !== 'win32') require('node:fs').chmodSync('dist/cli.js', 0o755)\"", - "prepack": "pnpm exec turbo run build --filter=.", - "pretest": "pnpm --filter @digital/sqlite build", - "pretest:e2e": "pnpm --filter @digital/sqlite build && pnpm --filter @digital/cli build", - "test": "vitest run test/unit test/integration --reporter=verbose", - "test:e2e": "node --conditions=digital-source test/e2e/run-journeys.ts --default source distribution", - "test:corpus-real": "node --conditions=digital-source test/e2e/run-journeys.ts --real source distribution", - "test:e2e:bangumi-live": "DIGITAL_E2E_JOURNEY=bangumi-episodic-live node --conditions=digital-source test/e2e/run-journeys.ts source distribution", - "test:e2e:fanbox-live": "DIGITAL_E2E_JOURNEY=fanbox-five-live node --conditions=digital-source test/e2e/run-journeys.ts source distribution", - "test:e2e:pdf-live": "DIGITAL_E2E_JOURNEY=pdf-publication-live node --conditions=digital-source test/e2e/run-journeys.ts source distribution", - "test:e2e:tmdb-omdb-live": "DIGITAL_E2E_JOURNEY=tmdb-omdb-live node --conditions=digital-source test/e2e/run-journeys.ts source distribution", - "test:e2e:deep-integrity": "DIGITAL_DEEP_CORPUS_INTEGRITY=1 node --conditions=digital-source test/e2e/run-journeys.ts source distribution", - "pretest:e2e:environment": "pnpm --filter @digital/sqlite build && pnpm --filter @digital/cli build", - "test:e2e:environment": "node --conditions=digital-source test/e2e/environment-contracts.ts", - "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" - }, - "dependencies": { - "@digital/cli-delivery": "workspace:*", - "@digital/composition": "workspace:*", - "@digital/test-support": "workspace:*" - }, - "devDependencies": { - "@digital/application": "workspace:*", - "@digital/db-migrations": "workspace:*", - "@digital/environment": "workspace:*", - "@digital/fanbox": "workspace:*", - "@digital/typescript-config": "workspace:*", - "@types/node": "catalog:", - "oxfmt": "catalog:", - "oxlint": "catalog:", - "oxlint-tsgolint": "catalog:", - "typescript-7": "catalog:", - "vitest": "catalog:" - }, - "engines": { - "node": "24" - } -} diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts deleted file mode 100644 index b932126..0000000 --- a/apps/cli/src/cli.ts +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env node -import { createRequire } from "node:module"; - -import { claimProcessTemporaryRoot } from "@digital/composition/process-temporary-root"; - -const processTemporaryRoot = await claimProcessTemporaryRoot(process.env, { - prefix: "digital-cli-process-", -}); - -let failed = false; -let failure: unknown; -try { - const { runCli } = await import("#main"); - const require = createRequire(import.meta.url); - const packageManifest = require("../package.json") as { version: string }; - - const runtime = { - argv: process.argv, - streams: { - stdin: process.stdin, - stdout: process.stdout, - stderr: process.stderr, - }, - cwd: process.cwd(), - env: process.env, - tty: { - stdin: process.stdin.isTTY, - stdout: process.stdout.isTTY, - stderr: process.stderr.isTTY, - }, - version: packageManifest.version, - waitForShutdown: async () => - await new Promise((resolve) => { - const stop = () => { - process.off("SIGINT", stop); - process.off("SIGTERM", stop); - resolve(); - }; - process.once("SIGINT", stop); - process.once("SIGTERM", stop); - }), - }; - - process.exitCode = await processTemporaryRoot.closeAfterSuccessful( - runCli(runtime), - ); -} catch (error) { - failed = true; - failure = error; -} -if (failed) throw failure; diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts deleted file mode 100644 index 2dd0fa0..0000000 --- a/apps/cli/src/main.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { homedir } from "node:os"; -import path from "node:path"; - -import { - CliResources, - createCliCommand, - greet, - runCli as runCliDelivery, - type CliProductApplication, - type CliRuntime, - type Greeting, -} from "@digital/cli-delivery"; -import { runProduct } from "@digital/composition"; -import { resolveSourceIoFromEnvironment } from "@digital/test-support/source-io"; - -export { CliResources, createCliCommand, greet }; -export type { CliProductApplication, CliRuntime, Greeting }; - -/** 可执行包只解析部署配置,然后把 CLI 交付绑定到产品应用。 */ -export async function runCli( - runtime: CliRuntime, - application?: CliProductApplication, -): Promise { - const productDataDirectory = configuredProductDataDirectory(runtime); - const configuredRuntime: CliRuntime = { - ...runtime, - env: { - ...runtime.env, - DIGITAL_PRODUCT_DATA: productDataDirectory, - }, - }; - if (application !== undefined) - return await runCliDelivery(configuredRuntime, application); - // 来源装配在任何产品工作之前解析:受控清单无效时这条正式 CLI 入口直接失败, - // 不会带着真实默认值继续。 - const sourceIo = await resolveSourceIoFromEnvironment(configuredRuntime.env); - const product = runProduct( - { - productDataDirectory, - environment: runtime.env, - sourceIo, - }, - { - start: (product) => runCliDelivery(configuredRuntime, product), - }, - ); - try { - return await product.result; - } finally { - await product.close(); - } -} - -function configuredProductDataDirectory(runtime: CliRuntime): string { - return ( - runtime.env.DIGITAL_PRODUCT_DATA?.trim() || - path.join( - runtime.env.XDG_DATA_HOME?.trim() || - path.join(homedir(), ".local", "share"), - "digital", - ) - ); -} diff --git a/apps/cli/test/e2e/environment-contracts.ts b/apps/cli/test/e2e/environment-contracts.ts deleted file mode 100644 index 5a951a1..0000000 --- a/apps/cli/test/e2e/environment-contracts.ts +++ /dev/null @@ -1,2037 +0,0 @@ -/** - * 本票的环境归属与负向证据套件:全部通过正式 CLI 生产入口与环境包的公开接口运行, - * 不用替身代替产品,也不改动产品语义。逐项检查彼此独立,任一失败都不会掩盖其它检查。 - * - * 覆盖:自有环境的成功回收、失败保留与可显式重启、必需产物用途缺位时的拒绝、 - * 提供中的环境在写入者被拒或被取消后仍由所有者持有、父终端遗留配置不会进入生产入口、 - * 信号中断只停止并保留自有环境、正式入口收到真实 SIGINT/SIGTERM 后的晚到收尾、 - * 运行器主失败保留的世代仍可公开重启、服务在运行时删除的实际分类、并行世代互不重叠。 - */ -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import type { ChildProcess } from "node:child_process"; -import { cp, mkdtemp, readdir, readFile, rm, stat } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -import { captureRawFilesystemSnapshot } from "@digital/test-support"; - -import { - appendStartLedger, - confirmOwnerExit, - deleteScenarioEnvironment, - fingerprintDifference, - fingerprintEnvironment, - openProvidedEnvironment, - prepareScenarioEnvironment, - processStartTimeTicks, - recycleOwnedEnvironment, - reserveFreePort, - scenarioEnvironmentName, - scenarioRunId, - startLedgerPath, - startScenarioEnvironment, - stopScenarioEnvironment, -} from "./environment-runtime.ts"; -import type { - CliJourney, - CliJourneyCommand, - CliJourneyMode, - CliJourneyResult, -} from "./journey.ts"; -import { - ownedEnvironmentLifecycle, - providedEnvironmentLifecycle, - runJourney, -} from "./run-journeys.ts"; - -const e2eRoot = path.dirname(fileURLToPath(import.meta.url)); -const packageRoot = path.resolve(e2eRoot, "..", ".."); -const workspaceRoot = path.resolve(packageRoot, "..", ".."); -const cliEntry = path.join(packageRoot, "dist", "cli.js"); -const fixtureRoot = path.resolve( - workspaceRoot, - "fixtures", - "controlled-source-io", -); -/** 与验收运行器共用的证据目录:父终端污染检查据此核对本次新增的那一份。 */ -const evidenceRoot = path.join(workspaceRoot, "run", "02-evidence"); - -/** 本套件自己拥有的环境一律带 completion-02- 前缀,端口只取 53001—53999 内的空闲结果。 */ -function nameFor(purpose: string): { runId: string; environment: string } { - const runId = scenarioRunId(); - return { - runId, - environment: scenarioEnvironmentName({ purpose, runId }), - }; -} - -type CommandOutput = { - readonly exitCode: number; - readonly stdout: string; - readonly stderr: string; -}; - -/** - * 取消只以 SIGINT 转达给子进程:正式入口按自己的信号语义收尾,调用方不硬杀产品。 - * 子进程的退出结论原样返回,由检查判断它是否真的停下来了。 - */ -function forwardCancellation( - child: ChildProcess, - signal: AbortSignal | undefined, -): void { - if (signal === undefined) return; - const abort = (): void => { - child.kill("SIGINT"); - }; - if (signal.aborted) abort(); - else signal.addEventListener("abort", abort, { once: true }); - child.once("close", () => { - signal.removeEventListener("abort", abort); - }); -} - -async function runCli( - args: readonly string[], - environment: Readonly>, - cwd: string, - signal?: AbortSignal, -): Promise { - return await new Promise((resolve, reject) => { - const child = spawn(process.execPath, [cliEntry, ...args], { - cwd, - env: { ...environment }, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - forwardCancellation(child, signal); - child.once("error", reject); - child.once("close", (code) => { - resolve({ exitCode: code ?? 1, stdout, stderr }); - }); - }); -} - -async function runNode( - args: readonly string[], - environment: Readonly>, - signal?: AbortSignal, -): Promise { - return await new Promise((resolve, reject) => { - const child = spawn(process.execPath, args, { - cwd: workspaceRoot, - env: { ...environment }, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - forwardCancellation(child, signal); - child.once("error", reject); - child.once("close", (code) => { - resolve({ exitCode: code ?? 1, stdout, stderr }); - }); - }); -} - -/** 自有世代的启动登记:先记意图(工作区/环境/端口),启动后再记结果(PID/启动时刻)。 */ -async function ledgerIntent(input: { - readonly runId: string; - readonly environment: string; - readonly port: number; -}): Promise { - await appendStartLedger(startLedgerPath, { - phase: "intent", - workspacePath: workspaceRoot, - environment: input.environment, - port: input.port, - at: new Date().toISOString(), - runId: input.runId, - }); -} - -async function ledgerStartResult(input: { - readonly runId: string; - readonly environment: string; - readonly port: number; - readonly status: string; - readonly pid: number | null; -}): Promise { - await appendStartLedger(startLedgerPath, { - phase: "result", - workspacePath: workspaceRoot, - environment: input.environment, - port: input.port, - at: new Date().toISOString(), - runId: input.runId, - status: input.status, - pid: input.pid, - startTimeTicks: - input.pid === null ? null : await processStartTimeTicks(input.pid), - }); -} - -/** - * 检查结束时对自己拥有的世代收尾:未收到取消时按公开操作停止后删除;一旦取消到达, - * 只停止并保留现场,好让外部验收脚本还能核对被保留的数据与日志。 - */ -async function finalizeOwnedEnvironment( - environment: string, - signal: AbortSignal, -): Promise { - const input = { workspacePath: workspaceRoot, environment }; - await stopScenarioEnvironment(input); - if (signal.aborted) return; - await deleteScenarioEnvironment(input); -} - -/** 等到取消到达;超时即返回 false,由检查自己报告真实信号从未送达。 */ -function waitForAbort( - signal: AbortSignal, - timeoutMs: number, -): Promise { - if (signal.aborted) return Promise.resolve(true); - return new Promise((resolve) => { - const timer = setTimeout(() => { - signal.removeEventListener("abort", abort); - resolve(false); - }, timeoutMs); - const abort = (): void => { - clearTimeout(timer); - resolve(true); - }; - signal.addEventListener("abort", abort, { once: true }); - }); -} - -/** - * 等到正式入口真正开始使用自己的世代:新容器出现且产品已经打开目录数据库。 - * 这样随后送去的 SIGINT 一定落在场景进行中,而不是入口还没拥有任何资源之前。 - */ -async function waitForOwnedSceneInFlight( - environmentsRoot: string, - before: ReadonlySet, - timeoutMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - for (;;) { - for (const entry of await readdir(environmentsRoot).catch(() => [])) { - if (before.has(entry) || !entry.startsWith("completion-02-")) continue; - const opened = await stat( - path.join(environmentsRoot, entry, "product-data", "catalogue.sqlite"), - ).then( - () => true, - () => false, - ); - if (opened) return entry; - } - if (Date.now() >= deadline) - throw new Error( - "等待正式入口真正使用自有世代超时:容器或目录数据库没有出现", - ); - await new Promise((resolve) => { - setTimeout(resolve, 40); - }); - } -} - -/** 素材的逐字节指纹:并行世代只读观察作者夹具,任何改动都会在这里显形。 */ -async function materialDigests(root: string): Promise { - const snapshot = await captureRawFilesystemSnapshot(root, { - contentDigests: true, - }); - return snapshot.entries - .filter((entry) => entry.relativePath !== ".") - .map((entry) => - entry.kind === "directory" - ? `${entry.relativePath}/` - : `${entry.relativePath}:${entry.byteLength}:${entry.contentDigest ?? ""}`, - ) - .toSorted(); -} - -type JsonObject = Record; - -function jsonObject(text: string, label: string): JsonObject { - let parsed: unknown; - try { - parsed = JSON.parse(text) as unknown; - } catch { - throw new Error( - `${label} 的 stdout 不是机器可读结果:${text.slice(0, 400)}`, - ); - } - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) - throw new Error(`${label} 的 stdout 不是 JSON 对象:${text.slice(0, 400)}`); - return parsed as JsonObject; -} - -function objectField( - object: JsonObject, - field: string, -): JsonObject | undefined { - const value = object[field]; - if (value === undefined || value === null) return undefined; - if (typeof value !== "object" || Array.isArray(value)) - throw new Error(`${field} 不是对象`); - return value as JsonObject; -} - -function arrayField(object: JsonObject, field: string): readonly JsonObject[] { - const value = object[field]; - if (value === undefined) return []; - if (!Array.isArray(value)) throw new Error(`${field} 不是数组`); - return value as readonly JsonObject[]; -} - -function stringField(object: JsonObject, field: string): string | undefined { - const value = object[field]; - return typeof value === "string" ? value : undefined; -} - -async function exists(target: string): Promise { - return await stat(target).then( - () => true, - () => false, - ); -} - -async function walk( - root: string, - directory: string, -): Promise { - const found: string[] = []; - const entries = await readdir(path.join(root, directory), { - withFileTypes: true, - }).catch(() => []); - for (const entry of entries) { - const relative = path.posix.join(directory, entry.name); - if (entry.isDirectory()) found.push(...(await walk(root, relative))); - else if (entry.isFile()) found.push(relative); - } - return found; -} - -async function ordinaryCopy(workDir: string): Promise { - const target = path.join(workDir, "ordinary-library", "RJ01668691"); - await cp(path.join(fixtureRoot, "material", "RJ01668691"), target, { - recursive: true, - }); - return target; -} - -async function scanRequiringCover( - environment: Readonly>, - workDir: string, - rootId: string, - signal?: AbortSignal, -): Promise { - return await runCli( - [ - "scan", - rootId, - "--scope", - ".", - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - environment, - workDir, - signal, - ); -} - -/** 必需产物用途缺位时,正式入口必须交出可见拒绝,且不得留下封面修订或二进制。 */ -async function assertCoverRequirementRefused( - scanned: CommandOutput, - label: string, -): Promise { - assert.equal(scanned.exitCode, 1, `${label} 的刷新必须非零退出`); - assert.match( - scanned.stderr, - /^error: .+/mu, - `${label} 必须由产品自己交出错误行:${scanned.stderr.slice(0, 300)}`, - ); - const outcome = jsonObject(scanned.stdout, `${label} scan`); - const execution = objectField(outcome, "execution"); - assert.equal( - stringField(execution ?? {}, "outcome"), - "partially-succeeded", - `${label} 的执行结论应部分成功而非接受`, - ); - for (const item of arrayField(outcome, "itemResults")) { - const accepted = objectField(item, "accepted"); - if (accepted === undefined) continue; - const roles = arrayField(accepted, "artifactAssociations").map( - (association) => stringField(association, "role") ?? "", - ); - assert.ok( - !roles.includes("asset.primary-cover"), - `${label} 不该在没有封面资料时交出 asset.primary-cover 关联`, - ); - assert.equal( - arrayField(accepted, "artifactRevisions").length, - 0, - `${label} 不该交出任何产物修订`, - ); - assert.equal( - arrayField(accepted, "blobs").length, - 0, - `${label} 不该落任何二进制`, - ); - } -} - -type Check = { - readonly name: string; - /** - * 需要外部条件才可能真实执行的检查:条件不满足时按未执行报告, - * 既不写成通过,也不冒充失败。返回 undefined 表示条件已满足。 - */ - readonly requires?: () => string | undefined; - /** 取消上下文:真实信号到达后各检查只停止并保留自有现场,不再启动新资源。 */ - run(options: { readonly signal: AbortSignal }): Promise; -}; - -const checks: readonly Check[] = [ - { - name: "owned-success-recycles-through-public-operations", - async run({ signal }) { - const { environment } = nameFor("owned-success"); - const prepared = await prepareScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - source: { mode: "offline" }, - }); - const workDir = await mkdtemp(path.join(tmpdir(), "02-owned-success-")); - try { - const surface = await runCli( - ["catalogue-surface", "--surface", "current-catalogue"], - prepared.baseEnvironment, - workDir, - signal, - ); - assert.equal(surface.exitCode, 0, `只读面应成功:${surface.stderr}`); - const recycling = await recycleOwnedEnvironment({ - workspacePath: prepared.workspacePath, - environment: prepared.environment, - outcome: "success", - signal, - }); - assert.deepEqual( - [...recycling.problems], - [], - `成功收尾不该留下问题:${recycling.problems.join(";")}`, - ); - assert.ok( - ["stopped", "already-stopped"].includes(recycling.stop?.status ?? ""), - ); - assert.equal(recycling.exitConfirmation?.confirmed, true); - assert.equal( - recycling.deletion?.status, - "deleted", - `未收到取消的成功收尾必须删除:delete=${String(recycling.deletion?.status)} 取消已到达=${String(signal.aborted)}`, - ); - assert.equal( - await exists( - path.join(workspaceRoot, ".digital", "environments", environment), - ), - false, - "删除后容器目录不应存在", - ); - const inspected = await fingerprintEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - assert.equal( - inspected.state, - "unprepared", - "删除后应回到没有已准备世代的结论", - ); - return [ - `stop=${recycling.stop?.status} 退出确认=${String(recycling.exitConfirmation?.confirmed)} ` + - `delete=${recycling.deletion?.status} 收尾后 state=${inspected.state}`, - `公开收尾叙述:${recycling.recyclingDiagnostics.join(" / ") || "无"}`, - ]; - } finally { - await rm(workDir, { recursive: true, force: true }); - } - }, - }, - { - name: "owned-failure-retains-restartable-data-and-logs", - async run({ signal }) { - const { runId, environment } = nameFor("owned-failure"); - const port = await reserveFreePort({ runId }); - const prepared = await prepareScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - source: { mode: "offline" }, - webPort: port, - }); - const workDir = await mkdtemp(path.join(tmpdir(), "02-owned-failure-")); - const ordinary = await ordinaryCopy(workDir); - try { - const created = await runCli( - [ - "library-create", - "失败保留库", - "--directory", - ordinary, - "--profile", - "dlsite-voice", - "--source", - "dlsite", - "--json", - ], - prepared.baseEnvironment, - workDir, - signal, - ); - assert.equal(created.exitCode, 0, `登记应成功:${created.stderr}`); - const scanned = await scanRequiringCover( - prepared.baseEnvironment, - workDir, - rootIdOf(created), - signal, - ); - await assertCoverRequirementRefused(scanned, "离线失败保留"); - const recycling = await recycleOwnedEnvironment({ - workspacePath: prepared.workspacePath, - environment: prepared.environment, - outcome: "failure", - signal, - }); - assert.equal(recycling.deletion, undefined, "失败结局绝不删除"); - assert.equal(recycling.retainedDataRoot, prepared.productDataRoot); - const catalogue = path.join( - prepared.productDataRoot, - "catalogue.sqlite", - ); - assert.ok(await exists(catalogue), "失败保留必须留下可重启的产品数据"); - const container = path.join( - workspaceRoot, - ".digital", - "environments", - environment, - ); - const logs = (await walk(container, ".")).filter((file) => - file.startsWith("logs/"), - ); - assert.ok(logs.length > 0, "失败保留必须留下日志目录"); - const leaked: string[] = []; - for (const file of logs) { - const text = await readFile(path.join(container, file), "utf8").catch( - () => "", - ); - for (const marker of [ - "authorization:", - "Bearer ", - "api_key=", - "launch.json", - ]) - if (text.includes(marker)) leaked.push(`${file} 含 ${marker}`); - } - assert.deepEqual( - leaked, - [], - `保留日志不该携带凭据材料:${leaked.join(";")}`, - ); - // 取消已到达就不再接管新的自有服务:保留的现场交给收尾环节统一停止。 - if (signal.aborted) - return [ - `失败收尾:stop=${recycling.stop?.status} 保留数据根=${recycling.retainedDataRoot} 日志 ${logs.length} 份`, - "取消已到达:不再启动新的自有服务,保留现场只停止不删除", - ]; - // 重启同样按本票资源规则登记:先写意图(工作区/环境/端口),再执行启动。 - await ledgerIntent({ runId, environment, port }); - const restarted = await startScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - await ledgerStartResult({ - runId, - environment, - port: restarted.run.port ?? 0, - status: restarted.status, - pid: restarted.run.pid, - }); - assert.ok( - ["ready", "already-ready"].includes(restarted.status), - `保留的数据必须可显式重启:status=${restarted.status} ${restarted.failure?.message ?? ""}`, - ); - const stopped = await stopScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - const closed = await recycleOwnedEnvironment({ - workspacePath: workspaceRoot, - environment, - outcome: "success", - signal, - }); - assert.equal(stopped.status, "stopped"); - if (signal.aborted) - assert.equal( - closed.deletion, - undefined, - "取消在重启之后到达:只停止并保留现场,绝不删除", - ); - else - assert.equal( - closed.deletion?.status, - "deleted", - `显式重启后由所有者回收应删除:delete=${String(closed.deletion?.status)} 问题=${closed.problems.join(";")}`, - ); - return [ - `失败收尾:stop=${recycling.stop?.status} 保留数据根=${recycling.retainedDataRoot} 日志 ${logs.length} 份`, - `显式重启:start=${restarted.status} 端口=${String(restarted.run.port)};随后停止=${stopped.status} 删除=${String(closed.deletion?.status)}`, - ]; - } finally { - await rm(workDir, { recursive: true, force: true }); - await finalizeOwnedEnvironment(environment, signal); - } - }, - }, - { - name: "required-role-refusal-offline", - async run({ signal }) { - const { environment } = nameFor("refusal-offline"); - const prepared = await prepareScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - source: { mode: "offline" }, - }); - const workDir = await mkdtemp(path.join(tmpdir(), "02-refusal-offline-")); - try { - const ordinary = await ordinaryCopy(workDir); - const created = await runCli( - [ - "library-create", - "离线必需用途库", - "--directory", - ordinary, - "--profile", - "dlsite-voice", - "--source", - "dlsite", - "--json", - ], - prepared.baseEnvironment, - workDir, - signal, - ); - assert.equal(created.exitCode, 0, `登记应成功:${created.stderr}`); - const scanned = await scanRequiringCover( - prepared.baseEnvironment, - workDir, - rootIdOf(created), - signal, - ); - await assertCoverRequirementRefused(scanned, "离线"); - assert.match( - scanned.stderr, - /来源补充未完成/u, - "拒绝必须点明来源补充未完成", - ); - return [ - `离线环境缺少资料时刷新被拒:exit=${String(scanned.exitCode)} ${scanned.stderr.split("\n")[0]}`, - ]; - } finally { - await rm(workDir, { recursive: true, force: true }); - await finalizeOwnedEnvironment(environment, signal); - } - }, - }, - { - name: "required-role-refusal-controlled-without-cover", - async run({ signal }) { - const { environment } = nameFor("refusal-no-cover"); - const prepared = await prepareScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - source: { - mode: "controlled", - manifestPath: path.join(fixtureRoot, "manifest-without-cover.json"), - }, - }); - const workDir = await mkdtemp( - path.join(tmpdir(), "02-refusal-no-cover-"), - ); - try { - const ordinary = await ordinaryCopy(workDir); - const created = await runCli( - [ - "library-create", - "受控无封面库", - "--directory", - ordinary, - "--profile", - "dlsite-voice", - "--source", - "dlsite", - "--json", - ], - prepared.baseEnvironment, - workDir, - signal, - ); - assert.equal(created.exitCode, 0, `登记应成功:${created.stderr}`); - const scanned = await scanRequiringCover( - prepared.baseEnvironment, - workDir, - rootIdOf(created), - signal, - ); - await assertCoverRequirementRefused(scanned, "受控无封面"); - return [ - `受控清单缺少封面时刷新被拒:exit=${String(scanned.exitCode)} ${scanned.stderr.split("\n")[0]}`, - ]; - } finally { - await rm(workDir, { recursive: true, force: true }); - await finalizeOwnedEnvironment(environment, signal); - } - }, - }, - { - name: "provided-served-keeps-owner-and-refuses-writers", - async run({ signal }) { - const { runId, environment } = nameFor("provided-served"); - const port = await reserveFreePort({ runId }); - await ledgerIntent({ runId, environment, port }); - const prepared = await prepareScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - source: { mode: "offline" }, - webPort: port, - }); - const started = await startScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - await ledgerStartResult({ - runId, - environment, - port: started.run.port ?? port, - status: started.status, - pid: started.run.pid, - }); - assert.equal( - started.status, - "ready", - `提供中的环境应就绪:${started.status}`, - ); - const before = await fingerprintEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - const evidenceDirectory = await mkdtemp( - path.join(tmpdir(), "02-provided-evidence-"), - ); - const workDir = await mkdtemp(path.join(tmpdir(), "02-provided-work-")); - let outcomeLines: readonly string[] = []; - try { - const ordinary = await ordinaryCopy(workDir); - const write = await runCli( - [ - "library-create", - "并发写入者库", - "--directory", - ordinary, - "--profile", - "dlsite-voice", - "--source", - "dlsite", - "--json", - ], - prepared.baseEnvironment, - workDir, - signal, - ); - assert.equal(write.exitCode, 1, "服务中的产品根必须拒绝并发写入者"); - assert.match( - write.stderr, - /^error: 产品正在写入;拒绝并发启动另一个产品写入者/u, - `拒绝应由产品自己交出:${write.stderr.slice(0, 300)}`, - ); - const read = await runCli( - ["catalogue-surface", "--surface", "current-catalogue"], - prepared.baseEnvironment, - workDir, - signal, - ); - assert.equal(read.exitCode, 1, "同一产品根被服务时只读面也应拒绝"); - const maintenance = await runCli( - ["offline-backup", await mkdtemp(path.join(tmpdir(), "02-backup-"))], - prepared.baseEnvironment, - workDir, - signal, - ); - assert.equal(maintenance.exitCode, 1, "离线维护必须被租约准入拒绝"); - assert.match(maintenance.stderr, /^error: 产品正在运行;/u); - - // 同一世代交给验收运行器:旅程照常发起接入,失败可见,但世代归属留在提供者。 - const journey = await loadControlledJourney(); - const provided = await openProvidedEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - let journeyFailure = ""; - try { - await runJourney({ - journey, - mode: "distribution", - lifecycle: providedEnvironmentLifecycle(provided), - evidenceDirectory, - signal, - }); - } catch (error) { - journeyFailure = - error instanceof Error ? error.message : String(error); - } - assert.match(journeyFailure, /main failure: /u, "主失败必须独立可见"); - assert.match(journeyFailure, /产品正在写入/u, "拒绝原因应原样进入首因"); - assert.ok( - !journeyFailure.includes(":passed"), - "被拒绝的场景绝不报告为通过", - ); - const evidence = await readSingleEvidence(evidenceDirectory); - assert.equal(stringField(evidence, "outcome"), "failure"); - assert.equal(evidence["stopStatus"], null, "provided 收尾绝不调用停止"); - assert.equal(evidence["deleteStatus"], null, "provided 收尾绝不删除"); - assert.equal( - evidence["exitConfirmation"], - null, - "provided 不需要退出确认", - ); - assert.equal( - stringField(evidence, "retainedDataRoot"), - provided.productDataRoot, - "证据应记录数据根仍由所有者持有", - ); - const diagnostics = JSON.stringify(evidence["recyclingDiagnostics"]); - assert.match(diagnostics, /未停止、未重建、未删除/u); - - const after = await fingerprintEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - assert.deepEqual( - [...fingerprintDifference(before, after)].filter( - (difference) => !difference.startsWith("容器内文件清单"), - ), - [], - "调用方不得改变提供中世代任何运行事实", - ); - assert.equal(after.serviceReadiness, "ready"); - assert.equal(after.processLiveness, "alive"); - assert.equal(after.port, started.run.port); - - // 只有环境的所有者显式收尾;停止后立即确认这一产品根从未收到场景数据。 - const stopped = await stopScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - const confirmation = await confirmOwnerExit({ - workspacePath: workspaceRoot, - environment, - }); - const surface = await runCli( - ["catalogue-surface", "--surface", "current-catalogue"], - provided.baseEnvironment, - workDir, - signal, - ); - assert.equal( - surface.exitCode, - 0, - `所有者停止后应可读:${surface.stderr}`, - ); - assert.deepEqual( - arrayField(jsonObject(surface.stdout, "catalogue-surface"), "items"), - [], - "并行场景的数据绝不进入被服务环境", - ); - - await appendStartLedger(startLedgerPath, { - phase: "release", - workspacePath: workspaceRoot, - environment, - port: after.port ?? port, - at: new Date().toISOString(), - runId, - status: `stop=${stopped.status} exit=${String(confirmation.confirmed)}`, - pid: null, - }); - const deleted = await deleteScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - assert.equal(deleted.status, "deleted", "所有者显式回收后容器应删除"); - outcomeLines = [ - `写入者拒绝:library-create exit=${String(write.exitCode)};offline-backup exit=${String(maintenance.exitCode)}`, - `旅程失败可见而世代保留:stopStatus=${String(evidence["stopStatus"])} deleteStatus=${String(evidence["deleteStatus"])} readiness=${after.serviceReadiness}`, - `所有者收尾:stop=${stopped.status} 退出确认=${String(confirmation.confirmed)} delete=${deleted.status}`, - ]; - return outcomeLines; - } finally { - await rm(workDir, { recursive: true, force: true }); - await rm(evidenceDirectory, { recursive: true, force: true }); - await finalizeOwnedEnvironment(environment, signal); - } - }, - }, - { - name: "polluted-parent-terminal-never-reaches-production-entry", - async run({ signal }) { - const pollutedRoot = await mkdtemp(path.join(tmpdir(), "02-polluted-")); - const beforeEvidence = new Set( - await readdir(evidenceRoot).catch(() => []), - ); - const polluted = { - ...process.env, - DIGITAL_E2E_JOURNEY: "controlled-ordinary-library", - DIGITAL_PRODUCT_DATA: pollutedRoot, - DIGITAL_SOURCE_IO_MODE: "real", - DIGITAL_SOURCE_IO_CONFIG: path.join( - pollutedRoot, - "not-a-manifest.json", - ), - DIGITAL_SOURCE_IO_IDENTITY: - "sha256:0000000000000000000000000000000000000000000000000000000000000000", - } as Record; - try { - const result = await runNode( - [ - "--conditions=digital-source", - "apps/cli/test/e2e/run-journeys.ts", - "distribution", - ], - polluted, - signal, - ); - assert.equal( - result.exitCode, - 0, - `遗留配置下的正式入口应仍按记录运行:${result.stderr.slice(0, 600)}`, - ); - assert.match( - result.stdout, - /distribution:controlled-ordinary-library:passed/u, - ); - const written = await readdir(pollutedRoot); - assert.deepEqual( - [...written], - [], - `父终端遗留的产品根不得被写入:${written.join("、")}`, - ); - const evidence = await readNewestEvidence(evidenceRoot, beforeEvidence); - assert.equal(stringField(evidence, "sourceMode"), "controlled"); - assert.match( - stringField(evidence, "productDataRoot") ?? "", - /\.digital[/\\]environments[/\\]completion-02-/u, - ); - return [ - `遗留 DIGITAL_SOURCE_IO_MODE=real 与遗留产品根都被忽略:exit=${String(result.exitCode)} 遗留根内容=${written.length}`, - `证据世代:sourceMode=${String(evidence["sourceMode"])} 数据根=${String(evidence["productDataRoot"])}`, - ]; - } finally { - await rm(pollutedRoot, { recursive: true, force: true }); - } - }, - }, - { - name: "signal-interruption-retains-owned-environment", - async run({ signal }) { - const journey = await loadControlledJourney(); - const evidenceDirectory = await mkdtemp( - path.join(tmpdir(), "02-interrupt-evidence-"), - ); - const cancellation = new AbortController(); - let issued = 0; - const executor = async (options: { - readonly mode: CliJourneyMode; - readonly packedBin: string | undefined; - readonly command: CliJourneyCommand; - readonly previousResults: readonly CliJourneyResult[]; - readonly cwd: string; - readonly environment: Readonly>; - readonly signal?: AbortSignal; - }): Promise => { - issued += 1; - const output = await runCli( - commandArgs(options.command, options.previousResults), - options.environment, - options.cwd, - options.signal, - ); - if (issued === 1) - cancellation.abort(new Error("验收在首条命令后注入信号中断")); - return { - commandName: options.command.name, - exitCode: output.exitCode, - stdout: output.stdout, - stderr: output.stderr, - }; - }; - let failure = ""; - try { - await runJourney({ - journey, - mode: "distribution", - lifecycle: ownedEnvironmentLifecycle(), - evidenceDirectory, - executeCommand: executor, - signal: cancellation.signal, - }); - } catch (error) { - failure = error instanceof Error ? error.message : String(error); - } - try { - assert.match( - failure, - /interruption failure: /u, - "中断结局必须独立可见", - ); - assert.ok(!failure.includes(":passed"), "中断绝不报告为通过"); - const evidence = await readSingleEvidence(evidenceDirectory); - assert.equal(stringField(evidence, "outcome"), "interrupted"); - assert.equal( - stringField(evidence, "retainedDataRoot") !== undefined, - true, - "中断只停止并保留自有数据根", - ); - assert.equal(evidence["deleteStatus"], null, "中断结局绝不删除"); - const retained = stringField(evidence, "retainedDataRoot") ?? ""; - assert.match( - retained, - /\.digital[/\\]environments[/\\]completion-02-/u, - ); - const environmentName = path.basename(path.dirname(retained)); - assert.equal( - path.dirname(retained), - path.join(workspaceRoot, ".digital", "environments", environmentName), - `保留的数据根应指向环境容器:${retained}`, - ); - assert.ok( - await exists(path.join(retained, "catalogue.sqlite")), - `中断必须保留可重启的产品数据:${JSON.stringify(evidence)}`, - ); - const confirmation = await confirmOwnerExit({ - workspacePath: workspaceRoot, - environment: environmentName, - }); - assert.equal( - confirmation.confirmed, - true, - `保留前应确认进程退出:${confirmation.detail}`, - ); - const closed = await recycleOwnedEnvironment({ - workspacePath: workspaceRoot, - environment: environmentName, - outcome: "success", - signal, - }); - if (signal.aborted) - assert.equal( - closed.deletion, - undefined, - "正式入口已收到取消:中断保留的世代只停止不删除", - ); - else - assert.equal( - closed.deletion?.status, - "deleted", - `诊断:删除结论=${String(closed.deletion?.status)} 收尾问题=${closed.problems.join(";")} 中断失败全文=${failure.slice(0, 3000)} 证据=${JSON.stringify(evidence)}`, - ); - return [ - `中断结局:outcome=${String(evidence["outcome"])} deleteStatus=${String(evidence["deleteStatus"])} 退出确认=${String(confirmation.confirmed)}`, - `所有者随后的显式回收:delete=${closed.deletion?.status} 叙述=${closed.recyclingDiagnostics.join(" / ") || "无"}`, - ]; - } finally { - await rm(evidenceDirectory, { recursive: true, force: true }); - } - }, - }, - { - /** - * 取消落在“正被服务”的提供方世代上:调用方只执行公开查看,绝不停止、绝不删除, - * 提供方服务照常就绪、身份与端口不变;只有所有者自己收尾时才停止并读取。 - */ - name: "provided-generation-survives-cancelled-scene", - async run({ signal }) { - const { runId, environment } = nameFor("provided-cancel"); - const port = await reserveFreePort({ runId }); - await ledgerIntent({ runId, environment, port }); - const prepared = await prepareScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - source: { mode: "offline" }, - webPort: port, - }); - const started = await startScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - await ledgerStartResult({ - runId, - environment, - port: started.run.port ?? port, - status: started.status, - pid: started.run.pid, - }); - assert.equal( - started.status, - "ready", - `提供中的环境应就绪:${started.status}`, - ); - const before = await fingerprintEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - const provided = await openProvidedEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - const evidenceDirectory = await mkdtemp( - path.join(tmpdir(), "02-provided-cancel-evidence-"), - ); - const cancellation = new AbortController(); - let issued = 0; - const executor = async (options: { - readonly mode: CliJourneyMode; - readonly packedBin: string | undefined; - readonly command: CliJourneyCommand; - readonly previousResults: readonly CliJourneyResult[]; - readonly cwd: string; - readonly environment: Readonly>; - readonly signal?: AbortSignal; - }): Promise => { - issued += 1; - const output = await runCli( - commandArgs(options.command, options.previousResults), - options.environment, - options.cwd, - options.signal, - ); - if (issued === 1) - cancellation.abort(new Error("提供方场景在首条命令后收到取消")); - return { - commandName: options.command.name, - exitCode: output.exitCode, - stdout: output.stdout, - stderr: output.stderr, - }; - }; - let failure = ""; - try { - await runJourney({ - journey: await loadControlledJourney(), - mode: "distribution", - lifecycle: providedEnvironmentLifecycle(provided), - evidenceDirectory, - executeCommand: executor, - signal: cancellation.signal, - }); - } catch (error) { - failure = error instanceof Error ? error.message : String(error); - } - try { - assert.match( - failure, - /interruption failure: /u, - "取消结局必须独立可见", - ); - assert.ok(!failure.includes(":passed"), "被取消的场景绝不报告为通过"); - const evidence = await readSingleEvidence(evidenceDirectory); - assert.equal(stringField(evidence, "outcome"), "interrupted"); - assert.equal(evidence["stopStatus"], null, "取消不得触碰提供方的停止"); - assert.equal(evidence["deleteStatus"], null, "取消不得删除提供方世代"); - assert.equal( - evidence["exitConfirmation"], - null, - "调用方无权确认提供方退出", - ); - const after = await fingerprintEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - assert.deepEqual( - [...fingerprintDifference(before, after)].filter( - (difference) => !difference.startsWith("容器内文件清单"), - ), - [], - "取消后的调用方不得改变提供方任何运行事实", - ); - assert.equal(after.serviceReadiness, "ready"); - assert.equal(after.processLiveness, "alive"); - assert.equal(after.port, started.run.port); - const stopped = await stopScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - const confirmation = await confirmOwnerExit({ - workspacePath: workspaceRoot, - environment, - }); - const surface = await runCli( - ["catalogue-surface", "--surface", "current-catalogue"], - prepared.baseEnvironment, - await mkdtemp(path.join(tmpdir(), "02-provided-cancel-read-")), - signal, - ); - assert.equal( - surface.exitCode, - 0, - `所有者停止后该世代仍可读取:${surface.stderr.slice(0, 300)}`, - ); - const deleted = await deleteScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - assert.equal(deleted.status, "deleted", "所有者显式回收后容器应删除"); - return [ - `取消打在提供中的世代:outcome=${String(evidence["outcome"])} ` + - `stop=${String(evidence["stopStatus"])} delete=${String(evidence["deleteStatus"])}`, - `提供方继续归所有者:readiness=${after.serviceReadiness} ` + - `liveness=${after.processLiveness} 端口=${String(after.port)}`, - `所有者自己收尾:stop=${stopped.status} ` + - `退出确认=${String(confirmation.confirmed)} delete=${deleted.status} ` + - `只读面 exit=${String(surface.exitCode)}`, - ]; - } finally { - await rm(evidenceDirectory, { recursive: true, force: true }); - await finalizeOwnedEnvironment(environment, signal); - } - }, - }, - { - /** - * 真实信号打在正式入口自己身上:外部验收脚本读到就绪标记后向本进程发送 SIGINT。 - * 窗口内确实有一个由本票拥有的产品服务在运行;取消到达后只停止并保留现场, - * 保留的世代随后仍由所有者公开重启、读取并删除。 - */ - name: "formal-entry-signal-window-retains-own-scene", - requires: () => - process.env.DIGITAL_T02_SIGNAL_WINDOW === "1" - ? undefined - : "需要外部脚手架先读取就绪标记再向正式入口发送真实 SIGINT(DIGITAL_T02_SIGNAL_WINDOW=1)", - async run({ signal }) { - const { runId, environment } = nameFor("signal-window"); - const port = await reserveFreePort({ runId }); - await ledgerIntent({ runId, environment, port }); - const prepared = await prepareScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - source: { mode: "offline" }, - webPort: port, - }); - const workDir = await mkdtemp(path.join(tmpdir(), "02-signal-window-")); - try { - const ordinary = await ordinaryCopy(workDir); - const created = await runCli( - [ - "library-create", - "信号窗口库", - "--directory", - ordinary, - "--profile", - "dlsite-voice", - "--source", - "dlsite", - "--json", - ], - prepared.baseEnvironment, - workDir, - signal, - ); - assert.equal(created.exitCode, 0, `登记应成功:${created.stderr}`); - const root = rootIdOf(created); - const started = await startScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - await ledgerStartResult({ - runId, - environment, - port: started.run.port ?? port, - status: started.status, - pid: started.run.pid, - }); - assert.equal( - started.status, - "ready", - `窗口内应有真实服务在运行:status=${started.status} ${started.failure?.message ?? ""}`, - ); - process.stdout.write( - `SIGNAL-WINDOW-READY environment=${environment} ` + - `port=${String(started.run.port ?? port)} pid=${String(started.run.pid)}\n`, - ); - const interrupted = await waitForAbort(signal, 300_000); - assert.ok( - interrupted, - "信号窗口超时:外部脚本没有向正式入口发出真实 SIGINT", - ); - // 取消在停止与删除之间到达:绝不沿用窗口开头的成功结局去删除现场。 - const recycling = await recycleOwnedEnvironment({ - workspacePath: prepared.workspacePath, - environment: prepared.environment, - outcome: "success", - signal, - }); - assert.deepEqual( - [...recycling.problems], - [], - `收尾不该留下问题:${recycling.problems.join(";")}`, - ); - assert.equal( - recycling.exitConfirmation?.confirmed, - true, - `保留前必须确认服务真的退出:${recycling.exitConfirmation?.detail ?? ""}`, - ); - const deletionAtCancel = recycling.deletion?.status ?? null; - assert.equal(deletionAtCancel, null, "取消到达后绝不删除现场"); - const cancellationNarrative = - recycling.recyclingDiagnostics.join(" / "); - assert.match( - cancellationNarrative, - /取消在收尾期间到达/u, - "收尾叙述要写明取消阻止了删除", - ); - assert.ok( - await exists(path.join(prepared.productDataRoot, "catalogue.sqlite")), - "取消保留的世代必须留下可显式重启的产品数据", - ); - // 被取消的检查不会走到 passed 明细,收尾观察必须在建立时就直接写出。 - process.stdout.write( - ` - formal-entry-signal-window-retains-own-scene: ` + - `真实信号后停止并保留:stop=${recycling.stop?.status} ` + - `退出确认=${String(recycling.exitConfirmation?.confirmed)} ` + - `删除=${String(deletionAtCancel)} 保留根=${prepared.productDataRoot} ` + - `收尾叙述=${cancellationNarrative}\n`, - ); - await ledgerIntent({ runId, environment, port }); - const restarted = await startScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - await ledgerStartResult({ - runId, - environment, - port: restarted.run.port ?? port, - status: restarted.status, - pid: restarted.run.pid, - }); - assert.ok( - ["ready", "already-ready"].includes(restarted.status), - `保留的世代仍可公开重启:status=${restarted.status} ${restarted.failure?.message ?? ""}`, - ); - const stopped = await stopScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - assert.equal(stopped.status, "stopped"); - // 取消已如实收尾;公开重启与读取是所有者此刻发起的新动作, - // 不能沿用中断上下文,否则只会让本票自己的取消转发杀掉产品子进程。 - const reread = await scanRequiringCover( - prepared.baseEnvironment, - workDir, - root, - ); - await assertCoverRequirementRefused(reread, "取消保留后重启"); - const deleted = await deleteScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - assert.equal( - deleted.status, - "deleted", - `只有所有者显式回收才删除:delete=${deleted.status}`, - ); - return [ - `保留的世代重启后仍被产品识别:start=${restarted.status} ` + - `端口=${String(restarted.run.port)};观察根 ${root} 的必需用途拒绝照常交出;随后删除=${deleted.status}`, - ]; - } finally { - await rm(workDir, { recursive: true, force: true }); - await finalizeOwnedEnvironment(environment, signal); - } - }, - }, - { - /** - * 真实信号打在旅程入口子进程上:本检查向自己启动的正式 run-journeys 进程发送 SIGINT, - * 核验入口按公开收尾停止并保留世代,父侧只按公开查看确认没有留下仍在运行的进程。 - */ - name: "child-formal-entry-signal-retains-owned-scene", - async run({ signal }) { - const environmentsRoot = path.join( - workspaceRoot, - ".digital", - "environments", - ); - const beforeDirectories = new Set( - await readdir(environmentsRoot).catch(() => []), - ); - const beforeEvidence = new Set( - await readdir(evidenceRoot).catch(() => []), - ); - const child = spawn( - process.execPath, - [ - "--conditions=digital-source", - "apps/cli/test/e2e/run-journeys.ts", - "distribution", - ], - { - cwd: workspaceRoot, - env: { - ...process.env, - DIGITAL_E2E_JOURNEY: "controlled-ordinary-library", - }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - forwardCancellation(child, signal); - const exitCode = new Promise((resolve, reject) => { - child.once("error", reject); - child.once("close", (code) => { - resolve(code ?? 1); - }); - }); - let observed = ""; - try { - observed = await Promise.race([ - waitForOwnedSceneInFlight( - environmentsRoot, - beforeDirectories, - 120_000, - ), - exitCode.then((code) => { - throw new Error( - `正式入口在收到信号前就结束了(exit=${String(code)}),窗口不足以证明中断收尾:\n${stdout}\n${stderr}`, - ); - }), - ]); - child.kill("SIGINT"); - const exited = await exitCode; - assert.notEqual(exited, 0, "被信号中断的正式入口不得报告成功"); - const evidence = await readNewestEvidence(evidenceRoot, beforeEvidence); - assert.equal(stringField(evidence, "outcome"), "interrupted"); - assert.equal( - evidence["deleteStatus"], - null, - "中断结局绝不删除自有世代", - ); - assert.equal( - stringField(evidence, "sourceMode"), - "controlled", - "证据要记录本票选择的受控来源", - ); - const failures = JSON.stringify(evidence["failures"]); - assert.match(failures, /journey interrupted by signal/u); - const environment = stringField(evidence, "environment") ?? ""; - const productDataRoot = stringField(evidence, "productDataRoot") ?? ""; - assert.ok( - environment.startsWith("completion-02-") && - productDataRoot.endsWith("product-data"), - `中断的世代归属可辨:environment=${environment} root=${productDataRoot}`, - ); - assert.ok( - environment === observed, - `子进程自有的世代应与实际观察到的容器一致:观察=${observed} 证据=${environment}`, - ); - assert.ok( - await exists(path.join(productDataRoot, "catalogue.sqlite")), - "被中断的入口必须留下可重启的产品数据", - ); - const confirmation = await confirmOwnerExit({ - workspacePath: workspaceRoot, - environment, - }); - assert.equal( - confirmation.confirmed, - true, - `入口收尾后不得留下仍在运行的进程:${confirmation.detail}`, - ); - const closed = await recycleOwnedEnvironment({ - workspacePath: workspaceRoot, - environment, - outcome: "success", - signal, - }); - assert.deepEqual( - [...closed.problems], - [], - `所有者收尾不该留下问题:${closed.problems.join(";")}`, - ); - assert.equal( - signal.aborted || closed.deletion?.status === "deleted", - true, - `所有者显式回收应删除:delete=${String(closed.deletion?.status)} 取消已到达=${String(signal.aborted)}`, - ); - return [ - `真实 SIGINT 打在正式旅程入口:子进程 exit=${String(exited)} 结局=${String(evidence["outcome"])} 删除=${String(evidence["deleteStatus"])}`, - `入口自己完成收尾:退出确认=${String(confirmation.confirmed)} 产品数据保留于 ${productDataRoot}`, - `所有者显式回收:delete=${String(closed.deletion?.status)} 叙述=${closed.recyclingDiagnostics.join(" / ") || "无"}`, - ]; - } finally { - // 收尾只针对本票自己启动的子进程与它新建的世代:仍未退出就再转达一次 SIGINT, - // 世代一律经公开停止与删除处理,不用 rm 直接抹掉容器。 - if (child.exitCode === null && child.signalCode === null) - child.kill("SIGINT"); - await exitCode.catch(() => undefined); - for (const entry of await readdir(environmentsRoot).catch(() => [])) - if ( - !beforeDirectories.has(entry) && - entry.startsWith("completion-02-") - ) - await finalizeOwnedEnvironment(entry, signal); - } - }, - }, - { - /** - * 运行器把主失败交回调用方之后,保留的世代仍能通过环境包公开入口重启并被产品读取; - * 这条路径全程使用真实的 runJourney 与真实正式 CLI,不用替身代替收尾。 - */ - name: "runner-failure-retained-scene-restarts-through-public-entry", - async run({ signal }) { - const runId = scenarioRunId(); - const port = await reserveFreePort({ runId }); - const evidenceDirectory = await mkdtemp( - path.join(tmpdir(), "02-runner-failure-"), - ); - // 观察根放在检查自己拥有的目录里:运行器的临时工作目录会被回收, - // 重启后要读回的必须是仍然存在的同一份现场,而不是悬空引用。 - const sceneStore = await mkdtemp(path.join(tmpdir(), "02-runner-scene-")); - const scene = await ordinaryCopy(sceneStore); - const journey: CliJourney = { - name: "runner-failure-retention", - modes: ["distribution"], - source: { mode: "offline" }, - async setup() {}, - commands() { - return [ - { - name: "library-create registers the scene root", - args: [ - "library-create", - "运行器失败保留", - "--directory", - scene, - "--json", - ], - haltOnFailure: true, - }, - { - name: "library-create on a missing directory", - args: [ - "library-create", - "不存在的目录", - "--directory", - "does-not-exist-completion-02", - "--json", - ], - haltOnFailure: true, - }, - ]; - }, - async assertions() { - throw new Error("主命令失败后不应执行普通断言"); - }, - }; - let failure = ""; - let owned = ""; - try { - await runJourney({ - journey, - mode: "distribution", - lifecycle: ownedEnvironmentLifecycle({ webPort: port }), - evidenceDirectory, - signal, - }); - } catch (error) { - failure = error instanceof Error ? error.message : String(error); - } - try { - assert.match(failure, /main failure: /u, "主失败必须独立可见"); - assert.ok(!failure.includes(":passed"), "被拒绝的世代绝不报告为通过"); - const evidence = await readSingleEvidence(evidenceDirectory); - assert.equal(stringField(evidence, "outcome"), "failure"); - assert.equal( - evidence["deleteStatus"], - null, - "运行器主失败后绝不删除可重启数据", - ); - const productDataRoot = stringField(evidence, "retainedDataRoot") ?? ""; - const environment = stringField(evidence, "environment") ?? ""; - owned = environment; - assert.match(productDataRoot, /completion-02-/u); - assert.ok( - await exists(path.join(productDataRoot, "catalogue.sqlite")), - `主失败必须留下可显式重启的产品数据:${JSON.stringify(evidence)}`, - ); - const confirmation = await confirmOwnerExit({ - workspacePath: workspaceRoot, - environment, - }); - assert.equal( - confirmation.confirmed, - true, - `保留前确认没有残留进程:${confirmation.detail}`, - ); - if (signal.aborted) - return [ - `运行器主失败保留世代:outcome=${String(evidence["outcome"])} 删除=${String(evidence["deleteStatus"])} 保留根=${productDataRoot}`, - "取消已到达:不再启动新的自有服务,保留的世代只停止不删除", - ]; - await ledgerIntent({ runId, environment, port }); - const restarted = await startScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - await ledgerStartResult({ - runId, - environment, - port: restarted.run.port ?? port, - status: restarted.status, - pid: restarted.run.pid, - }); - assert.ok( - ["ready", "already-ready"].includes(restarted.status), - `运行器保留的世代可经公开入口重启:status=${restarted.status} ${restarted.failure?.message ?? ""}`, - ); - const stopped = await stopScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - assert.equal(stopped.status, "stopped"); - const reopened = await openProvidedEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - const surface = await runCli( - ["catalogue-surface", "--surface", "current-catalogue"], - reopened.baseEnvironment, - await mkdtemp(path.join(tmpdir(), "02-runner-failure-read-")), - signal, - ); - assert.equal( - surface.exitCode, - 0, - `重启后的产品数据仍由正式 CLI 读取:${surface.stderr.slice(0, 300)}`, - ); - const closed = await recycleOwnedEnvironment({ - workspacePath: workspaceRoot, - environment, - outcome: "success", - signal, - }); - assert.equal( - closed.deletion?.status, - "deleted", - `所有者显式回收应删除:delete=${String(closed.deletion?.status)} 问题=${closed.problems.join(";")}`, - ); - return [ - `运行器主失败保留世代:outcome=${String(evidence["outcome"])} 删除=${String(evidence["deleteStatus"])} 保留根=${productDataRoot}`, - `公开重启并读取:start=${restarted.status} 端口=${String(restarted.run.port)} 停止=${stopped.status} 只读面 exit=${String(surface.exitCode)}`, - `所有者收尾:delete=${String(closed.deletion?.status)} 退出确认=${String(closed.exitConfirmation?.confirmed)}`, - ]; - } finally { - await rm(evidenceDirectory, { recursive: true, force: true }); - await rm(sceneStore, { recursive: true, force: true }); - if (owned.length > 0) await finalizeOwnedEnvironment(owned, signal); - } - }, - }, - { - /** - * 删除的真实分类:服务仍在运行时公开删除必须给出被拒绝的实际结论,既不删一半, - * 也不把仍在运行的进程写成已退出;停止之后才由所有者完成删除。 - */ - name: "delete-refused-while-own-service-is-running", - async run({ signal }) { - const { runId, environment } = nameFor("delete-refused"); - const port = await reserveFreePort({ runId }); - await ledgerIntent({ runId, environment, port }); - await prepareScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - source: { mode: "offline" }, - webPort: port, - }); - try { - const started = await startScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - await ledgerStartResult({ - runId, - environment, - port: started.run.port ?? port, - status: started.status, - pid: started.run.pid, - }); - assert.equal( - started.status, - "ready", - `需要真实在运行的服务:status=${started.status} ${started.failure?.message ?? ""}`, - ); - const refused = await deleteScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - assert.equal( - refused.status, - "refused", - `运行中的删除要给出实际分类:delete=${refused.status} ${refused.failure?.message ?? ""}`, - ); - assert.equal( - refused.cleanup.state, - "not-started", - "被拒绝的删除不得开始清理", - ); - const container = path.join( - workspaceRoot, - ".digital", - "environments", - environment, - ); - assert.ok(await exists(container), "被拒绝的删除不得留下半删的容器"); - const confirmation = await confirmOwnerExit({ - workspacePath: workspaceRoot, - environment, - }); - assert.equal( - confirmation.confirmed, - false, - `仍在运行时不得伪造退出确认:${confirmation.detail}`, - ); - const stopped = await stopScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - }); - const closed = await recycleOwnedEnvironment({ - workspacePath: workspaceRoot, - environment, - outcome: "success", - signal, - }); - assert.equal(stopped.status, "stopped"); - assert.equal( - signal.aborted || closed.deletion?.status === "deleted", - true, - `停止后由所有者删除:delete=${String(closed.deletion?.status)} 取消已到达=${String(signal.aborted)}`, - ); - return [ - `运行中的删除按实际分类拒绝:delete=${refused.status} ` + - `清理=${refused.cleanup.state} 退出确认=${String(confirmation.confirmed)}`, - `停止后收尾:stop=${stopped.status} ` + - `退出确认=${String(closed.exitConfirmation?.confirmed)} 删除=${String(closed.deletion?.status)}`, - ]; - } finally { - await finalizeOwnedEnvironment(environment, signal); - } - }, - }, - { - /** - * 票内并行运行隔离:两个交付模式各自拥有独立的世代与产品数据根,互不重叠, - * 作者夹具素材在并行期间逐字节不变。 - */ - name: "concurrent-owned-scenes-share-nothing", - async run({ signal }) { - const journey = await loadControlledJourney(); - const material = path.join(fixtureRoot, "material", "RJ01668691"); - const before = await materialDigests(material); - const runId = scenarioRunId(); - // 端口逐个实际探测并显式分配给各世代:并行双方绝不共用同一个监听口。 - const firstPort = await reserveFreePort({ runId }); - const secondPort = await reserveFreePort({ runId, exclude: [firstPort] }); - assert.notEqual(firstPort, secondPort, "并行世代不得共用同一个预留端口"); - const ports = { distribution: firstPort, source: secondPort }; - const evidenceDirectory = await mkdtemp( - path.join(tmpdir(), "02-concurrent-evidence-"), - ); - try { - const passed = await Promise.all( - (["distribution", "source"] as const).map((mode) => - runJourney({ - journey, - mode, - lifecycle: ownedEnvironmentLifecycle({ webPort: ports[mode] }), - evidenceDirectory, - signal, - }), - ), - ); - const entries = (await readdir(evidenceDirectory)) - .filter((entry) => entry.endsWith(".json")) - .toSorted(); - assert.equal( - entries.length, - 2, - `两个并行世代各留一份证据:${entries.join("、")}`, - ); - const evidences: JsonObject[] = []; - for (const entry of entries) - evidences.push( - jsonObject( - await readFile(path.join(evidenceDirectory, entry), "utf8"), - `并行证据 ${entry}`, - ), - ); - const names = evidences.map( - (evidence) => stringField(evidence, "environment") ?? "", - ); - assert.equal( - new Set(names).size, - 2, - `并行世代的名字不得重叠:${names.join("、")}`, - ); - const roots = evidences.map( - (evidence) => stringField(evidence, "productDataRoot") ?? "", - ); - for (const root of roots) - assert.ok( - root.length > 0 && root.includes("completion-02-"), - `并行世代各自拥有独立的产品数据根:${roots.join(" 与 ")}`, - ); - assert.ok( - path.relative(roots[0] ?? "", roots[1] ?? "").startsWith("..") && - path.relative(roots[1] ?? "", roots[0] ?? "").startsWith(".."), - `并行世代的数据根互不嵌套:${roots[0]} 与 ${roots[1]}`, - ); - const workDirs = evidences.map( - (evidence) => stringField(evidence, "callerWorkDir") ?? "", - ); - assert.equal( - new Set(workDirs).size, - 2, - `并行世代各自的调用方工作区不重叠:${workDirs.join("、")}`, - ); - for (const evidence of evidences) { - assert.equal(stringField(evidence, "outcome"), "success"); - assert.equal( - evidence["deleteStatus"], - "deleted", - "并行世代的成功收尾各自删除自己的容器", - ); - } - const after = await materialDigests(material); - assert.deepEqual( - after, - before, - "并行世代改变了作者夹具素材的逐字节内容", - ); - return [ - `并行两个交付模式各自通过:${passed.join(" / ")}`, - `世代互不重叠:${names.join(" / ")} 端口=${String(ports.distribution)} 与 ${String(ports.source)}`, - `素材哈希不变:${String(after.length)} 份文件的摘要与长度逐一相同`, - ]; - } finally { - await rm(evidenceDirectory, { recursive: true, force: true }); - } - }, - }, -]; - -function rootIdOf(created: CommandOutput): string { - const parsed = jsonObject(created.stdout, "library-create"); - const rootIds = parsed["rootIds"]; - if (!Array.isArray(rootIds) || typeof rootIds[0] !== "string") - throw new Error("library-create 未交出一个观察根编号"); - return rootIds[0]; -} - -function commandArgs( - command: CliJourneyCommand, - previousResults: readonly CliJourneyResult[], -): readonly string[] { - return typeof command.args === "function" - ? command.args(previousResults) - : command.args; -} - -async function loadControlledJourney(): Promise { - const loaded = (await import( - pathToFileURL( - path.join(e2eRoot, "journeys", "controlled-ordinary-library.journey.ts"), - ).href - )) as { default: CliJourney }; - return loaded.default; -} - -/** 共享证据目录会累积历次结果:只采信本次运行新增的那一份。 */ -async function readNewestEvidence( - directory: string, - before: ReadonlySet, -): Promise { - const entries = await readdir(directory); - const fresh = entries.filter( - (entry) => entry.endsWith(".json") && !before.has(entry), - ); - const [name] = fresh; - assert.ok( - name !== undefined && fresh.length === 1, - `本次运行应只新增一份证据,实际 ${fresh.length} 份`, - ); - const text = await readFile(path.join(directory, name), "utf8"); - return jsonObject(text, "证据"); -} - -async function readSingleEvidence(directory: string): Promise { - const entries = await readdir(directory); - const files = entries.filter((entry) => entry.endsWith(".json")); - const [name] = files; - assert.ok( - name !== undefined && files.length === 1, - `${directory} 应只有一份证据,实际 ${files.length} 份`, - ); - const text = await readFile(path.join(directory, name), "utf8"); - return jsonObject(text, "证据"); -} - -export async function runEnvironmentContractChecks( - selection: readonly string[], - signal: AbortSignal = new AbortController().signal, -): Promise { - const summary: string[] = []; - const failed: string[] = []; - const skipped: string[] = []; - const passed: string[] = []; - const notRun: string[] = []; - const report = (lines: readonly string[]): void => { - for (const line of lines) { - summary.push(line); - process.stdout.write(`${line}\n`); - } - }; - for (const check of checks) { - if (selection.length > 0 && !selection.includes(check.name)) continue; - // 外部条件不满足的检查根本没有执行:如实报未执行,绝不冒充第 N 项业务通过。 - const unmetRequirement = check.requires?.(); - if (unmetRequirement !== undefined) { - notRun.push(check.name); - report([ - `${check.name}: not-run 未执行:${unmetRequirement};不计通过也不计失败`, - ]); - continue; - } - // 取消到达后不再启动新的检查:每一项都会拥有自己的端口与世代。 - if (signal.aborted) { - skipped.push(check.name); - report([ - `${check.name}: skipped-cancellation 正式入口已收到取消,未启动`, - ]); - continue; - } - const startedAt = new Date().toISOString(); - try { - const observations = await check.run({ signal }); - const detail = observations.map( - (observation) => ` - ${check.name}: ${observation}`, - ); - // 取消之后才跑完的检查体只留观察,不写成通过。 - if (signal.aborted) - report([ - `${check.name}: interrupted @ ${startedAt} 检查体在取消之后完成,其观察不构成通过`, - ...detail, - ]); - else { - passed.push(check.name); - report([`${check.name}: passed @ ${startedAt}`, ...detail]); - } - } catch (error) { - failed.push( - `${check.name}: ${error instanceof Error ? error.message : String(error)}`, - ); - report([`${check.name}: FAILED @ ${startedAt}`]); - } - } - if (signal.aborted) { - report([ - `cancellation: 正式入口收到取消,${String(skipped.length)} 项检查未启动;` + - "已启动的世代只停止并保留现场", - ]); - // 取消就是未完成:即使选中的检查恰好全部跑完,也绝不把被取消的运行写成通过。 - failed.push( - skipped.length === 0 - ? "cancellation: 本次运行被取消,按未完成收尾而不报通过" - : `cancellation: 未启动的检查 ${skipped.join("、")}`, - ); - } - report([ - `summary: 实际执行并通过 ${String(passed.length)} 项、未执行 ${String(notRun.length)} 项` + - (notRun.length === 0 ? "" : `(${notRun.join("、")})`) + - ";未执行不计入通过", - ]); - if (failed.length > 0) - throw new Error(`环境归属检查失败:\n${failed.join("\n\n")}`); - return summary; -} - -if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - const cancellation = new AbortController(); - const handlers: [NodeJS.Signals, () => void][] = []; - const received: string[] = []; - for (const signalName of ["SIGINT", "SIGTERM"] as const) { - const handler = (): void => { - // 只中止验收流程:默认终止会让本票拥有的服务在父进程之后继续运行。 - if (cancellation.signal.aborted) return; - received.push(signalName); - cancellation.abort( - new Error(`正式入口收到 ${signalName}:自有环境只停止并保留现场`), - ); - }; - handlers.push([signalName, handler]); - process.on(signalName, handler); - } - try { - await runEnvironmentContractChecks( - process.argv.slice(2), - cancellation.signal, - ); - } catch (error) { - process.stderr.write( - `error: ${error instanceof Error ? error.message : String(error)}\n`, - ); - process.exitCode = 1; - } finally { - if (received.length > 0) - process.stdout.write( - `cancellation-received: ${received.join("+")}:取消上下文已交给收尾路径\n`, - ); - for (const [signalName, handler] of handlers) - process.removeListener(signalName, handler); - } -} diff --git a/apps/cli/test/e2e/environment-runtime.ts b/apps/cli/test/e2e/environment-runtime.ts deleted file mode 100644 index acd56fd..0000000 --- a/apps/cli/test/e2e/environment-runtime.ts +++ /dev/null @@ -1,800 +0,0 @@ -import { createHash } from "node:crypto"; -import { appendFile, mkdir, readFile, readdir } from "node:fs/promises"; -import net from "node:net"; -import { homedir, tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { - deleteEnvironment, - inspectEnvironment, - prepareEnvironment, - startEnvironment, - stopEnvironment, - type DeleteOperationResult, - type EnvironmentFacts, - type EnvironmentRunFacts, - type InspectEnvironmentResult, - type JsonValue, - type PrepareEnvironmentRequest, - type PrepareOperationResult, - type StartOperationResult, - type StopOperationResult, -} from "@digital/environment"; -import { loadControlledSourceIo } from "@digital/test-support/source-io"; - -/** 场景自己声明的来源装配选择;环境只转达,不代替场景选择来源。 */ -export type ScenarioSource = - | { readonly mode: "offline" } - | { readonly mode: "real" } - | { readonly mode: "controlled"; readonly manifestPath: string }; - -/** 交给验收运行器的环境:产品根、来源选择与子进程基础环境都来自记录的事实。 */ -export type AcceptanceEnvironment = { - readonly workspacePath: string; - readonly environment: string; - readonly productDataRoot: string; - readonly facts: EnvironmentFacts; - /** 正式 CLI 子进程的基础环境:白名单变量加上本环境记录的产品根与来源选择。 */ - readonly baseEnvironment: Readonly>; -}; - -/** 自有场景世代:由调用方经公开准备接口得到,回收责任也在调用方。 */ -export type ScenarioEnvironment = AcceptanceEnvironment & { - readonly preparation: PrepareOperationResult; -}; - -/** 一次公开环境操作给出的失败;保留阶段、退出码与日志,供调用方与首因并列报告。 */ -export type EnvironmentOperationProblem = { - readonly operation: "prepare" | "start" | "stop" | "delete" | "inspect"; - readonly status: string; - readonly stage?: string; - readonly message: string; - readonly exitCode: number | null; - readonly logs: readonly string[]; - readonly diagnostics: readonly string[]; -}; - -export class EnvironmentOperationError extends Error { - readonly problem: EnvironmentOperationProblem; - - constructor(problem: EnvironmentOperationProblem) { - super( - `环境 ${problem.operation} 未成功:status=${problem.status}` + - `${problem.stage === undefined ? "" : ` stage=${problem.stage}`} ` + - `exit=${String(problem.exitCode)} ${problem.message}`, - ); - this.name = "EnvironmentOperationError"; - this.problem = problem; - } -} - -const environmentNamePattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; - -/** 调用方自有前缀的环境名:并行场景与开发环境靠名字与容器目录互不重叠。 */ -export function scenarioEnvironmentName(input: { - readonly purpose: string; - readonly runId: string; -}): string { - const slug = (value: string): string => - value.toLowerCase().replace(/[^a-z0-9._-]/gu, "-"); - const runId = slug(input.runId); - const purpose = slug(input.purpose); - const budget = 64 - `completion-02--${runId}`.length; - const kept = - purpose.length <= budget - ? purpose - : `${purpose.slice(0, Math.max(1, budget - 9))}-${digest8(purpose)}`; - const candidate = `completion-02-${kept}-${runId}`; - if (candidate.length > 64) - throw new Error(`环境名超出 64 个字符上限:${candidate}`); - if (!environmentNamePattern.test(candidate)) - throw new Error(`环境名不符合环境包命名规则:${candidate}`); - return candidate; -} - -function digest8(value: string): string { - return createHash("sha256").update(value).digest("hex").slice(0, 8); -} - -async function sourceSelectionConfig( - source: ScenarioSource, -): Promise> { - if (source.mode !== "controlled") return {}; - const manifestPath = path.resolve(source.manifestPath); - const loaded = await loadControlledSourceIo({ manifestPath }); - return { - sourceIo: { - manifestPath, - contentIdentity: loaded.contentIdentity, - }, - }; -} - -/** - * 从环境记录的事实派生子进程来源选择变量。托管世代的每一种选择都必须显式声明: - * 缺失即视为父终端遗留配置,直接拒绝而不是落回真实来源。 - */ -function sourceVariables( - facts: EnvironmentFacts, -): Readonly> { - if (facts.sourceMode === "offline") - return { DIGITAL_SOURCE_IO_MODE: "offline" }; - if (facts.sourceMode === "real") return { DIGITAL_SOURCE_IO_MODE: "real" }; - if (facts.sourceMode !== "controlled") - throw new Error( - `环境记录了无法转达的来源模式:${JSON.stringify(facts.sourceMode)}`, - ); - const manifestPath = recordedText(facts.config.sourceIo, "manifestPath"); - const contentIdentity = recordedText( - facts.config.sourceIo, - "contentIdentity", - ); - if (manifestPath === undefined || contentIdentity === undefined) - throw new Error("受控环境的记录缺少清单路径或内容身份"); - return { - DIGITAL_SOURCE_IO_MODE: "controlled", - DIGITAL_SOURCE_IO_CONFIG: manifestPath, - DIGITAL_SOURCE_IO_IDENTITY: contentIdentity, - }; -} - -/** 从记录的 JSON 配置里读取一个字符串字段;数组与非对象一律视为缺失。 */ -function recordedText( - value: JsonValue | undefined, - field: string, -): string | undefined { - if (value === null || typeof value !== "object") return undefined; - for (const [name, item] of Object.entries(value)) { - if (name !== field) continue; - return typeof item === "string" ? item : undefined; - } - return undefined; -} - -/** - * 交给正式 CLI 子进程的环境:只带白名单变量。父终端的产品根、来源模式、端口与 - * NODE_OPTIONS 一律不继承,避免遗留配置让场景意外使用其他产品数据或真实来源。 - */ -export function scenarioCommandEnvironment( - facts: EnvironmentFacts, -): Readonly> { - return { - PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", - HOME: process.env.HOME ?? homedir(), - TMPDIR: process.env.TMPDIR ?? tmpdir(), - DIGITAL_PRODUCT_DATA: facts.productDataRoot, - ...sourceVariables(facts), - }; -} - -const authoritativeVariables = [ - "DIGITAL_PRODUCT_DATA", - "DIGITAL_SOURCE_IO_MODE", - "DIGITAL_SOURCE_IO_CONFIG", - "DIGITAL_SOURCE_IO_IDENTITY", -] as const; - -export type MergedCommandEnvironment = { - readonly env: Readonly>; - /** 场景声明的变量与环境记录不一致的键:产品资源归属始终属于环境。 */ - readonly overridden: readonly string[]; -}; - -export function mergeCommandEnvironment( - environment: AcceptanceEnvironment, - extra: Readonly> | undefined, -): MergedCommandEnvironment { - const merged: Record = { ...environment.baseEnvironment }; - const overridden: string[] = []; - for (const [key, value] of Object.entries(extra ?? {})) { - if ((authoritativeVariables as readonly string[]).includes(key)) { - if (merged[key] !== value) overridden.push(key); - continue; - } - merged[key] = value; - } - return { env: merged, overridden }; -} - -function prepareProblem( - result: PrepareOperationResult, -): EnvironmentOperationProblem { - const failure = result.failure; - return { - operation: "prepare", - status: result.status, - ...(failure === undefined - ? { message: result.incompatibility?.join(";") ?? "", exitCode: null } - : { - stage: failure.stage, - message: failure.message, - exitCode: failure.exitCode, - }), - logs: result.logs.files, - diagnostics: result.diagnostics, - }; -} - -export async function prepareScenarioEnvironment(input: { - readonly workspacePath: string; - readonly environment: string; - readonly source: ScenarioSource; - readonly inputs?: readonly string[]; - readonly webPort?: number; - readonly signal?: AbortSignal; -}): Promise { - const config: Record = await sourceSelectionConfig( - input.source, - ); - if (input.webPort !== undefined) config.webPort = input.webPort; - const request: PrepareEnvironmentRequest = { - workspacePath: input.workspacePath, - environment: input.environment, - sourceMode: input.source.mode, - config, - ...(input.inputs === undefined ? {} : { inputs: input.inputs }), - ...(input.signal === undefined ? {} : { signal: input.signal }), - }; - const preparation = await prepareEnvironment(request); - if (preparation.status !== "prepared") - throw new EnvironmentOperationError(prepareProblem(preparation)); - return { - workspacePath: preparation.environment.workspacePath, - environment: preparation.environment.environment, - productDataRoot: preparation.environment.productDataRoot, - facts: preparation.environment, - baseEnvironment: scenarioCommandEnvironment(preparation.environment), - preparation, - }; -} - -export async function startScenarioEnvironment(input: { - readonly workspacePath: string; - readonly environment: string; - readonly signal?: AbortSignal; -}): Promise { - return await startEnvironment({ - workspacePath: input.workspacePath, - environment: input.environment, - ...(input.signal === undefined ? {} : { signal: input.signal }), - }); -} - -export function stopScenarioEnvironment(input: { - readonly workspacePath: string; - readonly environment: string; -}): Promise { - return stopEnvironment(input); -} - -export function deleteScenarioEnvironment(input: { - readonly workspacePath: string; - readonly environment: string; -}): Promise { - return deleteEnvironment(input); -} - -export function inspectScenarioEnvironment(input: { - readonly workspacePath: string; - readonly environment: string; -}): Promise { - return inspectEnvironment(input); -} - -/** - * 独立确认收尾:只采信公开查看给出的进程存活与就绪结论。端口能否连上、记录里是否 - * 写了 ended 都不算退出证明;无法确认时保留为未确认,绝不改写成已退出。 - */ -export type OwnerExitConfirmation = { - readonly confirmed: boolean; - readonly state: InspectEnvironmentResult["state"]; - readonly processLiveness: string; - readonly serviceReadiness: InspectEnvironmentResult["serviceReadiness"]; - readonly detail: string; -}; - -export async function confirmOwnerExit(input: { - readonly workspacePath: string; - readonly environment: string; -}): Promise { - const inspected = await inspectEnvironment(input); - const exited = - inspected.run.state === "absent" || - inspected.run.processLiveness === "exited" || - inspected.run.processLiveness === "absent"; - const notServing = - inspected.serviceReadiness === "not-ready" || - inspected.serviceReadiness === "not-evaluated"; - // 没有记录的世代(未准备/非本管理器)没有任何可确认的进程,一律回到未确认。 - const recorded = - inspected.state === "prepared" || inspected.state === "failed"; - return { - confirmed: recorded && exited && notServing, - state: inspected.state, - processLiveness: inspected.run.processLiveness, - serviceReadiness: inspected.serviceReadiness, - detail: - `state=${inspected.state} run.state=${inspected.run.state} ` + - `liveness=${inspected.run.processLiveness} readiness=${inspected.serviceReadiness}` + - (inspected.runProblem === undefined - ? "" - : ` runProblem=${inspected.runProblem}`), - }; -} - -/** - * 调用方提供的(provided)环境:只按公开查看接受既有世代,绝不准备、启动、停止、 - * 重建或删除。数据根、来源选择与运行分支都取自环境包记录的事实。 - */ -export type ProvidedEnvironment = AcceptanceEnvironment & { - readonly inspection: InspectEnvironmentResult; -}; - -export class ProvidedEnvironmentUnavailable extends Error { - readonly inspection: InspectEnvironmentResult; - - constructor(inspection: InspectEnvironmentResult) { - super( - `提供的环境不可采信:status=${inspection.status} state=${inspection.state}` + - `${inspection.reason === undefined ? "" : ` ${inspection.reason}`}`, - ); - this.name = "ProvidedEnvironmentUnavailable"; - this.inspection = inspection; - } -} - -export async function openProvidedEnvironment(input: { - readonly workspacePath: string; - readonly environment: string; -}): Promise { - const inspection = await inspectScenarioEnvironment(input); - if ( - inspection.status !== "ok" || - inspection.state === "unmanaged" || - inspection.environment.productDataRoot.length === 0 - ) - throw new ProvidedEnvironmentUnavailable(inspection); - return { - workspacePath: inspection.environment.workspacePath, - environment: inspection.environment.environment, - productDataRoot: inspection.environment.productDataRoot, - facts: inspection.environment, - baseEnvironment: scenarioCommandEnvironment(inspection.environment), - inspection, - }; -} - -/** - * 一次 provided 收尾:只有查看,并如实指出无法确认的部分。它复用自己的环境的 - * 收尾形状,但停止、退出确认与删除一律缺席,好让证据无法把 provided 写成已回收。 - */ -export type ProvidedRetention = OwnedRecycling & { - /** 调用方对 provided 环境实际执行过的公开操作:只可能是查看。 */ - readonly performedOperations: readonly string[]; - readonly fingerprint: EnvironmentFingerprint; - readonly serviceReadiness: string; -}; - -export async function retainProvidedEnvironment(input: { - readonly workspacePath: string; - readonly environment: string; - readonly outcome: OwnedRecycling["outcome"]; -}): Promise { - const inspection = await inspectScenarioEnvironment(input); - const fingerprint = await fingerprintEnvironment(input); - const problems: string[] = []; - if (inspection.status !== "ok" || inspection.state === "unmanaged") - problems.push( - `提供的环境收尾无法确认归属:status=${inspection.status} state=${inspection.state}`, - ); - if (inspection.runProblem !== undefined) - problems.push( - `提供的环境运行分支未能确认:readiness=${inspection.serviceReadiness} ${inspection.runProblem}`, - ); - return { - outcome: input.outcome, - problems, - recyclingDiagnostics: [ - `provided 环境只执行查看:readiness=${inspection.serviceReadiness} ` + - `liveness=${inspection.run.processLiveness} port=${String(inspection.run.port)};` + - "未停止、未重建、未删除,世代由所有者继续持有", - ], - retainedDataRoot: inspection.environment.productDataRoot, - retainedLogs: [], - performedOperations: ["inspect"], - fingerprint, - serviceReadiness: inspection.serviceReadiness, - }; -} - -/** 供归属核验的环境快照:身份、数据世代、运行分支与容器内文件清单。 */ -export type EnvironmentFingerprint = { - readonly state: string; - readonly environmentId: string | null; - readonly dataGeneration: string | null; - readonly sourceMode: string; - readonly runId: string | null; - readonly runState: string; - readonly processLiveness: string; - readonly serviceReadiness: string; - readonly port: number | null; - readonly logFiles: readonly string[]; -}; - -export async function fingerprintEnvironment(input: { - readonly workspacePath: string; - readonly environment: string; -}): Promise { - const inspected = await inspectEnvironment(input); - return { - state: inspected.state, - environmentId: inspected.environment.environmentId, - dataGeneration: inspected.environment.dataGeneration, - sourceMode: inspected.environment.sourceMode, - runId: inspected.run.runId, - runState: inspected.run.state, - processLiveness: inspected.run.processLiveness, - serviceReadiness: inspected.serviceReadiness, - port: inspected.run.port, - logFiles: await listFiles(inspected.environment.containerDirectory), - }; -} - -async function listFiles(root: string): Promise { - const found: string[] = []; - async function walk(directory: string): Promise { - const entries = await readdir(directory, { withFileTypes: true }).catch( - () => [], - ); - for (const entry of entries) { - const next = path.join(directory, entry.name); - if (entry.isDirectory()) await walk(next); - else if (entry.isFile()) found.push(path.relative(root, next)); - } - } - await walk(root); - return found.toSorted(); -} - -export function fingerprintDifference( - before: EnvironmentFingerprint, - after: EnvironmentFingerprint, -): readonly string[] { - const differences: string[] = []; - for (const key of [ - "state", - "environmentId", - "dataGeneration", - "sourceMode", - "runId", - "runState", - "processLiveness", - "serviceReadiness", - "port", - ] as const) { - const left = before[key]; - const right = after[key]; - if (left !== right) - differences.push(`${key}: ${String(left)} → ${String(right)}`); - } - if (before.logFiles.join("\n") !== after.logFiles.join("\n")) - differences.push("容器内文件清单发生变化"); - return differences; -} - -export const reservedPortRange = { min: 53_001, max: 53_999 } as const; - -/** - * 端口只在真正要启动一个由本工单拥有的服务时按空闲结果选定:先真实 bind 再释放, - * 让环境包自己占有它,避免与其他并行工单的私有端口重叠。 - */ -export async function reserveFreePort(input: { - readonly exclude?: readonly number[]; - readonly runId: string; -}): Promise { - const excluded = new Set(input.exclude ?? []); - const span = reservedPortRange.max - reservedPortRange.min + 1; - const offset = hashInt(input.runId) % span; - for (let attempt = 0; attempt < span; attempt += 1) { - const candidate = reservedPortRange.min + ((offset + attempt * 37) % span); - if (excluded.has(candidate)) continue; - if (await portIsFree(candidate)) return candidate; - } - throw new Error( - `在 ${reservedPortRange.min}—${reservedPortRange.max} 区间内找不到可用端口(轮次 ${input.runId})`, - ); -} - -function hashInt(value: string): number { - return createHash("sha256").update(value).digest().readUInt32BE(0); -} - -async function portIsFree(port: number): Promise { - const server = net.createServer(); - return await new Promise((resolve) => { - server.once("error", () => { - resolve(false); - }); - server.listen(port, "127.0.0.1", () => { - server.close(() => { - resolve(true); - }); - }); - }); -} - -export type OwnedRecycling = { - readonly outcome: "success" | "failure" | "interrupted"; - readonly stop?: StopOperationResult; - readonly exitConfirmation?: OwnerExitConfirmation; - readonly deletion?: DeleteOperationResult; - /** 收尾各环节的独立可见问题;不与首因合并,也不掩盖首因。 */ - readonly problems: readonly string[]; - /** 公开环境操作给出的收尾叙述;如实记录但不算失败。 */ - readonly recyclingDiagnostics: readonly string[]; - readonly retainedDataRoot?: string; - readonly retainedLogs: readonly string[]; -}; - -function recyclingProblems( - stopped: StopOperationResult, - confirmation: OwnerExitConfirmation, -): string[] { - const problems: string[] = []; - const failure = stopped.failure; - if (stopped.status === "not-confirmed" || !confirmation.confirmed) - problems.push( - `收尾停止未确认:stop=${stopped.status} ${confirmation.detail}` + - (failure === undefined ? "" : ` ${failure.message}`), - ); - if (stopped.status === "invalid-request" || stopped.status === "unmanaged") - problems.push( - `收尾停止未被接受:stop=${stopped.status}` + - (failure === undefined ? "" : ` ${failure.message}`), - ); - for (const error of stopped.cleanup.errors) - problems.push(`收尾停止的清理错误:${error}`); - return problems; -} - -/** - * 自有环境的回收:保存结果之后先停止,再由独立查看确认退出,成功才删除。 - * 失败或中断只停止并保留可显式重启的数据与日志;无法确认退出时既不删除, - * 也绝不把未确认写成已退出。 - */ -export async function recycleOwnedEnvironment(input: { - readonly workspacePath: string; - readonly environment: string; - readonly outcome: OwnedRecycling["outcome"]; - /** 成功交接给上传调用方:停止和退出确认照常执行,数据暂不删除。 */ - readonly retainOnSuccess?: boolean; - /** 清理上下文:取消在停止/确认之后到达时,删除一律不执行、现场保留。 */ - readonly signal?: AbortSignal; -}): Promise { - const stopped = await stopScenarioEnvironment(input); - const confirmation = await confirmOwnerExit(input); - const problems = recyclingProblems(stopped, confirmation); - const retainedLogs = stopped.logs.files; - const diagnostics = [...stopped.diagnostics]; - if (input.outcome !== "success") - return { - outcome: input.outcome, - stop: stopped, - exitConfirmation: confirmation, - problems, - recyclingDiagnostics: diagnostics, - retainedDataRoot: stopped.environment.productDataRoot, - retainedLogs, - }; - if (!confirmation.confirmed) - return { - outcome: input.outcome, - stop: stopped, - exitConfirmation: confirmation, - problems, - recyclingDiagnostics: diagnostics, - retainedDataRoot: stopped.environment.productDataRoot, - retainedLogs, - }; - if (input.signal?.aborted === true) { - diagnostics.push( - "取消在收尾期间到达:自有服务已停止并确认退出,数据与日志按现场保留", - ); - return { - outcome: input.outcome, - stop: stopped, - exitConfirmation: confirmation, - problems, - recyclingDiagnostics: diagnostics, - retainedDataRoot: stopped.environment.productDataRoot, - retainedLogs, - }; - } - if (input.retainOnSuccess === true) - return { - outcome: input.outcome, - stop: stopped, - exitConfirmation: confirmation, - problems, - recyclingDiagnostics: [ - ...diagnostics, - "成功结果已保留,等待调用方实际上传后显式删除", - ], - retainedDataRoot: stopped.environment.productDataRoot, - retainedLogs, - }; - const deletion = await deleteScenarioEnvironment(input); - if (deletion.status !== "deleted" && deletion.status !== "already-deleted") - problems.push( - `临时数据删除未完成:delete=${deletion.status} ${deletion.failure?.message ?? ""}`, - ); - for (const error of deletion.cleanup.errors) - problems.push(`删除自有环境的清理错误:${error}`); - return { - outcome: input.outcome, - stop: stopped, - exitConfirmation: confirmation, - deletion, - problems, - recyclingDiagnostics: [...diagnostics, ...deletion.diagnostics], - retainedLogs, - }; -} - -/** 每次启动前必须登记的资源归属;先写 intent(工作区/环境/端口),再写 result。 */ -export type StartLedgerRecord = { - readonly phase: "intent" | "result" | "release"; - readonly workspacePath: string; - readonly environment: string; - readonly port: number; - readonly at: string; - readonly runId?: string; - readonly status?: string; - readonly pid?: number | null; - readonly startTimeTicks?: number | null; - readonly exitCode?: number | null; -}; - -/** - * 读取进程启动时刻(内核滴答):与 PID 一起构成重启后不误认同一进程的依据。 - * 非 Linux 或进程已退出时返回 null,登记仍保留 PID 供人工核对。 - */ -export async function processStartTimeTicks( - pid: number, -): Promise { - try { - const stat = await readFile(`/proc/${pid}/stat`, "utf8"); - const fields = stat - .slice(stat.lastIndexOf(")") + 1) - .trim() - .split(/\s+/u); - const ticks = Number(fields[19]); - return Number.isSafeInteger(ticks) ? ticks : null; - } catch { - return null; - } -} - -const runtimeRoot = path.dirname(fileURLToPath(import.meta.url)); - -/** 本票独占的启动登记表:位于作者树 run/ 下,前缀 02-。 */ -export const startLedgerPath = path.resolve( - runtimeRoot, - "..", - "..", - "..", - "..", - "run", - "02-start-ledger.ndjson", -); - -export async function appendStartLedger( - ledgerPath: string, - record: StartLedgerRecord, -): Promise { - await mkdir(path.dirname(ledgerPath), { recursive: true }); - await appendFile(ledgerPath, `${JSON.stringify(record)}\n`, "utf8"); -} - -/** 一次场景世代短标识:进入环境名,也让并行场景互不重叠。 */ -export function scenarioRunId(): string { - return `${Date.now().toString(36)}${createHash("sha256") - .update(String(process.pid) + Math.random()) - .digest("hex") - .slice(0, 4)}`; -} - -/** - * 只给验收单元/归属测试构造一个不经环境包的场景环境形状:用于检查调用方的归属与 - * 收尾分支,不用于任何正式 CLI 运行。 - */ -export function stubScenarioEnvironment(input: { - readonly workspacePath: string; - readonly environment?: string; - readonly sourceMode?: ScenarioSource["mode"]; -}): ScenarioEnvironment { - const environment = input.environment ?? "completion-02-stub"; - const facts: EnvironmentFacts = { - workspacePath: input.workspacePath, - environment, - environmentId: null, - containerDirectory: path.join( - input.workspacePath, - ".digital", - "environments", - environment, - ), - productDataRoot: path.join( - input.workspacePath, - ".digital", - "environments", - environment, - "product-data", - ), - inputs: [], - sourceMode: input.sourceMode ?? "offline", - config: {}, - dataGeneration: null, - }; - return { - workspacePath: input.workspacePath, - environment, - productDataRoot: facts.productDataRoot, - facts, - baseEnvironment: scenarioCommandEnvironment(facts), - preparation: { - kind: "prepare", - status: "prepared", - environment: facts, - cleanup: { state: "not-started", errors: [] }, - logs: { directory: null, files: [] }, - diagnostics: [], - }, - }; -} - -/** 一次成功的自有环境收尾形状,供只关心主路径与错误分离的测试复用。 */ -export function stubRecycling( - outcome: OwnedRecycling["outcome"], - overrides: Partial = {}, -): OwnedRecycling { - return { - outcome, - problems: [], - recyclingDiagnostics: [], - retainedLogs: [], - ...overrides, - }; -} - -/** 构造一个已删除的公开结果形状,供只核验调用方收尾分支的单元测试复用。 */ -export const stubRunFacts: EnvironmentRunFacts = { - runId: null, - state: "absent", - address: null, - port: null, - productLogFile: null, - logHealth: null, - pid: null, - processLiveness: "exited", - controlReachable: false, - serviceReady: null, - startedAt: null, - updatedAt: null, - endedAt: null, -}; - -export function stubDeletion( - environment: EnvironmentFacts, -): DeleteOperationResult { - return { - kind: "delete", - status: "deleted", - environment, - run: stubRunFacts, - cleanup: { state: "released", errors: [] }, - logs: { directory: null, files: [] }, - diagnostics: [], - }; -} diff --git a/apps/cli/test/e2e/fixed-corpus.ts b/apps/cli/test/e2e/fixed-corpus.ts deleted file mode 100644 index fc32ce0..0000000 --- a/apps/cli/test/e2e/fixed-corpus.ts +++ /dev/null @@ -1,690 +0,0 @@ -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; - -import { - captureRawFilesystemSnapshot, - type RawFilesystemSnapshot, -} from "@digital/test-support"; - -import type { CliJourneyContext } from "./journey.ts"; - -export type FixedCorpusRoot = { - readonly logicalRoot: string; - readonly directory: string; -}; - -export type FixedCorpusInventory = { - readonly logicalRoot: string; - readonly fileCount: number; - readonly directoryCount: number; - readonly byteLength: number; - readonly inventoryDigest: string; -}; - -export type FixedCorpusContentDigest = { - readonly logicalRoot: string; - readonly contentDigest: string; -}; - -export type FixedCorpusDedicatedIntegrationStatus = - | "implemented" - | "external-gated" - | "pending"; - -/** - * 仅用于本地验收路由。所有权指明最终负责证明某个根满足验收标准的议题; - * 绝不表示所有者已经完成其闸门。 - */ -export type FixedCorpusDedicatedIntegrationOwnershipGroup = { - readonly ownerTicket: string; - readonly journey?: string; - readonly status: FixedCorpusDedicatedIntegrationStatus; - readonly roots: readonly FixedCorpusRoot[]; -}; - -type FixedCorpusInventoryBaseline = { - readonly schemaVersion: 2; - readonly roots: readonly (FixedCorpusInventory & FixedCorpusContentDigest)[]; - readonly totals: Omit< - FixedCorpusInventory, - "logicalRoot" | "inventoryDigest" - >; -}; - -function normalizeLogicalRoot(value: string): string { - const normalized = path.posix.normalize(value.replace(/\/+$/u, "")); - if ( - normalized.length === 0 || - normalized === "." || - path.posix.isAbsolute(normalized) || - normalized === ".." || - normalized.startsWith("../") - ) { - throw new Error(`invalid fixed-corpus logical root: ${value}`); - } - return normalized; -} - -export async function fixedCorpusRoots( - context: CliJourneyContext, -): Promise { - const corpusDirectory = path.resolve( - context.packageRoot, - "../..", - "fixtures/live-corpus", - ); - const selection = await readFile( - path.join(corpusDirectory, "files-from.txt"), - "utf8", - ); - return selection - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith("#")) - .map((logicalRoot) => { - const normalized = normalizeLogicalRoot(logicalRoot); - return { - logicalRoot: normalized, - directory: path.resolve( - context.packageRoot, - "../..", - ".scratch/acceptance-corpus/assets", - ...normalized.split("/"), - ), - }; - }); -} - -/** - * 在真实语料旅程开始前,验证单独审核且与位置无关的清单。这里刻意绝不写入或重新生成基线: - * 接受变化后的语料是有意的本地审核动作,而不是测试副作用。 - */ -export async function assertFixedCorpusInventoryBaseline( - context: CliJourneyContext, - snapshots?: ReadonlyMap, -): Promise { - const corpusDirectory = path.resolve( - context.packageRoot, - "../..", - "fixtures/live-corpus", - ); - const baseline = JSON.parse( - await readFile( - path.join( - corpusDirectory, - "references/fixed-corpus-inventory-baseline.json", - ), - "utf8", - ), - ) as FixedCorpusInventoryBaseline; - if (baseline.schemaVersion !== 2) - throw new Error("unsupported fixed-corpus inventory baseline schema"); - const roots = await fixedCorpusRoots(context); - if (roots.length !== baseline.roots.length) - throw new Error("fixed corpus inventory baseline root count drifted"); - let totalFiles = 0; - let totalDirectories = 0; - let totalByteLength = 0; - for (const [index, root] of roots.entries()) { - const expected = baseline.roots[index]; - if (expected?.logicalRoot !== root.logicalRoot) - throw new Error( - `fixed corpus inventory baseline root drifted: ${root.logicalRoot}`, - ); - const actual = - snapshots === undefined - ? await fixedCorpusInventory(root) - : fixedCorpusInventoryFromSnapshot( - root, - requiredSnapshot(snapshots, root.logicalRoot), - ); - assertInventoryMatchesBaseline(actual, expected); - totalFiles = safeAdd(totalFiles, actual.fileCount, "file count"); - totalDirectories = safeAdd( - totalDirectories, - actual.directoryCount, - "directory count", - ); - totalByteLength = safeAdd( - totalByteLength, - actual.byteLength, - "byte length", - ); - } - assertSafeNonnegative(baseline.totals.fileCount, "baseline file count"); - assertSafeNonnegative( - baseline.totals.directoryCount, - "baseline directory count", - ); - assertSafeNonnegative(baseline.totals.byteLength, "baseline byte length"); - if ( - baseline.totals.fileCount !== totalFiles || - baseline.totals.directoryCount !== totalDirectories || - baseline.totals.byteLength !== totalByteLength - ) - throw new Error("fixed corpus inventory baseline totals drifted"); -} - -export async function fixedCorpusContentDigests( - roots: readonly FixedCorpusRoot[], -): Promise> { - const snapshots = await captureFixedCorpusSnapshots(roots, true); - return fixedCorpusContentDigestsFromSnapshots(roots, snapshots); -} - -export async function fixedCorpusContentDigest( - root: FixedCorpusRoot, -): Promise { - return fixedCorpusContentDigestFromSnapshot( - root, - await captureRawFilesystemSnapshot(root.directory, { - contentDigests: true, - }), - ); -} - -export function fixedCorpusContentDigestFromSnapshot( - root: FixedCorpusRoot, - snapshot: RawFilesystemSnapshot, -): FixedCorpusContentDigest { - const rootDigest = createHash("sha256"); - for (const entry of snapshot.entries) { - if (entry.kind !== "regular-file") continue; - if (entry.contentDigest === undefined) - throw new Error( - `fixed corpus content digest was not captured: ${root.logicalRoot}/${entry.relativePath}`, - ); - rootDigest.update(entry.relativePath); - rootDigest.update("\0"); - rootDigest.update(entry.contentDigest); - rootDigest.update("\n"); - } - return { - logicalRoot: root.logicalRoot, - contentDigest: rootDigest.digest("hex"), - }; -} - -export function fixedCorpusContentDigestsFromSnapshots( - roots: readonly FixedCorpusRoot[], - snapshots: ReadonlyMap, -): ReadonlyMap { - return new Map( - roots.map((root) => [ - root.logicalRoot, - fixedCorpusContentDigestFromSnapshot( - root, - requiredSnapshot(snapshots, root.logicalRoot), - ), - ]), - ); -} - -export async function captureFixedCorpusSnapshots( - roots: readonly FixedCorpusRoot[], - contentDigests: boolean, -): Promise> { - return new Map( - await Promise.all( - roots.map( - async (root) => - [ - root.logicalRoot, - await captureRawFilesystemSnapshot(root.directory, { - contentDigests, - }), - ] as const, - ), - ), - ); -} - -export async function assertFixedCorpusContentBaseline( - context: CliJourneyContext, - actual: ReadonlyMap, -): Promise { - const baseline = await readFixedCorpusBaseline(context); - if (actual.size !== baseline.roots.length) - throw new Error("fixed corpus content baseline root count drifted"); - await assertFixedCorpusContentBaselineSubset(context, actual); -} - -/** 将单个真实门的有限根集合与同一份人工批准内容摘要比较。 */ -export async function assertFixedCorpusContentBaselineSubset( - context: CliJourneyContext, - actual: ReadonlyMap, -): Promise { - const baseline = await readFixedCorpusBaseline(context); - const expectedByRoot = new Map( - baseline.roots.map((expected) => [expected.logicalRoot, expected]), - ); - for (const observed of actual.values()) { - const expected = expectedByRoot.get(observed.logicalRoot); - if (expected === undefined) - throw new Error( - `fixed corpus content baseline root missing: ${observed.logicalRoot}`, - ); - assertContentDigestMatchesBaseline(observed, expected); - } -} - -export function assertContentDigestMatchesBaseline( - actual: FixedCorpusContentDigest, - expected: FixedCorpusContentDigest, -): void { - if ( - actual.logicalRoot !== expected.logicalRoot || - actual.contentDigest !== expected.contentDigest - ) - throw new Error( - `fixed corpus content baseline drifted: ${actual.logicalRoot}`, - ); -} - -async function readFixedCorpusBaseline( - context: CliJourneyContext, -): Promise { - const corpusDirectory = path.resolve( - context.packageRoot, - "../..", - "fixtures/live-corpus", - ); - const baseline = JSON.parse( - await readFile( - path.join( - corpusDirectory, - "references/fixed-corpus-inventory-baseline.json", - ), - "utf8", - ), - ) as FixedCorpusInventoryBaseline; - if (baseline.schemaVersion !== 2) - throw new Error("unsupported fixed-corpus inventory baseline schema"); - return baseline; -} - -function assertSafeNonnegative(value: number, label: string): void { - if (!Number.isSafeInteger(value) || value < 0) - throw new Error(`fixed corpus ${label} is outside the safe integer range`); -} - -function safeAdd(total: number, value: number, label: string): number { - assertSafeNonnegative(total, label); - assertSafeNonnegative(value, label); - const result = total + value; - assertSafeNonnegative(result, label); - return result; -} - -export function assertInventoryMatchesBaseline( - actual: FixedCorpusInventory, - expected: FixedCorpusInventory, -): void { - if ( - expected.logicalRoot !== actual.logicalRoot || - expected.fileCount !== actual.fileCount || - expected.directoryCount !== actual.directoryCount || - expected.byteLength !== actual.byteLength || - expected.inventoryDigest !== actual.inventoryDigest - ) - throw new Error( - `fixed corpus inventory baseline drifted: ${actual.logicalRoot}`, - ); -} - -export async function fixedCorpusInventory( - root: FixedCorpusRoot, -): Promise { - return fixedCorpusInventoryFromSnapshot( - root, - await captureRawFilesystemSnapshot(root.directory), - ); -} - -export function fixedCorpusInventoryFromSnapshot( - root: FixedCorpusRoot, - snapshot: RawFilesystemSnapshot, -): FixedCorpusInventory { - let fileCount = 0; - let directoryCount = 0; - let byteLength = 0; - const serialized: string[] = []; - for (const entry of snapshot.entries) { - serialized.push( - `${entry.relativePath}\0${entry.kind}\0${entry.byteLength}${entry.symbolicLinkTarget === undefined ? "" : `\0${entry.symbolicLinkTarget}`}\n`, - ); - if (entry.kind === "regular-file") { - fileCount += 1; - byteLength += entry.byteLength; - if (!Number.isSafeInteger(byteLength)) - throw new Error( - `fixed corpus total size is unsafe: ${root.logicalRoot}`, - ); - } else if (entry.kind === "directory") directoryCount += 1; - } - return { - logicalRoot: root.logicalRoot, - fileCount, - directoryCount, - byteLength, - inventoryDigest: createHash("sha256") - .update(serialized.join("")) - .digest("hex"), - }; -} - -function requiredSnapshot( - snapshots: ReadonlyMap, - logicalRoot: string, -): RawFilesystemSnapshot { - const snapshot = snapshots.get(logicalRoot); - if (snapshot === undefined) - throw new Error(`fixed corpus raw snapshot missing: ${logicalRoot}`); - return snapshot; -} - -type DedicatedIntegrationOwnershipContract = { - readonly ownerTicket: string; - readonly rootCount: number; - readonly status: FixedCorpusDedicatedIntegrationStatus; - readonly journey?: string; -}; - -const dedicatedIntegrationOwnershipContracts: readonly DedicatedIntegrationOwnershipContract[] = - [ - { - ownerTicket: "v1-live-acceptance/issues/01-dlsite-eight-live-gate.md", - journey: "dlsite-eight-live", - status: "implemented", - rootCount: 8, - }, - { - ownerTicket: "v1-live-acceptance/issues/02-fanbox-five-live-gate.md", - journey: "fanbox-five-live", - status: "external-gated", - rootCount: 5, - }, - { - ownerTicket: "v1-live-acceptance/issues/03-epub-publication-live-gate.md", - journey: "epub-publication-live", - status: "implemented", - rootCount: 6, - }, - { - ownerTicket: "v1-live-acceptance/issues/04-pdf-series-live-gate.md", - journey: "pdf-publication-live", - status: "implemented", - rootCount: 1, - }, - { - ownerTicket: "v1-live-acceptance/issues/05-bangumi-episodic-live-gate.md", - journey: "bangumi-episodic-live", - status: "implemented", - rootCount: 2, - }, - { - ownerTicket: "v1-live-acceptance/issues/06-film-tmdb-omdb-live-gate.md", - journey: "tmdb-omdb-live", - status: "implemented", - rootCount: 2, - }, - ]; - -const dedicatedIntegrationOwnershipContractByTicket = new Map( - dedicatedIntegrationOwnershipContracts.map((contract) => [ - contract.ownerTicket, - contract, - ]), -); - -function fixedCorpusDirectory(context: CliJourneyContext): string { - return path.resolve(context.packageRoot, "../..", "fixtures/live-corpus"); -} - -function requiredRecord( - value: unknown, - label: string, -): Record { - if (value === null || typeof value !== "object" || Array.isArray(value)) - throw new Error(`fixed corpus routing ${label} must be an object`); - return value as Record; -} - -function requiredArray(value: unknown, label: string): readonly unknown[] { - if (!Array.isArray(value)) - throw new Error(`fixed corpus routing ${label} must be an array`); - return value; -} - -function requiredString(value: unknown, label: string): string { - if (typeof value !== "string" || value.trim().length === 0) - throw new Error(`fixed corpus routing ${label} must be a non-empty string`); - return value; -} - -function requiredStatus( - value: unknown, - label: string, -): FixedCorpusDedicatedIntegrationStatus { - switch (value) { - case "implemented": - case "external-gated": - case "pending": - return value; - default: - throw new Error(`fixed corpus routing ${label} has an unknown status`); - } -} - -function knownRootsByLogicalRoot( - roots: readonly FixedCorpusRoot[], -): ReadonlyMap { - const known = new Map(); - for (const root of roots) { - if (known.has(root.logicalRoot)) - throw new Error( - `fixed corpus routing selection contains a duplicate root: ${root.logicalRoot}`, - ); - known.set(root.logicalRoot, root); - } - return known; -} - -function resolveKnownRoots( - value: unknown, - label: string, - known: ReadonlyMap, -): readonly FixedCorpusRoot[] { - const roots: FixedCorpusRoot[] = []; - const seen = new Set(); - for (const [index, rawRoot] of requiredArray(value, label).entries()) { - const logicalRoot = normalizeLogicalRoot( - requiredString(rawRoot, `${label}[${index}]`), - ); - if (seen.has(logicalRoot)) - throw new Error( - `fixed corpus routing ${label} contains a duplicate root: ${logicalRoot}`, - ); - seen.add(logicalRoot); - const root = known.get(logicalRoot); - if (root === undefined) - throw new Error( - `fixed corpus routing ${label} contains an unknown root: ${logicalRoot}`, - ); - roots.push(root); - } - return roots; -} - -export function fixedCorpusHistoricallyReviewedRootsFromOracle( - rawOracle: unknown, - roots: readonly FixedCorpusRoot[], -): readonly FixedCorpusRoot[] { - const oracle = requiredRecord(rawOracle, "oracle"); - const known = knownRootsByLogicalRoot(roots); - return resolveKnownRoots( - oracle.reviewedSupportedRoots, - "reviewedSupportedRoots", - known, - ); -} - -export function fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle( - rawOracle: unknown, - roots: readonly FixedCorpusRoot[], -): readonly FixedCorpusDedicatedIntegrationOwnershipGroup[] { - const oracle = requiredRecord(rawOracle, "oracle"); - const known = knownRootsByLogicalRoot(roots); - const rawGroups = requiredArray( - oracle.dedicatedIntegrationOwnershipGroups, - "dedicatedIntegrationOwnershipGroups", - ); - if (rawGroups.length !== dedicatedIntegrationOwnershipContracts.length) - throw new Error( - "fixed corpus routing dedicated integration ownership group count drifted", - ); - - const seenOwners = new Set(); - const seenOwnedRoots = new Set(); - const ownershipGroups: FixedCorpusDedicatedIntegrationOwnershipGroup[] = []; - for (const [index, rawGroup] of rawGroups.entries()) { - const group = requiredRecord( - rawGroup, - `dedicatedIntegrationOwnershipGroups[${index}]`, - ); - const ownerTicket = requiredString( - group.ownerTicket, - `dedicatedIntegrationOwnershipGroups[${index}].ownerTicket`, - ); - if (seenOwners.has(ownerTicket)) - throw new Error( - `fixed corpus routing has a duplicate owner ticket ${ownerTicket}`, - ); - seenOwners.add(ownerTicket); - const contract = - dedicatedIntegrationOwnershipContractByTicket.get(ownerTicket); - if (contract === undefined) - throw new Error( - `fixed corpus routing has an unknown owner ticket ${ownerTicket}`, - ); - const status = requiredStatus( - group.status, - `dedicatedIntegrationOwnershipGroups[${index}].status`, - ); - if (status !== contract.status) - throw new Error( - `fixed corpus routing owner ticket ${ownerTicket} status must be ${contract.status}`, - ); - - const hasJourney = Object.hasOwn(group, "journey"); - if (contract.journey === undefined) { - if (hasJourney) - throw new Error( - `fixed corpus routing owner ticket ${ownerTicket} must not declare a journey while pending`, - ); - } else if ( - requiredString( - group.journey, - `dedicatedIntegrationOwnershipGroups[${index}].journey`, - ) !== contract.journey - ) - throw new Error( - `fixed corpus routing owner ticket ${ownerTicket} journey must be ${contract.journey}`, - ); - - const groupRoots = resolveKnownRoots( - group.roots, - `dedicatedIntegrationOwnershipGroups[${index}].roots`, - known, - ); - if (groupRoots.length !== contract.rootCount) - throw new Error( - `fixed corpus routing owner ticket ${ownerTicket} root count must be ${contract.rootCount}`, - ); - for (const root of groupRoots) { - if (seenOwnedRoots.has(root.logicalRoot)) - throw new Error( - `fixed corpus routing has a duplicate owned root: ${root.logicalRoot}`, - ); - seenOwnedRoots.add(root.logicalRoot); - } - ownershipGroups.push({ - ownerTicket, - status, - roots: groupRoots, - ...(contract.journey === undefined ? {} : { journey: contract.journey }), - }); - } - for (const contract of dedicatedIntegrationOwnershipContracts) { - if (!seenOwners.has(contract.ownerTicket)) - throw new Error( - `fixed corpus routing is missing owner ticket ${contract.ownerTicket}`, - ); - } - return ownershipGroups.toSorted((left, right) => - left.ownerTicket < right.ownerTicket - ? -1 - : left.ownerTicket > right.ownerTicket - ? 1 - : 0, - ); -} - -async function fixedCorpusRoutingInput(context: CliJourneyContext): Promise<{ - readonly roots: readonly FixedCorpusRoot[]; - readonly rawOracle: unknown; -}> { - const [roots, rawOracle] = await Promise.all([ - fixedCorpusRoots(context), - readFile( - path.join( - fixedCorpusDirectory(context), - "references/reviewed-oracle.json", - ), - "utf8", - ).then((contents) => JSON.parse(contents) as unknown), - ]); - return { roots, rawOracle }; -} - -/** - * 历史审核保持为独立本地参考材料。它可以帮助人类选择要检查的案例, - * 但绝不授予集成所有者,也不记录实时验收结果。 - */ -export async function fixedCorpusHistoricallyReviewedRoots( - context: CliJourneyContext, -): Promise { - const { roots, rawOracle } = await fixedCorpusRoutingInput(context); - return fixedCorpusHistoricallyReviewedRootsFromOracle(rawOracle, roots); -} - -/** - * 返回五个显式集成所有权组。路由是责任边界,不是对其议题已经通过验收标准的断言。 - */ -export async function fixedCorpusDedicatedIntegrationOwnershipGroups( - context: CliJourneyContext, -): Promise { - const { roots, rawOracle } = await fixedCorpusRoutingInput(context); - return fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle( - rawOracle, - roots, - ); -} - -/** - * 安全旅程只接收显式路由根的补集。它根据专属所有权派生,绝不根据历史审核数据派生。 - */ -export async function unsupportedFixedCorpusRoots( - context: CliJourneyContext, -): Promise { - const { roots, rawOracle } = await fixedCorpusRoutingInput(context); - const ownershipGroups = - fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle(rawOracle, roots); - const ownedLogicalRoots = new Set( - ownershipGroups.flatMap((group) => - group.roots.map((root) => root.logicalRoot), - ), - ); - return roots.filter((root) => !ownedLogicalRoots.has(root.logicalRoot)); -} diff --git a/apps/cli/test/e2e/journey.ts b/apps/cli/test/e2e/journey.ts deleted file mode 100644 index 30f1525..0000000 --- a/apps/cli/test/e2e/journey.ts +++ /dev/null @@ -1,90 +0,0 @@ -export type CliJourneyMode = "source" | "distribution" | "packed"; - -export type CliJourneyCommand = { - readonly name: string; - readonly args: - | readonly string[] - | ((previousResults: readonly CliJourneyResult[]) => readonly string[]); - readonly env?: Readonly>; - /** 实时前置条件失败:在后续依赖命令掩盖它之前停止。 */ - readonly haltOnFailure?: boolean; -}; - -export type CliJourneyResult = { - readonly commandName: string; - readonly exitCode: number; - readonly stdout: string; - readonly stderr: string; -}; - -/** - * 读取 CLI 正式库登记返回的目录归属身份,用于既有按单一目录扫描的验收路径。 - * `library-create` 输出的资产库携带该目录对应的观察根编号。 - */ -export function registeredObservedRootId( - result: CliJourneyResult | undefined, -): string { - let output: unknown; - try { - output = JSON.parse(result?.stdout ?? "{}"); - } catch { - throw new Error( - `library-create did not return JSON\nstdout: ${result?.stdout ?? ""}\nstderr: ${result?.stderr ?? ""}`, - ); - } - if ( - typeof output !== "object" || - output === null || - !("rootIds" in output) || - !Array.isArray(output.rootIds) || - output.rootIds.length !== 1 || - typeof output.rootIds[0] !== "string" || - output.rootIds[0].trim().length === 0 - ) { - throw new Error( - `library-create did not expose exactly one observed-root id\nstdout: ${result?.stdout ?? ""}\nstderr: ${result?.stderr ?? ""}`, - ); - } - return output.rootIds[0]; -} - -export type CliJourneyContext = { - readonly mode: CliJourneyMode; - readonly packageRoot: string; - readonly workDir: string; - /** - * 本次场景的产品数据根:由环境包公开接口准备出来的自有世代根。旅程不再自建 - * 迁移或写入,接入与刷新仍由被测 CLI 命令完成。 - */ - readonly productDataDirectory: string; - /** 本次场景环境的名字,用于把结果与归属证据对应到同一个环境。 */ - readonly environmentName: string; - /** 交给正式 CLI 子进程的基础环境;旅程自管子进程时复用它,而不是继承父终端。 */ - readonly commandEnvironment: Readonly>; -}; - -export type CliJourney = { - readonly name: string; - readonly modes: readonly CliJourneyMode[]; - /** 私有真实语料旅程只在显式入口运行,普通受控检查保留独立业务覆盖。 */ - readonly realCorpus?: boolean; - /** - * 旅程显式选定的来源装配。省略即离线:绝不因父终端遗留配置使用真实来源。 - * `controlled` 的清单由旅程给出,环境只转达选择,不读取清单内容。 - */ - readonly source?: - | { readonly mode: "offline" } - | { readonly mode: "real" } - | { readonly mode: "controlled"; readonly manifestPath: string }; - setup(context: CliJourneyContext): Promise; - commands(context: CliJourneyContext): readonly CliJourneyCommand[]; - assertions(options: { - readonly context: CliJourneyContext; - readonly results: readonly CliJourneyResult[]; - }): Promise; - /** 基线已建立后无论主路径结果如何都执行,只核对外部不变量并释放登记状态。 */ - teardown?(options: { - readonly context: CliJourneyContext; - readonly results: readonly CliJourneyResult[]; - }): Promise; -}; diff --git a/apps/cli/test/e2e/journeys/asset-refresh.journey.ts b/apps/cli/test/e2e/journeys/asset-refresh.journey.ts deleted file mode 100644 index 543e6f0..0000000 --- a/apps/cli/test/e2e/journeys/asset-refresh.journey.ts +++ /dev/null @@ -1,935 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile, stat } from "node:fs/promises"; -import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -import type { AssetRefreshInspection } from "@digital/application"; -import { captureRawFilesystemSnapshot } from "@digital/test-support"; - -import { - registeredObservedRootId, - type CliJourney, - type CliJourneyResult, -} from "../journey.ts"; - -async function tree(root: string): Promise { - const snapshot = await captureRawFilesystemSnapshot(root, { - contentDigests: true, - }); - return snapshot.entries - .filter((entry) => entry.relativePath !== ".") - .map((entry) => - entry.kind === "directory" - ? `${entry.relativePath}/` - : `${entry.relativePath}:${entry.kind}:${entry.byteLength}:${entry.modifiedAtNanoseconds}:${entry.symbolicLinkTarget ?? ""}:${entry.contentDigest ?? ""}`, - ); -} - -const baselines = new Map(); - -function corpusRoot(context: Parameters[0]): string { - return path.resolve( - context.packageRoot, - "../..", - ".scratch/acceptance-corpus/assets/kikoeru/VoiceWork/RJ01668691", - ); -} - -function oraclePath(context: Parameters[0]): string { - return path.resolve( - context.packageRoot, - "../..", - "fixtures/live-corpus/references/RJ01668691.reviewed-oracle.json", - ); -} - -const journey: CliJourney = { - name: "asset-refresh", - realCorpus: true, - modes: ["source", "distribution"], - async setup(context) { - const source = corpusRoot(context); - const details = await stat(source).catch(() => undefined); - if (details?.isDirectory() !== true) { - throw new Error(`required live acceptance corpus is missing: ${source}`); - } - await stat(oraclePath(context)).catch(() => { - throw new Error("required reviewed RJ01668691 oracle is missing"); - }); - baselines.set(source, await tree(source)); - }, - commands(context) { - const source = corpusRoot(context); - const data = context.productDataDirectory; - type IdOutput = { - readonly execution?: { readonly id?: string }; - readonly changeImpactPreview?: { readonly id?: string }; - readonly itemResults?: readonly { - readonly outcome?: string; - readonly accepted?: { readonly entity?: { readonly id?: string } }; - }[]; - }; - const jsonId = ( - results: readonly CliJourneyResult[], - index: number, - selector: (value: IdOutput) => string | undefined, - ) => { - const result = results[index + 1]; - const value = selector(JSON.parse(result?.stdout ?? "{}") as IdOutput); - if (value === undefined) { - throw new Error( - `command ${index + 1} did not expose its required id\nstdout: ${result?.stdout ?? ""}\nstderr: ${result?.stderr ?? ""}`, - ); - } - return value; - }; - const acceptedEntityId = (value: IdOutput): string | undefined => { - const acceptedItems = (value.itemResults ?? []).filter( - (item) => item.outcome === "accepted", - ); - assert.equal( - acceptedItems.length, - 1, - "fresh scan must expose exactly one accepted work item", - ); - return acceptedItems[0]?.accepted?.entity?.id; - }; - return [ - { - name: "register live RJ01668691 corpus root", - args: [ - "library-create", - "RJ01668691 验收语料", - "--directory", - source, - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - haltOnFailure: true, - }, - { - name: "fresh live accepted scan", - args: (results) => [ - "scan", - registeredObservedRootId(results[0]), - "--scope", - ".", - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - haltOnFailure: true, - }, - { - name: "unchanged ordinary scan reuses accepted result", - args: (results) => [ - "scan", - registeredObservedRootId(results[0]), - "--scope", - ".", - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - { - name: "refresh current DLsite metadata", - args: (results) => [ - "refresh-metadata", - jsonId(results, 0, acceptedEntityId), - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - { - name: "directed rescrape previews RJ01497788", - args: (results) => [ - "rescrape", - registeredObservedRootId(results[0]), - "dlsite", - JSON.stringify({ product: "RJ01497788" }), - "--scope", - ".", - "--subject-id", - jsonId(results, 0, acceptedEntityId), - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - haltOnFailure: true, - }, - { - name: "show complete RJ01497788 impact preview", - args: (results) => [ - "show-preview", - jsonId(results, 3, (value) => value.changeImpactPreview?.id), - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - { - name: "apply RJ01497788 impact preview", - args: (results) => [ - "apply-preview", - jsonId(results, 3, (value) => value.changeImpactPreview?.id), - "--actor", - "asset-refresh-live-reviewer", - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - { - name: "inspect fully applied RJ01497788 projection", - args: (results) => [ - "inspect", - jsonId(results, 0, (value) => value.execution?.id), - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - { - name: "refresh applied RJ01497788 metadata", - args: (results) => [ - "refresh-metadata", - jsonId(results, 0, acceptedEntityId), - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - { - name: "directed rescrape back to RJ01668691", - args: (results) => [ - "rescrape", - registeredObservedRootId(results[0]), - "dlsite", - JSON.stringify({ product: "RJ01668691" }), - "--scope", - ".", - "--subject-id", - jsonId(results, 0, acceptedEntityId), - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - haltOnFailure: true, - }, - { - name: "show reverse identity impact preview", - args: (results) => [ - "show-preview", - jsonId(results, 8, (value) => value.changeImpactPreview?.id), - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - { - name: "reject reverse identity impact preview", - args: (results) => [ - "reject-preview", - jsonId(results, 8, (value) => value.changeImpactPreview?.id), - "--actor", - "asset-refresh-live-reviewer", - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - { - name: "inspect unchanged RJ01497788 identity after rejection", - args: (results) => [ - "inspect", - jsonId(results, 0, (value) => value.execution?.id), - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - ]; - }, - async assertions({ context, results: allResults }) { - assert.equal(allResults[0]?.exitCode, 0, allResults[0]?.stderr); - registeredObservedRootId(allResults[0]); - const results = allResults.slice(1); - const oracle = JSON.parse(await readFile(oraclePath(context), "utf8")) as { - readonly status: string; - readonly selection: { - readonly identity: string; - readonly sourceNativeAddress: string; - readonly regularFileCount: number; - }; - readonly projection: { - readonly title: string; - readonly introduction: string; - readonly releaseDate: string; - readonly declaredLanguage: string; - readonly contentClassification: string; - readonly circle: { readonly label: string }; - readonly credits: { - readonly voice: readonly string[]; - readonly scenario: readonly string[]; - readonly illustration: readonly string[]; - }; - readonly relationshipRoles: readonly string[]; - }; - }; - assert.equal(oracle.status, "official-source-and-filesystem-reviewed"); - type AcceptedProjection = { - readonly entity: { readonly kind: string }; - readonly sourceAssociations: readonly { - readonly nativeAddress: string; - }[]; - readonly recognitionSourceAssociation?: { - readonly nativeAddress: string; - }; - readonly artifacts: readonly { readonly id: string }[]; - readonly locations: readonly { readonly kind: string }[]; - readonly blobs: readonly { - readonly digest: string; - readonly mediaType: string; - }[]; - readonly artifactRevisions: readonly { - readonly artifactId: string; - readonly blobDigest: string; - readonly producer: { readonly capabilityId: string }; - }[]; - readonly artifactAssociations: readonly { - readonly artifactId: string; - readonly role: string; - }[]; - readonly metadata: readonly { - readonly property: string; - readonly value: string; - readonly sourceRecordIds: readonly string[]; - }[]; - readonly currentRelatedEntities?: NonNullable< - AssetRefreshInspection["accepted"] - >["currentRelatedEntities"]; - readonly relationships?: readonly { - readonly participants: readonly { readonly role: string }[]; - readonly sourceRecordIds: readonly string[]; - }[]; - readonly recognitionExplanation: string; - }; - const initial = JSON.parse(results[0]?.stdout ?? "") as { - readonly format: string; - readonly unstable: boolean; - readonly execution: { - readonly outcome: string; - readonly snapshot: { readonly files: readonly unknown[] }; - }; - readonly sourceRecords: readonly { - readonly id: string; - readonly coverage: { readonly id: string }; - readonly nativeAddress: string; - }[]; - readonly itemResults?: readonly { - readonly outcome?: string; - readonly accepted?: AcceptedProjection; - }[]; - }; - assert.equal(results[0]?.exitCode, 0, results[0]?.stderr); - assert.equal(initial.format, "temporary-asset-refresh-inspection/v1"); - assert.equal(initial.unstable, true); - assert.equal(initial.execution.outcome, "accepted"); - const acceptedItems = (initial.itemResults ?? []).filter( - (item) => item.outcome === "accepted", - ); - assert.equal( - acceptedItems.length, - 1, - "fresh scan must project exactly one accepted work item", - ); - const accepted = acceptedItems[0]?.accepted; - assert.ok( - accepted, - "fresh scan accepted work item is missing its projection", - ); - assert.equal( - initial.execution.snapshot.files.length, - oracle.selection.regularFileCount, - ); - assert.deepEqual( - initial.sourceRecords.map((record) => record.coverage.id), - ["dlsite.product-json", "dlsite.product-html"], - ); - assert.equal( - new Set(initial.sourceRecords.map((record) => record.nativeAddress)).size, - 1, - ); - assert.equal(accepted.entity.kind, "digital-asset"); - assert.ok( - accepted.recognitionSourceAssociation, - "accepted asset is missing its recognition source", - ); - assert.match( - accepted.recognitionSourceAssociation.nativeAddress, - new RegExp(oracle.selection.identity, "u"), - ); - assert.match(accepted.recognitionExplanation, /精确来源地址/u); - assert.equal( - accepted.artifacts.length, - initial.execution.snapshot.files.length + 1, - ); - assert.equal( - accepted.locations.filter((location) => location.kind === "observed") - .length, - initial.execution.snapshot.files.length, - ); - assert.deepEqual( - accepted.artifactRevisions - .map(({ producer }) => producer.capabilityId) - .toSorted(), - ["dlsite.source-artifact-acquirer", "pdf.publication-inspection"], - ); - const initialCoverId = accepted.artifactAssociations.find( - ({ role }) => role === "asset.primary-cover", - )?.artifactId; - assert.ok(initialCoverId, "accepted asset is missing its primary cover"); - const initialCoverRevision = accepted.artifactRevisions.find( - ({ artifactId }) => artifactId === initialCoverId, - ); - assert.ok( - initialCoverRevision, - "accepted primary cover is missing its revision", - ); - assert.match(initialCoverRevision.blobDigest, /^[a-f0-9]{64}$/u); - assert.match( - accepted.blobs.find( - ({ digest }) => digest === initialCoverRevision.blobDigest, - )?.mediaType ?? "", - /^image\//u, - ); - assert.deepEqual( - accepted.artifactAssociations - .map((association) => association.role) - .toSorted(), - [ - ...Array.from( - { length: oracle.selection.regularFileCount }, - () => "asset.material-delivered", - ), - "asset.primary-cover", - ], - ); - assert.ok( - accepted.metadata.some( - (entry) => - entry.property === "asset.title" && - entry.value === oracle.projection.title, - ), - ); - for (const [property, value] of [ - ["asset.introduction", oracle.projection.introduction], - ["asset.release-date", oracle.projection.releaseDate], - ["asset.declared-language", oracle.projection.declaredLanguage], - ["asset.content-classification", oracle.projection.contentClassification], - ] as const) { - assert.ok( - accepted.metadata.some( - (entry) => - entry.property === property && - entry.value === value && - entry.sourceRecordIds.length > 0, - ), - `missing reviewed metadata ${property}`, - ); - } - assert.ok( - accepted.currentRelatedEntities?.some( - (entity) => - entity.kind.status === "ordinary" && - entity.identityNamespace === "dlsite.circle" && - entity.label === oracle.projection.circle.label, - ), - ); - assert.ok( - accepted.currentRelatedEntities?.some( - (entity) => - entity.kind.status === "ordinary" && - entity.identityNamespace.startsWith("dlsite.credit.voice") && - entity.label === oracle.projection.credits.voice[0], - ), - ); - assert.deepEqual( - accepted.relationships - ?.flatMap((relationship) => - relationship.participants - .filter(({ role }) => role !== "asset") - .map((participant) => participant.role), - ) - .toSorted(), - oracle.projection.relationshipRoles.toSorted(), - ); - for (const record of initial.sourceRecords) { - assert.equal("body" in record, false); - assert.equal("acquisition" in record, false); - } - - const unchanged = JSON.parse(results[1]?.stdout ?? "") as { - readonly execution: { - readonly outcome: string; - }; - readonly sourceRecords: readonly unknown[]; - readonly itemResults?: readonly { readonly outcome?: string }[]; - }; - assert.equal(results[1]?.exitCode, 0, results[1]?.stderr); - assert.equal(unchanged.execution.outcome, "no-change"); - assert.deepEqual( - (unchanged.itemResults ?? []).map(({ outcome }) => outcome), - ["no-change"], - ); - assert.equal(unchanged.sourceRecords.length, 0); - assert.doesNotMatch( - results[1]?.stderr ?? "", - /正在获取官方来源|正在获取主封面/u, - ); - - type JourneyInspection = { - readonly execution: { - readonly id: string; - readonly outcome: string; - readonly selection: { readonly intent?: string }; - }; - readonly sourceRecords: readonly { - readonly id: string; - readonly nativeAddress: string; - readonly coverage: { readonly id: string }; - }[]; - readonly accepted?: { - readonly entity: { readonly id: string }; - readonly sourceAssociations: readonly { - readonly nativeAddress: string; - }[]; - readonly recognitionSourceAssociation?: { - readonly nativeAddress: string; - }; - readonly metadata: readonly { - readonly property: string; - readonly value: string; - readonly sourceRecordIds: readonly string[]; - }[]; - readonly artifacts: readonly { readonly id: string }[]; - readonly blobs: readonly { - readonly digest: string; - readonly mediaType: string; - }[]; - readonly artifactRevisions: readonly { - readonly artifactId: string; - readonly blobDigest: string; - }[]; - readonly locations: readonly { - readonly artifactId: string; - readonly kind: string; - }[]; - readonly artifactAssociations: readonly { - readonly artifactId: string; - readonly role: string; - }[]; - readonly currentRelatedEntities?: NonNullable< - AssetRefreshInspection["accepted"] - >["currentRelatedEntities"]; - readonly relationships?: readonly { - readonly participants: readonly { - readonly entityId: string; - readonly role: string; - }[]; - readonly type: string; - readonly sourceRecordIds: readonly string[]; - }[]; - }; - readonly itemResults?: readonly { - readonly outcome?: string; - readonly accepted?: JourneyInspection["accepted"]; - }[]; - readonly changeImpactPreview?: { - readonly id: string; - readonly status: string; - readonly recognitionSourceAssociation?: { - readonly nativeAddress: string; - }; - readonly metadataCount: number; - readonly entityCount: number; - readonly mentionCount: number; - readonly resolutionCount: number; - readonly relationshipCount: number; - readonly artifactCount: number; - readonly blockingConditions: readonly string[]; - }; - }; - const acceptedItemProjection = (inspection: JourneyInspection) => { - const items = (inspection.itemResults ?? []).filter( - ({ outcome }) => outcome === "accepted", - ); - assert.equal(items.length, 1, "inspection must expose one accepted item"); - const projection = items[0]?.accepted; - assert.ok(projection, "accepted work item is missing its projection"); - return projection; - }; - const metadataRefresh = JSON.parse( - results[2]?.stdout ?? "", - ) as JourneyInspection; - assert.equal(results[2]?.exitCode, 0, results[2]?.stderr); - assert.equal(metadataRefresh.execution.outcome, "accepted"); - assert.equal( - metadataRefresh.execution.selection.intent, - "metadata-refresh", - ); - assert.ok( - metadataRefresh.accepted?.recognitionSourceAssociation, - "metadata refresh is missing its recognition source", - ); - assert.match( - metadataRefresh.accepted.recognitionSourceAssociation.nativeAddress, - /RJ01668691/u, - ); - assert.deepEqual( - metadataRefresh.sourceRecords.map((record) => record.coverage.id), - ["dlsite.product-json", "dlsite.product-html"], - ); - const initialSourceIds = new Set( - initial.sourceRecords.map((record) => record.id), - ); - const refreshedSourceIds = new Set( - metadataRefresh.sourceRecords.map((record) => record.id), - ); - assert.ok( - [...refreshedSourceIds].every( - (recordId) => !initialSourceIds.has(recordId), - ), - ); - const knownSourceIds = new Set([ - ...initialSourceIds, - ...refreshedSourceIds, - ]); - assert.ok( - metadataRefresh.accepted?.metadata.every((assertion) => - assertion.sourceRecordIds.every((recordId) => - knownSourceIds.has(recordId), - ), - ), - "metadata refresh included an assertion outside its known source lineages", - ); - assert.doesNotMatch( - results[2]?.stderr ?? "", - /正在识别目录|正在获取主封面/u, - ); - - const replacement = JSON.parse( - results[3]?.stdout ?? "", - ) as JourneyInspection; - assert.equal(results[3]?.exitCode, 0, results[3]?.stderr); - assert.equal(replacement.execution.outcome, "proposal"); - assert.equal( - replacement.execution.selection.intent, - "directed-recognition", - ); - assert.match( - replacement.changeImpactPreview?.recognitionSourceAssociation - ?.nativeAddress ?? "", - /RJ01497788/u, - ); - assert.equal( - replacement.changeImpactPreview?.status, - "awaiting-confirmation", - ); - assert.equal(replacement.changeImpactPreview?.blockingConditions.length, 0); - assert.ok((replacement.changeImpactPreview?.metadataCount ?? 0) > 0); - assert.ok((replacement.changeImpactPreview?.entityCount ?? 0) > 0); - assert.ok((replacement.changeImpactPreview?.mentionCount ?? 0) > 0); - assert.ok((replacement.changeImpactPreview?.resolutionCount ?? 0) > 0); - assert.ok((replacement.changeImpactPreview?.relationshipCount ?? 0) > 0); - const replacementSourceIds = new Set( - replacement.sourceRecords.map((record) => record.id), - ); - - const shownReplacement = JSON.parse(results[4]?.stdout ?? "") as { - readonly preview: JourneyInspection["changeImpactPreview"]; - }; - assert.equal(results[4]?.exitCode, 0, results[4]?.stderr); - assert.equal( - shownReplacement.preview?.id, - replacement.changeImpactPreview?.id, - ); - assert.equal(shownReplacement.preview?.status, "awaiting-confirmation"); - const applied = JSON.parse(results[5]?.stdout ?? "") as { - readonly preview: JourneyInspection["changeImpactPreview"]; - }; - assert.equal(results[5]?.exitCode, 0, results[5]?.stderr); - assert.equal(applied.preview?.status, "applied"); - - const projected = JSON.parse(results[6]?.stdout ?? "") as JourneyInspection; - assert.equal(results[6]?.exitCode, 0, results[6]?.stderr); - const projectedAccepted = acceptedItemProjection(projected); - assert.equal( - replacement.changeImpactPreview?.artifactCount, - oracle.selection.regularFileCount, - "identity preview must retain every current material artifact", - ); - assert.ok( - projectedAccepted.recognitionSourceAssociation, - "applied identity projection is missing its recognition source", - ); - assert.match( - projectedAccepted.recognitionSourceAssociation.nativeAddress, - /RJ01497788/u, - ); - assert.ok( - projectedAccepted.metadata.every((assertion) => - assertion.sourceRecordIds.every((recordId) => - replacementSourceIds.has(recordId), - ), - ), - "applied identity projection mixed old metadata facts", - ); - const oldCoverId = accepted.artifactAssociations.find( - (association) => association.role === "asset.primary-cover", - )?.artifactId; - assert.ok(oldCoverId, "initial accepted asset is missing its source cover"); - const newCoverId = projectedAccepted.artifactAssociations.find( - (association) => association.role === "asset.primary-cover", - )?.artifactId; - assert.equal(newCoverId, undefined); - const materialArtifactIds = accepted.artifactAssociations - .filter(({ role }) => role === "asset.material-delivered") - .map(({ artifactId }) => artifactId) - .toSorted(); - assert.deepEqual( - projectedAccepted.artifactAssociations - .filter(({ role }) => role === "asset.material-delivered") - .map(({ artifactId }) => artifactId) - .toSorted(), - materialArtifactIds, - ); - assert.equal( - projectedAccepted.artifacts.length, - oracle.selection.regularFileCount, - "applied identity projection must contain only current materials", - ); - assert.equal( - projectedAccepted.locations.filter( - (location) => location.kind === "observed", - ).length, - oracle.selection.regularFileCount, - "applied identity projection must retain every observed material location", - ); - assert.equal( - new Set( - projectedAccepted.currentRelatedEntities?.map((entity) => entity.id), - ).size, - projectedAccepted.currentRelatedEntities?.length, - ); - assert.ok( - projectedAccepted.relationships?.every( - (relationship) => - relationship.participants.some( - (participant) => - participant.entityId === projectedAccepted.entity.id && - participant.role === "asset", - ) && - relationship.participants.some( - (participant) => - participant.entityId !== projectedAccepted.entity.id, - ) && - relationship.sourceRecordIds.every((recordId) => - replacementSourceIds.has(recordId), - ), - ), - "directed recognition invented a cross-asset relationship", - ); - assert.ok( - projectedAccepted.relationships?.some( - (relationship) => relationship.type === "asset.contribution", - ), - "directed recognition lost its contribution relationships", - ); - const replacementRefresh = JSON.parse( - results[7]?.stdout ?? "", - ) as JourneyInspection; - assert.equal(results[7]?.exitCode, 0, results[7]?.stderr); - assert.ok( - replacementRefresh.accepted?.recognitionSourceAssociation, - "replacement metadata refresh is missing its recognition source", - ); - assert.match( - replacementRefresh.accepted.recognitionSourceAssociation.nativeAddress, - /RJ01497788/u, - ); - const refreshedCoverId = - replacementRefresh.accepted.artifactAssociations.find( - ({ role }) => role === "asset.primary-cover", - )?.artifactId; - assert.equal(refreshedCoverId, undefined); - assert.deepEqual( - replacementRefresh.accepted.artifactAssociations - .filter(({ role }) => role === "asset.material-delivered") - .map(({ artifactId }) => artifactId) - .toSorted(), - materialArtifactIds, - ); - assert.equal( - replacementRefresh.accepted.artifacts.length, - oracle.selection.regularFileCount, - ); - const replacementRefreshIds = new Set( - replacementRefresh.sourceRecords.map((record) => record.id), - ); - const knownReplacementSourceIds = new Set([ - ...replacementSourceIds, - ...replacementRefreshIds, - ]); - assert.ok( - replacementRefresh.accepted?.metadata.every((assertion) => - assertion.sourceRecordIds.every((recordId) => - knownReplacementSourceIds.has(recordId), - ), - ), - "post-apply metadata refresh included an assertion outside its known source lineages", - ); - assert.doesNotMatch(results[7]?.stderr ?? "", /正在识别目录/u); - - const reverse = JSON.parse(results[8]?.stdout ?? "") as JourneyInspection; - assert.equal(results[8]?.exitCode, 0, results[8]?.stderr); - assert.match( - reverse.changeImpactPreview?.recognitionSourceAssociation - ?.nativeAddress ?? "", - /RJ01668691/u, - ); - const shownReverse = JSON.parse(results[9]?.stdout ?? "") as { - readonly preview: JourneyInspection["changeImpactPreview"]; - }; - assert.equal(shownReverse.preview?.status, "awaiting-confirmation"); - const rejected = JSON.parse(results[10]?.stdout ?? "") as { - readonly preview: JourneyInspection["changeImpactPreview"]; - }; - assert.equal(results[10]?.exitCode, 0, results[10]?.stderr); - assert.equal(rejected.preview?.status, "rejected"); - const afterReject = JSON.parse( - results[11]?.stdout ?? "", - ) as JourneyInspection; - assert.equal(results[11]?.exitCode, 0, results[11]?.stderr); - const afterRejectAccepted = acceptedItemProjection(afterReject); - assert.ok( - afterRejectAccepted.recognitionSourceAssociation, - "rejected identity replacement lost its recognition source", - ); - assert.match( - afterRejectAccepted.recognitionSourceAssociation.nativeAddress, - /RJ01497788/u, - ); - const { - sourceArtifactCandidates: _refreshCandidates, - ...refreshedProjection - } = replacementRefresh.accepted as typeof replacementRefresh.accepted & { - readonly sourceArtifactCandidates?: unknown; - }; - const { - sourceArtifactCandidates: _rejectedCandidates, - ...rejectedProjection - } = afterRejectAccepted as typeof afterRejectAccepted & { - readonly sourceArtifactCandidates?: unknown; - }; - assert.deepEqual(rejectedProjection, refreshedProjection); - - type StoredImpactProjection = { - readonly recognitionSourceAssociation: { - readonly nativeAddress: string; - }; - readonly relationships: readonly { - readonly type: string; - readonly participants: readonly { readonly role: string }[]; - readonly sourceRecordIds: readonly string[]; - }[]; - }; - type StoredImpactPreview = { - readonly current: StoredImpactProjection; - readonly projected: StoredImpactProjection; - }; - const database = new DatabaseSync( - path.join(context.productDataDirectory, "catalogue.sqlite"), - { readOnly: true }, - ); - const storedPreview = (id: string) => { - const row = database - .prepare("SELECT preview_json FROM change_impact_previews WHERE id = ?") - .get(id) as { readonly preview_json: string } | undefined; - assert.ok(row, `missing durable Change Impact Preview ${id}`); - return JSON.parse(row.preview_json) as StoredImpactPreview; - }; - const assertSingleIdentityRelationships = ( - label: string, - projection: StoredImpactProjection, - expectedIdentity: RegExp, - allowedSourceRecordIds: ReadonlySet, - forbiddenSourceRecordIds: ReadonlySet, - ) => { - assert.match( - projection.recognitionSourceAssociation.nativeAddress, - expectedIdentity, - ); - for (const relationship of projection.relationships) { - assert.doesNotMatch( - `${relationship.type} ${relationship.participants.map((item) => item.role).join(" ")}`, - /bonus|supplement|特典|赠品|贈品/iu, - "directed identity evidence invented a bonus or supplement relationship", - ); - assert.ok( - relationship.sourceRecordIds.length > 0 && - relationship.sourceRecordIds.every((id) => - allowedSourceRecordIds.has(id), - ), - `${label} relationship mixed facts from another source identity`, - ); - assert.equal( - relationship.sourceRecordIds.some((id) => - forbiddenSourceRecordIds.has(id), - ), - false, - "relationship simultaneously connected both directed source identities", - ); - } - }; - const forwardPreview = storedPreview( - replacement.changeImpactPreview?.id ?? "", - ); - assertSingleIdentityRelationships( - "forward current", - forwardPreview.current, - /RJ01668691/u, - knownSourceIds, - replacementSourceIds, - ); - assertSingleIdentityRelationships( - "forward projected", - forwardPreview.projected, - /RJ01497788/u, - replacementSourceIds, - refreshedSourceIds, - ); - const reverseSourceIds = new Set( - reverse.sourceRecords.map((record) => record.id), - ); - const reversePreview = storedPreview(reverse.changeImpactPreview?.id ?? ""); - assertSingleIdentityRelationships( - "reverse current", - reversePreview.current, - /RJ01497788/u, - knownReplacementSourceIds, - reverseSourceIds, - ); - assertSingleIdentityRelationships( - "reverse projected", - reversePreview.projected, - /RJ01668691/u, - reverseSourceIds, - replacementRefreshIds, - ); - database.close(); - - const source = corpusRoot(context); - assert.deepEqual(await tree(source), baselines.get(source)); - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/journeys/bangumi-episodic-live.journey.ts b/apps/cli/test/e2e/journeys/bangumi-episodic-live.journey.ts deleted file mode 100644 index 6251d54..0000000 --- a/apps/cli/test/e2e/journeys/bangumi-episodic-live.journey.ts +++ /dev/null @@ -1,924 +0,0 @@ -import assert from "node:assert/strict"; -import { access, chmod, copyFile, mkdir, readFile } from "node:fs/promises"; -import path from "node:path"; - -import type { AssetRefreshInspection } from "@digital/application"; -import type { RawFilesystemSnapshot } from "@digital/test-support"; - -import { - assertFixedCorpusContentBaselineSubset, - captureFixedCorpusSnapshots, - fixedCorpusContentDigestsFromSnapshots, - type FixedCorpusContentDigest, - type FixedCorpusRoot, -} from "../fixed-corpus.ts"; -import { - registeredObservedRootId, - type CliJourney, - type CliJourneyResult, -} from "../journey.ts"; - -const bloomLogicalRoot = "media/番剧/终将成为你 (2018)"; -const frierenLogicalRoot = "media/番剧/葬送的芙莉莲 (2023)"; -const liveProductDataEnvironment = "DIGITAL_BANGUMI_LIVE_PRODUCT_DATA"; -const bangumiOracleReviewStatus = "reviewed-current-official-source"; - -type BangumiJourneyState = { - readonly roots: readonly FixedCorpusRoot[]; - readonly before: ReadonlyMap; - readonly deepBefore?: ReadonlyMap; - readonly bloom: BangumiEpisodicRootOracle; - readonly frieren: BangumiEpisodicRootOracle; -}; - -const stateByWorkDirectory = new Map(); - -export type BangumiEpisodicRootOracle = { - readonly title: string; - readonly subjects: readonly { - readonly season: number; - readonly sourceAddress: string; - }[]; - readonly episodes: readonly { - readonly season: number; - readonly seasonPosition: number; - readonly continuousPosition: number; - readonly sourceAddress: string; - }[]; -}; - -function object(value: unknown, label: string): Record { - assert.equal( - typeof value === "object" && value !== null && !Array.isArray(value), - true, - `${label} must be an object`, - ); - return value as Record; -} - -function text(value: unknown, label: string): string { - assert.equal(typeof value, "string", `${label} must be a string`); - assert.ok((value as string).length > 0, `${label} must not be empty`); - return value as string; -} - -function pair(value: unknown, label: string): readonly [number, number] { - assert.ok(Array.isArray(value), `${label} must be an array`); - assert.equal(value.length, 2, `${label} must have two bounds`); - assert.ok( - value.every((item) => Number.isSafeInteger(item) && item > 0), - `${label} must contain positive integers`, - ); - return [value[0] as number, value[1] as number]; -} - -function addresses(value: unknown, label: string): readonly string[] { - assert.ok(Array.isArray(value), `${label} must be an array`); - return value.map((item, index) => text(item, `${label}[${String(index)}]`)); -} - -function episodesFromSeason( - value: unknown, - season: number, - label: string, -): readonly BangumiEpisodicRootOracle["episodes"][number][] { - const seasonOracle = object(value, label); - const local = pair( - seasonOracle.localEpisodeNumbers, - `${label}.localEpisodeNumbers`, - ); - const official = pair( - seasonOracle.officialEpisodeNumbers, - `${label}.officialEpisodeNumbers`, - ); - const continuous = pair( - seasonOracle.officialSeriesSort, - `${label}.officialSeriesSort`, - ); - const sourceAddresses = addresses( - seasonOracle.episodeSourceAddresses, - `${label}.episodeSourceAddresses`, - ); - const count = local[1] - local[0] + 1; - assert.equal(official[1] - official[0] + 1, count); - assert.equal(continuous[1] - continuous[0] + 1, count); - assert.equal(sourceAddresses.length, count); - return sourceAddresses.map((sourceAddress, index) => ({ - season, - seasonPosition: official[0] + index, - continuousPosition: continuous[0] + index, - sourceAddress, - })); -} - -async function readBangumiOracles( - context: Parameters[0], -): Promise<{ - readonly bloom: BangumiEpisodicRootOracle; - readonly frieren: BangumiEpisodicRootOracle; -}> { - const raw = object( - JSON.parse( - await readFile( - path.resolve( - context.packageRoot, - "../..", - "fixtures/live-corpus/references/reviewed-oracle.json", - ), - "utf8", - ), - ) as unknown, - "reviewed oracle", - ); - const reviews = object(raw.reviews, "reviewed oracle reviews"); - const review = object(reviews.bangumi, "Bangumi oracle review"); - assert.equal( - review.status, - bangumiOracleReviewStatus, - "Bangumi live oracle must retain its completed current official review", - ); - const video = object(raw.video, "reviewed oracle video"); - const bloomRaw = object(video.series, "reviewed oracle video.series"); - const bloomAssociations = object( - bloomRaw.sourceAssociations, - "reviewed oracle video.series.sourceAssociations", - ); - const bloomAddresses = addresses( - bloomRaw.episodeSourceAddresses, - "reviewed oracle video.series.episodeSourceAddresses", - ); - assert.equal(bloomRaw.seasonCount, 1); - assert.equal(bloomRaw.episodeCount, bloomAddresses.length); - const bloom: BangumiEpisodicRootOracle = { - title: text(bloomRaw.title, "reviewed oracle Bloom title"), - subjects: [ - { - season: 1, - sourceAddress: text( - bloomAssociations.Bangumi, - "reviewed oracle Bloom subject", - ), - }, - ], - episodes: bloomAddresses.map((sourceAddress, index) => ({ - season: 1, - seasonPosition: index + 1, - continuousPosition: index + 1, - sourceAddress, - })), - }; - const frierenRaw = object( - video.multiSeasonSeries, - "reviewed oracle video.multiSeasonSeries", - ); - const frierenAssociations = object( - frierenRaw.sourceAssociations, - "reviewed oracle Frieren sourceAssociations", - ); - const frieren: BangumiEpisodicRootOracle = { - title: text(frierenRaw.title, "reviewed oracle Frieren title"), - subjects: [ - { - season: 1, - sourceAddress: text( - frierenAssociations.BangumiSeason1, - "reviewed oracle Frieren season 1 subject", - ), - }, - { - season: 2, - sourceAddress: text( - frierenAssociations.BangumiSeason2, - "reviewed oracle Frieren season 2 subject", - ), - }, - ], - episodes: [ - ...episodesFromSeason(frierenRaw.season1, 1, "Frieren season1"), - ...episodesFromSeason(frierenRaw.season2, 2, "Frieren season2"), - ], - }; - return { bloom, frieren }; -} - -function currentRecordIds(value: AssetRefreshInspection): ReadonlySet { - assert.ok( - value.sourceRecords.length > 0, - "live acceptance must capture records", - ); - for (const record of value.sourceRecords) { - assert.equal(record.sourceIdentity, "bangumi"); - assert.equal(record.coverage.authority, "official"); - assert.equal(record.coverage.completeness, "complete"); - } - return new Set(value.sourceRecords.map((record) => record.id)); -} - -function assertCurrentReferences( - ids: readonly string[], - current: ReadonlySet, - label: string, -): void { - assert.ok(ids.length > 0, `${label} must cite current Source Records`); - assert.ok( - ids.every((id) => current.has(id)), - `${label} must cite only current Source Records`, - ); -} - -/** - * 实时旅程按来源地址核对逐项结果;数组位置不携带身份语义。 - */ -export function assertBangumiAcceptedEpisodeSet( - value: AssetRefreshInspection, - oracle: BangumiEpisodicRootOracle, -): void { - assert.equal(value.execution.outcome, "accepted"); - const current = currentRecordIds(value); - const expectedCoverage = new Set(["bangumi.subject-search"]); - for (const subject of oracle.subjects) { - expectedCoverage.add(`bangumi.subject:${subject.sourceAddress}`); - expectedCoverage.add(`bangumi.relationships:${subject.sourceAddress}`); - expectedCoverage.add( - `bangumi.ordinary-episodes:${subject.sourceAddress}:0`, - ); - } - for (const episode of oracle.episodes) - expectedCoverage.add(`bangumi.episode:${episode.sourceAddress}`); - assert.deepEqual( - value.sourceRecords.map((record) => record.coverage.id).toSorted(), - [...expectedCoverage].toSorted(), - "live execution Source Record coverage must match the reviewed subjects and episodes", - ); - for (const record of value.sourceRecords) { - const address = record.coverage.id.match(/^bangumi\.episode:(\d+)$/u)?.[1]; - if (address !== undefined) - assert.equal(record.nativeAddress, `https://bgm.tv/episode/${address}`); - const subject = record.coverage.id.match( - /^bangumi\.(?:subject|relationships|ordinary-episodes):(\d+)/u, - )?.[1]; - if (subject !== undefined) - assert.equal(record.nativeAddress, `https://bgm.tv/subject/${subject}`); - } - const items = value.itemResults ?? []; - assert.equal(items.length, oracle.episodes.length); - const byAddress = new Map(); - for (const item of items) { - const address = item.accepted?.recognitionSourceAssociation?.nativeAddress; - if (address === undefined || !address.startsWith("https://bgm.tv/episode/")) - throw new Error( - "accepted work item must expose an exact Bangumi episode address", - ); - assert.equal( - byAddress.has(address), - false, - "each exact Bangumi episode address must occur once", - ); - byAddress.set(address, item); - } - const acceptedEntityIds = new Set(); - for (const expected of oracle.episodes) { - const address = `https://bgm.tv/episode/${expected.sourceAddress}`; - const item = byAddress.get(address); - assert.ok( - item !== undefined, - `missing exact Bangumi episode address ${expected.sourceAddress}`, - ); - assert.equal(item.outcome, "accepted"); - const accepted = item.accepted!; - assert.equal(accepted.entity.kind, "digital-asset"); - assert.equal( - acceptedEntityIds.has(accepted.entity.id), - false, - "each local episode boundary must produce one independent Digital Asset", - ); - acceptedEntityIds.add(accepted.entity.id); - assert.deepEqual(accepted.sourceAssociations, [ - { sourceIdentity: "bangumi", nativeAddress: address }, - ]); - assert.ok( - accepted.recognitionSourceAssociation, - "accepted Bangumi episode must retain its recognition source", - ); - assert.deepEqual(accepted.recognitionSourceAssociation, { - sourceIdentity: "bangumi", - nativeAddress: address, - }); - assert.equal( - item.key, - `bangumi:episode:${JSON.stringify([ - ".", - expected.season, - expected.seasonPosition, - ])}`, - ); - // 边界可含同集多技术与字幕,但媒体文件恰好一个;技术后缀只区分材料,不另立单集。 - const mediaPaths = item.relativePaths.filter((relativePath) => - /\.(?:mkv|mp4)$/iu.test(relativePath), - ); - assert.equal( - mediaPaths.length, - 1, - "episode boundary must contain exactly one media file", - ); - for (const relativePath of item.relativePaths) - if (!mediaPaths.includes(relativePath)) - assert.match( - relativePath, - /\.(?:ass|srt|ssa|sup)$/iu, - "额外边界材料必须是已确认字幕扩展名", - ); - const pathMatch = - /^Season ([1-9]\d*)\/(.+) - S(\d{2})E(\d{2})(?: - 第 (\d+) 集)?(?: - .+)?\.(?:mkv|mp4)$/u.exec( - mediaPaths[0]!, - ); - assert.ok( - pathMatch, - "episode media file must follow the dash naming convention", - ); - assert.equal(Number(pathMatch[1]), expected.season); - assert.equal(pathMatch[2], oracle.title); - assert.equal(Number(pathMatch[3]), expected.season); - assert.equal(Number(pathMatch[4]), expected.seasonPosition); - if (pathMatch[5] !== undefined) - assert.equal(Number(pathMatch[5]), expected.seasonPosition); - const position = (property: string, label: string): string => { - const matches = accepted.metadata.filter( - (metadata) => - metadata.target.kind === "entity" && - metadata.target.entityId === accepted.entity.id && - metadata.property === property, - ); - assert.equal(matches.length, 1, `episode must retain one ${label}`); - assertCurrentReferences(matches[0]!.sourceRecordIds, current, label); - return matches[0]!.value; - }; - assert.equal( - position("bangumi.episode.season-position", "season position"), - String(expected.seasonPosition), - "episode season position drifted", - ); - assert.equal( - position("bangumi.episode.continuous-position", "continuous position"), - String(expected.continuousPosition), - "episode continuous position drifted", - ); - const observed = accepted.locations.filter( - (location) => location.kind === "observed", - ); - assert.equal( - observed.length, - 1, - "each work item must retain one observed episode artifact", - ); - assert.equal( - accepted.artifactAssociations.filter( - (association) => - association.artifactId === observed[0]!.artifactId && - association.role === "asset.material-delivered", - ).length, - 1, - "observed episode bytes must be delivered material", - ); - assert.equal( - accepted.artifactAssociations.some( - (association) => - association.artifactId === observed[0]!.artifactId && - association.role === "asset.primary-cover", - ), - false, - "observed writeback material must not become a primary cover", - ); - const expectedSubjects = oracle.subjects; - const entities = accepted.currentRelatedEntities ?? []; - assert.deepEqual( - entities - .map((entity) => `${entity.identityNamespace}:${entity.sourceKey}`) - .toSorted(), - expectedSubjects - .map((subject) => `bangumi.subject:${subject.sourceAddress}`) - .toSorted(), - "only exact reviewed Bangumi subjects may become related identities", - ); - for (const entity of entities) { - assert.equal(entity.sourceIdentity, "bangumi"); - assertCurrentReferences( - entity.sourceRecordIds, - current, - "Bangumi subject identity", - ); - } - const seasonSubject = oracle.subjects.find( - (subject) => subject.season === expected.season, - )!; - const seasonEntity = entities.find( - (entity) => entity.sourceKey === seasonSubject.sourceAddress, - )!; - const relationships = accepted.relationships ?? []; - const memberships = relationships.filter( - (relationship) => - relationship.type === "asset.membership" && - relationship.participants.some( - (participant) => - participant.role === "bangumi.subject.ordinary-episode", - ), - ); - assert.equal(memberships.length, 1); - assert.equal( - memberships[0]!.participants.some( - (participant) => - participant.entityId === accepted.entity.id && - participant.role === "asset", - ), - true, - ); - assert.equal( - memberships[0]!.participants.some( - (participant) => - participant.entityId === seasonEntity.id && - participant.role === "bangumi.subject.ordinary-episode", - ), - true, - ); - assert.equal( - memberships[0]!.sourceSlot, - `bangumi.subject.ordinary-episode:${seasonSubject.sourceAddress}:${expected.sourceAddress}`, - ); - assert.deepEqual( - (memberships[0]!.qualifiers ?? []) - .map((qualifier) => ({ key: qualifier.key, value: qualifier.value })) - .toSorted((left, right) => left.key.localeCompare(right.key)), - [ - { - key: "bangumi.episode.continuous-position", - value: String(expected.continuousPosition), - }, - { - key: "bangumi.episode.season-position", - value: String(expected.seasonPosition), - }, - ], - "episode membership must retain both relationship ordering qualifiers", - ); - assertCurrentReferences( - memberships[0]!.sourceRecordIds, - current, - "episode membership", - ); - const sequels = relationships.filter( - (relationship) => - relationship.type === "bangumi.subject.related" && - relationship.participants.some( - (participant) => participant.role === "bangumi.subject.sequel", - ), - ); - assert.equal( - sequels.length, - Math.max(0, expectedSubjects.length - 1), - "reviewed Bangumi sequel relationship count drifted", - ); - for (let index = 1; index < expectedSubjects.length; index += 1) { - const predecessor = entities.find( - (entity) => - entity.sourceKey === expectedSubjects[index - 1]!.sourceAddress, - )!; - const successor = entities.find( - (entity) => entity.sourceKey === expectedSubjects[index]!.sourceAddress, - )!; - const sequel = sequels.find( - (relationship) => - relationship.participants.some( - (participant) => - participant.entityId === predecessor.id && - participant.role === "bangumi.subject.predecessor", - ) && - relationship.participants.some( - (participant) => - participant.entityId === successor.id && - participant.role === "bangumi.subject.sequel", - ), - ); - assert.ok(sequel, "reviewed Bangumi season sequel relation is missing"); - assert.equal( - sequel.sourceSlot, - `bangumi.subject.sequel:${expectedSubjects[index - 1]!.sourceAddress}:${expectedSubjects[index]!.sourceAddress}`, - ); - assertCurrentReferences( - sequel.sourceRecordIds, - current, - "season sequel relationship", - ); - } - } -} - -function acceptedItemsByAddress( - value: AssetRefreshInspection, -): ReadonlyMap< - string, - NonNullable[number] -> { - const byAddress = new Map< - string, - NonNullable[number] - >(); - for (const item of value.itemResults ?? []) { - const address = item.accepted?.recognitionSourceAssociation?.nativeAddress; - assert.ok(address, "reopened work item must retain its source address"); - assert.equal(byAddress.has(address), false); - byAddress.set(address, item); - } - return byAddress; -} - -function stableAcceptedIdentity( - accepted: NonNullable< - NonNullable[number]["accepted"] - >, -): unknown { - return { - entity: accepted.entity, - recognitionSourceAssociation: accepted.recognitionSourceAssociation, - sourceAssociations: accepted.sourceAssociations, - entities: (accepted.currentRelatedEntities ?? []) - .map((entity) => ({ - id: entity.id, - identityNamespace: entity.identityNamespace, - sourceKey: entity.sourceKey, - })) - .toSorted((left, right) => left.id.localeCompare(right.id)), - artifacts: accepted.artifacts.map((artifact) => artifact.id).toSorted(), - locations: accepted.locations - .map((location) => ({ - id: location.id, - artifactId: location.artifactId, - address: location.address, - kind: location.kind, - })) - .toSorted((left, right) => left.id.localeCompare(right.id)), - artifactRevisions: accepted.artifactRevisions - .map((revision) => revision.id) - .toSorted(), - artifactAssociations: accepted.artifactAssociations - .map((association) => ({ - id: association.id, - target: association.target, - artifactId: association.artifactId, - role: association.role, - })) - .toSorted((left, right) => left.id.localeCompare(right.id)), - metadata: accepted.metadata - .map((metadata) => ({ - target: metadata.target, - property: metadata.property, - value: metadata.value, - sourceSlot: metadata.sourceSlot, - sourceRecordIds: metadata.sourceRecordIds, - })) - .toSorted((left, right) => - `${JSON.stringify(left.target)}:${left.property}:${left.sourceSlot}`.localeCompare( - `${JSON.stringify(right.target)}:${right.property}:${right.sourceSlot}`, - ), - ), - relationships: (accepted.relationships ?? []) - .map((relationship) => ({ - id: relationship.id, - participants: relationship.participants, - type: relationship.type, - sourceSlot: relationship.sourceSlot, - sourceRecordIds: relationship.sourceRecordIds, - qualifiers: relationship.qualifiers, - })) - .toSorted((left, right) => left.id.localeCompare(right.id)), - }; -} - -/** 每条 CLI 命令都会重开同一个 SQLite 产品目录;按地址比较,绝不按数组顺序比较。 */ -export function assertBangumiReopenedEpisodeSet( - initial: AssetRefreshInspection, - reopened: AssetRefreshInspection, - oracle: BangumiEpisodicRootOracle, -): void { - assert.equal(reopened.execution.outcome, "no-change"); - assert.deepEqual(reopened.sourceRecords, []); - const initialByAddress = acceptedItemsByAddress(initial); - const reopenedByAddress = acceptedItemsByAddress(reopened); - assert.equal(initialByAddress.size, oracle.episodes.length); - assert.equal(reopenedByAddress.size, oracle.episodes.length); - for (const expected of oracle.episodes) { - const address = `https://bgm.tv/episode/${expected.sourceAddress}`; - const prior = initialByAddress.get(address); - const current = reopenedByAddress.get(address); - assert.ok(prior?.accepted, `initial episode ${address} is missing`); - assert.ok(current?.accepted, `reopened episode ${address} is missing`); - assert.equal(current.outcome, "no-change"); - assert.deepEqual(current.relativePaths, prior.relativePaths); - assert.deepEqual( - stableAcceptedIdentity(current.accepted), - stableAcceptedIdentity(prior.accepted), - `episode ${address} must retain stable identity after SQLite reopen`, - ); - } -} - -/** - * 人工检查点提供一个一次性 Digital 产品数据根,它已通过 Digital 自己的开始/回调流程授权。 - * 本旅程绝不读取或导入其他产品的 OAuth 导出内容。 - */ -function liveProductDataDirectory(): string { - const productData = process.env[liveProductDataEnvironment]?.trim(); - if (productData === undefined || productData.length === 0) { - throw new Error( - "Bangumi live journey requires a disposable Digital product-data directory with an authorized Bangumi connection", - ); - } - return productData; -} - -async function seedDisposableDigitalConnections( - context: Parameters[0], -): Promise { - const source = path.join( - liveProductDataDirectory(), - "private-source-connections", - "bangumi-oauth.json", - ); - try { - await access(source); - } catch { - throw new Error( - "Bangumi live journey Digital OAuth connection is unavailable", - ); - } - // 复制到本次场景环境实际交给正式 CLI 的产品数据根:产品的来源连接按该根解析, - // 复制到其他位置的真实采集一律读不到授权。它随该世代一起被停止、保留或删除。 - const privateDirectory = path.join( - context.productDataDirectory, - "private-source-connections", - ); - await mkdir(privateDirectory, { recursive: true, mode: 0o700 }); - await chmod(privateDirectory, 0o700); - const destination = path.join(privateDirectory, "bangumi-oauth.json"); - await copyFile(source, destination); - await chmod(destination, 0o600); -} - -function corpusRoot(context: Parameters[0]): string { - return path.resolve( - context.packageRoot, - "../..", - ".scratch/acceptance-corpus", - ); -} - -function stateKey(context: Parameters[0]): string { - return `${context.mode}:${context.workDir}`; -} - -function currentState( - context: Parameters[0], -): BangumiJourneyState { - const state = stateByWorkDirectory.get(stateKey(context)); - if (state === undefined) - throw new Error("Bangumi live journey setup did not run"); - return state; -} - -function fixedRoots( - context: Parameters[0], -): readonly FixedCorpusRoot[] { - const assets = path.join(corpusRoot(context), "assets"); - return [bloomLogicalRoot, frierenLogicalRoot].map((logicalRoot) => ({ - logicalRoot, - directory: path.join(assets, ...logicalRoot.split("/")), - })); -} - -function inspection( - result: CliJourneyResult | undefined, -): AssetRefreshInspection { - return JSON.parse(result?.stdout ?? "") as AssetRefreshInspection; -} - -function acceptedAssetIdForAddress( - result: CliJourneyResult | undefined, - sourceAddress: string, -): string { - const value = inspection(result); - const address = `https://bgm.tv/episode/${sourceAddress}`; - const id = value.itemResults?.find( - (item) => - item.accepted?.recognitionSourceAssociation?.nativeAddress === address, - )?.accepted?.entity.id; - if (id === undefined) - throw new Error( - `expected accepted episode ${address} in public inspection`, - ); - return id; -} - -function assertObservedInventory( - snapshots: ReadonlyMap, - oracles: readonly { - readonly logicalRoot: string; - readonly oracle: BangumiEpisodicRootOracle; - }[], -): void { - for (const { logicalRoot, oracle } of oracles) { - const snapshot = snapshots.get(logicalRoot); - assert.ok(snapshot, `missing fixed corpus snapshot ${logicalRoot}`); - const files = snapshot.entries.filter( - (entry) => entry.kind === "regular-file", - ); - const episodicMedia = files.filter((entry) => - /\.(?:mkv|mp4)$/iu.test(entry.relativePath), - ); - assert.equal( - episodicMedia.length, - oracle.episodes.length, - `${logicalRoot} episodic media count drifted`, - ); - } -} - -function assertEpisodeRefresh( - value: AssetRefreshInspection, - sourceAddress: string, - expectedAssetId: string, -): void { - const address = `https://bgm.tv/episode/${sourceAddress}`; - assert.equal(value.execution.outcome, "accepted"); - assert.equal(value.execution.selection.intent, "metadata-refresh"); - assert.equal(value.accepted?.entity.id, expectedAssetId); - assert.ok( - value.accepted?.recognitionSourceAssociation, - "refreshed Bangumi episode must retain its recognition source", - ); - assert.deepEqual(value.accepted.recognitionSourceAssociation, { - sourceIdentity: "bangumi", - nativeAddress: address, - }); - assert.deepEqual( - value.sourceRecords.map((record) => ({ - sourceIdentity: record.sourceIdentity, - nativeAddress: record.nativeAddress, - coverage: record.coverage.id, - })), - [ - { - sourceIdentity: "bangumi", - nativeAddress: address, - coverage: `bangumi.episode:${sourceAddress}`, - }, - ], - "single-episode refresh must reacquire only its exact current address", - ); -} - -const journey: CliJourney = { - name: "bangumi-episodic-live", - source: { mode: "real" }, - modes: ["source", "distribution"], - async setup(context) { - await seedDisposableDigitalConnections(context); - const { bloom, frieren } = await readBangumiOracles(context); - const roots = fixedRoots(context); - const deepIntegrity = process.env.DIGITAL_DEEP_CORPUS_INTEGRITY === "1"; - const before = await captureFixedCorpusSnapshots(roots, deepIntegrity); - assertObservedInventory(before, [ - { logicalRoot: bloomLogicalRoot, oracle: bloom }, - { logicalRoot: frierenLogicalRoot, oracle: frieren }, - ]); - const deepBefore = deepIntegrity - ? fixedCorpusContentDigestsFromSnapshots(roots, before) - : undefined; - if (deepBefore !== undefined) - await assertFixedCorpusContentBaselineSubset(context, deepBefore); - stateByWorkDirectory.set(stateKey(context), { - roots, - before, - bloom, - frieren, - ...(deepBefore === undefined ? {} : { deepBefore }), - }); - }, - commands(context) { - const state = currentState(context); - // 产品数据根属于本次场景环境:旅程不再声明 DIGITAL_PRODUCT_DATA,正式 CLI 一律 - // 按环境记录的世界运行,读取同一份来源连接。 - const scan = ( - rootResultIndex: number, - name: string, - haltOnFailure = false, - ) => ({ - name, - args: (results: readonly CliJourneyResult[]) => [ - "scan", - registeredObservedRootId(results[rootResultIndex]), - "--scope", - ".", - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - ...(haltOnFailure ? { haltOnFailure: true as const } : {}), - }); - return [ - { - name: "register live Bloom root", - args: [ - "library-create", - "终将成为你 验收语料", - "--directory", - state.roots[0]!.directory, - "--json", - ], - haltOnFailure: true, - }, - { - name: "register live Frieren root", - args: [ - "library-create", - "葬送的芙莉莲 验收语料", - "--directory", - state.roots[1]!.directory, - "--json", - ], - haltOnFailure: true, - }, - scan(0, "live accept Bloom", true), - scan(0, "live no-change Bloom after SQLite reopen", true), - { - name: "live metadata refresh Bloom episode", - args: (results) => [ - "refresh-metadata", - acceptedAssetIdForAddress( - results[2], - state.bloom.episodes[0]!.sourceAddress, - ), - "--json", - ], - haltOnFailure: true, - }, - scan(1, "live accept Frieren"), - scan(1, "live no-change Frieren after SQLite reopen", true), - ]; - }, - async assertions({ context, results: allResults }) { - const state = currentState(context); - try { - for (const registeredRoot of allResults.slice(0, 2)) { - assert.equal(registeredRoot.exitCode, 0, registeredRoot.stderr); - registeredObservedRootId(registeredRoot); - } - const results = allResults.slice(2); - assert.equal(results.length, 5); - for (const result of results) { - assert.equal(result.exitCode, 0, `${result.commandName} failed`); - assert.doesNotMatch( - `${result.stdout}\n${result.stderr}`, - /access_token|refresh_token|client_secret|oauth\.json|authorization\s*:|bearer\s+|cookie\s*:/iu, - "private Bangumi configuration leaked through delivery output", - ); - } - const parsed = results.map(inspection); - assertBangumiAcceptedEpisodeSet(parsed[0]!, state.bloom); - assertBangumiReopenedEpisodeSet(parsed[0]!, parsed[1]!, state.bloom); - const refreshedAddress = state.bloom.episodes[0]!.sourceAddress; - assertEpisodeRefresh( - parsed[2]!, - refreshedAddress, - acceptedAssetIdForAddress(results[0], refreshedAddress), - ); - assertBangumiAcceptedEpisodeSet(parsed[3]!, state.frieren); - assertBangumiReopenedEpisodeSet(parsed[3]!, parsed[4]!, state.frieren); - - const deepIntegrity = state.deepBefore !== undefined; - const after = await captureFixedCorpusSnapshots( - state.roots, - deepIntegrity, - ); - assertObservedInventory(after, [ - { logicalRoot: bloomLogicalRoot, oracle: state.bloom }, - { logicalRoot: frierenLogicalRoot, oracle: state.frieren }, - ]); - assert.deepEqual( - after, - state.before, - "Bangumi fixed roots changed during the live journey", - ); - if (state.deepBefore !== undefined) { - const deepAfter = fixedCorpusContentDigestsFromSnapshots( - state.roots, - after, - ); - await assertFixedCorpusContentBaselineSubset(context, deepAfter); - assert.deepEqual(deepAfter, state.deepBefore); - } - } finally { - stateByWorkDirectory.delete(stateKey(context)); - } - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/journeys/controlled-ordinary-library.journey.ts b/apps/cli/test/e2e/journeys/controlled-ordinary-library.journey.ts deleted file mode 100644 index a4db6ca..0000000 --- a/apps/cli/test/e2e/journeys/controlled-ordinary-library.journey.ts +++ /dev/null @@ -1,483 +0,0 @@ -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { cp, readFile, stat } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -import { captureRawFilesystemSnapshot } from "@digital/test-support"; - -import { - registeredObservedRootId, - type CliJourney, - type CliJourneyResult, -} from "../journey.ts"; - -const journeyDirectory = path.dirname(fileURLToPath(import.meta.url)); -/** 受控来源夹具与作品目录都在作者树内,默认执行不读取私人语料也不触碰真实来源。 */ -const fixtureRoot = path.resolve( - journeyDirectory, - "..", - "..", - "..", - "..", - "..", - "fixtures", - "controlled-source-io", -); -const controlledManifest = path.join(fixtureRoot, "manifest.json"); - -const oraclePath = path.join( - fixtureRoot, - "expectations", - "RJ01668691.reviewed-oracle.json", -); -const coverResponse = path.join(fixtureRoot, "responses", "dlsite-cover.png"); - -type Oracle = { - readonly status: string; - readonly selection: { - readonly identity: string; - readonly sourceNativeAddress: string; - }; - readonly projection: { - readonly title: string; - readonly introduction: string; - readonly releaseDate: string; - readonly declaredLanguage: string; - readonly contentClassification: string; - readonly circle: { readonly sourceKey: string; readonly label: string }; - readonly credits: { - readonly voice: readonly string[]; - readonly scenario: readonly string[]; - readonly illustration: readonly string[]; - }; - readonly relationshipRoles: readonly string[]; - readonly cover: { readonly role: string }; - }; -}; - -type AcceptedProjection = { - readonly entity: { readonly id: string; readonly kind: string }; - readonly sourceAssociations: readonly { - readonly sourceIdentity: string; - readonly nativeAddress: string; - }[]; - readonly recognitionSourceAssociation?: { readonly nativeAddress: string }; - readonly metadata: readonly { - readonly property: string; - readonly value: string; - readonly sourceRecordIds: readonly string[]; - }[]; - readonly currentRelatedEntities?: readonly RelatedEntity[]; - readonly relationships?: readonly { - readonly type: string; - readonly participants: readonly { readonly role: string }[]; - }[]; - readonly artifactAssociations?: readonly { - readonly artifactId: string; - readonly role: string; - }[]; - readonly artifactRevisions?: readonly { - readonly artifactId: string; - readonly blobDigest: string; - }[]; - readonly blobs?: readonly { readonly digest: string }[]; -}; - -type RelatedEntity = { - readonly id: string; - readonly identityNamespace: string; - readonly sourceKey: string; - readonly label: string; -}; - -type RefreshOutcome = { - readonly format: string; - readonly executionId?: string; - readonly execution: { readonly id: string; readonly outcome: string }; - readonly sourceRecords?: readonly { - readonly sourceIdentity: string; - readonly coverage: { readonly id: string }; - readonly nativeAddress: string; - }[]; - readonly itemResults?: readonly { - readonly outcome?: string; - readonly accepted?: AcceptedProjection; - }[]; -}; - -type CataloguePage = { - readonly items: readonly Record[]; - readonly nextCursor?: string; -}; - -async function tree(root: string): Promise { - const snapshot = await captureRawFilesystemSnapshot(root, { - contentDigests: true, - }); - return snapshot.entries - .filter((entry) => entry.relativePath !== ".") - .map((entry) => - entry.kind === "directory" - ? `${entry.relativePath}/` - : `${entry.relativePath}:${entry.byteLength}:${entry.contentDigest ?? ""}`, - ); -} - -function parse(result: CliJourneyResult | undefined, label: string): T { - assert.notEqual(result, undefined, `${label} 没有执行结果`); - try { - return JSON.parse(result?.stdout ?? "{}") as T; - } catch { - throw new Error( - `${label} 未返回机器可读结果\nstdout: ${result?.stdout ?? ""}\nstderr: ${result?.stderr ?? ""}`, - ); - } -} - -function acceptedWork( - value: RefreshOutcome, - label: string, -): AcceptedProjection { - const accepted = (value.itemResults ?? []).filter( - (item) => item.outcome === "accepted", - ); - assert.equal( - accepted.length, - 1, - `${label} 必须给出恰好一个已接受的主体:${JSON.stringify( - (value.itemResults ?? []).map((item) => item.outcome), - )}`, - ); - const projection = accepted[0]?.accepted; - assert.ok(projection !== undefined, `${label} 缺少已接受投影`); - return projection; -} - -/** 普通场景目录:每个交付模式各自拥有一份副本,产品数据根绝不落在素材里。 */ -function ordinaryDirectory(context: { readonly workDir: string }): string { - return path.join(context.workDir, "ordinary-library", "RJ01668691"); -} - -const baselines = new Map(); - -const journey: CliJourney = { - name: "controlled-ordinary-library", - modes: ["source", "distribution", "packed"], - source: { mode: "controlled", manifestPath: controlledManifest }, - async setup(context) { - const source = path.join(fixtureRoot, "material", "RJ01668691"); - assert.equal( - (await stat(source).catch(() => undefined))?.isDirectory() === true, - true, - `受控夹具作品目录缺失:${source}`, - ); - assert.equal( - (await stat(oraclePath).catch(() => undefined))?.isFile() === true, - true, - `受控夹具人工复核期望缺失:${oraclePath}`, - ); - const target = ordinaryDirectory(context); - await cp(source, target, { recursive: true }); - baselines.set(target, await tree(target)); - }, - commands(context) { - const directory = ordinaryDirectory(context); - return [ - { - name: "catalogue surface before the CLI admits the directory", - args: ["catalogue-surface", "--surface", "current-catalogue"], - haltOnFailure: true, - }, - { - name: "CLI admits the ordinary directory into an asset library", - args: [ - "library-create", - "受控普通目录验收库", - "--directory", - directory, - "--profile", - "dlsite-voice", - "--source", - "dlsite", - "--json", - ], - haltOnFailure: true, - }, - { - name: "CLI refreshes the admitted directory through the controlled source", - args: (results) => [ - "scan", - registeredObservedRootId(results[1]), - "--scope", - ".", - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - haltOnFailure: true, - }, - { - name: "catalogue surface after the CLI admitted the directory", - args: ["catalogue-surface", "--surface", "current-catalogue"], - }, - { - name: "CLI inspects the applied refresh execution", - args: (results) => [ - "inspect", - parse(results[2], "scan").execution.id, - "--json", - ], - }, - { - name: "CLI refreshes metadata for the admitted subject", - args: (results) => [ - "refresh-metadata", - acceptedWork(parse(results[2], "scan"), "scan").entity - .id, - "--json", - ], - }, - ]; - }, - async assertions({ context, results }) { - for (const [index, result] of results.entries()) - assert.equal( - result?.exitCode, - 0, - `命令 ${index + 1}(${result?.commandName ?? ""})未成功:\nstdout: ${ - result?.stdout ?? "" - }\nstderr: ${result?.stderr ?? ""}`, - ); - - const oracle = JSON.parse(await readFile(oraclePath, "utf8")) as Oracle; - assert.equal(oracle.status, "official-source-and-filesystem-reviewed"); - - const directory = ordinaryDirectory(context); - - // 合同②:接入前该目录未被任何资产库观察,交付页面里没有这个作品。 - const before = parse(results[0], "接入前 current 页面"); - assert.deepEqual( - before.items.filter((item) => - JSON.stringify(item).includes(oracle.projection.title), - ), - [], - "环境准备不得代替被测 CLI 完成接入:接入前目录已出现在当前目录页", - ); - - const registered = parse<{ - readonly id: string; - readonly configVersion: number; - readonly status: string; - readonly recognitionProfileIds: readonly string[]; - readonly sourceIdentities: readonly string[]; - }>(results[1], "资产库接入"); - assert.equal(registered.status, "active"); - assert.equal(registered.configVersion, 1); - assert.deepEqual(registered.sourceIdentities, ["dlsite"]); - assert.deepEqual(registered.recognitionProfileIds, ["dlsite-voice"]); - - const scan = parse(results[2], "扫描刷新"); - assert.equal(scan.execution.outcome, "accepted", "受控刷新未被接受"); - const work = acceptedWork(scan, "受控刷新"); - assert.equal( - work.entity.kind, - "digital-asset", - `受控刷新的主体类型异常:${work.entity.kind}`, - ); - - const after = parse(results[3], "接入后 current 页面"); - assert.equal( - after.items.some((item) => - JSON.stringify(item).includes(oracle.projection.title), - ), - true, - "被测 CLI 完成接入与刷新后,当前目录页仍看不到该作品", - ); - - const inspection = parse(results[4], "刷新查看"); - assert.equal(inspection.execution.outcome, "accepted"); - assert.equal( - inspection.execution.id, - scan.execution.id, - "查看的执行与本次刷新不是同一次", - ); - const applied = acceptedWork(inspection, "刷新查看"); - assert.equal( - applied.sourceAssociations.some( - (association) => - association.sourceIdentity === "dlsite" && - association.nativeAddress === oracle.selection.sourceNativeAddress, - ), - true, - `缺少人工复核的 DLsite 来源地址:${oracle.selection.sourceNativeAddress}`, - ); - for (const expected of [ - { property: "asset.title", value: oracle.projection.title }, - { - property: "asset.introduction", - value: oracle.projection.introduction, - }, - { - property: "asset.release-date", - value: oracle.projection.releaseDate.slice(0, 10), - prefix: true, - }, - { - property: "asset.declared-language", - value: oracle.projection.declaredLanguage, - }, - { - property: "asset.content-classification", - value: oracle.projection.contentClassification, - }, - ]) - assert.equal( - applied.metadata.some( - (item) => - item.property === expected.property && - (expected.prefix === true - ? item.value.startsWith(expected.value) - : item.value === expected.value), - ), - true, - `缺少人工复核的 ${expected.property}(期望 ${expected.value}):${JSON.stringify( - applied.metadata.filter( - (item) => item.property === expected.property, - ), - )}`, - ); - - // 夹具里的协议代表值同样经由两份普通响应读取,而不是调用方写入。 - for (const property of ["dlsite.product.price-jpy", "dlsite.product.genre"]) - assert.equal( - applied.metadata.some((item) => item.property === property), - true, - `受控夹具的协议代表值未进入交付投影:${property}`, - ); - assert.equal( - (inspection.sourceRecords ?? []).every( - (record) => record.sourceIdentity === "dlsite", - ), - true, - "受控刷新读到了 DLsite 之外的来源记录", - ); - assert.deepEqual( - [ - ...new Set( - (inspection.sourceRecords ?? []).map((record) => record.coverage.id), - ), - ].toSorted(), - ["dlsite.product-html", "dlsite.product-json"], - "受控刷新没有同时读取作品接口与普通页面两份响应", - ); - - const related = applied.currentRelatedEntities ?? []; - const circle = related.find( - (entity) => - entity.identityNamespace === "dlsite.circle" && - entity.sourceKey === oracle.projection.circle.sourceKey, - ); - assert.ok( - circle !== undefined, - `缺少人工复核的圈:${oracle.projection.circle.sourceKey}`, - ); - assert.equal(circle.label, oracle.projection.circle.label); - for (const credited of [ - ...oracle.projection.credits.voice, - ...oracle.projection.credits.scenario, - ...oracle.projection.credits.illustration, - ]) - assert.equal( - related.some((entity) => entity.label === credited), - true, - `缺少人工复核的制作人员:${credited}`, - ); - const roles = new Set( - (applied.relationships ?? []).flatMap((relationship) => - relationship.participants.map((p) => p.role), - ), - ); - for (const role of [ - "circle", - ...oracle.projection.relationshipRoles.filter( - (value) => value !== "circle", - ), - ]) - assert.equal( - roles.has(role), - true, - `缺少人工复核的关系角色:${[...roles].join("、")}`, - ); - - // 必需产物用途由受控来源的真实封面字节满足,而不是由调用方写入。 - const coverId = (applied.artifactAssociations ?? []) - .filter( - (association) => association.role === oracle.projection.cover.role, - ) - .map((association) => association.artifactId); - assert.equal( - coverId.length, - 1, - `必需的封面产物用途应有恰好一个:${JSON.stringify( - applied.artifactAssociations ?? [], - )}`, - ); - const revision = (applied.artifactRevisions ?? []).find( - (item) => item.artifactId === coverId[0], - ); - assert.ok(revision !== undefined, "封面产物缺少修订"); - const expectedCoverDigest = createHash("sha256") - .update(await readFile(coverResponse)) - .digest("hex"); - assert.equal( - revision.blobDigest, - expectedCoverDigest, - "封面产物字节与受控夹具的普通响应不一致", - ); - assert.equal( - (applied.blobs ?? []).some((blob) => blob.digest === expectedCoverDigest), - true, - "产品数据里没有登记该封面 blob", - ); - - const deliveredViews = [results[0], results[3], results[4], results[5]] - .map((result) => `${result?.stdout ?? ""}\n${result?.stderr ?? ""}`) - .join("\n"); - for (const marker of [ - "受控来源拒绝", - "请求键", - "controlled-source-io", - "DIGITAL_SOURCE_IO", - "manifestPath", - "sha256:", - ]) - assert.equal( - deliveredViews.includes(marker), - false, - `交付视图泄漏了受控装配细节:${marker}`, - ); - - // 被测 CLI 只读观察输入:场景目录在接入与刷新后逐字节保持不变。 - assert.deepEqual( - await tree(directory), - baselines.get(directory), - "受控场景目录被产品写入改动", - ); - - // 产品数据归属本场景环境:正式 CLI 的工作区落在环境自己的产品数据根内。 - assert.equal( - ( - await stat(path.join(context.productDataDirectory, "catalogue.sqlite")) - ).isFile(), - true, - `产品数据不在环境产品数据根内:${context.productDataDirectory}`, - ); - assert.ok( - path.relative(context.productDataDirectory, directory).startsWith(".."), - "场景输入目录不应位于环境产品数据根之内", - ); - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/journeys/dlsite-eight-live.journey.ts b/apps/cli/test/e2e/journeys/dlsite-eight-live.journey.ts deleted file mode 100644 index 2acd279..0000000 --- a/apps/cli/test/e2e/journeys/dlsite-eight-live.journey.ts +++ /dev/null @@ -1,636 +0,0 @@ -import assert from "node:assert/strict"; -import { lstat, readFile } from "node:fs/promises"; -import path from "node:path"; - -import { captureRawFilesystemSnapshot } from "@digital/test-support"; - -import { - assertFixedCorpusContentBaselineSubset, - captureFixedCorpusSnapshots, - fixedCorpusContentDigestsFromSnapshots, - type FixedCorpusContentDigest, - type FixedCorpusRoot, -} from "../fixed-corpus.ts"; -import { - registeredObservedRootId, - type CliJourney, - type CliJourneyResult, -} from "../journey.ts"; - -type AudioOracle = { - readonly logicalRoot: string; - readonly directory: string; - readonly sourceNativeAddress: string; - readonly title: string; - readonly releaseDate: string; - readonly language: string; - readonly priceJpy: string; - readonly circle: string; - readonly circleSourceKey: string; - readonly credits: { - readonly voice: readonly string[]; - readonly scenario: readonly string[]; - readonly illustration: readonly string[]; - readonly author: readonly string[]; - }; - readonly reviewedSeries?: { - readonly entityKind: string; - readonly identityNamespace: string; - readonly sourceKey: string; - readonly label: string; - readonly relationshipType: string; - readonly relationshipRole: string; - readonly officialCoverage: string; - readonly officialIdField: string; - readonly officialNameField: string; - readonly officialWorkCount?: number; - }; -}; - -type DlsiteCorpusState = { - readonly roots: readonly FixedCorpusRoot[]; - readonly deepBefore?: ReadonlyMap; -}; - -let works: readonly AudioOracle[] = []; -const baselines = new Map< - string, - readonly { - readonly path: string; - readonly byteLength: number; - readonly modifiedAtNanoseconds: string; - readonly changedAtNanoseconds: string; - }[] ->(); -const stateByWorkDirectory = new Map(); - -function stateKey(context: Parameters[0]): string { - return `${context.mode}:${context.workDir}`; -} - -function currentState( - context: Parameters[0], -): DlsiteCorpusState { - const state = stateByWorkDirectory.get(stateKey(context)); - if (state === undefined) throw new Error("DLsite journey setup did not run"); - return state; -} - -function corpusRoot(context: Parameters[0]): string { - return path.resolve( - context.packageRoot, - "../..", - ".scratch/acceptance-corpus", - ); -} - -async function regularFileSignature(root: string) { - return (await captureRawFilesystemSnapshot(root)).entries - .filter((entry) => entry.kind === "regular-file") - .map((entry) => ({ - path: entry.relativePath, - byteLength: entry.byteLength, - modifiedAtNanoseconds: entry.modifiedAtNanoseconds, - changedAtNanoseconds: entry.changedAtNanoseconds, - })); -} - -const journey: CliJourney = { - name: "dlsite-eight-live", - source: { mode: "real" }, - modes: ["source", "distribution"], - async setup(context) { - const corpus = corpusRoot(context); - const oracle = JSON.parse( - await readFile( - path.resolve( - context.packageRoot, - "../..", - "fixtures/live-corpus/references/reviewed-oracle.json", - ), - "utf8", - ), - ) as { - readonly reviews: { - readonly dlsite: { - readonly status: string; - readonly reviewedAt: string; - readonly reviewMethod: string; - }; - }; - readonly audioWorks: readonly AudioOracle[]; - }; - assert.equal( - oracle.reviews.dlsite.status, - "reviewed-current-official-source", - ); - assert.equal(oracle.reviews.dlsite.reviewedAt, "2026-08-29"); - assert.match( - oracle.reviews.dlsite.reviewMethod, - /Independently fetched and compared each current official DLsite product JSON and HTML page/u, - ); - works = oracle.audioWorks; - assert.equal(works.length, 8, "reviewed audio selection drifted"); - const roots: FixedCorpusRoot[] = []; - for (const work of works) { - const root = path.join( - corpus, - "assets/kikoeru/VoiceWork", - work.directory, - ); - assert.equal((await lstat(root)).isDirectory(), true); - baselines.set(root, await regularFileSignature(root)); - roots.push({ logicalRoot: work.logicalRoot, directory: root }); - } - const deepIntegrity = process.env.DIGITAL_DEEP_CORPUS_INTEGRITY === "1"; - const before = await captureFixedCorpusSnapshots(roots, deepIntegrity); - const deepBefore = deepIntegrity - ? fixedCorpusContentDigestsFromSnapshots(roots, before) - : undefined; - if (deepBefore !== undefined) - await assertFixedCorpusContentBaselineSubset(context, deepBefore); - stateByWorkDirectory.set(stateKey(context), { - roots, - ...(deepBefore === undefined ? {} : { deepBefore }), - }); - }, - commands(context) { - const corpus = corpusRoot(context); - const data = context.productDataDirectory; - return [ - ...works.map((work) => ({ - name: `register ${work.sourceNativeAddress} root`, - args: [ - "library-create", - `${work.sourceNativeAddress} 验收语料`, - "--directory", - path.join(corpus, "assets/kikoeru/VoiceWork", work.directory), - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - haltOnFailure: true, - })), - ...works.map((work, index) => ({ - name: `live refresh ${work.sourceNativeAddress}`, - args: (results: readonly CliJourneyResult[]) => [ - "scan", - registeredObservedRootId(results[index]), - "--scope", - ".", - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - })), - ]; - }, - async assertions({ context, results: allResults }) { - for (const registeredRoot of allResults.slice(0, works.length)) { - assert.equal(registeredRoot.exitCode, 0, registeredRoot.stderr); - registeredObservedRootId(registeredRoot); - } - const results = allResults.slice(works.length); - assert.equal(results.length, works.length); - const corpus = corpusRoot(context); - const failures: { - readonly sourceNativeAddress: string; - readonly outcome?: string; - readonly reason?: string; - readonly exitCode: number; - }[] = []; - const sourcedEntityIds = new Map(); - const sharedCircleIds = new Map(); - let sawRj = false; - let sawVj = false; - let sawMultipleCredits = false; - let sawLanguageEvidence = false; - let sawSeriesEvidence = false; - let sawBundledFiles = false; - for (const [index, work] of works.entries()) { - const result = results[index]!; - const inspection = JSON.parse(result.stdout) as { - readonly execution: { - readonly outcome: string; - readonly reason?: string; - readonly snapshot: { readonly files: readonly unknown[] }; - }; - readonly sourceRecords: readonly { - readonly id: string; - readonly sourceIdentity: string; - readonly coverage: { readonly id: string }; - }[]; - readonly accepted?: { - readonly sourceAssociations: readonly { - readonly nativeAddress: string; - }[]; - readonly recognitionSourceAssociation?: { - readonly nativeAddress: string; - }; - readonly artifacts: readonly { readonly id: string }[]; - readonly artifactAssociations: readonly { - readonly artifactId: string; - readonly role: string; - readonly sourceRecordIds?: readonly string[]; - }[]; - readonly metadata: readonly { - readonly property: string; - readonly value: string; - readonly sourceRecordIds: readonly string[]; - }[]; - readonly entities?: readonly { - readonly id: string; - readonly kind: string; - readonly label: string; - readonly identityNamespace: string; - readonly sourceKey: string; - readonly sourceRecordIds: readonly string[]; - }[]; - readonly entityMentions?: readonly { - readonly id: string; - readonly field: string; - readonly role: string; - readonly label: string; - readonly sourceRecordIds: readonly string[]; - }[]; - readonly entityResolutionDecisions?: readonly { - readonly mentionId: string; - readonly entityId: string; - readonly sourceRecordIds: readonly string[]; - }[]; - readonly relationships?: readonly { - readonly participants: readonly { - readonly entityId: string; - readonly role: string; - readonly mentionIds?: readonly string[]; - }[]; - readonly type: string; - readonly sourceRecordIds: readonly string[]; - }[]; - }; - }; - if (result.exitCode !== 0) { - const failedRoot = path.join( - corpus, - "assets/kikoeru/VoiceWork", - work.directory, - ); - assert.deepEqual( - await regularFileSignature(failedRoot), - baselines.get(failedRoot), - ); - failures.push({ - sourceNativeAddress: work.sourceNativeAddress, - outcome: inspection.execution.outcome, - ...(inspection.execution.reason === undefined - ? {} - : { reason: inspection.execution.reason }), - exitCode: result.exitCode, - }); - continue; - } - try { - assert.equal(inspection.execution.outcome, "accepted"); - assert.equal( - inspection.sourceRecords.every( - (record) => record.sourceIdentity === "dlsite", - ), - true, - ); - assert.deepEqual( - inspection.sourceRecords.map((record) => record.coverage.id), - ["dlsite.product-json", "dlsite.product-html"], - ); - assert.ok( - inspection.accepted?.recognitionSourceAssociation, - `accepted DLsite work ${work.sourceNativeAddress} is missing its recognition source`, - ); - assert.match( - inspection.accepted.recognitionSourceAssociation.nativeAddress, - new RegExp(`${work.sourceNativeAddress}\\.html$`, "u"), - ); - sawRj ||= work.sourceNativeAddress.startsWith("RJ"); - sawVj ||= work.sourceNativeAddress.startsWith("VJ"); - const metadata = inspection.accepted?.metadata ?? []; - const jsonRecordIds = new Set( - inspection.sourceRecords - .filter((record) => record.coverage.id === "dlsite.product-json") - .map((record) => record.id), - ); - const htmlRecordIds = new Set( - inspection.sourceRecords - .filter((record) => record.coverage.id === "dlsite.product-html") - .map((record) => record.id), - ); - assert.equal(jsonRecordIds.size, 1); - assert.equal(htmlRecordIds.size, 1); - for (const expected of [ - { property: "asset.title", value: work.title }, - { property: "asset.declared-language", value: work.language }, - { property: "dlsite.product.price-jpy", value: work.priceJpy }, - ]) { - assert.equal( - metadata.some( - (item) => - item.property === expected.property && - item.value === expected.value && - item.sourceRecordIds.length === 1 && - item.sourceRecordIds.every((id) => jsonRecordIds.has(id)), - ), - true, - `missing reviewed ${expected.property} for ${work.sourceNativeAddress}`, - ); - } - assert.equal( - metadata.some( - (item) => - item.property === "asset.release-date" && - item.value.startsWith(work.releaseDate) && - item.sourceRecordIds.length === 1 && - item.sourceRecordIds.every((id) => jsonRecordIds.has(id)), - ), - true, - `missing reviewed release date for ${work.sourceNativeAddress}`, - ); - const entities = inspection.accepted?.entities ?? []; - const circle = entities.find( - (entity) => - entity.identityNamespace === "dlsite.circle" && - entity.sourceKey === work.circleSourceKey && - entity.label === work.circle, - ); - assert.notEqual(circle, undefined); - assert.equal( - circle?.sourceRecordIds.length === 1 && - circle.sourceRecordIds.every((id) => htmlRecordIds.has(id)), - true, - `circle did not cite reviewed HTML for ${work.sourceNativeAddress}`, - ); - const priorCircle = sharedCircleIds.get(work.circle); - if (priorCircle === undefined) - sharedCircleIds.set(work.circle, circle!.id); - else assert.equal(circle!.id, priorCircle); - for (const [field, expectedCredits] of Object.entries(work.credits)) { - const namespace = `dlsite.credit.${field}.${field}`; - const actualCredits = entities - .filter((entity) => entity.identityNamespace === namespace) - .map((entity) => entity.label) - .toSorted(); - assert.deepEqual( - actualCredits, - [...expectedCredits].toSorted(), - `reviewed ${field} credits drifted for ${work.sourceNativeAddress}`, - ); - for (const credit of expectedCredits) { - const persona = entities.find( - (entity) => - entity.identityNamespace === namespace && - entity.sourceKey === credit && - entity.label === credit && - entity.sourceRecordIds.length === 1 && - entity.sourceRecordIds.every((id) => htmlRecordIds.has(id)), - ); - assert.notEqual(persona, undefined); - const mention = inspection.accepted?.entityMentions?.find( - (candidate) => - candidate.field === field && - candidate.role === field && - candidate.label === credit && - candidate.sourceRecordIds.length === 1 && - candidate.sourceRecordIds.every((id) => htmlRecordIds.has(id)), - ); - assert.notEqual(mention, undefined); - assert.equal( - inspection.accepted?.entityResolutionDecisions?.some( - (decision) => - decision.mentionId === mention!.id && - decision.entityId === persona!.id, - ), - true, - ); - assert.equal( - inspection.accepted?.relationships?.some((relationship) => - relationship.participants.some( - (participant) => - participant.role === field && - participant.entityId === persona!.id, - ), - ), - true, - ); - } - } - sawMultipleCredits ||= Object.values(work.credits).some( - (credits) => credits.length > 1, - ); - assert.equal( - entities.some((entity) => entity.label.toUpperCase() === "N/A"), - false, - ); - assert.equal( - JSON.stringify({ - entities, - mentions: inspection.accepted?.entityMentions, - relationships: inspection.accepted?.relationships, - }) - .toUpperCase() - .includes('"N/A"'), - false, - ); - for (const entity of entities) { - const key = `${entity.identityNamespace}\u0000${entity.sourceKey}`; - const prior = sourcedEntityIds.get(key); - if (prior === undefined) sourcedEntityIds.set(key, entity.id); - else assert.equal(entity.id, prior); - } - for (const mention of inspection.accepted?.entityMentions ?? []) { - const resolution = - inspection.accepted?.entityResolutionDecisions?.find( - (decision) => decision.mentionId === mention.id, - ); - assert.notEqual(resolution, undefined); - assert.equal( - inspection.accepted?.relationships?.some((relationship) => - relationship.participants.some( - (participant) => - participant.role === mention.role && - participant.entityId === resolution!.entityId, - ), - ), - true, - ); - } - const retainedIds = new Set( - inspection.sourceRecords.map((record) => record.id), - ); - const sourcedFacts = [ - ...metadata, - ...(inspection.accepted?.entityMentions ?? []), - ...(inspection.accepted?.entityResolutionDecisions ?? []), - ...(inspection.accepted?.relationships ?? []), - ]; - assert.equal( - sourcedFacts.every( - (fact) => - fact.sourceRecordIds.length > 0 && - fact.sourceRecordIds.every((id) => retainedIds.has(id)), - ), - true, - ); - assert.equal( - entities.every((entity) => entity.sourceRecordIds.length > 0), - true, - ); - sawLanguageEvidence = true; - const seriesRelationships = - inspection.accepted?.relationships?.filter((relationship) => - relationship.participants.some( - (participant) => participant.role === "dlsite.series.member", - ), - ) ?? []; - assert.equal( - metadata.some((item) => item.property === "asset.series-position") && - seriesRelationships.length === 0, - false, - ); - sawSeriesEvidence ||= seriesRelationships.length > 0; - if (work.reviewedSeries !== undefined) { - const seriesEntity = entities.find( - (entity) => - entity.kind === work.reviewedSeries?.entityKind && - entity.identityNamespace === - work.reviewedSeries.identityNamespace && - entity.sourceKey === work.reviewedSeries.sourceKey && - entity.label === work.reviewedSeries.label, - ); - assert.ok( - seriesEntity, - `missing reviewed series Entity for ${work.sourceNativeAddress}`, - ); - const officialRecordIds = new Set( - inspection.sourceRecords - .filter( - (record) => - record.coverage.id === work.reviewedSeries?.officialCoverage, - ) - .map((record) => record.id), - ); - assert.equal(officialRecordIds.size, 1); - assert.ok( - seriesRelationships.some( - (relationship) => - relationship.type === work.reviewedSeries?.relationshipType && - relationship.participants.some( - (participant) => - participant.entityId === seriesEntity.id && - participant.role === work.reviewedSeries?.relationshipRole, - ) && - relationship.sourceRecordIds.length === 1 && - relationship.sourceRecordIds.every((id) => - officialRecordIds.has(id), - ), - ), - `missing reviewed series relationship for ${work.sourceNativeAddress}`, - ); - } - const root = path.join( - corpus, - "assets/kikoeru/VoiceWork", - work.directory, - ); - const localFiles = baselines.get(root) ?? []; - sawBundledFiles ||= localFiles.length > 1; - assert.equal( - inspection.execution.snapshot.files.length, - localFiles.length, - ); - assert.equal( - inspection.accepted?.artifacts.length, - localFiles.length + 1, - ); - const associations = inspection.accepted?.artifactAssociations ?? []; - assert.equal( - associations.filter( - (association) => association.role === "asset.material-delivered", - ).length, - localFiles.length, - ); - assert.equal( - associations.filter( - (association) => association.role === "asset.primary-cover", - ).length, - 1, - ); - assert.equal( - associations.every( - (association) => - association.role === "asset.material-delivered" || - association.role === "asset.primary-cover", - ), - true, - ); - assert.equal( - new Set(associations.map((association) => association.artifactId)) - .size, - inspection.accepted?.artifacts.length, - ); - assert.deepEqual(await regularFileSignature(root), localFiles); - } catch (error) { - const failedRoot = path.join( - corpus, - "assets/kikoeru/VoiceWork", - work.directory, - ); - assert.deepEqual( - await regularFileSignature(failedRoot), - baselines.get(failedRoot), - ); - failures.push({ - sourceNativeAddress: work.sourceNativeAddress, - outcome: inspection.execution.outcome, - reason: `reviewed-oracle assertion failed: ${ - error instanceof Error ? error.message : String(error) - }`, - exitCode: result.exitCode, - }); - } - } - const state = currentState(context); - if (state.deepBefore !== undefined) { - const after = await captureFixedCorpusSnapshots(state.roots, true); - assert.deepEqual( - fixedCorpusContentDigestsFromSnapshots(state.roots, after), - state.deepBefore, - "DLsite live journey must not alter approved source bytes", - ); - } - if (failures.length > 0) - throw new Error( - `DLsite eight-work live acceptance failures:\n${JSON.stringify(failures, undefined, 2)}`, - ); - assert.equal(sawRj, true, "eight-work gate did not exercise RJ addressing"); - assert.equal(sawVj, true, "eight-work gate did not exercise VJ addressing"); - assert.equal( - sawMultipleCredits, - true, - "eight-work gate did not exercise multiple credited Personas", - ); - assert.equal( - sawLanguageEvidence, - true, - "eight-work gate did not retain language-edition evidence", - ); - assert.equal( - sawSeriesEvidence, - true, - "eight-work gate did not retain official series evidence", - ); - assert.equal( - sawBundledFiles, - true, - "eight-work gate did not exercise heterogeneous bundled files", - ); - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/journeys/epub-publication-live.journey.ts b/apps/cli/test/e2e/journeys/epub-publication-live.journey.ts deleted file mode 100644 index 0988b9a..0000000 --- a/apps/cli/test/e2e/journeys/epub-publication-live.journey.ts +++ /dev/null @@ -1,773 +0,0 @@ -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; - -import type { AssetRefreshInspection } from "@digital/application"; -import type { RawFilesystemSnapshot } from "@digital/test-support"; - -import { - assertFixedCorpusContentBaselineSubset, - captureFixedCorpusSnapshots, - fixedCorpusContentDigestsFromSnapshots, - fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle, - fixedCorpusRoots, - type FixedCorpusContentDigest, - type FixedCorpusRoot, -} from "../fixed-corpus.ts"; -import { - registeredObservedRootId, - type CliJourney, - type CliJourneyContext, - type CliJourneyResult, -} from "../journey.ts"; - -const ownerTicket = - "v1-live-acceptance/issues/03-epub-publication-live-gate.md"; -export const approvedEpubOracleSliceDigest = - "4852cefe20b121df4547307a7a28fbc49d3bb8fd95ae860e9e7a611a01ce179e"; -const maximumOfficialResponseBytes = 2 * 1024 * 1024; - -function record(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function epubOracleSlice(rawOracle: unknown): unknown { - const oracle = record(rawOracle); - const reviews = record(oracle?.reviews); - const groups = Array.isArray(oracle?.dedicatedIntegrationOwnershipGroups) - ? oracle.dedicatedIntegrationOwnershipGroups - : []; - return { - reviews: { epub: reviews?.epub }, - dedicatedIntegrationOwnershipGroups: groups.filter( - (group) => record(group)?.ownerTicket === ownerTicket, - ), - epubPublications: oracle?.epubPublications, - }; -} - -/** 在任何真实媒体或 CLI 动作前固定本 gate 自己审核过的 oracle 语义。 */ -export function assertApprovedEpubOracleSlice(rawOracle: unknown): void { - const digest = createHash("sha256") - .update(JSON.stringify(epubOracleSlice(rawOracle))) - .digest("hex"); - assert.equal( - digest, - approvedEpubOracleSliceDigest, - "EPUB reviewed oracle slice digest drifted", - ); -} - -function validIsbn13(value: string): boolean { - if (!/^(?:97[89])\d{10}$/u.test(value)) return false; - const sum = Array.from(value) - .slice(0, 12) - .reduce( - (total, digit, index) => - total + Number(digit) * (index % 2 === 0 ? 1 : 3), - 0, - ); - return (10 - (sum % 10)) % 10 === Number(value[12]); -} - -function strictIsbn13(value: unknown): string | undefined { - if (typeof value !== "string") return undefined; - const match = - /^(?:ISBN(?:-13)?(?:\s*:\s*|\s+))?(97[89](?:[ -]?\d){10})$/iu.exec(value); - const normalized = match?.[1]?.replaceAll(/[ -]/gu, ""); - return normalized !== undefined && validIsbn13(normalized) - ? normalized - : undefined; -} - -type OfficialMetadata = { - readonly property: string; - readonly value: string; -}; - -export type CanonicalOfficialProjection = { - readonly isbn: string; - readonly sourceAddress: string; - readonly metadata: readonly OfficialMetadata[]; - readonly digest: string; -}; - -function strings(value: unknown): readonly string[] { - const values = Array.isArray(value) ? value : [value]; - return values.flatMap((item) => - typeof item === "string" && item.trim().length > 0 ? [item.trim()] : [], - ); -} - -function comparableMetadata(item: OfficialMetadata): string { - return `${item.property}\u0000${item.value}`; -} - -function canonicalSourceAddress(isbn: string): string { - const query = new URLSearchParams({ - inst: "886NCL_NBINET", - vid: "886NCL_NBINET:NBINET", - lang: "zh-tw", - q: `isbn,exact,${isbn}`, - scope: "MyInstitution", - tab: "NBINET", - skipDelivery: "Y", - }); - return `https://nbinet.primo.exlibrisgroup.com/discovery/search?${query}`; -} - -/** 验证 transient NBINet bytes,并只保留确定性的完整书目投影。 */ -export function canonicalExactOfficialResponse( - bytes: Uint8Array, - isbn: string, - sourceAddress: string, -): CanonicalOfficialProjection { - assert.ok( - bytes.byteLength > 0 && bytes.byteLength <= maximumOfficialResponseBytes, - "NBINet exact response is outside the bounded body size", - ); - let parsed: unknown; - try { - parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown; - } catch { - throw new Error("NBINet exact response is not valid JSON"); - } - const docs = record(parsed)?.docs; - const documents = Array.isArray(docs) ? docs : []; - assert.equal( - documents.length, - 1, - "NBINet exact response must contain exactly one document", - ); - const document = record(documents[0]); - const pnx = record(document?.pnx); - const identifiers = record(pnx?.addata)?.isbn; - const exactMatches = (Array.isArray(identifiers) ? identifiers : []) - .map(strictIsbn13) - .filter((value) => value === isbn); - assert.equal( - exactMatches.length, - 1, - "NBINet exact document must contain the strict target ISBN exactly once", - ); - assert.equal( - sourceAddress, - canonicalSourceAddress(isbn), - "NBINet source address must be the canonical exact ISBN address", - ); - const display = record(pnx?.display); - const metadata = [ - { property: "book.isbn-13", value: isbn }, - ...strings(display?.title).map((value) => ({ - property: "asset.title", - value, - })), - ...strings(display?.creator).map((value) => ({ - property: "book.contributor", - value, - })), - ...strings(display?.publisher).map((value) => ({ - property: "book.publisher", - value, - })), - ...strings(display?.creationdate).map((value) => ({ - property: "book.publication-date", - value, - })), - ...strings(display?.language).map((value) => ({ - property: "asset.declared-language", - value, - })), - ].toSorted((left, right) => - comparableMetadata(left).localeCompare(comparableMetadata(right)), - ); - const canonical = { isbn, sourceAddress, metadata }; - return { - ...canonical, - digest: createHash("sha256") - .update(JSON.stringify(canonical)) - .digest("hex"), - }; -} - -export function digestExactOfficialResponse( - bytes: Uint8Array, - isbn: string, - sourceAddress: string, -): string { - return canonicalExactOfficialResponse(bytes, isbn, sourceAddress).digest; -} - -type FormatFactOracle = { - readonly property: string; - readonly value: string; - readonly locator: string; -}; - -type OfficialOracle = { - readonly isbn: string; - readonly requestAddress: string; - readonly sourceAddress: string; - readonly metadata: readonly OfficialMetadata[]; -}; - -type PublicationOracle = { - readonly logicalRoot: string; - readonly revision: { - readonly digest: string; - readonly mediaType: string; - readonly byteLength: number; - }; - readonly formatFacts: readonly FormatFactOracle[]; - readonly cover: { readonly locator: string; readonly mediaType: string }; - readonly official?: OfficialOracle; -}; - -type JourneyState = { - readonly roots: readonly FixedCorpusRoot[]; - readonly publications: readonly PublicationOracle[]; - readonly before: ReadonlyMap; - readonly officialProjections: ReadonlyMap< - string, - CanonicalOfficialProjection - >; - readonly deepBefore?: ReadonlyMap; -}; - -const stateByContext = new Map(); - -function stateKey(context: CliJourneyContext): string { - return `${context.mode}\u0000${context.workDir}`; -} - -function currentState(context: CliJourneyContext): JourneyState { - const state = stateByContext.get(stateKey(context)); - if (state === undefined) - throw new Error("EPUB publication live journey setup did not run"); - return state; -} - -function corpusRoot(context: CliJourneyContext): string { - return path.resolve(context.packageRoot, "../..", "fixtures/live-corpus"); -} - -function exactRootSet(actual: readonly string[], expected: readonly string[]) { - assert.equal(new Set(actual).size, actual.length); - assert.deepEqual([...actual].toSorted(), [...expected].toSorted()); -} - -function inspection( - result: CliJourneyResult | undefined, -): AssetRefreshInspection { - return JSON.parse(result?.stdout ?? "{}") as AssetRefreshInspection; -} - -function acceptedProjection(value: AssetRefreshInspection) { - const items = value.itemResults ?? []; - assert.equal(items.length, 1, "one EPUB root must yield one work item"); - assert.equal(items[0]!.relativePaths.length, 1); - assert.equal(items[0]!.outcome, "accepted"); - const accepted = items[0]!.accepted ?? value.accepted; - assert.ok(accepted, "EPUB work item must expose its accepted projection"); - return accepted; -} - -function assertFormatFact( - accepted: ReturnType, - expected: FormatFactOracle, -): void { - const matches = accepted.metadata.filter( - (fact) => - fact.property === expected.property && - fact.value === expected.value && - fact.observedArtifactPart?.locator === expected.locator, - ); - assert.equal( - matches.length, - 1, - `missing exact EPUB fact ${expected.property}:${expected.value}`, - ); - assert.deepEqual(matches[0]!.sourceRecordIds, []); -} - -function assertCompleteFormatFacts( - accepted: ReturnType, - expected: readonly FormatFactOracle[], -): void { - const comparable = (fact: FormatFactOracle) => - `${fact.property}\u0000${fact.value}\u0000${fact.locator}`; - assert.deepEqual( - accepted.metadata - .filter((fact) => fact.sourceRecordIds.length === 0) - .map((fact) => ({ - property: fact.property, - value: fact.value, - locator: fact.observedArtifactPart?.locator ?? "", - })) - .map(comparable) - .toSorted(), - expected.map(comparable).toSorted(), - "revision-bound EPUB facts must be complete and must not include path-derived metadata", - ); -} - -function expectedNbinetRequest(isbn: string): string { - const query = new URLSearchParams({ - inst: "886NCL_NBINET", - vid: "886NCL_NBINET:NBINET", - lang: "zh-tw", - q: `isbn,exact,${isbn}`, - scope: "MyInstitution", - tab: "NBINET", - skipDelivery: "Y", - }); - return `https://nbinet.primo.exlibrisgroup.com/primaws/rest/pub/pnxs?${query}`; -} - -async function exactOfficialProjection( - official: OfficialOracle, -): Promise { - assert.equal(official.requestAddress, expectedNbinetRequest(official.isbn)); - assert.equal(official.sourceAddress, canonicalSourceAddress(official.isbn)); - const response = await fetch(official.requestAddress, { - method: "GET", - redirect: "error", - }); - assert.equal( - response.ok, - true, - `NBINet exact request failed with HTTP ${String(response.status)}`, - ); - const declaredLength = response.headers.get("content-length"); - if (declaredLength !== null) { - const value = Number(declaredLength); - assert.equal( - Number.isSafeInteger(value) && - value > 0 && - value <= maximumOfficialResponseBytes, - true, - "NBINet exact response declared an invalid body size", - ); - } - assert.ok(response.body, "NBINet exact response has no body"); - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let length = 0; - try { - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - length += chunk.value.byteLength; - if (length > maximumOfficialResponseBytes) { - await reader.cancel(); - throw new Error("NBINet exact response exceeded the body limit"); - } - chunks.push(chunk.value); - } - } finally { - reader.releaseLock(); - } - const bytes = new Uint8Array(length); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - return [ - official.isbn, - canonicalExactOfficialResponse( - bytes, - official.isbn, - official.sourceAddress, - ), - ]; -} - -type ExactOfficialAcceptedProjection = { - readonly sourceAssociations: readonly { - readonly sourceIdentity: string; - readonly nativeAddress: string; - }[]; - readonly recognitionSourceAssociation?: { - readonly sourceIdentity: string; - readonly nativeAddress: string; - }; - readonly metadata: readonly { - readonly property: string; - readonly value: string; - readonly sourceRecordIds: readonly string[]; - }[]; -}; - -/** 只使用普通 CLI 公开 DTO 证明本次 transient official response 的完整投影。 */ -export function assertExactOfficialPublicProjection( - sourceRecords: AssetRefreshInspection["sourceRecords"], - accepted: ExactOfficialAcceptedProjection, - official: OfficialOracle, - direct: CanonicalOfficialProjection, -): void { - assert.equal(sourceRecords.length, 1); - const sourceRecord = sourceRecords[0]!; - assert.deepEqual( - { - sourceIdentity: sourceRecord.sourceIdentity, - nativeAddress: sourceRecord.nativeAddress, - coverage: sourceRecord.coverage, - }, - { - sourceIdentity: "nbinet", - nativeAddress: official.sourceAddress, - coverage: { - id: "nbinet.exact-isbn", - completeness: "complete", - authority: "official", - }, - }, - "public NBINet Source Record provenance drifted", - ); - assert.match( - sourceRecord.digest, - /^[0-9a-f]{64}$/u, - "public NBINet Source Record digest must be lowercase SHA-256", - ); - assert.deepEqual(accepted.sourceAssociations, [ - { sourceIdentity: "nbinet", nativeAddress: official.sourceAddress }, - ]); - assert.deepEqual(accepted.recognitionSourceAssociation, { - sourceIdentity: "nbinet", - nativeAddress: official.sourceAddress, - }); - assert.equal(direct.isbn, official.isbn); - assert.equal(direct.sourceAddress, official.sourceAddress); - const propertyValues = (items: readonly OfficialMetadata[]) => - items.map(comparableMetadata).toSorted(); - assert.deepEqual( - propertyValues(direct.metadata), - propertyValues(official.metadata), - "direct official metadata must equal the reviewed property/value multiset", - ); - const sourcedMetadata = accepted.metadata - .filter((item) => item.sourceRecordIds.length > 0) - .map(({ property, value, sourceRecordIds }) => ({ - property, - value, - sourceRecordIds, - })); - const sourcedComparable = (item: (typeof sourcedMetadata)[number]) => - JSON.stringify(item); - const expectedSourced = (items: readonly OfficialMetadata[]) => - items - .map(({ property, value }) => ({ - property, - value, - sourceRecordIds: [sourceRecord.id], - })) - .map(sourcedComparable) - .toSorted(); - const actualSourced = sourcedMetadata.map(sourcedComparable).toSorted(); - assert.deepEqual( - actualSourced, - expectedSourced(direct.metadata), - "CLI sourced metadata must equal the direct official property/value multiset", - ); - assert.deepEqual( - actualSourced, - expectedSourced(official.metadata), - "CLI sourced metadata must equal the reviewed property/value multiset", - ); -} - -function assertOfficialProjection( - value: AssetRefreshInspection, - accepted: ReturnType, - official: OfficialOracle | undefined, - direct: CanonicalOfficialProjection | undefined, -): void { - if (official === undefined) { - assert.equal(direct, undefined); - assert.deepEqual(value.sourceRecords, []); - assert.deepEqual(accepted.sourceAssociations, []); - assert.equal(accepted.recognitionSourceAssociation, undefined); - assert.match(accepted.recognitionExplanation, /无有效 ISBN/u); - return; - } - assert.equal(official.requestAddress, expectedNbinetRequest(official.isbn)); - assert.ok(direct, "transient NBINet canonical projection is missing"); - assertExactOfficialPublicProjection( - value.sourceRecords, - accepted, - official, - direct, - ); -} - -function stableBusinessProjection( - value: AssetRefreshInspection, - accepted: ReturnType, -): unknown { - return { - sourceRecords: value.sourceRecords.map((record) => ({ - sourceIdentity: record.sourceIdentity, - nativeAddress: record.nativeAddress, - coverage: record.coverage, - })), - sourceAssociations: accepted.sourceAssociations, - recognitionSourceAssociation: accepted.recognitionSourceAssociation, - blobs: accepted.blobs - .map((blob) => ({ - digest: blob.digest, - mediaType: blob.mediaType, - byteLength: blob.byteLength, - })) - .toSorted((left, right) => left.digest.localeCompare(right.digest)), - metadata: accepted.metadata - .map((fact) => ({ - property: fact.property, - value: fact.value, - locator: fact.observedArtifactPart?.locator, - sourced: fact.sourceRecordIds.length > 0, - })) - .toSorted((left, right) => - `${left.property}:${left.value}:${left.locator ?? ""}`.localeCompare( - `${right.property}:${right.value}:${right.locator ?? ""}`, - ), - ), - covers: accepted.artifactAssociations - .filter((association) => association.role === "asset.primary-cover") - .map((association) => association.observedArtifactPart?.locator), - entityCount: accepted.currentRelatedEntities?.length ?? 0, - relationshipCount: accepted.relationships?.length ?? 0, - }; -} - -const sourceBusinessProjection = new Map(); - -const journey: CliJourney = { - name: "epub-publication-live", - source: { mode: "real" }, - modes: ["source", "distribution"], - async setup(context) { - const untrustedOracle = JSON.parse( - await readFile( - path.join(corpusRoot(context), "references/reviewed-oracle.json"), - "utf8", - ), - ) as unknown; - assertApprovedEpubOracleSlice(untrustedOracle); - const rawOracle = untrustedOracle as { - readonly reviews: { - readonly epub: { - readonly status: string; - readonly reviewedAt: string; - }; - }; - readonly epubPublications: readonly PublicationOracle[]; - }; - assert.equal( - rawOracle.reviews.epub.status, - "reviewed-current-official-source", - ); - assert.equal(rawOracle.reviews.epub.reviewedAt, "2026-08-30"); - const allRoots = await fixedCorpusRoots(context); - const owner = fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle( - rawOracle, - allRoots, - ).find((group) => group.ownerTicket === ownerTicket); - assert.ok(owner, "EPUB fixed-corpus ownership group is missing"); - assert.equal(owner.status, "implemented"); - assert.equal(owner.journey, journey.name); - assert.equal(rawOracle.epubPublications.length, 6); - assert.equal( - rawOracle.epubPublications.filter((item) => item.official !== undefined) - .length, - 2, - ); - const officialProjections = new Map(); - for (const item of rawOracle.epubPublications) - if (item.official !== undefined) { - const [isbn, projection] = await exactOfficialProjection(item.official); - officialProjections.set(isbn, projection); - } - assert.equal(officialProjections.size, 2); - assert.equal( - new Set([...officialProjections.values()].map(({ digest }) => digest)) - .size, - 2, - "the two exact ISBN canonical projections must remain distinct", - ); - exactRootSet( - rawOracle.epubPublications.map((item) => item.logicalRoot), - owner.roots.map((root) => root.logicalRoot), - ); - const byRoot = new Map(owner.roots.map((root) => [root.logicalRoot, root])); - const roots = rawOracle.epubPublications.map( - (publication) => byRoot.get(publication.logicalRoot)!, - ); - const deepIntegrity = process.env.DIGITAL_DEEP_CORPUS_INTEGRITY === "1"; - const before = await captureFixedCorpusSnapshots(roots, deepIntegrity); - const deepBefore = deepIntegrity - ? fixedCorpusContentDigestsFromSnapshots(roots, before) - : undefined; - if (deepBefore !== undefined) - await assertFixedCorpusContentBaselineSubset(context, deepBefore); - stateByContext.set(stateKey(context), { - roots, - publications: rawOracle.epubPublications, - before, - officialProjections, - ...(deepBefore === undefined ? {} : { deepBefore }), - }); - }, - commands(context) { - const state = currentState(context); - const environment = { - DIGITAL_PRODUCT_DATA: context.productDataDirectory, - }; - return [ - ...state.roots.map((root, index) => ({ - name: `register EPUB publication root ${String(index + 1)}`, - args: [ - "library-create", - `EPUB publication ${String(index + 1)}`, - "--directory", - root.directory, - "--json", - ], - env: environment, - haltOnFailure: true, - })), - ...state.roots.map((_root, index) => ({ - name: `live refresh EPUB publication ${String(index + 1)}`, - args: (results: readonly CliJourneyResult[]) => [ - "scan", - registeredObservedRootId(results[index]), - "--scope", - ".", - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - env: environment, - haltOnFailure: true, - })), - ]; - }, - async assertions({ context, results: allResults }) { - const state = currentState(context); - try { - for (const result of allResults.slice(0, state.roots.length)) { - assert.equal(result.exitCode, 0, result.stderr); - registeredObservedRootId(result); - } - const results = allResults.slice(state.roots.length); - assert.equal(results.length, state.publications.length); - const assetIds = new Set(); - for (const [index, publication] of state.publications.entries()) { - const result = results[index]!; - assert.equal(result.exitCode, 0, result.stderr); - const value = inspection(result); - assert.equal(value.execution.outcome, "accepted"); - const accepted = acceptedProjection(value); - assert.equal(accepted.entity.kind, "digital-asset"); - assert.equal(assetIds.has(accepted.entity.id), false); - assetIds.add(accepted.entity.id); - const revisionBlob = accepted.blobs.find( - (blob) => blob.digest === publication.revision.digest, - ); - assert.deepEqual(revisionBlob, publication.revision); - assert.equal( - accepted.artifactRevisions.some( - (revision) => revision.blobDigest === publication.revision.digest, - ), - true, - ); - for (const fact of publication.formatFacts) - assertFormatFact(accepted, fact); - assertCompleteFormatFacts(accepted, publication.formatFacts); - const covers = accepted.artifactAssociations.filter( - (association) => association.role === "asset.primary-cover", - ); - assert.equal(covers.length, 1); - assert.equal( - covers[0]!.observedArtifactPart?.locator, - publication.cover.locator, - ); - assert.match(publication.cover.locator, /^epub:/u); - const coverArtifact = accepted.artifacts.find( - (artifact) => artifact.id === covers[0]!.artifactId, - ); - assert.ok(coverArtifact); - const coverRevision = accepted.artifactRevisions.find( - (revision) => revision.artifactId === coverArtifact.id, - ); - assert.ok(coverRevision); - const parentRevision = accepted.artifactRevisions.find( - (revision) => revision.blobDigest === publication.revision.digest, - ); - assert.ok(parentRevision); - assert.equal( - covers[0]!.observedArtifactPart?.revisionId, - parentRevision.id, - "cover locator must remain bound to the exact parent EPUB revision", - ); - assert.equal( - accepted.blobs.find( - (blob) => blob.digest === coverRevision.blobDigest, - )?.mediaType, - publication.cover.mediaType, - ); - assertOfficialProjection( - value, - accepted, - publication.official, - publication.official === undefined - ? undefined - : state.officialProjections.get(publication.official.isbn), - ); - assert.deepEqual(accepted.currentRelatedEntities ?? [], []); - assert.deepEqual(accepted.entityMentions ?? [], []); - assert.deepEqual(accepted.relationships ?? [], []); - const stable = stableBusinessProjection(value, accepted); - if (context.mode === "source") - sourceBusinessProjection.set(publication.logicalRoot, stable); - else if (sourceBusinessProjection.has(publication.logicalRoot)) - assert.deepEqual( - stable, - sourceBusinessProjection.get(publication.logicalRoot), - `source/distribution projection drifted: ${publication.logicalRoot}`, - ); - } - assert.equal(assetIds.size, 6); - const after = await captureFixedCorpusSnapshots( - state.roots, - state.deepBefore !== undefined, - ); - assert.deepEqual( - after, - state.before, - "EPUB fixed roots changed during the live journey", - ); - if (state.deepBefore !== undefined) { - const deepAfter = fixedCorpusContentDigestsFromSnapshots( - state.roots, - after, - ); - await assertFixedCorpusContentBaselineSubset(context, deepAfter); - assert.deepEqual(deepAfter, state.deepBefore); - } - for (const [isbn, projection] of [...state.officialProjections].toSorted( - ([left], [right]) => left.localeCompare(right), - )) - console.log( - `epub-publication-live NBINet ${isbn} canonical-sha256 ${projection.digest}`, - ); - } finally { - stateByContext.delete(stateKey(context)); - } - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/journeys/fanbox-five-live.journey.ts b/apps/cli/test/e2e/journeys/fanbox-five-live.journey.ts deleted file mode 100644 index 6056d43..0000000 --- a/apps/cli/test/e2e/journeys/fanbox-five-live.journey.ts +++ /dev/null @@ -1,1986 +0,0 @@ -import assert from "node:assert/strict"; -import { constants } from "node:fs"; -import { lstat, open, readFile, type FileHandle } from "node:fs/promises"; -import path from "node:path"; -import { isDeepStrictEqual } from "node:util"; - -import { decodeFanboxCollectionFailureCode } from "@digital/fanbox"; -import { captureRawFilesystemSnapshot } from "@digital/test-support"; - -import { - assertFixedCorpusContentBaselineSubset, - captureFixedCorpusSnapshots, - fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle, - fixedCorpusContentDigestsFromSnapshots, - fixedCorpusRoots, - type FixedCorpusContentDigest, - type FixedCorpusRoot, -} from "../fixed-corpus.ts"; -import { - registeredObservedRootId, - type CliJourney, - type CliJourneyCommand, - type CliJourneyContext, - type CliJourneyResult, -} from "../journey.ts"; - -type FanboxBodyBlockOracle = { - readonly ordinal: number; - readonly type: "header" | "p" | "image" | "file" | "embed" | "url_embed"; - readonly value: Readonly>; -}; - -type FanboxAttachmentOracle = { - readonly ordinal: number; - readonly name?: string; - readonly kind: "file" | "image"; - readonly byteLength?: string; -}; - -export type FanboxPostOracle = { - readonly logicalRoot: string; - readonly directoryDate: string; - readonly sourceNativeAddress: string; - readonly title: string; - readonly publishedDatetime: string; - readonly postType: "text" | "file" | "image" | "video" | "article"; - readonly creatorId: string; - readonly entitlement: "available" | "restricted"; - readonly cover: "present" | "absent"; - readonly body: - | { - readonly status: "available"; - readonly text: string; - readonly blocks: readonly FanboxBodyBlockOracle[]; - } - | { - readonly status: "restricted"; - readonly blocks: readonly []; - }; - readonly attachments: readonly FanboxAttachmentOracle[]; -}; - -type MetadataFact = { - readonly id?: string; - readonly property: string; - readonly sourceSlot: string; - readonly value: string; - readonly sourceRecordIds: readonly string[]; - readonly qualifiers?: readonly { - readonly key: string; - readonly value: string; - }[]; - readonly observedArtifactPart?: { - readonly artifactId: string; - readonly revisionId: string; - readonly locator: string; - }; -}; - -type AcceptedProjection = { - readonly entity: { readonly id: string }; - readonly sourceAssociations: readonly { - readonly sourceIdentity: string; - readonly nativeAddress: string; - }[]; - readonly recognitionSourceAssociation?: { - readonly sourceIdentity: string; - readonly nativeAddress: string; - }; - readonly artifacts: readonly { readonly id: string }[]; - readonly artifactAssociations: readonly { - readonly artifactId: string; - readonly role: string; - readonly sourceRecordIds?: readonly string[]; - }[]; - readonly locations: readonly { - readonly artifactId: string; - readonly kind: string; - }[]; - readonly blobs: readonly { readonly digest: string }[]; - readonly artifactRevisions: readonly { - readonly id: string; - readonly artifactId: string; - readonly blobDigest: string; - }[]; - readonly metadata: readonly MetadataFact[]; - readonly sourceArtifactCandidates?: readonly { - readonly sourceRecordId: string; - readonly role: string; - readonly mediaType: string; - readonly selectionEvidence?: { - readonly suggestedFilename?: string; - readonly declaredByteLength?: number; - readonly sourceOrdinal?: number; - }; - }[]; - readonly entities?: readonly { - readonly id?: string; - readonly sourceKey: string; - readonly sourceRecordIds: readonly string[]; - }[]; - readonly entityMentions?: readonly { - readonly sourceRecordIds: readonly string[]; - }[]; - readonly entityResolutionDecisions?: readonly { - readonly sourceRecordIds: readonly string[]; - }[]; - readonly relationships?: readonly { - readonly sourceRecordIds: readonly string[]; - }[]; -}; - -export type FanboxJourneyInspection = { - readonly execution: { - readonly outcome: string; - readonly reusedExecutionId?: string; - readonly dispositions?: readonly { - readonly key: string; - readonly reason?: string; - readonly sourceFailureCodes?: readonly { - readonly capability: { - readonly capabilityId: string; - readonly capabilityVersion: string; - readonly contractVersion: string; - readonly identity?: string; - readonly implementationVersion?: string; - readonly kind?: string; - readonly interpretationContractVersions?: readonly string[]; - }; - readonly code: string; - }[]; - }[]; - }; - readonly sourceRecords: readonly { - readonly id: string; - readonly sourceIdentity: string; - readonly coverage: { readonly id: string }; - }[]; - readonly accepted?: AcceptedProjection; -}; - -type JourneyState = { - readonly posts: readonly FanboxPostOracle[]; - readonly roots: readonly FixedCorpusRoot[]; - readonly signatures: ReadonlyMap; - readonly deepBefore?: ReadonlyMap; - assertionsFailed: boolean; - attributedInitialOutcome?: { - readonly resultIndex: number; - readonly resultCount: number; - readonly result: CliJourneyResult; - }; -}; - -type EvidenceIdentity = { - readonly device: number; - readonly inode: number; -}; - -type FanboxLiveFailureCause = - | { readonly category: "source-connection-unavailable" } - | { readonly category: "source-connection-verification" } - | { readonly category: "cloudflare-challenge" } - | { - readonly category: "http-status"; - readonly status: number; - readonly class: "4xx" | "5xx"; - } - | { readonly category: "transport-failure" } - | { readonly category: "summary-selection" } - | { readonly category: "response-contract" } - | { readonly category: "source-identity-conflict" } - | { readonly category: "detail-contract" } - | { readonly category: "artifact-acquisition" } - | { readonly category: "journey-output-invalid" } - | { readonly category: "unknown" }; - -type FanboxLiveFailureEvidence = { - readonly format: "fanbox-live-failure/1"; - readonly evidenceState: "cause-persisted" | "teardown-finished"; - readonly mode: "source" | "distribution"; - readonly journey: "fanbox-five-live"; - readonly stage: - | "connection-import" - | "root-registration" - | "initial-accept" - | "reopened-no-change" - | "metadata-refresh" - | "assertions" - | "teardown"; - readonly command?: { - readonly ordinal: number; - readonly name: string; - readonly post?: { - readonly ordinal: number; - readonly nativeAddress: string; - }; - readonly exitCode: number; - }; - readonly inspection?: { - readonly outcome: "failed" | "partially-succeeded"; - readonly workflowStage?: - | "recognition" - | "source-records-retained" - | "source-artifacts-acquired" - | "source-artifacts-stored" - | "completed" - | "failed"; - }; - readonly cause: FanboxLiveFailureCause; - readonly teardown: "pending" | "passed" | "failed"; - readonly stateReleased: "pending" | "yes"; -}; - -type ExpectedFanboxCommand = { - readonly name: string; - readonly stage: FanboxLiveFailureEvidence["stage"]; - readonly post?: { - readonly ordinal: number; - readonly nativeAddress: string; - }; -}; - -const fanboxFailureEvidenceEnvironment = - "DIGITAL_FANBOX_LIVE_FAILURE_EVIDENCE_PATH"; -const fanboxFailureEvidenceMaximumBytes = 2_048; -const workflowStages = [ - "recognition", - "source-records-retained", - "source-artifacts-acquired", - "source-artifacts-stored", - "completed", - "failed", -] as const; - -const stateByContext = new Map(); - -function stateKey(context: CliJourneyContext): string { - return `${context.mode}\u0000${context.workDir}`; -} - -function currentState(context: CliJourneyContext): JourneyState { - const state = stateByContext.get(stateKey(context)); - if (state === undefined) - throw new Error("FANBOX live journey was not set up"); - return state; -} - -function expectedFanboxCommands( - posts: readonly FanboxPostOracle[], -): readonly ExpectedFanboxCommand[] { - const phases = [ - ["root-registration", (id: string) => `register FANBOX post root ${id}`], - ["initial-accept", (id: string) => `live accept FANBOX post ${id}`], - [ - "reopened-no-change", - (id: string) => `live no-change FANBOX post ${id} after SQLite reopen`, - ], - [ - "metadata-refresh", - (id: string) => `live metadata refresh FANBOX post ${id}`, - ], - ] as const; - return [ - { name: "import FANBOX creator connection", stage: "connection-import" }, - ...phases.flatMap(([stage, commandName]) => - posts.map((post, index) => ({ - name: commandName(post.sourceNativeAddress), - stage, - post: { - ordinal: index + 1, - nativeAddress: post.sourceNativeAddress, - }, - })), - ), - ]; -} - -function record(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function hasExactKeys( - value: Record, - expected: readonly string[], -): boolean { - const actual = Object.keys(value).toSorted(); - const allowed = expected.toSorted(); - return ( - actual.length === allowed.length && - actual.every((key, index) => key === allowed[index]) - ); -} - -function liveFailureCause( - decoded: NonNullable>, -): FanboxLiveFailureCause { - if (decoded.category === "http-status") return decoded; - switch (decoded.category) { - case "connection-unavailable": - return { category: "source-connection-unavailable" }; - case "cloudflare-challenge": - case "transport-failure": - case "summary-selection": - case "response-contract": - case "source-identity-conflict": - case "detail-contract": - case "unknown": - return { category: decoded.category }; - } -} - -function inspectionDiagnostic(stdout: string): { - readonly inspection?: FanboxLiveFailureEvidence["inspection"]; - readonly cause: FanboxLiveFailureCause; -} { - let parsed: unknown; - try { - parsed = JSON.parse(stdout) as unknown; - } catch { - return { cause: { category: "journey-output-invalid" } }; - } - const inspection = record(parsed); - const execution = record(inspection?.execution); - if ( - inspection?.format !== "temporary-asset-refresh-inspection/v1" || - (execution?.outcome !== "failed" && - execution?.outcome !== "partially-succeeded") - ) { - return { cause: { category: "journey-output-invalid" } }; - } - const decodedCauses: FanboxLiveFailureCause[] = []; - if (Array.isArray(execution.dispositions)) { - for (const disposition of execution.dispositions) { - const item = record(disposition); - if (item === undefined || !Object.hasOwn(item, "sourceFailureCodes")) - continue; - if ( - !Array.isArray(item.sourceFailureCodes) || - item.sourceFailureCodes.length === 0 || - item.sourceFailureCodes.length > 16 - ) - return { cause: { category: "unknown" } }; - for (const candidate of item.sourceFailureCodes) { - const code = record(candidate); - const capability = record(code?.capability); - if ( - code === undefined || - !hasExactKeys(code, ["capability", "code"]) || - capability === undefined || - !hasExactKeys(capability, [ - "capabilityId", - "capabilityVersion", - "contractVersion", - "identity", - "implementationVersion", - "kind", - "interpretationContractVersions", - ]) || - typeof code?.code !== "string" || - typeof capability?.capabilityId !== "string" || - typeof capability.capabilityVersion !== "string" || - typeof capability.contractVersion !== "string" || - typeof capability.identity !== "string" || - typeof capability.implementationVersion !== "string" || - typeof capability.kind !== "string" || - !Array.isArray(capability.interpretationContractVersions) || - !capability.interpretationContractVersions.every( - (version) => typeof version === "string", - ) - ) - return { cause: { category: "unknown" } }; - const decoded = decodeFanboxCollectionFailureCode({ - capability: { - capabilityId: capability.capabilityId, - capabilityVersion: capability.capabilityVersion, - contractVersion: capability.contractVersion, - identity: capability.identity, - implementationVersion: capability.implementationVersion, - kind: capability.kind, - interpretationContractVersions: - capability.interpretationContractVersions, - }, - code: code.code, - }); - if (decoded === undefined) return { cause: { category: "unknown" } }; - decodedCauses.push(liveFailureCause(decoded)); - } - } - } - const workflowStage = workflowStages.find( - (stage) => stage === execution.stage, - ); - return { - inspection: { - outcome: execution.outcome, - ...(workflowStage === undefined ? {} : { workflowStage }), - }, - cause: - decodedCauses.length > 0 && - decodedCauses.every( - (cause) => JSON.stringify(cause) === JSON.stringify(decodedCauses[0]), - ) - ? decodedCauses[0]! - : { category: "unknown" }, - }; -} - -function failureEvidence( - context: CliJourneyContext, - state: JourneyState, - results: readonly CliJourneyResult[], -): FanboxLiveFailureEvidence | undefined { - assert.notEqual(context.mode, "packed"); - const mode = context.mode as "source" | "distribution"; - const invalid = ( - stage: "assertions" | "teardown", - ): FanboxLiveFailureEvidence => ({ - format: "fanbox-live-failure/1", - evidenceState: "cause-persisted", - mode, - journey: "fanbox-five-live", - stage, - cause: { category: "journey-output-invalid" }, - teardown: "pending", - stateReleased: "pending", - }); - const expected = expectedFanboxCommands(state.posts); - const evidenceFor = ( - resultIndex: number, - result: CliJourneyResult, - ): FanboxLiveFailureEvidence => { - const expectedCommand = expected[resultIndex]!; - const diagnostic = - resultIndex === 0 - ? { cause: { category: "unknown" } as FanboxLiveFailureCause } - : expectedCommand.stage === "initial-accept" || - expectedCommand.stage === "reopened-no-change" || - expectedCommand.stage === "metadata-refresh" - ? inspectionDiagnostic(result.stdout) - : { cause: { category: "unknown" } as FanboxLiveFailureCause }; - return { - format: "fanbox-live-failure/1", - evidenceState: "cause-persisted", - mode, - journey: "fanbox-five-live", - stage: expectedCommand.stage, - command: { - ordinal: resultIndex + 1, - name: expectedCommand.name, - ...(expectedCommand.post === undefined - ? {} - : { post: expectedCommand.post }), - exitCode: result.exitCode, - }, - ...(diagnostic.inspection === undefined - ? {} - : { inspection: diagnostic.inspection }), - cause: diagnostic.cause, - teardown: "pending", - stateReleased: "pending", - }; - }; - const attributed = state.attributedInitialOutcome; - if (attributed !== undefined) { - const expectedCommand = expected[attributed.resultIndex]; - const validAttribution = - expectedCommand?.stage === "initial-accept" && - results.length === attributed.resultCount && - results.length <= expected.length && - results[attributed.resultIndex] === attributed.result && - Array.from( - { length: results.length }, - (_, index) => results[index], - ).every( - (result, index) => - result !== undefined && - result.commandName === expected[index]?.name && - result.exitCode === 0, - ); - return validAttribution - ? evidenceFor(attributed.resultIndex, attributed.result) - : invalid("teardown"); - } - const failedIndex = results.findIndex((result) => result.exitCode !== 0); - if (failedIndex < 0) { - const completeSuccess = - results.length === expected.length && - results.every( - (result, index) => - result.commandName === expected[index]!.name && result.exitCode === 0, - ); - if (completeSuccess) - return state.assertionsFailed ? invalid("assertions") : undefined; - return invalid("teardown"); - } - const validSequence = - results.length <= expected.length && - failedIndex === results.length - 1 && - results.every( - (result, index) => - result.commandName === expected[index]?.name && - Number.isSafeInteger(result.exitCode) && - (index === failedIndex - ? result.exitCode >= 1 && result.exitCode <= 255 - : result.exitCode === 0), - ); - if (!validSequence) { - return invalid("teardown"); - } - return evidenceFor(failedIndex, results[failedIndex]!); -} - -function corpusRoot(context: CliJourneyContext): string { - return path.resolve( - context.packageRoot, - "../..", - ".scratch/acceptance-corpus", - ); -} - -async function entrySignature(root: string): Promise { - return (await captureRawFilesystemSnapshot(root)).entries - .filter((entry) => entry.relativePath !== ".") - .map( - (entry) => - `${entry.relativePath}:${entry.kind === "directory" ? "d" : entry.kind === "regular-file" ? "f" : entry.kind === "symbolic-link" ? "l" : "o"}:${entry.byteLength}:${entry.modifiedAtNanoseconds}:${entry.changedAtNanoseconds}:${entry.symbolicLinkTarget ?? ""}`, - ); -} - -function serializedFailureEvidence( - evidence: FanboxLiveFailureEvidence, -): string { - const serialized = `${JSON.stringify(evidence)}\n`; - assert.ok( - Buffer.byteLength(serialized) <= fanboxFailureEvidenceMaximumBytes, - "FANBOX 失败证据超过字节上限", - ); - assertSafe( - !/FANBOXSESSID|cf_clearance|authorization|cookie|docs[\\/]fanbox\.json/iu.test( - serialized, - ), - "FANBOX 失败证据包含禁止的私密标记", - ); - return serialized; -} - -async function syncParent(directory: string): Promise { - const handle = await open(directory, "r"); - try { - await handle.sync(); - } finally { - await handle.close(); - } -} - -function isMissing(error: unknown): boolean { - return ( - error !== null && - typeof error === "object" && - "code" in error && - error.code === "ENOENT" - ); -} - -async function assertEvidencePathAvailable( - context: CliJourneyContext, - evidencePath: string, -): Promise { - assert.equal(path.isAbsolute(evidencePath), true); - const relativeToWorkDir = path.relative(context.workDir, evidencePath); - assert.equal( - relativeToWorkDir === ".." || - relativeToWorkDir.startsWith(`..${path.sep}`) || - path.isAbsolute(relativeToWorkDir), - true, - "FANBOX 失败证据必须位于旅程工作目录之外", - ); - const parent = path.dirname(evidencePath); - const parentStatus = await lstat(parent); - assert.equal(parentStatus.isDirectory(), true); - assert.equal(parentStatus.isSymbolicLink(), false); - if (process.getuid !== undefined) - assert.equal(parentStatus.uid, process.getuid()); - assert.equal(parentStatus.mode & 0o777, 0o700); - try { - await lstat(evidencePath); - } catch (error) { - if (isMissing(error)) return; - throw error; - } - throw new Error("FANBOX 失败证据路径已存在"); -} - -function evidenceIdentity(device: number, inode: number): EvidenceIdentity { - assert.equal(Number.isSafeInteger(device) && device >= 0, true); - assert.equal(Number.isSafeInteger(inode) && inode > 0, true); - return { device, inode }; -} - -async function assertEvidenceInode( - evidencePath: string, - handle: FileHandle, - expected?: EvidenceIdentity, -): Promise { - const [pathStatus, handleStatus] = await Promise.all([ - lstat(evidencePath), - handle.stat(), - ]); - for (const status of [pathStatus, handleStatus]) { - assert.equal(status.isFile(), true); - assert.equal(status.isSymbolicLink(), false); - if (process.getuid !== undefined) - assert.equal(status.uid, process.getuid()); - assert.equal(status.mode & 0o777, 0o600); - } - const identity = evidenceIdentity(handleStatus.dev, handleStatus.ino); - assert.deepEqual( - evidenceIdentity(pathStatus.dev, pathStatus.ino), - identity, - "FANBOX 失败证据路径与已打开文件的身份不一致", - ); - if (expected !== undefined) - assert.deepEqual(identity, expected, "FANBOX 失败证据 inode 身份发生漂移"); - return identity; -} - -async function readEvidenceHandle(handle: FileHandle): Promise { - const status = await handle.stat(); - assert.ok(status.size <= fanboxFailureEvidenceMaximumBytes * 2); - const contents = Buffer.alloc(status.size); - let offset = 0; - while (offset < contents.length) { - const { bytesRead } = await handle.read( - contents, - offset, - contents.length - offset, - offset, - ); - assert.notEqual(bytesRead, 0); - offset += bytesRead; - } - return contents.toString("utf8"); -} - -function assertEvidenceLog( - contents: string, - expected: readonly FanboxLiveFailureEvidence[], -): void { - const serialized = expected.map(serializedFailureEvidence).join(""); - assert.equal(Buffer.byteLength(serialized) <= 4_096, true); - assert.equal(contents, serialized); - const parsed = contents - .slice(0, -1) - .split("\n") - .map((line) => JSON.parse(line) as unknown); - assert.deepEqual(parsed, expected); -} - -async function persistInitialEvidence( - evidencePath: string, - evidence: FanboxLiveFailureEvidence, -): Promise { - const parent = path.dirname(evidencePath); - const handle = await open(evidencePath, "wx", 0o600); - let createdIdentity: EvidenceIdentity; - try { - await handle.writeFile(serializedFailureEvidence(evidence), "utf8"); - await handle.sync(); - createdIdentity = await assertEvidenceInode(evidencePath, handle); - } finally { - await handle.close(); - } - await syncParent(parent); - const reopened = await open(evidencePath, constants.O_RDONLY); - try { - const identity = await assertEvidenceInode( - evidencePath, - reopened, - createdIdentity, - ); - assertEvidenceLog(await readEvidenceHandle(reopened), [evidence]); - return identity; - } finally { - await reopened.close(); - } -} - -async function appendFinishedEvidence( - evidencePath: string, - initial: FanboxLiveFailureEvidence, - finished: FanboxLiveFailureEvidence, - identity: EvidenceIdentity, -): Promise { - const handle = await open( - evidencePath, - constants.O_RDWR | constants.O_APPEND, - ); - try { - await assertEvidenceInode(evidencePath, handle, identity); - assertEvidenceLog(await readEvidenceHandle(handle), [initial]); - await handle.writeFile(serializedFailureEvidence(finished), "utf8"); - await handle.sync(); - await assertEvidenceInode(evidencePath, handle, identity); - assertEvidenceLog(await readEvidenceHandle(handle), [initial, finished]); - } finally { - await handle.close(); - } -} - -async function assertFanboxMediaUnchanged( - context: CliJourneyContext, - state: JourneyState, -): Promise { - for (const [index, post] of state.posts.entries()) { - assertSensitiveEqual( - await entrySignature(state.roots[index]!.directory), - state.signatures.get(post.logicalRoot), - "FANBOX 实时旅程改变了单根条目签名", - ); - } - if (state.deepBefore !== undefined) { - const after = await captureFixedCorpusSnapshots(state.roots, true); - const deepAfter = fixedCorpusContentDigestsFromSnapshots( - state.roots, - after, - ); - await assertFixedCorpusContentBaselineSubset(context, deepAfter); - assertSensitiveEqual( - deepAfter, - state.deepBefore, - "FANBOX live journey must not alter approved source bytes", - ); - } -} - -function parseSensitiveJson(value: string, message: string): unknown { - try { - return JSON.parse(value) as unknown; - } catch { - throw new Error(message); - } -} - -function assertSensitiveEqual( - actual: unknown, - expected: unknown, - message: string, -): void { - assertSafe(isDeepStrictEqual(actual, expected), message); -} - -function assertSafe(condition: boolean, message: string): asserts condition { - if (!condition) throw new Error(message); -} - -function observedRootId(result: CliJourneyResult | undefined): string { - try { - return registeredObservedRootId(result); - } catch { - throw new Error("FANBOX observed-root result is invalid"); - } -} - -function inspection( - result: CliJourneyResult | undefined, -): FanboxJourneyInspection { - return parseSensitiveJson( - result?.stdout ?? "{}", - "FANBOX inspection output is invalid", - ) as FanboxJourneyInspection; -} - -function qualifier(fact: MetadataFact, key: string): string | undefined { - return fact.qualifiers?.find((item) => item.key === key)?.value; -} - -function exactMetadataValue( - accepted: AcceptedProjection, - property: string, -): string | undefined { - const values = accepted.metadata.filter((item) => item.property === property); - assertSafe( - values.length <= 1, - `${property} must have at most one current value`, - ); - return values[0]?.value; -} - -function assertBodyProjection( - accepted: AcceptedProjection, - post: FanboxPostOracle, -): void { - if (post.body.status === "available") - assertSensitiveEqual( - exactMetadataValue(accepted, "asset.body"), - post.body.text, - "FANBOX asset.body value drifted", - ); - else - assertSensitiveEqual( - exactMetadataValue(accepted, "asset.body"), - undefined, - "FANBOX restricted asset.body must be absent", - ); - const actual = accepted.metadata - .filter((item) => item.property === "fanbox.post.body-block") - .map((item) => ({ - ordinal: Number(qualifier(item, "fanbox.post.block-ordinal")), - type: qualifier(item, "fanbox.post.block-type"), - value: parseSensitiveJson( - item.value, - "FANBOX body block value is invalid", - ), - sourceRecordIds: item.sourceRecordIds, - })) - .toSorted((left, right) => left.ordinal - right.ordinal); - assertSensitiveEqual( - actual.map(({ sourceRecordIds: _sourceRecordIds, ...item }) => item), - post.body.blocks, - "FANBOX complete body block projection drifted", - ); - assertSafe( - actual.every((item) => item.sourceRecordIds.length > 0), - "FANBOX body blocks require current Source Record provenance", - ); -} - -function assertAttachmentProjection( - accepted: AcceptedProjection, - post: FanboxPostOracle, -): void { - const actual = (accepted.sourceArtifactCandidates ?? []) - .filter((candidate) => candidate.role.startsWith("fanbox.attachment.")) - .toSorted( - (left, right) => - (left.selectionEvidence?.sourceOrdinal ?? Number.MAX_SAFE_INTEGER) - - (right.selectionEvidence?.sourceOrdinal ?? Number.MAX_SAFE_INTEGER), - ) - .map((candidate) => ({ - ordinal: candidate.selectionEvidence?.sourceOrdinal, - kind: candidate.role.slice("fanbox.attachment.".length), - ...(candidate.selectionEvidence?.suggestedFilename === undefined - ? {} - : { name: candidate.selectionEvidence.suggestedFilename }), - ...(candidate.selectionEvidence?.declaredByteLength === undefined - ? {} - : { - byteLength: String(candidate.selectionEvidence.declaredByteLength), - }), - })); - assertSensitiveEqual( - actual, - post.attachments, - "FANBOX attachment listing drifted", - ); -} - -function stableAcceptedProjection(accepted: AcceptedProjection): unknown { - return { - entityId: accepted.entity.id, - sourceAssociations: accepted.sourceAssociations, - recognitionSourceAssociation: accepted.recognitionSourceAssociation, - artifacts: accepted.artifacts.map((item) => item.id).toSorted(), - locations: accepted.locations - .map((item) => ({ artifactId: item.artifactId, kind: item.kind })) - .toSorted((left, right) => - left.artifactId.localeCompare(right.artifactId), - ), - artifactAssociations: accepted.artifactAssociations - .map((item) => ({ artifactId: item.artifactId, role: item.role })) - .toSorted((left, right) => - `${left.artifactId}:${left.role}`.localeCompare( - `${right.artifactId}:${right.role}`, - ), - ), - metadata: accepted.metadata - .map((item) => ({ - property: item.property, - sourceSlot: item.sourceSlot, - value: item.value, - qualifiers: item.qualifiers, - })) - .toSorted((left, right) => - `${left.property}:${left.sourceSlot ?? ""}`.localeCompare( - `${right.property}:${right.sourceSlot ?? ""}`, - ), - ), - sourceArtifactCandidates: accepted.sourceArtifactCandidates?.map( - ({ role, mediaType, selectionEvidence }) => ({ - role, - mediaType, - selectionEvidence, - }), - ), - entities: (accepted.entities ?? []) - .map((item) => ({ id: item.id, sourceKey: item.sourceKey })) - .toSorted((left, right) => (left.id ?? "").localeCompare(right.id ?? "")), - }; -} - -type FanboxSourcedFact = { - readonly kind: - | "metadata" - | "entity" - | "entity-mention" - | "entity-resolution-decision" - | "relationship" - | "source-artifact-candidate" - | "artifact-association"; - readonly value: unknown; - readonly sourceRecordIds: readonly string[]; -}; - -function denseItems(values: readonly T[], label: string): readonly T[] { - assertSafe(Array.isArray(values), `FANBOX ${label} must be an array`); - const dense = Array.from( - { length: values.length }, - (_, index) => values[index], - ); - assertSafe( - dense.every((value) => value !== undefined), - `FANBOX ${label} must not be sparse`, - ); - return dense as readonly T[]; -} - -function sourceGeneration( - value: FanboxJourneyInspection, - stage: "initial" | "refresh", -): ReadonlySet { - const records = denseItems(value.sourceRecords, `${stage} Source Records`); - assertSafe( - records.length === 2, - `FANBOX ${stage} Source Records must contain summary and detail`, - ); - const expectedCoverage = [ - "fanbox.creator-post-summaries", - "fanbox.post-detail", - ] as const; - const ids = new Set(); - for (const [index, record] of records.entries()) { - assertSafe( - typeof record.id === "string" && record.id.trim().length > 0, - `FANBOX ${stage} Source Record id is invalid`, - ); - assertSafe( - record.sourceIdentity === "fanbox", - `FANBOX ${stage} Source Record identity is invalid`, - ); - assertSafe( - record.coverage.id === expectedCoverage[index], - `FANBOX ${stage} Source Record coverage is invalid`, - ); - ids.add(record.id); - } - assertSafe( - ids.size === records.length, - `FANBOX ${stage} Source Record ids must be unique`, - ); - return ids; -} - -function sourcedFacts( - accepted: AcceptedProjection, -): readonly FanboxSourcedFact[] { - const facts: FanboxSourcedFact[] = []; - for (const fact of denseItems(accepted.metadata, "metadata")) - if (fact.observedArtifactPart === undefined) - facts.push({ - kind: "metadata", - value: fact, - sourceRecordIds: fact.sourceRecordIds, - }); - for (const [kind, values] of [ - ["entity", accepted.entities ?? []], - ["entity-mention", accepted.entityMentions ?? []], - ["entity-resolution-decision", accepted.entityResolutionDecisions ?? []], - ["relationship", accepted.relationships ?? []], - ] as const) - for (const fact of denseItems(values, kind)) - facts.push({ kind, value: fact, sourceRecordIds: fact.sourceRecordIds }); - for (const candidate of denseItems( - accepted.sourceArtifactCandidates ?? [], - "source artifact candidates", - )) - facts.push({ - kind: "source-artifact-candidate", - value: candidate, - sourceRecordIds: [candidate.sourceRecordId], - }); - for (const association of denseItems( - accepted.artifactAssociations, - "artifact associations", - )) - if (association.sourceRecordIds !== undefined) - facts.push({ - kind: "artifact-association", - value: association, - sourceRecordIds: association.sourceRecordIds, - }); - return facts; -} - -function assertFactIds( - fact: FanboxSourcedFact, - allowed: ReadonlySet, - stage: "initial" | "refresh", -): void { - const ids = denseItems( - fact.sourceRecordIds, - `${stage} ${fact.kind} Source Record ids`, - ); - assertSafe( - ids.length > 0 && - ids.every( - (id) => - typeof id === "string" && id.trim().length > 0 && allowed.has(id), - ), - `FANBOX ${stage} ${fact.kind} provenance is invalid`, - ); -} - -function assertFanboxPostProjection( - value: FanboxJourneyInspection, - post: FanboxPostOracle, -): { - readonly accepted: AcceptedProjection; - readonly facts: readonly FanboxSourcedFact[]; -} { - assertSafe( - value.execution.outcome === "accepted", - "FANBOX inspection outcome must be accepted", - ); - const accepted = value.accepted; - assertSafe(accepted !== undefined, "FANBOX accepted projection is missing"); - denseItems(accepted.sourceAssociations, "source associations"); - denseItems(accepted.artifacts, "artifacts"); - const locations = denseItems(accepted.locations, "locations"); - const revisions = denseItems(accepted.artifactRevisions, "revisions"); - const blobs = denseItems(accepted.blobs, "blobs"); - const metadata = denseItems(accepted.metadata, "metadata"); - const artifactAssociations = denseItems( - accepted.artifactAssociations, - "artifact associations", - ); - const expectedSourceAssociation = { - sourceIdentity: "fanbox", - nativeAddress: `https://www.fanbox.cc/@${post.creatorId}/posts/${post.sourceNativeAddress}`, - }; - assertSensitiveEqual( - accepted.sourceAssociations, - [expectedSourceAssociation], - "FANBOX source association drifted", - ); - assertSafe( - accepted.recognitionSourceAssociation !== undefined, - "FANBOX accepted projection is missing its recognition source", - ); - assertSensitiveEqual( - accepted.recognitionSourceAssociation, - expectedSourceAssociation, - "FANBOX recognition source association drifted", - ); - for (const [property, expected] of [ - ["asset.title", post.title], - ["asset.publication-datetime", post.publishedDatetime], - ["fanbox.post.form", post.postType], - ["fanbox.post.entitlement", post.entitlement], - ] as const) - assertSensitiveEqual( - exactMetadataValue(accepted, property), - expected, - `FANBOX ${property} value drifted`, - ); - assertBodyProjection(accepted, post); - assertAttachmentProjection(accepted, post); - assertSafe( - accepted.entities?.some((entity) => entity.sourceKey === post.creatorId) === - true, - "FANBOX creator entity is missing", - ); - const observedArtifactIds = new Set( - locations - .filter((location) => location.kind === "observed") - .map((location) => location.artifactId), - ); - const artifactRevisions = new Map( - revisions.map((revision) => [revision.id, revision]), - ); - const blobDigests = new Set(blobs.map((blob) => blob.digest)); - const genericFanboxSourceOwnedProperties = new Set([ - "asset.title", - "asset.publication-datetime", - "asset.body", - ]); - for (const fact of metadata) { - const observedPart = fact.observedArtifactPart; - const isFanboxSourceOwned = - fact.sourceSlot.startsWith("fanbox.post.") || - fact.property.startsWith("fanbox.post.") || - genericFanboxSourceOwnedProperties.has(fact.property); - if (isFanboxSourceOwned) - assertSafe( - observedPart === undefined, - "FANBOX source-owned metadata cannot use local provenance", - ); - if (observedPart === undefined) continue; - const revision = artifactRevisions.get(observedPart.revisionId); - const localSourceIds = denseItems( - fact.sourceRecordIds, - "local metadata Source Record ids", - ); - assertSafe( - localSourceIds.length === 0 && - observedPart.locator.trim().length > 0 && - observedArtifactIds.has(observedPart.artifactId) && - revision?.artifactId === observedPart.artifactId && - blobDigests.has(revision.blobDigest), - "FANBOX local metadata requires a complete observed artifact revision", - ); - } - assertSafe( - artifactAssociations - .filter((association) => observedArtifactIds.has(association.artifactId)) - .every((association) => association.role === "asset.material-delivered"), - "FANBOX observed artifact association is invalid", - ); - const managedArtifactIds = new Set( - locations - .filter((location) => location.kind === "managed") - .map((location) => location.artifactId), - ); - const sourceArtifacts = artifactAssociations.filter( - (association) => (association.sourceRecordIds?.length ?? 0) > 0, - ); - assertSafe( - sourceArtifacts.every( - (association) => - association.role === "asset.primary-cover" && - managedArtifactIds.has(association.artifactId), - ), - "FANBOX journey managed an unrequested source attachment", - ); - assertSafe( - sourceArtifacts.filter( - (association) => association.role === "asset.primary-cover", - ).length === (post.cover === "present" ? 1 : 0), - "FANBOX primary cover count is invalid", - ); - return { accepted, facts: sourcedFacts(accepted) }; -} - -function initialPostLifecycle( - value: FanboxJourneyInspection, - post: FanboxPostOracle, -): { - readonly sourceRecordIds: ReadonlySet; - readonly accepted: AcceptedProjection; - readonly facts: readonly FanboxSourcedFact[]; -} { - const sourceRecordIds = sourceGeneration(value, "initial"); - const projection = assertFanboxPostProjection(value, post); - for (const fact of projection.facts) - assertFactIds(fact, sourceRecordIds, "initial"); - return { sourceRecordIds, ...projection }; -} - -export function assertFanboxAcceptedPost( - value: FanboxJourneyInspection, - post: FanboxPostOracle, -): void { - initialPostLifecycle(value, post); -} - -export function assertFanboxRefreshedPost( - initial: FanboxJourneyInspection, - refreshed: FanboxJourneyInspection, - post: FanboxPostOracle, -): void { - const initialLifecycle = initialPostLifecycle(initial, post); - const refreshedSourceRecordIds = sourceGeneration(refreshed, "refresh"); - for (const id of refreshedSourceRecordIds) - assertSafe( - !initialLifecycle.sourceRecordIds.has(id), - "FANBOX refresh Source Record ids must not reuse initial ids", - ); - const refreshedProjection = assertFanboxPostProjection(refreshed, post); - const unmatchedInitialFacts = [...initialLifecycle.facts]; - for (const fact of refreshedProjection.facts) { - const ids = denseItems( - fact.sourceRecordIds, - `refresh ${fact.kind} Source Record ids`, - ); - const usesRefresh = - ids.length > 0 && ids.every((id) => refreshedSourceRecordIds.has(id)); - const initialMatch = - ids.length > 0 && - ids.every((id) => initialLifecycle.sourceRecordIds.has(id)) - ? unmatchedInitialFacts.findIndex( - (initialFact) => - initialFact.kind === fact.kind && - isDeepStrictEqual(initialFact.value, fact.value), - ) - : -1; - const preservesInitial = initialMatch >= 0; - if (preservesInitial) unmatchedInitialFacts.splice(initialMatch, 1); - assertSafe( - usesRefresh || preservesInitial, - `FANBOX refresh ${fact.kind} provenance is invalid`, - ); - } - assertSensitiveEqual( - stableAcceptedProjection(refreshedProjection.accepted), - stableAcceptedProjection(initialLifecycle.accepted), - "FANBOX refresh changed stable identity or reviewed business facts", - ); -} - -export function assertFanboxReopenedPost( - initial: FanboxJourneyInspection, - reopened: FanboxJourneyInspection, - post: FanboxPostOracle, -): void { - assertSafe( - reopened.execution.outcome === "no-change", - "FANBOX reopen outcome must be no-change", - ); - assertSensitiveEqual( - reopened.sourceRecords, - [], - "FANBOX reopen must not publish Source Records", - ); - const prior = initial.accepted; - const current = reopened.accepted; - assertSafe(prior !== undefined, "initial FANBOX projection is missing"); - assertSafe(current !== undefined, "reopened FANBOX projection is missing"); - assertSensitiveEqual( - stableAcceptedProjection(current), - stableAcceptedProjection(prior), - "FANBOX identity and business facts changed after SQLite reopen", - ); - assertBodyProjection(current, post); - assertAttachmentProjection(current, post); -} - -function oracleRecord(value: unknown, label: string): Record { - assertSafe( - value !== null && typeof value === "object" && !Array.isArray(value), - `${label} must be an object`, - ); - return value as Record; -} - -function oracleArray(value: unknown, label: string): readonly unknown[] { - assertSafe(Array.isArray(value), `${label} must be an array`); - return value; -} - -function oracleString(value: unknown, label: string): string { - assertSafe( - typeof value === "string" && value.trim().length > 0, - `${label} must be a non-empty string`, - ); - return value; -} - -function oracleParagraphText(value: unknown, label: string): string { - assertSafe(typeof value === "string", `${label} must be a string`); - const text = value; - assertSafe( - text === "" || text.trim().length > 0, - `${label} must be exactly empty or contain non-whitespace text`, - ); - return text; -} - -function assertExactKeys( - value: Record, - keys: readonly string[], - label: string, -): void { - assertSafe( - isDeepStrictEqual(Object.keys(value).toSorted(), [...keys].toSorted()), - `${label} shape drifted`, - ); -} - -function assertOracleBodyBlock(value: unknown, ordinal: number): void { - const block = oracleRecord(value, `FANBOX reviewed body block ${ordinal}`); - assertExactKeys( - block, - ["ordinal", "type", "value"], - `FANBOX reviewed body block ${ordinal}`, - ); - assertSafe( - block.ordinal === ordinal, - "FANBOX reviewed body ordinals must be contiguous", - ); - const type = oracleString( - block.type, - `FANBOX reviewed body block ${ordinal} type`, - ); - assertSafe( - ["header", "p", "image", "file", "embed", "url_embed"].includes(type), - `FANBOX reviewed body block ${ordinal} type was invalid`, - ); - const projected = oracleRecord( - block.value, - `FANBOX reviewed body block ${ordinal} value`, - ); - assertSafe( - projected.type === type, - `FANBOX reviewed body block ${ordinal} value type drifted`, - ); - if (type === "header" || type === "p") { - assertExactKeys( - projected, - ["type", "text"], - `FANBOX reviewed body block ${ordinal} value`, - ); - const label = `FANBOX reviewed body block ${ordinal} text`; - if (type === "p") oracleParagraphText(projected.text, label); - else oracleString(projected.text, label); - } else if (type === "file" || type === "image") { - const reference = type === "file" ? "fileId" : "imageId"; - assertExactKeys( - projected, - ["type", reference], - `FANBOX reviewed body block ${ordinal} value`, - ); - oracleString( - projected[reference], - `FANBOX reviewed body block ${ordinal} reference`, - ); - } else if (type === "embed") { - assertExactKeys( - projected, - ["type", "embed"], - `FANBOX reviewed body block ${ordinal} value`, - ); - const embed = oracleRecord( - projected.embed, - `FANBOX reviewed body block ${ordinal} embed`, - ); - assertExactKeys( - embed, - ["id", "serviceProvider", "contentId"], - `FANBOX reviewed body block ${ordinal} embed`, - ); - oracleString( - embed.id, - `FANBOX reviewed body block ${ordinal} embed identity`, - ); - oracleString( - embed.serviceProvider, - `FANBOX reviewed body block ${ordinal} embed provider`, - ); - oracleString( - embed.contentId, - `FANBOX reviewed body block ${ordinal} embed content identity`, - ); - } else { - assertExactKeys( - projected, - ["type", "urlEmbed"], - `FANBOX reviewed body block ${ordinal} value`, - ); - const embed = oracleRecord( - projected.urlEmbed, - `FANBOX reviewed body block ${ordinal} URL embed`, - ); - const embedType = oracleString( - embed.type, - `FANBOX reviewed body block ${ordinal} URL embed type`, - ); - if (embedType === "default") { - assertExactKeys( - embed, - ["id", "type", "url"], - `FANBOX reviewed body block ${ordinal} URL embed`, - ); - oracleString( - embed.url, - `FANBOX reviewed body block ${ordinal} URL embed address`, - ); - } else if (embedType === "html" || embedType === "html.card") { - assertExactKeys( - embed, - ["id", "type", "html"], - `FANBOX reviewed body block ${ordinal} URL embed`, - ); - oracleString( - embed.html, - `FANBOX reviewed body block ${ordinal} URL embed HTML`, - ); - } else { - assertSafe( - embedType === "fanbox.post", - `FANBOX reviewed body block ${ordinal} URL embed type was invalid`, - ); - assertExactKeys( - embed, - ["id", "type", "postInfo"], - `FANBOX reviewed body block ${ordinal} URL embed`, - ); - const postInfo = oracleRecord( - embed.postInfo, - `FANBOX reviewed body block ${ordinal} URL embed post`, - ); - assertExactKeys( - postInfo, - ["id"], - `FANBOX reviewed body block ${ordinal} URL embed post`, - ); - oracleString( - postInfo.id, - `FANBOX reviewed body block ${ordinal} URL embed post identity`, - ); - } - oracleString( - embed.id, - `FANBOX reviewed body block ${ordinal} URL embed identity`, - ); - } -} - -function assertReviewedOracle(value: unknown): readonly FanboxPostOracle[] { - const posts = oracleArray(value, "reviewed creator-post selection"); - assertSafe(posts.length === 5, "reviewed creator-post selection drifted"); - const identities = new Set(); - const logicalRoots = new Set(); - for (const [postIndex, rawPost] of posts.entries()) { - const post = oracleRecord(rawPost, `FANBOX reviewed post ${postIndex}`); - assertExactKeys( - post, - [ - "logicalRoot", - "directoryDate", - "sourceNativeAddress", - "title", - "publishedDatetime", - "postType", - "creatorId", - "entitlement", - "cover", - "body", - "attachments", - ], - `FANBOX reviewed post ${postIndex}`, - ); - const logicalRoot = oracleString( - post.logicalRoot, - `FANBOX reviewed post ${postIndex} logical root`, - ); - assertSafe( - !logicalRoots.has(logicalRoot), - "FANBOX reviewed logical roots must be unique", - ); - logicalRoots.add(logicalRoot); - const directoryDate = oracleString( - post.directoryDate, - `FANBOX reviewed post ${postIndex} directory date`, - ); - assertSafe( - /^\d{4}-\d{2}-\d{2}$/u.test(directoryDate), - `FANBOX reviewed post ${postIndex} directory date is invalid`, - ); - assertSafe( - new Date(`${directoryDate}T00:00:00Z`).toISOString().slice(0, 10) === - directoryDate, - `FANBOX reviewed post ${postIndex} directory date is invalid`, - ); - const sourceNativeAddress = oracleString( - post.sourceNativeAddress, - `FANBOX reviewed post ${postIndex} native address`, - ); - assertSafe( - /^(?:0|[1-9]\d*)$/u.test(sourceNativeAddress), - `FANBOX reviewed post ${postIndex} native address is invalid`, - ); - assertSafe( - Number.isSafeInteger(Number(sourceNativeAddress)) && - Number(sourceNativeAddress) > 0, - `FANBOX reviewed post ${postIndex} native address must be a positive safe integer`, - ); - const creatorId = oracleString( - post.creatorId, - `FANBOX reviewed post ${postIndex} creator`, - ); - assertSafe( - /^[0-9A-Za-z_-]+$/u.test(creatorId), - `FANBOX reviewed post ${postIndex} creator is invalid`, - ); - const identity = `${creatorId}\u0000${sourceNativeAddress}`; - assertSafe( - !identities.has(identity), - "FANBOX reviewed post identities must be unique", - ); - identities.add(identity); - oracleString(post.title, `FANBOX reviewed post ${postIndex} title`); - const publishedDatetime = oracleString( - post.publishedDatetime, - `FANBOX reviewed post ${postIndex} publication datetime`, - ); - assertSafe( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u.test( - publishedDatetime, - ), - `FANBOX reviewed post ${postIndex} publication datetime is invalid`, - ); - assertSafe( - !Number.isNaN(Date.parse(publishedDatetime)), - `FANBOX reviewed post ${postIndex} publication datetime is invalid`, - ); - assertSafe( - publishedDatetime.slice(0, 10) === directoryDate, - `FANBOX reviewed post ${postIndex} publication date drifted`, - ); - assertSafe( - ["text", "file", "image", "video", "article"].includes( - post.postType as string, - ), - `FANBOX reviewed post ${postIndex} form is invalid`, - ); - assertSafe( - ["available", "restricted"].includes(post.entitlement as string), - `FANBOX reviewed post ${postIndex} entitlement is invalid`, - ); - assertSafe( - ["present", "absent"].includes(post.cover as string), - `FANBOX reviewed post ${postIndex} cover status is invalid`, - ); - const body = oracleRecord( - post.body, - `FANBOX reviewed post ${postIndex} body`, - ); - const status = oracleString( - body.status, - `FANBOX reviewed post ${postIndex} body status`, - ); - if (status === "available") { - assertExactKeys( - body, - ["status", "text", "blocks"], - `FANBOX reviewed post ${postIndex} body`, - ); - oracleString(body.text, `FANBOX reviewed post ${postIndex} body text`); - } else { - assertSafe( - status === "restricted", - `FANBOX reviewed post ${postIndex} body status was invalid`, - ); - assertExactKeys( - body, - ["status", "blocks"], - `FANBOX reviewed post ${postIndex} body`, - ); - } - const blocks = oracleArray( - body.blocks, - `FANBOX reviewed post ${postIndex} body blocks`, - ); - if (status === "restricted") - assertSafe( - blocks.length === 0, - `FANBOX reviewed post ${postIndex} restricted body blocks must be empty`, - ); - for (const [ordinal, block] of blocks.entries()) - assertOracleBodyBlock(block, ordinal); - const attachments = oracleArray( - post.attachments, - `FANBOX reviewed post ${postIndex} attachments`, - ); - for (const [ordinal, rawAttachment] of attachments.entries()) { - const attachment = oracleRecord( - rawAttachment, - `FANBOX reviewed post ${postIndex} attachment ${ordinal}`, - ); - assertExactKeys( - attachment, - [ - "ordinal", - "kind", - ...(Object.hasOwn(attachment, "name") ? ["name"] : []), - ...(Object.hasOwn(attachment, "byteLength") ? ["byteLength"] : []), - ], - `FANBOX reviewed post ${postIndex} attachment ${ordinal}`, - ); - assertSafe( - attachment.ordinal === ordinal, - "FANBOX reviewed attachment ordinals must be contiguous", - ); - if (Object.hasOwn(attachment, "name")) - oracleString( - attachment.name, - `FANBOX reviewed post ${postIndex} attachment ${ordinal} name`, - ); - assertSafe( - attachment.kind === "file" || attachment.kind === "image", - `FANBOX reviewed post ${postIndex} attachment ${ordinal} kind is invalid`, - ); - if (Object.hasOwn(attachment, "byteLength")) { - const byteLength = oracleString( - attachment.byteLength, - `FANBOX reviewed post ${postIndex} attachment ${ordinal} byte length`, - ); - assertSafe( - /^(?:0|[1-9]\d*)$/u.test(byteLength), - `FANBOX reviewed post ${postIndex} attachment ${ordinal} byte length is invalid`, - ); - assertSafe( - Number.isSafeInteger(Number(byteLength)), - `FANBOX reviewed post ${postIndex} attachment ${ordinal} byte length is invalid`, - ); - } - } - if (post.entitlement === "restricted") { - assertSafe( - status === "restricted", - `FANBOX reviewed post ${postIndex} body status is invalid`, - ); - assertSafe( - attachments.length === 0, - `FANBOX reviewed post ${postIndex} restricted attachments must be empty`, - ); - } else - assertSafe( - status === "available", - `FANBOX reviewed post ${postIndex} body status is invalid`, - ); - } - return posts as readonly FanboxPostOracle[]; -} - -function assertExactLogicalRoots( - actual: readonly string[], - expected: readonly string[], - label: string, -): void { - assertSafe( - new Set(actual).size === actual.length, - `${label} contains duplicate roots`, - ); - assertSensitiveEqual( - [...actual].toSorted(), - [...expected].toSorted(), - `${label} root set drifted`, - ); -} - -export function fanboxJourneyCommands( - context: CliJourneyContext, - posts: readonly FanboxPostOracle[], - roots: readonly Pick[], -): readonly CliJourneyCommand[] { - const data = context.productDataDirectory; - const productEnv = { DIGITAL_PRODUCT_DATA: data }; - const expected = expectedFanboxCommands(posts); - const acceptedAssetIdForRefresh = ( - results: readonly CliJourneyResult[], - postIndex: number, - ): string => { - const resultIndex = 1 + posts.length + postIndex; - const result = results[resultIndex]; - try { - const initial = inspection(result); - const assetId = initial.accepted?.entity.id; - if ( - initial.execution.outcome === "accepted" && - typeof assetId === "string" && - assetId.trim().length > 0 - ) - return assetId; - } catch {} - if ( - result !== undefined && - result.commandName === expected[resultIndex]?.name && - result.exitCode === 0 - ) { - currentState(context).attributedInitialOutcome ??= { - resultIndex, - resultCount: results.length, - result, - }; - } - throw new Error( - `FANBOX metadata refresh requires accepted initial result for post ${postIndex + 1}`, - ); - }; - const scan = (index: number, name: string): CliJourneyCommand => ({ - name, - args: (results) => [ - "scan", - observedRootId(results[index + 1]), - "--scope", - ".", - "--json", - ], - env: productEnv, - haltOnFailure: true, - }); - return [ - { - name: expected[0]!.name, - args: [ - "fanbox-connection-import", - "customudon", - path.resolve(context.packageRoot, "../..", "docs/fanbox.json"), - ], - env: productEnv, - haltOnFailure: true, - }, - ...roots.map((root, index) => ({ - name: expected[index + 1]!.name, - args: [ - "library-create", - `FANBOX ${posts[index]!.sourceNativeAddress}`, - "--directory", - root.directory, - "--json", - ], - env: productEnv, - haltOnFailure: true, - })), - ...posts.map((_post, index) => - scan(index, expected[index + 1 + posts.length]!.name), - ), - ...posts.map((_post, index) => - scan(index, expected[index + 1 + posts.length * 2]!.name), - ), - ...posts.map((post, index) => ({ - name: expected[index + 1 + posts.length * 3]!.name, - args: (results: readonly CliJourneyResult[]) => [ - "refresh-metadata", - acceptedAssetIdForRefresh(results, index), - "--json", - ], - env: productEnv, - haltOnFailure: true, - })), - ]; -} - -const journey: CliJourney = { - name: "fanbox-five-live", - source: { mode: "real" }, - modes: ["source", "distribution"], - async setup(context) { - const corpus = corpusRoot(context); - const rawOracle = parseSensitiveJson( - await readFile( - path.resolve( - context.packageRoot, - "../..", - "fixtures/live-corpus/references/reviewed-oracle.json", - ), - "utf8", - ), - "FANBOX reviewed oracle JSON is invalid", - ); - const oracle = oracleRecord(rawOracle, "FANBOX oracle"); - const reviews = oracleRecord(oracle.reviews, "FANBOX oracle reviews"); - const fanboxReview = oracleRecord(reviews.fanbox, "FANBOX oracle review"); - assertSafe( - fanboxReview.status === "reviewed-current-official-source", - "FANBOX oracle requires current official source review", - ); - const posts = assertReviewedOracle(oracle.creatorPosts); - const allRoots = await fixedCorpusRoots(context); - let ownershipGroups: ReturnType< - typeof fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle - >; - try { - ownershipGroups = - fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle( - rawOracle, - allRoots, - ); - } catch { - throw new Error("FANBOX fixed-corpus ownership routing is invalid"); - } - const owner = ownershipGroups.find( - (group) => - group.ownerTicket === - "v1-live-acceptance/issues/02-fanbox-five-live-gate.md", - ); - assertSafe( - owner !== undefined, - "FANBOX fixed-corpus ownership group is missing", - ); - assertExactLogicalRoots( - posts.map((post) => post.logicalRoot), - owner.roots.map((root) => root.logicalRoot), - "FANBOX reviewed oracle", - ); - const roots = posts.map((post) => ({ - logicalRoot: post.logicalRoot, - directory: path.join(corpus, "assets", ...post.logicalRoot.split("/")), - })); - const signatures = new Map(); - for (const root of roots) { - assertSafe( - (await lstat(root.directory)).isDirectory(), - "FANBOX fixed-corpus root must be a directory", - ); - signatures.set(root.logicalRoot, await entrySignature(root.directory)); - } - const deepIntegrity = process.env.DIGITAL_DEEP_CORPUS_INTEGRITY === "1"; - const before = await captureFixedCorpusSnapshots(roots, deepIntegrity); - assertExactLogicalRoots( - [...before.keys()], - roots.map((root) => root.logicalRoot), - "FANBOX captured snapshot map", - ); - const deepBefore = deepIntegrity - ? fixedCorpusContentDigestsFromSnapshots(roots, before) - : undefined; - if (deepBefore !== undefined) { - assertExactLogicalRoots( - [...deepBefore.keys()], - roots.map((root) => root.logicalRoot), - "FANBOX deep content digest map", - ); - await assertFixedCorpusContentBaselineSubset(context, deepBefore); - } - stateByContext.set(stateKey(context), { - posts, - roots, - signatures, - assertionsFailed: false, - ...(deepBefore === undefined ? {} : { deepBefore }), - }); - }, - commands(context) { - const state = currentState(context); - return fanboxJourneyCommands(context, state.posts, state.roots); - }, - async assertions({ context, results: allResults }) { - const state = currentState(context); - try { - const importedConnection = allResults[0]; - if (importedConnection === undefined) - throw new Error("FANBOX live journey did not run connection import"); - assertSafe( - importedConnection.exitCode === 0, - "FANBOX connection import command failed", - ); - for (const registeredRoot of allResults.slice( - 1, - state.roots.length + 1, - )) { - assertSafe( - registeredRoot.exitCode === 0, - "FANBOX observed-root registration command failed", - ); - observedRootId(registeredRoot); - } - const results = allResults.slice(state.roots.length + 1); - assertSafe( - results.length === state.posts.length * 3, - "FANBOX journey command result count is invalid", - ); - for (const result of allResults) { - assertSafe(result.exitCode === 0, "FANBOX journey command failed"); - assertSafe( - !/FANBOXSESSID|cf_clearance|docs[\\/]fanbox\.json|cookie/iu.test( - `${result.stdout}\n${result.stderr}`, - ), - "private FANBOX configuration leaked through journey output", - ); - } - const initial = results.slice(0, state.posts.length).map(inspection); - const reopened = results - .slice(state.posts.length, state.posts.length * 2) - .map(inspection); - const refreshed = results.slice(state.posts.length * 2).map(inspection); - for (const [index, post] of state.posts.entries()) { - assertFanboxAcceptedPost(initial[index]!, post); - assertFanboxReopenedPost(initial[index]!, reopened[index]!, post); - assertFanboxRefreshedPost(initial[index]!, refreshed[index]!, post); - const observedCount = initial[index]!.accepted?.locations.filter( - (location) => location.kind === "observed", - ).length; - assertSafe( - observedCount === - state.signatures - .get(post.logicalRoot) - ?.filter((entry) => entry.includes(":f:")).length, - `FANBOX post ${index + 1} observed artifact count is invalid`, - ); - } - } catch (error) { - state.assertionsFailed = true; - throw error; - } - }, - async teardown({ context, results }) { - const key = stateKey(context); - const state = stateByContext.get(key); - if (state === undefined) return; - const teardownErrors: unknown[] = []; - let initialEvidence: FanboxLiveFailureEvidence | undefined; - let persistedEvidence: FanboxLiveFailureEvidence | undefined; - let evidenceIdentity: EvidenceIdentity | undefined; - const evidencePath = process.env[fanboxFailureEvidenceEnvironment]; - try { - try { - initialEvidence = failureEvidence(context, state, results); - } catch (error) { - teardownErrors.push(error); - } - if (initialEvidence !== undefined) { - if (evidencePath === undefined) { - teardownErrors.push(new Error("必须提供 FANBOX 失败证据路径")); - } else { - try { - await assertEvidencePathAvailable(context, evidencePath); - evidenceIdentity = await persistInitialEvidence( - evidencePath, - initialEvidence, - ); - persistedEvidence = initialEvidence; - } catch (error) { - teardownErrors.push(error); - } - } - } - try { - await assertFanboxMediaUnchanged(context, state); - } catch (error) { - teardownErrors.push(error); - } - } finally { - stateByContext.delete(key); - if ( - persistedEvidence !== undefined && - evidenceIdentity !== undefined && - evidencePath !== undefined - ) { - const finished: FanboxLiveFailureEvidence = { - ...persistedEvidence, - evidenceState: "teardown-finished", - teardown: teardownErrors.length === 0 ? "passed" : "failed", - stateReleased: "yes", - }; - try { - await appendFinishedEvidence( - evidencePath, - persistedEvidence, - finished, - evidenceIdentity, - ); - } catch (error) { - teardownErrors.push(error); - } - } - } - if (teardownErrors.length === 1) throw teardownErrors[0]; - if (teardownErrors.length > 1) throw new AggregateError(teardownErrors); - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/journeys/greet.journey.ts b/apps/cli/test/e2e/journeys/greet.journey.ts deleted file mode 100644 index a425bb6..0000000 --- a/apps/cli/test/e2e/journeys/greet.journey.ts +++ /dev/null @@ -1,46 +0,0 @@ -import assert from "node:assert/strict"; -import { readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; - -import type { CliJourney } from "../journey.ts"; - -const journey: CliJourney = { - name: "greet", - modes: ["source", "distribution", "packed"], - async setup(context) { - await writeFile( - path.join(context.workDir, "journey-ready.txt"), - `${context.mode}\n`, - ); - }, - commands() { - return [ - { - name: "valid greeting", - args: ["greet", " Ada Lovelace "], - }, - { - name: "invalid greeting", - args: ["greet", " "], - }, - ]; - }, - async assertions({ context, results }) { - assert.equal( - await readFile(path.join(context.workDir, "journey-ready.txt"), "utf8"), - `${context.mode}\n`, - ); - assert.deepEqual(results[0], { - commandName: "valid greeting", - exitCode: 0, - stdout: "Hello, Ada Lovelace\n", - stderr: "", - }); - assert.equal(results[1]?.commandName, "invalid greeting"); - assert.equal(results[1]?.exitCode, 1); - assert.equal(results[1]?.stdout, ""); - assert.match(results[1]?.stderr ?? "", /error: Name must not be empty/u); - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/journeys/inspection-and-metadata-refresh.journey.ts b/apps/cli/test/e2e/journeys/inspection-and-metadata-refresh.journey.ts deleted file mode 100644 index 7a16471..0000000 --- a/apps/cli/test/e2e/journeys/inspection-and-metadata-refresh.journey.ts +++ /dev/null @@ -1,204 +0,0 @@ -import assert from "node:assert/strict"; -import { stat } from "node:fs/promises"; -import path from "node:path"; - -import { captureRawFilesystemSnapshot } from "@digital/test-support"; - -import { - registeredObservedRootId, - type CliJourney, - type CliJourneyResult, -} from "../journey.ts"; - -async function entryState(root: string): Promise { - return (await captureRawFilesystemSnapshot(root)).entries - .filter((entry) => entry.relativePath !== ".") - .map( - (entry) => - `${entry.relativePath}:${entry.kind}:${entry.byteLength}:${entry.modifiedAtNanoseconds}:${entry.symbolicLinkTarget ?? ""}`, - ); -} - -const baselineByWorkDirectory = new Map(); - -function sourceDirectory(context: Parameters[0]): string { - return path.resolve( - context.packageRoot, - "../..", - ".scratch/acceptance-corpus/assets/kikoeru/VoiceWork/RJ01668691", - ); -} - -function executionId(result: CliJourneyResult | undefined): string { - const output = JSON.parse(result?.stdout ?? "{}") as { - readonly executionId?: string; - }; - if (output.executionId === undefined) - throw new Error(`expected execution id in ${result?.stdout ?? ""}`); - return output.executionId; -} - -function acceptedAssetId(result: CliJourneyResult | undefined): string { - const output = JSON.parse(result?.stdout ?? "{}") as { - readonly accepted?: { readonly entity?: { readonly id?: string } }; - }; - const id = output.accepted?.entity?.id; - if (id === undefined) - throw new Error(`expected accepted asset id in ${result?.stdout ?? ""}`); - return id; -} - -function printedExecutionId(result: CliJourneyResult | undefined): string { - const id = /^刷新记录:([^\s]+)/mu.exec(result?.stdout ?? "")?.[1]; - if (id === undefined) - throw new Error(`expected printed execution id in ${result?.stdout ?? ""}`); - return id; -} - -const journey: CliJourney = { - name: "inspection-and-metadata-refresh", - realCorpus: true, - modes: ["source", "distribution"], - async setup(context) { - const source = sourceDirectory(context); - if (!(await stat(source)).isDirectory()) - throw new Error(`required live acceptance corpus is missing: ${source}`); - baselineByWorkDirectory.set(context.workDir, await entryState(source)); - }, - commands(context) { - const source = sourceDirectory(context); - const data = context.productDataDirectory; - return [ - { - name: "register official work inspection root", - args: [ - "library-create", - "RJ01668691 检查语料", - "--directory", - source, - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - haltOnFailure: true, - }, - { - name: "accept one official work for inspection", - args: (results) => [ - "scan", - registeredObservedRootId(results[0]), - "--scope", - ".", - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - haltOnFailure: true, - }, - { - name: "inspect accepted work in Simplified Chinese", - args: (results) => ["inspect", executionId(results[1])], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - { - name: "refresh metadata in Simplified Chinese", - args: (results) => ["refresh-metadata", acceptedAssetId(results[1])], - env: { DIGITAL_PRODUCT_DATA: data }, - haltOnFailure: true, - }, - { - name: "inspect structured metadata refresh evidence", - args: (results) => [ - "inspect", - printedExecutionId(results[3]), - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - ]; - }, - async assertions({ context, results: allResults }) { - assert.equal(allResults[0]?.exitCode, 0, allResults[0]?.stderr); - registeredObservedRootId(allResults[0]); - const results = allResults.slice(1); - const accepted = JSON.parse(results[0]?.stdout ?? "{}") as { - readonly format?: string; - readonly unstable?: boolean; - readonly execution?: { readonly outcome?: string }; - readonly sourceRecords?: readonly Record[]; - readonly accepted?: { - readonly sourceAssociations: readonly unknown[]; - readonly recognitionSourceAssociation?: unknown; - }; - }; - assert.equal(results[0]?.exitCode, 0, results[0]?.stderr); - assert.equal(accepted.format, "temporary-asset-refresh-inspection/v1"); - assert.equal(accepted.unstable, true); - assert.equal(accepted.execution?.outcome, "accepted"); - assert.ok( - accepted.accepted?.recognitionSourceAssociation, - "accepted inspection is missing its recognition source", - ); - assert.ok((accepted.sourceRecords?.length ?? 0) > 0); - assert.ok( - accepted.sourceRecords?.every( - (record) => !("body" in record) && !("acquisition" in record), - ), - ); - - const humanInspection = results[1]?.stdout ?? ""; - assert.equal(results[1]?.exitCode, 0, results[1]?.stderr); - assert.match(humanInspection, /结果:已接受/u); - assert.match(humanInspection, /作品:/u); - assert.match(humanInspection, /来源:/u); - assert.match(humanInspection, /工作项:/u); - assert.doesNotMatch( - humanInspection, - /workflow_executions|source_records|acquisition_json|sqlite|drizzle|"body"/iu, - ); - - const humanMetadataRefresh = results[2]?.stdout ?? ""; - assert.equal(results[2]?.exitCode, 0, results[2]?.stderr); - assert.match(humanMetadataRefresh, /操作:刷新元数据/u); - assert.match(humanMetadataRefresh, /结果:已接受/u); - assert.match( - humanMetadataRefresh, - /作品:塩対応な義妹ちゃん、大好きな兄さんの濃厚快感責めでメロ堕ち♡〜逆転イチャあま同棲ASMR〜/u, - ); - assert.match(humanMetadataRefresh, /来源:/u); - assert.doesNotMatch( - results[2]?.stderr ?? "", - /正在识别目录|正在获取主封面/u, - ); - - const structuredMetadataRefresh = JSON.parse( - results[3]?.stdout ?? "{}", - ) as { - readonly execution?: { - readonly outcome?: string; - readonly selection?: { readonly intent?: string }; - }; - readonly sourceRecords?: readonly { - readonly coverage?: { readonly id?: string }; - }[]; - }; - assert.equal(results[3]?.exitCode, 0, results[3]?.stderr); - assert.equal(structuredMetadataRefresh.execution?.outcome, "accepted"); - assert.equal( - structuredMetadataRefresh.execution?.selection?.intent, - "metadata-refresh", - ); - assert.deepEqual( - structuredMetadataRefresh.sourceRecords?.map( - (record) => record.coverage?.id, - ), - ["dlsite.product-json", "dlsite.product-html"], - ); - assert.deepEqual( - await entryState(sourceDirectory(context)), - baselineByWorkDirectory.get(context.workDir), - ); - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/journeys/pdf-publication-live.journey.ts b/apps/cli/test/e2e/journeys/pdf-publication-live.journey.ts deleted file mode 100644 index 5cb32e2..0000000 --- a/apps/cli/test/e2e/journeys/pdf-publication-live.journey.ts +++ /dev/null @@ -1,841 +0,0 @@ -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; -import path from "node:path"; - -import type { AssetRefreshInspection } from "@digital/application"; -import type { RawFilesystemSnapshot } from "@digital/test-support"; - -import { - assertFixedCorpusContentBaselineSubset, - captureFixedCorpusSnapshots, - fixedCorpusContentDigestsFromSnapshots, - fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle, - fixedCorpusRoots, - type FixedCorpusContentDigest, - type FixedCorpusRoot, -} from "../fixed-corpus.ts"; -import { - registeredObservedRootId, - type CliJourney, - type CliJourneyCommand, - type CliJourneyContext, - type CliJourneyResult, -} from "../journey.ts"; - -const ownerTicket = "v1-live-acceptance/issues/04-pdf-series-live-gate.md"; -export const approvedPdfOracleSliceDigest = - "deced47cbd155603a5917e3404122c21e6f1038680128942f52c66e335ffce95"; - -type PdfPublicationOracle = { - readonly logicalRoot: string; - readonly relativePath: string; - readonly revision: { - readonly digest: string; - readonly mediaType: "application/pdf"; - readonly byteLength: number; - }; - readonly pageCount: number; - readonly documentAuthor: string; - readonly cover: { - readonly locator: "pdf:page[1]"; - readonly mediaType: "image/png"; - }; -}; - -type PdfOracle = { - readonly reviews: { - readonly pdf: { - readonly status: string; - readonly reviewedAt: string; - }; - }; - readonly dedicatedIntegrationOwnershipGroups: readonly unknown[]; - readonly pdfPublications: readonly PdfPublicationOracle[]; -}; - -type JourneyState = { - readonly root: FixedCorpusRoot; - readonly publications: readonly PdfPublicationOracle[]; - readonly before: ReadonlyMap; - readonly deepBefore?: ReadonlyMap; -}; - -type Accepted = NonNullable; - -type PublicationSourceInput = { - readonly relativePath: string; - readonly isbn: string; - readonly sourceIdentity: "taiwan-kadokawa" | "kadokawa-jp"; - readonly productAddress: string; - readonly expectedTitle: string; - readonly expectedCoverage: readonly string[]; -}; - -const publicationSourceInputs: readonly PublicationSourceInput[] = [ - { - relativePath: - "01. /终将成为你 佐伯沙弥香的追忆/终将成为你 佐伯沙弥香的追忆 - 入间人间 (2020).pdf", - isbn: "9789577435064", - sourceIdentity: "taiwan-kadokawa", - productAddress: "https://www.kadokawa.com.tw/products/9789577435064", - expectedTitle: "終將成為妳 關於佐伯沙彌香 (1)", - expectedCoverage: ["taiwan-kadokawa.product-page"], - }, - { - relativePath: - "02. /终将成为你 佐伯沙弥香的追忆2/终将成为你 佐伯沙弥香的追忆2 - 入间人间 (2020).pdf", - isbn: "9789577439680", - sourceIdentity: "taiwan-kadokawa", - productAddress: "https://www.kadokawa.com.tw/products/9789577439680", - expectedTitle: "終將成為妳 關於佐伯沙彌香 (2)", - expectedCoverage: ["taiwan-kadokawa.product-page"], - }, - { - relativePath: - "03. /终将成为你 佐伯沙弥香的追忆3/终将成为你 佐伯沙弥香的追忆3 - 入间人间 (2020).pdf", - isbn: "9784049131291", - sourceIdentity: "kadokawa-jp", - productAddress: "https://www.kadokawa.co.jp/product/321912000002/", - expectedTitle: "やがて君になる 佐伯沙弥香について(3)", - expectedCoverage: ["kadokawa-jp.search", "kadokawa-jp.detail"], - }, -]; - -type SourceDecisionView = { - readonly executionId: string; - readonly status: string; - readonly state: string; - readonly candidates: readonly { - readonly reference: string; - readonly outcome: string; - readonly sourceIdentity: string; - readonly nativeAddress: string; - }[]; - readonly preview?: { - readonly id: string; - readonly status: string; - readonly projected: { - readonly sourceAssociations: readonly { - readonly sourceIdentity: string; - readonly nativeAddress: string; - }[]; - }; - }; -}; - -type AppliedPreviewView = { - readonly preview: { - readonly status: string; - readonly projected: { - readonly sourceAssociations: readonly { - readonly sourceIdentity: string; - readonly nativeAddress: string; - }[]; - }; - }; -}; - -type CorrectionSubjectView = { - readonly current: readonly { - readonly id: string; - readonly sourceIdentity: string; - readonly nativeAddress: string; - }[]; - readonly historical: readonly unknown[]; -}; - -const stateByContext = new Map(); -const sourceBusinessProjection = new Map(); - -function record(value: unknown): Record | undefined { - return value !== null && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function pdfOracleSlice(rawOracle: unknown): unknown { - const oracle = record(rawOracle); - const reviews = record(oracle?.reviews); - const groups = Array.isArray(oracle?.dedicatedIntegrationOwnershipGroups) - ? oracle.dedicatedIntegrationOwnershipGroups - : []; - return { - reviews: { pdf: reviews?.pdf }, - dedicatedIntegrationOwnershipGroups: groups.filter( - (group) => record(group)?.ownerTicket === ownerTicket, - ), - pdfPublications: oracle?.pdfPublications, - }; -} - -/** 在任何真实媒体或 CLI 动作前固定本 gate 自己审核过的 oracle 语义。 */ -export function assertApprovedPdfOracleSlice(rawOracle: unknown): void { - const digest = createHash("sha256") - .update(JSON.stringify(pdfOracleSlice(rawOracle))) - .digest("hex"); - assert.equal( - digest, - approvedPdfOracleSliceDigest, - "PDF reviewed oracle slice digest drifted", - ); -} - -function stateKey(context: CliJourneyContext): string { - return `${context.mode}\u0000${context.workDir}`; -} - -function currentState(context: CliJourneyContext): JourneyState { - const state = stateByContext.get(stateKey(context)); - if (state === undefined) - throw new Error("PDF publication live journey setup did not run"); - return state; -} - -function corpusRoot(context: CliJourneyContext): string { - return path.resolve(context.packageRoot, "../..", "fixtures/live-corpus"); -} - -function inspection(result: CliJourneyResult | undefined) { - return JSON.parse(result?.stdout ?? "{}") as AssetRefreshInspection; -} - -function jsonResult( - result: CliJourneyResult | undefined, -): Record { - const value = record(JSON.parse(result?.stdout ?? "{}")); - if (value === undefined) throw new Error("CLI 没有返回 JSON 对象"); - return value; -} - -function scannedAssetId( - result: CliJourneyResult | undefined, - relativePath: string, -): string { - const item = (inspection(result).itemResults ?? []).find((candidate) => - candidate.relativePaths.includes(relativePath), - ); - const assetId = item?.accepted?.entity.id; - if (assetId === undefined) - throw new Error(`初次扫描没有返回 PDF 内容:${relativePath}`); - return assetId; -} - -function decisionExecutionId(result: CliJourneyResult | undefined): string { - const value = jsonResult(result).executionId; - if (typeof value !== "string" || value.length === 0) - throw new Error("来源决定没有返回执行编号"); - return value; -} - -function exactCandidateReference( - result: CliJourneyResult | undefined, - productAddress: string, -): string { - const value = jsonResult(result); - const matches = Array.isArray(value.candidates) - ? value.candidates.filter( - (candidate: Record) => - candidate.nativeAddress === productAddress && - candidate.outcome === "accepted", - ) - : []; - assert.equal(matches.length, 1, `来源候选没有唯一命中 ${productAddress}`); - const reference = matches[0]?.reference; - if (typeof reference !== "string" || reference.length === 0) - throw new Error("来源候选没有返回公开引用"); - return reference; -} - -function previewId(result: CliJourneyResult | undefined): string { - const value = record(jsonResult(result).preview)?.id; - if (typeof value !== "string" || value.length === 0) - throw new Error("来源决定没有返回待确认预演"); - return value; -} - -function exactLocalFact( - accepted: Accepted, - expected: { - readonly property: string; - readonly value: string; - readonly locator: string; - }, -): void { - const matches = accepted.metadata.filter( - (fact) => - fact.property === expected.property && - fact.value === expected.value && - fact.observedArtifactPart?.locator === expected.locator, - ); - assert.equal( - matches.length, - 1, - `missing exact PDF fact ${expected.property}`, - ); - assert.deepEqual(matches[0]!.sourceRecordIds, []); -} - -function stableBusinessProjection(value: AssetRefreshInspection): unknown { - return (value.itemResults ?? []).map((item) => { - const accepted = item.accepted; - assert.ok(accepted); - return { - relativePaths: item.relativePaths, - sourceAssociations: accepted.sourceAssociations, - blobs: accepted.blobs - .map((blob) => ({ - digest: blob.digest, - mediaType: blob.mediaType, - byteLength: blob.byteLength, - })) - .toSorted((left, right) => left.digest.localeCompare(right.digest)), - metadata: accepted.metadata - .map((fact) => ({ - property: fact.property, - value: fact.value, - locator: fact.observedArtifactPart?.locator, - })) - .toSorted((left, right) => - `${left.property}:${left.value}:${left.locator ?? ""}`.localeCompare( - `${right.property}:${right.value}:${right.locator ?? ""}`, - ), - ), - covers: accepted.artifactAssociations - .filter((association) => association.role === "asset.primary-cover") - .map((association) => association.observedArtifactPart?.locator), - entityCount: accepted.currentRelatedEntities?.length ?? 0, - mentionCount: accepted.entityMentions?.length ?? 0, - relationshipCount: accepted.relationships?.length ?? 0, - }; - }); -} - -function stablePublicationProjection(value: AssetRefreshInspection): unknown { - const accepted = value.accepted; - assert.ok(accepted); - return { - entityKind: accepted.entity.kind, - sourceAssociations: accepted.sourceAssociations - .map(({ sourceIdentity, nativeAddress }) => ({ - sourceIdentity, - nativeAddress, - })) - .toSorted((left, right) => - left.nativeAddress.localeCompare(right.nativeAddress), - ), - sourceRecords: value.sourceRecords - .map(({ sourceIdentity, nativeAddress, coverage }) => ({ - sourceIdentity, - nativeAddress, - coverage: coverage.id, - })) - .toSorted((left, right) => left.coverage.localeCompare(right.coverage)), - metadata: accepted.metadata - .map(({ property, value: factValue, sourceRecordIds }) => ({ - property, - value: factValue, - hasSourceProvenance: sourceRecordIds.length > 0, - })) - .toSorted((left, right) => - `${left.property}:${left.value}`.localeCompare( - `${right.property}:${right.value}`, - ), - ), - primaryCovers: accepted.artifactAssociations - .filter(({ role }) => role === "asset.primary-cover") - .map(({ observedArtifactPart, sourceRecordIds }) => ({ - locator: observedArtifactPart?.locator, - hasSourceProvenance: (sourceRecordIds?.length ?? 0) > 0, - })), - entityCount: accepted.currentRelatedEntities?.length ?? 0, - relationshipCount: accepted.relationships?.length ?? 0, - }; -} - -const journey: CliJourney = { - name: "pdf-publication-live", - source: { mode: "real" }, - modes: ["source", "distribution"], - async setup(context) { - const untrustedOracle = JSON.parse( - await readFile( - path.join(corpusRoot(context), "references/reviewed-oracle.json"), - "utf8", - ), - ) as unknown; - assertApprovedPdfOracleSlice(untrustedOracle); - const rawOracle = untrustedOracle as PdfOracle; - assert.equal(rawOracle.reviews.pdf.status, "reviewed-local-format"); - assert.equal(rawOracle.reviews.pdf.reviewedAt, "2026-08-31"); - assert.equal(rawOracle.pdfPublications.length, 3); - assert.equal( - new Set(rawOracle.pdfPublications.map((item) => item.relativePath)).size, - 3, - ); - assert.equal( - new Set(rawOracle.pdfPublications.map((item) => item.logicalRoot)).size, - 1, - ); - const allRoots = await fixedCorpusRoots(context); - const owner = fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle( - rawOracle, - allRoots, - ).find((group) => group.ownerTicket === ownerTicket); - assert.ok(owner, "PDF fixed-corpus ownership group is missing"); - assert.equal(owner.status, "implemented"); - assert.equal(owner.journey, journey.name); - assert.equal(owner.roots.length, 1); - assert.equal( - owner.roots[0]!.logicalRoot, - rawOracle.pdfPublications[0]!.logicalRoot, - ); - const deepIntegrity = process.env.DIGITAL_DEEP_CORPUS_INTEGRITY === "1"; - const before = await captureFixedCorpusSnapshots( - owner.roots, - deepIntegrity, - ); - const deepBefore = deepIntegrity - ? fixedCorpusContentDigestsFromSnapshots(owner.roots, before) - : undefined; - stateByContext.set(stateKey(context), { - root: owner.roots[0]!, - publications: rawOracle.pdfPublications, - before, - ...(deepBefore === undefined ? {} : { deepBefore }), - }); - if (deepBefore !== undefined) - await assertFixedCorpusContentBaselineSubset(context, deepBefore); - }, - commands(context) { - const state = currentState(context); - const environment = { - DIGITAL_PRODUCT_DATA: context.productDataDirectory, - }; - const initialCommands: readonly CliJourneyCommand[] = [ - { - name: "register PDF publication root", - args: [ - "library-create", - "PDF publication", - "--directory", - state.root.directory, - "--json", - ], - env: environment, - haltOnFailure: true, - }, - { - name: "live refresh PDF publication root", - args: (results: readonly CliJourneyResult[]) => [ - "scan", - registeredObservedRootId(results[0]), - "--scope", - ".", - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - env: environment, - haltOnFailure: true, - }, - ]; - const sourceCommands = publicationSourceInputs.flatMap( - (publication, publicationIndex) => { - const decisionIndex = 2 + publicationIndex * 7; - const settleIndex = decisionIndex + 1; - return [ - { - name: `establish ${publication.isbn} source association`, - args: (results: readonly CliJourneyResult[]) => [ - "source-decision", - "establish", - scannedAssetId(results[1], publication.relativePath), - publication.sourceIdentity, - JSON.stringify({ isbn: publication.isbn }), - "--actor", - "afk-live-gate", - "--json", - ], - env: environment, - haltOnFailure: true, - }, - { - name: `resolve ${publication.isbn} exact candidate after reopen`, - args: (results: readonly CliJourneyResult[]) => { - const decision = jsonResult(results[decisionIndex]); - const executionId = decisionExecutionId(results[decisionIndex]); - return decision.state === "awaiting-selection" - ? [ - "select-source-candidate", - executionId, - exactCandidateReference( - results[decisionIndex], - publication.productAddress, - ), - "--json", - ] - : ["inspect-source-decision", executionId, "--json"]; - }, - env: environment, - haltOnFailure: true, - }, - { - name: `inspect ${publication.isbn} retained source records`, - args: (results: readonly CliJourneyResult[]) => [ - "inspect", - decisionExecutionId(results[decisionIndex]), - "--json", - ], - env: environment, - haltOnFailure: true, - }, - { - name: `apply ${publication.isbn} source association`, - args: (results: readonly CliJourneyResult[]) => [ - "apply-preview", - previewId(results[settleIndex]), - "--actor", - "afk-live-gate", - "--json", - ], - env: environment, - haltOnFailure: true, - }, - { - name: `inspect ${publication.isbn} current association`, - args: (results: readonly CliJourneyResult[]) => [ - "inspect-correction-subject", - scannedAssetId(results[1], publication.relativePath), - "--json", - ], - env: environment, - haltOnFailure: true, - }, - { - name: `refresh ${publication.isbn} after reopen`, - args: (results: readonly CliJourneyResult[]) => [ - "refresh-metadata", - scannedAssetId(results[1], publication.relativePath), - "--json", - ], - env: environment, - haltOnFailure: true, - }, - { - name: `repeat ${publication.isbn} refresh`, - args: (results: readonly CliJourneyResult[]) => [ - "refresh-metadata", - scannedAssetId(results[1], publication.relativePath), - "--json", - ], - env: environment, - haltOnFailure: true, - }, - ]; - }, - ); - return [...initialCommands, ...sourceCommands]; - }, - async assertions({ context, results }) { - const state = currentState(context); - assert.equal(results.length, 23); - assert.equal(results[0]!.exitCode, 0, results[0]!.stderr); - registeredObservedRootId(results[0]); - assert.equal(results[1]!.exitCode, 0, results[1]!.stderr); - const value = inspection(results[1]); - assert.equal(value.execution.outcome, "accepted"); - const items = value.itemResults ?? []; - assert.equal(items.length, 3, "one PDF root must yield three work items"); - const byRelativePath = new Map( - items.map((item) => { - assert.equal(item.relativePaths.length, 1); - assert.equal(item.outcome, "accepted"); - assert.ok(item.accepted); - return [item.relativePaths[0]!, item.accepted] as const; - }), - ); - assert.equal(byRelativePath.size, 3); - const assetIds = new Set(); - for (const publication of state.publications) { - const accepted = byRelativePath.get(publication.relativePath); - assert.ok(accepted, `missing PDF work item ${publication.relativePath}`); - assert.equal(accepted.entity.kind, "digital-asset"); - assert.equal(assetIds.has(accepted.entity.id), false); - assetIds.add(accepted.entity.id); - assert.deepEqual(accepted.sourceAssociations, []); - assert.equal(accepted.recognitionSourceAssociation, undefined); - assert.match(accepted.recognitionExplanation, /无有效 ISBN/u); - assert.deepEqual(value.sourceRecords, []); - const revisionBlob = accepted.blobs.find( - (blob) => blob.digest === publication.revision.digest, - ); - assert.deepEqual(revisionBlob, publication.revision); - const parentRevision = accepted.artifactRevisions.find( - (revision) => revision.blobDigest === publication.revision.digest, - ); - assert.ok(parentRevision); - exactLocalFact(accepted, { - property: "pdf.page-count", - value: String(publication.pageCount), - locator: "pdf:catalog/pages", - }); - exactLocalFact(accepted, { - property: "pdf.document-author", - value: publication.documentAuthor, - locator: "pdf:info/Author", - }); - assert.equal( - accepted.metadata.some((fact) => fact.property === "asset.title"), - false, - ); - assert.equal( - accepted.metadata.some((fact) => fact.property === "book.contributor"), - false, - ); - const localFacts = accepted.metadata.filter( - (fact) => fact.sourceRecordIds.length === 0, - ); - assert.equal(localFacts.length, 2); - const covers = accepted.artifactAssociations.filter( - (association) => association.role === "asset.primary-cover", - ); - assert.equal(covers.length, 1); - assert.equal( - covers[0]!.observedArtifactPart?.locator, - publication.cover.locator, - ); - assert.equal( - covers[0]!.observedArtifactPart?.revisionId, - parentRevision.id, - ); - const coverRevision = accepted.artifactRevisions.find( - (revision) => revision.artifactId === covers[0]!.artifactId, - ); - assert.ok(coverRevision); - const coverBlob = accepted.blobs.find( - (blob) => blob.digest === coverRevision.blobDigest, - ); - assert.equal(coverBlob?.mediaType, publication.cover.mediaType); - assert.ok((coverBlob?.byteLength ?? 0) > 0); - assert.ok((coverBlob?.byteLength ?? 0) <= 16 * 1024 * 1024); - assert.deepEqual(accepted.currentRelatedEntities ?? [], []); - assert.deepEqual(accepted.entityMentions ?? [], []); - assert.deepEqual(accepted.relationships ?? [], []); - } - assert.equal(assetIds.size, 3); - const enrichedProjection: unknown[] = []; - for (const [ - publicationIndex, - publication, - ] of publicationSourceInputs.entries()) { - const decisionIndex = 2 + publicationIndex * 7; - const decision = jsonResult( - results[decisionIndex], - ) as unknown as SourceDecisionView; - assert.equal(results[decisionIndex]!.exitCode, 0); - assert.equal(decision.status, "running"); - assert.ok( - decision.state === "awaiting-selection" || - decision.state === "awaiting-confirmation", - `${publication.isbn} 来源决定没有进入可继续状态`, - ); - assert.equal( - decision.candidates.filter( - ({ outcome, sourceIdentity, nativeAddress }) => - outcome === "accepted" && - sourceIdentity === publication.sourceIdentity && - nativeAddress === publication.productAddress, - ).length, - 1, - ); - - const settled = jsonResult( - results[decisionIndex + 1], - ) as unknown as SourceDecisionView; - assert.equal(results[decisionIndex + 1]!.exitCode, 0); - assert.equal(settled.executionId, decision.executionId); - assert.equal(settled.state, "awaiting-confirmation"); - assert.equal(settled.preview?.status, "awaiting-confirmation"); - assert.deepEqual(settled.preview?.projected.sourceAssociations, [ - { - sourceIdentity: publication.sourceIdentity, - nativeAddress: publication.productAddress, - }, - ]); - - const retained = inspection(results[decisionIndex + 2]); - assert.equal(results[decisionIndex + 2]!.exitCode, 0); - assert.deepEqual( - retained.sourceRecords.map(({ coverage }) => coverage.id), - publication.expectedCoverage, - ); - assert.ok( - retained.sourceRecords.every( - ({ sourceIdentity }) => sourceIdentity === publication.sourceIdentity, - ), - `${publication.isbn} 保留了其他来源的记录`, - ); - assert.equal( - retained.sourceRecords.filter( - ({ nativeAddress }) => nativeAddress === publication.productAddress, - ).length, - 1, - ); - if (publication.sourceIdentity === "kadokawa-jp") - assert.deepEqual( - retained.sourceRecords.map(({ nativeAddress }) => nativeAddress), - [ - `kadokawa-jp:isbn-search:${publication.isbn}`, - publication.productAddress, - ], - ); - - const applied = jsonResult( - results[decisionIndex + 3], - ) as unknown as AppliedPreviewView; - assert.equal(results[decisionIndex + 3]!.exitCode, 0); - assert.equal(applied.preview.status, "applied"); - assert.deepEqual(applied.preview.projected.sourceAssociations, [ - { - sourceIdentity: publication.sourceIdentity, - nativeAddress: publication.productAddress, - }, - ]); - - const correction = jsonResult( - results[decisionIndex + 4], - ) as unknown as CorrectionSubjectView; - assert.equal(results[decisionIndex + 4]!.exitCode, 0); - assert.deepEqual( - correction.current.map(({ sourceIdentity, nativeAddress }) => ({ - sourceIdentity, - nativeAddress, - })), - [ - { - sourceIdentity: publication.sourceIdentity, - nativeAddress: publication.productAddress, - }, - ], - ); - assert.deepEqual(correction.historical, []); - - const refreshes = [ - inspection(results[decisionIndex + 5]), - inspection(results[decisionIndex + 6]), - ]; - for (const [refreshIndex, refresh] of refreshes.entries()) { - assert.equal(results[decisionIndex + 5 + refreshIndex]!.exitCode, 0); - assert.equal(refresh.execution.outcome, "accepted"); - assert.equal( - refresh.accepted?.entity.id, - scannedAssetId(results[1], publication.relativePath), - ); - assert.deepEqual( - refresh.accepted?.sourceAssociations.map( - ({ sourceIdentity, nativeAddress }) => ({ - sourceIdentity, - nativeAddress, - }), - ), - [ - { - sourceIdentity: publication.sourceIdentity, - nativeAddress: publication.productAddress, - }, - ], - ); - assert.deepEqual( - refresh.sourceRecords.map(({ coverage, nativeAddress }) => ({ - coverage: coverage.id, - nativeAddress, - })), - [ - { - coverage: - publication.sourceIdentity === "kadokawa-jp" - ? "kadokawa-jp.detail" - : "taiwan-kadokawa.product-page", - nativeAddress: publication.productAddress, - }, - ], - ); - const recordIds = new Set(refresh.sourceRecords.map(({ id }) => id)); - for (const [property, expected] of [ - ["book.isbn-13", publication.isbn], - ["asset.title", publication.expectedTitle], - ] as const) { - const acceptedMetadata: Accepted["metadata"] = - refresh.accepted?.metadata ?? []; - const matchingFacts: Accepted["metadata"] = acceptedMetadata.filter( - (fact) => fact.property === property && fact.value === expected, - ); - assert.equal(matchingFacts.length, 1); - assert.ok( - matchingFacts[0]!.sourceRecordIds.length > 0, - `${publication.isbn} 的 ${property} 缺少来源 provenance`, - ); - assert.ok( - matchingFacts[0]!.sourceRecordIds.some((id) => recordIds.has(id)), - `${publication.isbn} 的 ${property} 没有引用本轮当前响应`, - ); - } - const primaryCovers = - refresh.accepted?.artifactAssociations.filter( - ({ role }) => role === "asset.primary-cover", - ) ?? []; - assert.equal(primaryCovers.length, 1); - assert.equal( - primaryCovers[0]!.observedArtifactPart?.locator, - "pdf:page[1]", - ); - assert.deepEqual(primaryCovers[0]!.sourceRecordIds ?? [], []); - } - assert.notDeepEqual( - refreshes[0]!.sourceRecords.map(({ id }) => id), - refreshes[1]!.sourceRecords.map(({ id }) => id), - "重复刷新必须保留新的当前响应,而不是复用前一轮记录", - ); - enrichedProjection.push(stablePublicationProjection(refreshes[1]!)); - } - const stable = { - initial: stableBusinessProjection(value), - enriched: enrichedProjection, - }; - if (context.mode === "source") - sourceBusinessProjection.set(state.root.logicalRoot, stable); - else if (sourceBusinessProjection.has(state.root.logicalRoot)) - assert.deepEqual( - stable, - sourceBusinessProjection.get(state.root.logicalRoot), - "source/distribution PDF projection drifted", - ); - }, - async teardown({ context }) { - const key = stateKey(context); - const state = stateByContext.get(key); - if (state === undefined) return; - try { - const roots = [state.root]; - const after = await captureFixedCorpusSnapshots( - roots, - state.deepBefore !== undefined, - ); - assert.deepEqual( - after, - state.before, - "PDF fixed root changed during live journey", - ); - if (state.deepBefore !== undefined) { - const deepAfter = fixedCorpusContentDigestsFromSnapshots(roots, after); - await assertFixedCorpusContentBaselineSubset(context, deepAfter); - assert.deepEqual(deepAfter, state.deepBefore); - } - } finally { - stateByContext.delete(key); - } - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/journeys/resume-interrupted-refresh.journey.ts b/apps/cli/test/e2e/journeys/resume-interrupted-refresh.journey.ts deleted file mode 100644 index 0f1d587..0000000 --- a/apps/cli/test/e2e/journeys/resume-interrupted-refresh.journey.ts +++ /dev/null @@ -1,478 +0,0 @@ -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { mkdir, stat, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -import { captureRawFilesystemSnapshot } from "@digital/test-support"; - -import { - registeredObservedRootId, - type CliJourney, - type CliJourneyResult, -} from "../journey.ts"; - -const resumableStages = new Set([ - "source-records-retained", - "source-artifacts-acquired", - "source-artifacts-stored", - "cover-acquired", - "cover-stored", -]); - -type InterruptedExecution = { - readonly dataDirectory: string; - readonly executionId: string; - readonly stage: string; - readonly source: string; - readonly sourceBaseline: readonly string[]; - readonly terminalFailureSource: string; - readonly killedCheckpoint: DurableState; -}; - -type DurableState = { - readonly tableCounts: Readonly>; - readonly sourceRecordIds: readonly string[]; - readonly managedFiles: readonly string[]; -}; - -const interruptedByWorkDirectory = new Map(); - -function sourceDirectory(context: Parameters[0]): string { - return path.resolve( - context.packageRoot, - "../..", - ".scratch/acceptance-corpus/assets/kikoeru/VoiceWork/RJ01668691", - ); -} - -async function fingerprintTree(root: string): Promise { - return ( - await captureRawFilesystemSnapshot(root, { contentDigests: true }) - ).entries - .filter((entry) => entry.relativePath !== ".") - .map((entry) => - entry.kind === "directory" - ? `${entry.relativePath}/` - : `${entry.relativePath}:${entry.kind}:${entry.byteLength}:${entry.modifiedAtNanoseconds}:${entry.symbolicLinkTarget ?? ""}:${entry.contentDigest ?? ""}`, - ); -} - -async function durableState(dataDirectory: string): Promise { - const database = new DatabaseSync( - path.join(dataDirectory, "catalogue.sqlite"), - { readOnly: true }, - ); - try { - const tables = [ - "source_records", - "recognition_decisions", - "artifacts", - "locations", - "artifact_associations", - "metadata_assertions", - "entity_relationships", - ] as const; - return { - tableCounts: Object.fromEntries( - tables.map((table) => [ - table, - ( - database - .prepare(`SELECT COUNT(*) AS count FROM ${table}`) - .get() as { - readonly count: number; - } - ).count, - ]), - ), - sourceRecordIds: ( - database - .prepare("SELECT id FROM source_records ORDER BY id") - .all() as unknown as readonly { readonly id: string }[] - ).map((row) => row.id), - managedFiles: await managedFiles( - path.join(dataDirectory, "managed-artifacts"), - ), - }; - } finally { - database.close(); - } -} - -async function managedFiles(root: string): Promise { - try { - return (await fingerprintTree(root)).filter( - (item) => !item.endsWith("/") && !item.startsWith("staging/"), - ); - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") - return []; - throw error; - } -} - -function cliInvocation(context: Parameters[0]): { - readonly executable: string; - readonly prefix: readonly string[]; -} { - return context.mode === "source" - ? { - executable: process.execPath, - prefix: [ - "--conditions=digital-source", - path.join(context.packageRoot, "src", "cli.ts"), - ], - } - : { - executable: process.execPath, - prefix: [path.join(context.packageRoot, "dist", "cli.js")], - }; -} - -function executionId(result: CliJourneyResult | undefined): string { - const output = JSON.parse(result?.stdout ?? "{}") as { - readonly executionId?: string; - }; - if (output.executionId === undefined) - throw new Error(`expected execution id in ${result?.stdout ?? ""}`); - return output.executionId; -} - -async function sleep(milliseconds: number): Promise { - await new Promise((resolve) => { - setTimeout(resolve, milliseconds); - }); -} - -async function interruptAtPersistedCheckpoint( - context: Parameters[0], - sourceRootId: string, - dataDirectory: string, -): Promise<{ readonly executionId: string; readonly stage: string }> { - const target = cliInvocation(context); - const child = spawn( - target.executable, - [ - ...target.prefix, - "scan", - sourceRootId, - "--scope", - ".", - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - { - cwd: context.workDir, - env: { - ...context.commandEnvironment, - DIGITAL_PRODUCT_DATA: dataDirectory, - }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - let stderr = ""; - child.stderr.setEncoding("utf8"); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - const closed = new Promise<{ - readonly code: number | null; - readonly signal: NodeJS.Signals | null; - }>((resolve, reject) => { - child.once("error", reject); - child.once("close", (code, signal) => resolve({ code, signal })); - }); - const deadline = Date.now() + 30_000; - // 此看门狗在持久检查点观察子 CLI 进程。它刻意不是另一个已组合运行时:运行时初始化 - // 拥有迁移闸门(`BEGIN IMMEDIATE`),自身会与子检查点写入器争用。之后的 CLI `resume` - // 和 `inspect` 命令仍是重启端到端边界;该观察器只读取已提交阶段。 - let observer: DatabaseSync | undefined; - let executionId: string | undefined; - try { - while (Date.now() < deadline) { - const found = /刷新记录:([^\s]+)/u.exec(stderr)?.[1]; - if (found !== undefined) executionId = found; - if (executionId !== undefined) { - observer ??= new DatabaseSync( - path.join(dataDirectory, "catalogue.sqlite"), - { readOnly: true }, - ); - const execution = observer - .prepare("SELECT status, stage FROM workflow_executions WHERE id = ?") - .get(executionId) as - | { readonly status: string; readonly stage: string } - | undefined; - if ( - execution?.status === "running" && - resumableStages.has(execution.stage) - ) { - child.kill("SIGKILL"); - const outcome = await closed; - assert.equal(outcome.signal, "SIGKILL"); - return { executionId, stage: execution.stage }; - } - if (execution?.status !== undefined && execution.status !== "running") { - throw new Error( - `CLI completed before interruption at a resumable checkpoint: ${execution.status}/${execution.stage}`, - ); - } - } - await sleep(15); - } - child.kill("SIGKILL"); - await closed; - throw new Error( - `CLI did not expose a resumable checkpoint before timeout: ${stderr}`, - ); - } finally { - observer?.close(); - } -} - -async function registerObservedRoot(input: { - readonly context: Parameters[0]; - readonly dataDirectory: string; - readonly displayName: string; - readonly containerPath: string; -}): Promise { - const target = cliInvocation(input.context); - const result = await new Promise((resolve, reject) => { - const child = spawn( - target.executable, - [ - ...target.prefix, - "library-create", - input.displayName, - "--directory", - input.containerPath, - "--json", - ], - { - cwd: input.context.workDir, - env: { - ...input.context.commandEnvironment, - DIGITAL_PRODUCT_DATA: input.dataDirectory, - }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - child.once("error", reject); - child.once("close", (exitCode) => { - resolve({ - commandName: "register interrupted refresh source root", - exitCode: exitCode ?? 1, - stdout, - stderr, - }); - }); - }); - assert.equal(result.exitCode, 0, result.stderr); - return registeredObservedRootId(result); -} - -function current( - context: Parameters[0], -): InterruptedExecution { - const state = interruptedByWorkDirectory.get(context.workDir); - if (state === undefined) - throw new Error("interrupted execution was not set up"); - return state; -} - -const journey: CliJourney = { - name: "resume-interrupted-refresh", - realCorpus: true, - source: { mode: "real" }, - modes: ["source", "distribution"], - async setup(context) { - const source = sourceDirectory(context); - if (!(await stat(source)).isDirectory()) - throw new Error(`required live acceptance corpus is missing: ${source}`); - const sourceBaseline = await fingerprintTree(source); - const dataDirectory = context.productDataDirectory; - const terminalFailureSource = path.join( - context.workDir, - "terminal-failure-source", - ); - await mkdir(terminalFailureSource); - await writeFile( - path.join(terminalFailureSource, "ordinary.txt"), - "ordinary unresolved material\n", - ); - const sourceRootId = await registerObservedRoot({ - context, - dataDirectory, - displayName: "RJ01668691 恢复测试语料", - containerPath: source, - }); - const interrupted = await interruptAtPersistedCheckpoint( - context, - sourceRootId, - dataDirectory, - ); - interruptedByWorkDirectory.set(context.workDir, { - dataDirectory, - source, - sourceBaseline, - terminalFailureSource, - killedCheckpoint: await durableState(dataDirectory), - ...interrupted, - }); - }, - commands(context) { - const state = current(context); - return [ - { - name: "register terminal failed refresh root", - args: [ - "library-create", - "终端失败测试目录", - "--directory", - state.terminalFailureSource, - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: state.dataDirectory }, - haltOnFailure: true, - }, - { - name: "resume interrupted refresh in a second CLI process", - args: ["resume", state.executionId, "--json"], - env: { DIGITAL_PRODUCT_DATA: state.dataDirectory }, - }, - { - name: "inspect resumed refresh in a third CLI process", - args: ["inspect", state.executionId, "--json"], - env: { DIGITAL_PRODUCT_DATA: state.dataDirectory }, - }, - { - // 这个预期终态失败刻意产出供下一命令检查的执行 ID;它不得中止反向路径旅程。 - name: "create a terminal failed metadata refresh", - args: ["refresh-metadata", "missing-terminal-subject", "--json"], - env: { DIGITAL_PRODUCT_DATA: state.dataDirectory }, - }, - { - name: "reject resuming the terminal failure", - args: (results) => ["resume", executionId(results[2]), "--json"], - env: { DIGITAL_PRODUCT_DATA: state.dataDirectory }, - }, - { - name: "start another ordinary refresh after terminal failure", - args: (results) => [ - "scan", - registeredObservedRootId(results[0]), - "--scope", - ".", - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: state.dataDirectory }, - }, - ]; - }, - async assertions({ context, results: allResults }) { - assert.equal(allResults[0]?.exitCode, 0, allResults[0]?.stderr); - registeredObservedRootId(allResults[0]); - const results = allResults.slice(1); - const state = current(context); - const resumed = JSON.parse(results[0]?.stdout ?? "{}") as { - readonly executionId?: string; - readonly execution?: { - readonly status?: string; - readonly outcome?: string; - readonly stage?: string; - }; - readonly sourceRecords?: readonly { readonly id: string }[]; - readonly accepted?: { - readonly entity?: { readonly id: string }; - readonly artifacts?: readonly { readonly id: string }[]; - readonly locations?: readonly { readonly id: string }[]; - readonly artifactAssociations?: readonly { readonly id: string }[]; - }; - }; - const inspected = JSON.parse(results[1]?.stdout ?? "{}") as typeof resumed; - assert.equal(results[0]?.exitCode, 0, results[0]?.stderr); - assert.equal(resumed.executionId, state.executionId); - assert.equal(resumed.execution?.status, "completed"); - assert.equal(resumed.execution?.outcome, "accepted"); - assert.equal(resumed.sourceRecords?.length, 2); - assert.ok(resumed.accepted?.entity?.id); - assert.doesNotMatch(results[0]?.stderr ?? "", /正在获取官方来源/u); - assert.doesNotMatch( - JSON.stringify(resumed), - /"body"|"acquisition"|credential|cookie|plugin/u, - ); - assert.equal(results[1]?.exitCode, 0, results[1]?.stderr); - assert.deepEqual(inspected, resumed); - assert.equal( - state.killedCheckpoint.tableCounts.source_records, - state.killedCheckpoint.sourceRecordIds.length, - `killed checkpoint ${state.stage} contains duplicate source rows`, - ); - const completedState = await durableState(state.dataDirectory); - assert.deepEqual( - completedState.sourceRecordIds, - state.killedCheckpoint.sourceRecordIds, - `resume after ${state.stage} must reuse committed Source Records`, - ); - assert.equal(completedState.tableCounts.source_records, 2); - assert.equal(completedState.tableCounts.recognition_decisions, 1); - assert.equal( - completedState.tableCounts.artifacts, - resumed.accepted?.artifacts?.length, - ); - assert.equal( - completedState.tableCounts.locations, - resumed.accepted?.locations?.length, - ); - assert.equal( - completedState.tableCounts.artifact_associations, - resumed.accepted?.artifactAssociations?.length, - ); - assert.equal( - new Set(resumed.accepted?.artifactAssociations?.map((item) => item.id)) - .size, - resumed.accepted?.artifactAssociations?.length, - ); - assert.equal(completedState.managedFiles.length, 1); - - const failed = JSON.parse(results[2]?.stdout ?? "{}") as { - readonly executionId?: string; - readonly execution?: { - readonly status?: string; - readonly outcome?: string; - }; - }; - assert.equal(results[2]?.exitCode, 1, results[2]?.stderr); - assert.equal(failed.execution?.status, "failed"); - assert.equal(failed.execution?.outcome, "failed"); - assert.notEqual(failed.executionId, state.executionId); - assert.equal(results[3]?.exitCode, 1); - assert.match(results[3]?.stderr ?? "", /只有未完成的刷新记录可以继续/u); - - const next = JSON.parse(results[4]?.stdout ?? "{}") as { - readonly executionId?: string; - readonly execution?: { - readonly outcome?: string; - readonly selection?: { readonly intent?: string }; - }; - }; - assert.equal(results[4]?.exitCode, 0, results[4]?.stderr); - assert.notEqual(next.executionId, failed.executionId); - assert.equal(next.execution?.selection?.intent, "scan-now"); - assert.ok(next.execution?.outcome === "unresolved"); - assert.deepEqual(await fingerprintTree(state.source), state.sourceBaseline); - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/journeys/tmdb-omdb-live.journey.ts b/apps/cli/test/e2e/journeys/tmdb-omdb-live.journey.ts deleted file mode 100644 index 9983879..0000000 --- a/apps/cli/test/e2e/journeys/tmdb-omdb-live.journey.ts +++ /dev/null @@ -1,685 +0,0 @@ -import assert from "node:assert/strict"; -import path from "node:path"; - -import type { - AssetRefreshInspection, - SelectedMetadataRead, -} from "@digital/application"; -import type { RawFilesystemSnapshot } from "@digital/test-support"; - -import { - assertFixedCorpusContentBaselineSubset, - captureFixedCorpusSnapshots, - fixedCorpusContentDigestsFromSnapshots, - type FixedCorpusContentDigest, - type FixedCorpusRoot, -} from "../fixed-corpus.ts"; -import { - registeredObservedRootId, - type CliJourney, - type CliJourneyResult, -} from "../journey.ts"; - -const strictFilmLogicalRoot = "media/电影/恐怖直播 (2013)"; -const directedBdmvLogicalRoot = - "media/电影/[怒火救援].Man.on.Fire.2004.Blu-ray.1080p.AVC.DTS-HD.MA5.1-CMCT"; -const strictFilmAddress = "https://www.themoviedb.org/movie/209764"; -const directedBdmvAddress = "https://www.themoviedb.org/movie/9509"; -const strictFilmOmdbAddress = "omdb:imdb:tt2990738"; -const directedBdmvOmdbAddress = "omdb:imdb:tt0328107"; - -type MovieJourneyState = { - readonly roots: readonly FixedCorpusRoot[]; - readonly before: ReadonlyMap; - readonly deepBefore?: ReadonlyMap; -}; - -type JourneyInspection = AssetRefreshInspection & { - readonly executionId?: string; -}; -type AcceptedProjection = NonNullable; -export type InspectionProjection = "legacy" | "itemized"; - -const stateByWorkDirectory = new Map(); - -function present(value: string | undefined): boolean { - return value !== undefined && value.trim().length !== 0; -} - -function stateKey(context: Parameters[0]): string { - return `${context.mode}:${context.workDir}`; -} - -function currentState( - context: Parameters[0], -): MovieJourneyState { - const state = stateByWorkDirectory.get(stateKey(context)); - if (state === undefined) - throw new Error("TMDB and OMDb movie journey setup did not run"); - return state; -} - -function movieRoots( - context: Parameters[0], -): readonly FixedCorpusRoot[] { - const assetsDirectory = path.resolve( - context.packageRoot, - "../..", - ".scratch/acceptance-corpus/assets", - ); - return [strictFilmLogicalRoot, directedBdmvLogicalRoot].map( - (logicalRoot) => ({ - logicalRoot, - directory: path.join(assetsDirectory, ...logicalRoot.split("/")), - }), - ); -} - -function selectedRoot( - state: MovieJourneyState, - logicalRoot: string, -): FixedCorpusRoot { - const root = state.roots.find( - (candidate) => candidate.logicalRoot === logicalRoot, - ); - if (root === undefined) - throw new Error( - `required live acceptance corpus is missing: ${logicalRoot}`, - ); - return root; -} - -function inspection(result: CliJourneyResult | undefined): JourneyInspection { - return JSON.parse(result?.stdout ?? "") as JourneyInspection; -} - -/** - * 在显式包工作逐项列出时,应用层公开检查刻意保留旧版单边界投影。 - * 旅程必须验证每个公开工作流实际发出的结构。 - */ -export function acceptedProjection( - value: JourneyInspection, - projection: InspectionProjection, -): AcceptedProjection { - if (projection === "legacy") { - assert.equal( - value.itemResults, - undefined, - "legacy single-boundary inspection must not be read as an explicit work-item result", - ); - assert.ok( - value.accepted !== undefined, - "legacy single-boundary inspection must expose its accepted projection at top level", - ); - return value.accepted; - } - assert.ok( - value.itemResults !== undefined, - "explicit work-item inspection must expose itemResults", - ); - assert.equal( - value.itemResults.length, - 1, - "directed BDMV acceptance must have exactly one explicit work item", - ); - const item = value.itemResults[0]; - assert.ok( - item?.accepted !== undefined, - "explicit work item must be accepted", - ); - return item.accepted; -} - -function acceptedAssetId( - result: CliJourneyResult | undefined, - projection: InspectionProjection, -): string { - return acceptedProjection(inspection(result), projection).entity.id; -} - -function executionId(result: CliJourneyResult | undefined): string { - const value = inspection(result); - return value.executionId ?? value.execution.id; -} - -function currentSourceAssociations(accepted: AcceptedProjection): readonly { - readonly sourceIdentity: string; - readonly nativeAddress: string; -}[] { - return accepted.sourceAssociations - .map((association) => ({ - sourceIdentity: association.sourceIdentity, - nativeAddress: association.nativeAddress, - })) - .toSorted((left, right) => - `${left.sourceIdentity}:${left.nativeAddress}`.localeCompare( - `${right.sourceIdentity}:${right.nativeAddress}`, - ), - ); -} - -function sourceIdentityByRecord( - value: JourneyInspection, -): ReadonlyMap { - return new Map( - value.sourceRecords.map((record) => [record.id, record.sourceIdentity]), - ); -} - -function assertSourceRecordIdentity( - value: JourneyInspection, - sourceIdentity: string, - nativeAddress: string, -): void { - assert.ok( - value.sourceRecords.some( - (record) => - record.sourceIdentity === sourceIdentity && - record.nativeAddress === nativeAddress, - ), - `${sourceIdentity} record at ${nativeAddress} must remain inspectable`, - ); -} - -/** 普通读取只给出资产自身实体目标上已生效的选定值。 */ -function effectiveMetadataReads( - accepted: AcceptedProjection, -): readonly SelectedMetadataRead[] { - return accepted.facts.descriptions - .filter( - (description) => - description.target.kind === "entity" && - description.target.entityId === accepted.facts.id, - ) - .flatMap((description) => - description.metadata.flatMap((entry) => - entry.result.status === "resolved" ? entry.result.values : [], - ), - ); -} - -function assertEffectiveTmdbTitle( - value: JourneyInspection, - accepted: AcceptedProjection, -): void { - const title = effectiveMetadataReads(accepted).find( - (metadata) => metadata.property === "asset.title", - ); - assert.ok(title !== undefined, "an effective title must be readable"); - assert.ok(title.value.trim().length); - assert.equal(title.sourceIdentity, "tmdb"); - const citedRecords = new Set( - title.assertions.flatMap((assertion) => assertion.sourceRecordIds), - ); - assert.equal(title.sourceRecordIds.length, citedRecords.size); - assert.ok( - title.sourceRecordIds.every((id) => citedRecords.has(id)), - "the effective title must cite exactly its own assertion records", - ); - assert.ok( - title.assertions.every( - (assertion) => assertion.sourceSlot === title.sourceSlot, - ), - "the effective title must cite only its own source slot", - ); - const currentExecutionSourceIdentities = sourceIdentityByRecord(value); - assert.ok( - title.sourceRecordIds.every((id) => { - const sourceIdentity = currentExecutionSourceIdentities.get(id); - // 未变化的刷新可以继续引用更早的来源记录;公开执行检查只携带本次记录, - // 因而只有当前可见的记录 ID 能在这里再次核对身份。 - return sourceIdentity === undefined || sourceIdentity === "tmdb"; - }), - "the effective title must not cite a visible non-TMDB source record", - ); - assert.deepEqual(title.reasoning, { - kind: "source-preference", - preferredSource: "tmdb", - }); - // 生效读取不再携带策略备选值;未被选中的 OMDb 断言只能在已接受原始断言中复核。 - const omdbTitleAssertion = accepted.metadata.find( - (assertion) => - assertion.property === "asset.title" && - assertion.sourceRecordIds.some( - (id) => currentExecutionSourceIdentities.get(id) === "omdb", - ), - ); - assert.ok( - omdbTitleAssertion !== undefined, - "the accepted projection must retain OMDb's unselected title assertion", - ); - const selectedRecordIds = new Set(title.sourceRecordIds); - assert.ok( - omdbTitleAssertion.sourceRecordIds.every( - (id) => !selectedRecordIds.has(id), - ), - "the selected TMDB title must not cite OMDb's source records", - ); -} - -function assertEffectiveOmdbRating(accepted: AcceptedProjection): void { - const rating = effectiveMetadataReads(accepted).find( - (metadata) => - metadata.property.startsWith("rating.") && - metadata.sourceIdentity === "omdb", - ); - assert.ok( - rating !== undefined, - "OMDb must contribute an effective native rating", - ); - assert.ok( - (rating.qualifiers ?? []).length > 0, - "an OMDb rating must retain its native qualifiers", - ); -} - -export function assertSourceEnrichedFilm( - value: JourneyInspection, - expectedTmdbAddress: string, - expectedOmdbAddress: string, - projection: InspectionProjection, -): void { - const accepted = acceptedProjection(value, projection); - assert.equal(value.execution.outcome, "accepted"); - assert.equal(accepted.entity.kind, "digital-asset"); - assert.ok( - accepted.recognitionSourceAssociation, - "accepted TMDB film must retain its recognition source", - ); - assert.deepEqual(accepted.recognitionSourceAssociation, { - sourceIdentity: "tmdb", - nativeAddress: expectedTmdbAddress, - }); - assert.deepEqual(currentSourceAssociations(accepted), [ - { sourceIdentity: "omdb", nativeAddress: expectedOmdbAddress }, - { sourceIdentity: "tmdb", nativeAddress: expectedTmdbAddress }, - ]); - assertSourceRecordIdentity(value, "tmdb", expectedTmdbAddress); - assertSourceRecordIdentity(value, "omdb", expectedOmdbAddress); - assertEffectiveTmdbTitle(value, accepted); - assertEffectiveOmdbRating(accepted); - - const primaryCover = accepted.artifactAssociations.filter( - (association) => association.role === "asset.primary-cover", - ); - assert.equal( - primaryCover.length, - 1, - "one effective primary cover is required", - ); - const coverLocation = accepted.locations.find( - (location) => location.artifactId === primaryCover[0]?.artifactId, - ); - assert.equal(coverLocation?.kind, "managed"); -} - -function assertSameAcceptedProjection( - initial: AcceptedProjection, - reused: AcceptedProjection, -): void { - assert.equal(reused.entity.id, initial.entity.id); - assert.deepEqual( - reused.artifacts.map((artifact) => artifact.id).toSorted(), - initial.artifacts.map((artifact) => artifact.id).toSorted(), - ); - assert.deepEqual( - reused.artifactAssociations - .map((association) => `${association.artifactId}:${association.role}`) - .toSorted(), - initial.artifactAssociations - .map((association) => `${association.artifactId}:${association.role}`) - .toSorted(), - ); -} - -function assertMetadataRefresh( - value: JourneyInspection, - expectedTmdbAddress: string, - expectedOmdbAddress: string, - priorSourceRecordIds: readonly string[], -): void { - const accepted = acceptedProjection(value, "legacy"); - assert.equal(value.execution.outcome, "accepted"); - assert.equal(value.execution.selection.intent, "metadata-refresh"); - assert.ok( - accepted.recognitionSourceAssociation, - "refreshed TMDB film must retain its recognition source", - ); - assert.equal( - accepted.recognitionSourceAssociation.nativeAddress, - expectedTmdbAddress, - ); - assert.deepEqual(currentSourceAssociations(accepted), [ - { sourceIdentity: "omdb", nativeAddress: expectedOmdbAddress }, - { sourceIdentity: "tmdb", nativeAddress: expectedTmdbAddress }, - ]); - assert.deepEqual( - new Set(value.sourceRecords.map((record) => record.sourceIdentity)), - new Set(["omdb", "tmdb"]), - "ordinary metadata refresh must follow each current Source Association", - ); - assert.ok( - value.sourceRecords.some((record) => record.sourceIdentity === "tmdb"), - "metadata refresh must retain at least one new TMDB record", - ); - assert.ok( - value.sourceRecords.some((record) => record.sourceIdentity === "omdb"), - "metadata refresh must retain one new OMDb record", - ); - assert.ok( - value.sourceRecords.every( - (record) => !priorSourceRecordIds.includes(record.id), - ), - "each refresh source record must be new rather than reused history", - ); - const sourceRefreshRuns = value.sourceRefreshRuns ?? []; - assert.equal( - sourceRefreshRuns.length, - 2, - "metadata refresh must retain one independent domain run for each current source", - ); - assert.ok( - sourceRefreshRuns.every( - (run) => run.phase === "terminal" && run.outcome === "succeeded", - ), - "each current source refresh run must succeed independently", - ); - assert.deepEqual( - new Set(value.execution.sourceRefreshRunIds ?? []), - new Set(sourceRefreshRuns.map((run) => run.id)), - "workflow execution must reference every authoritative source refresh run", - ); - assert.ok( - value.execution.dispositions.every( - (disposition) => !disposition.key.startsWith("source-refresh:"), - ), - "workflow dispositions must not duplicate Source Refresh Run authority", - ); -} - -function assertBdmvPackage(accepted: AcceptedProjection): void { - const primaryCover = accepted.artifactAssociations.filter( - (item) => item.role === "asset.primary-cover", - ); - const delivered = accepted.artifactAssociations.filter( - (item) => item.role === "asset.material-delivered", - ); - assert.equal(primaryCover.length, 1, "BDMV must retain one managed cover"); - assert.equal( - delivered.length, - 1, - "BDMV must retain one delivered package Artifact", - ); - const observed = accepted.locations.filter( - (item) => item.kind === "observed", - ); - assert.equal(observed.length, 1, "BDMV is one observed package Artifact"); - assert.equal(observed[0]?.artifactId, delivered[0]?.artifactId); - assert.equal( - accepted.artifacts.length, - 2, - "no isolated artifacts may be admitted beside the managed cover and observed package", - ); - assert.deepEqual( - new Set(accepted.artifacts.map((item) => item.id)), - new Set([primaryCover[0]?.artifactId, delivered[0]?.artifactId]), - ); -} - -const journey: CliJourney = { - name: "tmdb-omdb-live", - source: { mode: "real" }, - modes: ["source", "distribution"], - async setup(context) { - if ( - !present(process.env.DIGITAL_TMDB_ACCESS_TOKEN) || - !present(process.env.DIGITAL_OMDB_API_KEY) - ) - throw new Error( - "TMDB and OMDb live journey requires DIGITAL_TMDB_ACCESS_TOKEN and DIGITAL_OMDB_API_KEY", - ); - const roots = movieRoots(context); - const deepIntegrity = process.env.DIGITAL_DEEP_CORPUS_INTEGRITY === "1"; - const before = await captureFixedCorpusSnapshots(roots, deepIntegrity); - const deepBefore = deepIntegrity - ? fixedCorpusContentDigestsFromSnapshots(roots, before) - : undefined; - if (deepBefore !== undefined) - await assertFixedCorpusContentBaselineSubset(context, deepBefore); - stateByWorkDirectory.set(stateKey(context), { - roots, - before, - ...(deepBefore === undefined ? {} : { deepBefore }), - }); - }, - commands(context) { - const state = currentState(context); - const strict = selectedRoot(state, strictFilmLogicalRoot); - const directed = selectedRoot(state, directedBdmvLogicalRoot); - const env = { - DIGITAL_PRODUCT_DATA: context.productDataDirectory, - DIGITAL_TMDB_ACCESS_TOKEN: process.env.DIGITAL_TMDB_ACCESS_TOKEN!, - DIGITAL_OMDB_API_KEY: process.env.DIGITAL_OMDB_API_KEY!, - }; - const live = { env, haltOnFailure: true as const }; - return [ - { - ...live, - name: "register strict TMDB film root", - args: [ - "library-create", - "恐怖直播 验收语料", - "--directory", - strict.directory, - "--json", - ], - }, - { - ...live, - name: "register directed BDMV film root", - args: [ - "library-create", - "怒火救援 验收语料", - "--directory", - directed.directory, - "--json", - ], - }, - { - ...live, - name: "live accept strict TMDB film with OMDb enrichment", - args: (results) => [ - "scan", - registeredObservedRootId(results[0]), - "--scope", - ".", - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - }, - { - ...live, - name: "live no-change strict TMDB film after SQLite restart", - args: (results) => [ - "scan", - registeredObservedRootId(results[0]), - "--scope", - ".", - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - }, - { - ...live, - name: "live refresh strict film metadata from current sources", - args: (results) => [ - "refresh-metadata", - acceptedAssetId(results[2], "legacy"), - "--json", - ], - }, - { - ...live, - name: "live inspect refreshed strict film projection", - args: (results) => ["inspect", executionId(results[4]), "--json"], - }, - { - ...live, - name: "live accept directed BDMV film with OMDb enrichment", - args: (results) => [ - "scrape-source", - registeredObservedRootId(results[1]), - "tmdb", - JSON.stringify({ movie: directedBdmvAddress }), - "--scope", - ".", - "--json", - ], - }, - { - ...live, - name: "live inspect directed BDMV film projection after SQLite restart", - args: (results) => ["inspect", executionId(results[6]), "--json"], - }, - { - ...live, - name: "live refresh directed BDMV film metadata from current sources", - args: (results) => [ - "refresh-metadata", - acceptedAssetId(results[6], "itemized"), - "--json", - ], - }, - { - ...live, - name: "live inspect refreshed directed BDMV film projection", - args: (results) => ["inspect", executionId(results[8]), "--json"], - }, - ]; - }, - async assertions({ context, results: allResults }) { - for (const registeredRoot of allResults.slice(0, 2)) { - assert.equal(registeredRoot.exitCode, 0, registeredRoot.stderr); - registeredObservedRootId(registeredRoot); - } - const results = allResults.slice(2); - assert.equal(results.length, 8); - for (const result of results) { - assert.equal( - result.exitCode, - 0, - `${result.commandName} failed: ${result.stderr}`, - ); - const output = `${result.stdout}\n${result.stderr}`; - for (const secret of [ - process.env.DIGITAL_TMDB_ACCESS_TOKEN, - process.env.DIGITAL_OMDB_API_KEY, - ]) { - if (secret !== undefined && secret.length > 0) - assert.doesNotMatch( - output, - new RegExp(secret.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u"), - ); - } - assert.doesNotMatch(output, /Bearer\s+[^\s]+/u); - } - - const strict = inspection(results[0]); - assertSourceEnrichedFilm( - strict, - strictFilmAddress, - strictFilmOmdbAddress, - "legacy", - ); - const strictAccepted = acceptedProjection(strict, "legacy"); - const strictRecordIds = strict.sourceRecords.map((record) => record.id); - - const strictNoChange = inspection(results[1]); - assert.equal(strictNoChange.execution.outcome, "no-change"); - assert.equal( - strictNoChange.execution.reusedExecutionId, - strict.execution.id, - ); - assert.deepEqual(strictNoChange.sourceRecords, []); - assertSameAcceptedProjection( - strictAccepted, - acceptedProjection(strictNoChange, "legacy"), - ); - - const strictRefresh = inspection(results[2]); - assertMetadataRefresh( - strictRefresh, - strictFilmAddress, - strictFilmOmdbAddress, - strictRecordIds, - ); - const strictInspection = inspection(results[3]); - assertSourceEnrichedFilm( - strictInspection, - strictFilmAddress, - strictFilmOmdbAddress, - "legacy", - ); - assert.equal(strictInspection.execution.id, strictRefresh.execution.id); - - const directed = inspection(results[4]); - assertSourceEnrichedFilm( - directed, - directedBdmvAddress, - directedBdmvOmdbAddress, - "itemized", - ); - assert.equal(directed.execution.snapshot?.files.length, 105); - const directedAccepted = acceptedProjection(directed, "itemized"); - assertBdmvPackage(directedAccepted); - const directedRecordIds = directed.sourceRecords.map((record) => record.id); - - const directedInspection = inspection(results[5]); - assertSourceEnrichedFilm( - directedInspection, - directedBdmvAddress, - directedBdmvOmdbAddress, - "itemized", - ); - assert.equal(directedInspection.execution.id, directed.execution.id); - const directedRefresh = inspection(results[6]); - assertMetadataRefresh( - directedRefresh, - directedBdmvAddress, - directedBdmvOmdbAddress, - directedRecordIds, - ); - const refreshedDirectedInspection = inspection(results[7]); - assertSourceEnrichedFilm( - refreshedDirectedInspection, - directedBdmvAddress, - directedBdmvOmdbAddress, - "legacy", - ); - assert.equal( - refreshedDirectedInspection.execution.id, - directedRefresh.execution.id, - ); - - const state = currentState(context); - const after = await captureFixedCorpusSnapshots( - state.roots, - state.deepBefore !== undefined, - ); - assert.deepEqual( - after, - state.before, - "live journey must not alter either source tree", - ); - if (state.deepBefore !== undefined) { - assert.deepEqual( - fixedCorpusContentDigestsFromSnapshots(state.roots, after), - state.deepBefore, - ); - } - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/journeys/unresolved-inspection.journey.ts b/apps/cli/test/e2e/journeys/unresolved-inspection.journey.ts deleted file mode 100644 index 995e7d8..0000000 --- a/apps/cli/test/e2e/journeys/unresolved-inspection.journey.ts +++ /dev/null @@ -1,148 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; - -import { captureRawFilesystemSnapshot } from "@digital/test-support"; - -import { - registeredObservedRootId, - type CliJourney, - type CliJourneyResult, -} from "../journey.ts"; - -async function fingerprintTree(root: string): Promise { - return ( - await captureRawFilesystemSnapshot(root, { contentDigests: true }) - ).entries - .filter((entry) => entry.relativePath !== ".") - .map((entry) => - entry.kind === "directory" - ? `${entry.relativePath}/` - : `${entry.relativePath}:${entry.kind}:${entry.byteLength}:${entry.modifiedAtNanoseconds}:${entry.symbolicLinkTarget ?? ""}:${entry.contentDigest ?? ""}`, - ); -} - -const baselineByWorkDirectory = new Map(); - -function sourceDirectory(context: Parameters[0]): string { - return path.join(context.workDir, "unresolved-source"); -} - -function executionId(result: CliJourneyResult | undefined): string { - const output = JSON.parse(result?.stdout ?? "{}") as { - readonly executionId?: string; - }; - if (output.executionId === undefined) - throw new Error(`scan did not return an execution id: ${result?.stdout}`); - return output.executionId; -} - -const journey: CliJourney = { - name: "unresolved-inspection", - modes: ["source", "distribution"], - async setup(context) { - const source = sourceDirectory(context); - await mkdir(path.join(source, "nested"), { recursive: true }); - await writeFile( - path.join(source, "ordinary.txt"), - "ordinary source bytes\n", - ); - await writeFile( - path.join(source, "nested", "unknown.bin"), - new Uint8Array([4, 8, 15]), - ); - baselineByWorkDirectory.set(context.workDir, await fingerprintTree(source)); - }, - commands(context) { - const source = sourceDirectory(context); - const data = context.productDataDirectory; - return [ - { - name: "register unknown directory root", - args: [ - "library-create", - "未识别测试目录", - "--directory", - source, - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - haltOnFailure: true, - }, - { - name: "scan unknown directory", - args: (results) => [ - "scan", - registeredObservedRootId(results[0]), - "--scope", - ".", - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - haltOnFailure: true, - }, - { - name: "inspect unresolved execution in a later process", - args: (results) => ["inspect", executionId(results[1]), "--json"], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - { - name: "render unresolved inspection in Simplified Chinese", - args: (results) => ["inspect", executionId(results[1])], - env: { DIGITAL_PRODUCT_DATA: data }, - }, - ]; - }, - async assertions({ context, results: allResults }) { - assert.equal(allResults[0]?.exitCode, 0, allResults[0]?.stderr); - registeredObservedRootId(allResults[0]); - const results = allResults.slice(1); - const scanned = JSON.parse(results[0]?.stdout ?? "{}") as { - readonly format?: string; - readonly unstable?: boolean; - readonly executionId?: string; - readonly execution?: { - readonly status?: string; - readonly outcome?: string; - readonly snapshot?: { - readonly complete?: boolean; - readonly files?: readonly { readonly relativePath: string }[]; - }; - }; - readonly accepted?: unknown; - readonly recognition?: unknown; - readonly sourceRecords?: readonly unknown[]; - }; - const inspected = JSON.parse(results[1]?.stdout ?? "{}") as typeof scanned; - assert.equal(results[0]?.exitCode, 0, results[0]?.stderr); - assert.equal(results[1]?.exitCode, 0, results[1]?.stderr); - assert.equal(scanned.format, "temporary-asset-refresh-inspection/v1"); - assert.equal(scanned.unstable, true); - assert.equal(scanned.execution?.status, "completed"); - assert.equal(scanned.execution?.outcome, "unresolved"); - assert.equal(scanned.execution?.snapshot?.complete, true); - assert.deepEqual( - scanned.execution?.snapshot?.files?.map((file) => file.relativePath), - ["nested/unknown.bin", "ordinary.txt"], - ); - assert.equal(scanned.accepted, undefined); - assert.equal(scanned.recognition, undefined); - assert.deepEqual(scanned.sourceRecords, []); - assert.equal(inspected.executionId, scanned.executionId); - assert.deepEqual(inspected.execution, scanned.execution); - assert.equal(results[2]?.exitCode, 0, results[2]?.stderr); - assert.match(results[2]?.stdout ?? "", /结果:未识别/u); - assert.match(results[2]?.stdout ?? "", /观察范围:/u); - assert.match(results[2]?.stdout ?? "", /代表文件:/u); - assert.match( - results[2]?.stdout ?? "", - /directory-interpretation:completed/u, - ); - assert.deepEqual( - await fingerprintTree(sourceDirectory(context)), - baselineByWorkDirectory.get(context.workDir), - ); - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/journeys/unsupported-fixed-corpus.journey.ts b/apps/cli/test/e2e/journeys/unsupported-fixed-corpus.journey.ts deleted file mode 100644 index 3defa77..0000000 --- a/apps/cli/test/e2e/journeys/unsupported-fixed-corpus.journey.ts +++ /dev/null @@ -1,299 +0,0 @@ -import assert from "node:assert/strict"; -import path from "node:path"; - -import type { - RawFilesystemEntry, - RawFilesystemSnapshot, -} from "@digital/test-support"; - -import { - assertFixedCorpusContentBaseline, - assertFixedCorpusInventoryBaseline, - captureFixedCorpusSnapshots, - fixedCorpusContentDigestsFromSnapshots, - fixedCorpusRoots, - unsupportedFixedCorpusRoots, - type FixedCorpusContentDigest, - type FixedCorpusRoot, -} from "../fixed-corpus.ts"; -import { - registeredObservedRootId, - type CliJourney, - type CliJourneyResult, -} from "../journey.ts"; - -type CorpusState = { - readonly corpusAssetsDirectory: string; - readonly allRoots: readonly FixedCorpusRoot[]; - readonly unsupportedRoots: readonly FixedCorpusRoot[]; - readonly before: ReadonlyMap; - readonly deepBefore?: ReadonlyMap; -}; - -const stateByMode = new Map(); - -function stateKey(context: Parameters[0]): string { - return `${context.mode}:${context.workDir}`; -} - -function currentState( - context: Parameters[0], -): CorpusState { - const state = stateByMode.get(stateKey(context)); - if (state === undefined) - throw new Error("fixed corpus journey setup did not run"); - return state; -} - -function expectedFileCount(fingerprint: readonly RawFilesystemEntry[]): number { - return fingerprint.filter((entry) => entry.kind === "regular-file").length; -} - -function expectedDirectoryPaths( - fingerprint: readonly RawFilesystemEntry[], -): readonly string[] { - return fingerprint - .filter((entry) => entry.kind === "directory") - .map((entry) => entry.relativePath) - .toSorted(compareCodeUnits); -} - -function expectedTotalSize(fingerprint: readonly RawFilesystemEntry[]): number { - return fingerprint - .filter((entry) => entry.kind === "regular-file") - .reduce((total, entry) => safeAdd(total, entry.byteLength), 0); -} - -function expectedRepresentativePaths( - fingerprint: readonly RawFilesystemEntry[], -): readonly string[] { - return fingerprint - .filter( - (entry) => entry.kind === "regular-file" && entry.relativePath !== ".", - ) - .map((entry) => entry.relativePath) - .toSorted(compareCodeUnits) - .slice(0, 8); -} - -function compareCodeUnits(left: string, right: string): number { - return left < right ? -1 : left > right ? 1 : 0; -} - -function safeAdd(total: number, value: number): number { - const result = total + value; - if (!Number.isSafeInteger(result) || result < 0) - throw new Error( - "fixed corpus total size is outside the safe integer range", - ); - return result; -} - -function corpusAssetsDirectory( - context: Parameters[0], -): string { - return path.resolve( - context.packageRoot, - "../..", - ".scratch/acceptance-corpus/assets", - ); -} - -function assertScopesBelongToCommonCorpusRoot( - rootDirectory: string, - roots: readonly FixedCorpusRoot[], -): void { - for (const root of roots) { - const scope = path.relative(rootDirectory, root.directory); - assert.equal( - scope.replaceAll(path.sep, "/"), - root.logicalRoot, - `${root.logicalRoot} must remain a bounded relative scope of the fixed corpus root`, - ); - } -} - -const journey: CliJourney = { - name: "unsupported-fixed-corpus", - realCorpus: true, - modes: ["source", "distribution"], - async setup(context) { - const [allRoots, unsupportedRoots] = await Promise.all([ - fixedCorpusRoots(context), - unsupportedFixedCorpusRoots(context), - ]); - assert.equal(allRoots.length, 29, "files-from selection drifted"); - assert.equal(unsupportedRoots.length, 5, "safety root scope drifted"); - const deepIntegrity = process.env.DIGITAL_DEEP_CORPUS_INTEGRITY === "1"; - const before = await captureFixedCorpusSnapshots(allRoots, deepIntegrity); - await assertFixedCorpusInventoryBaseline(context, before); - const deepBefore = deepIntegrity - ? fixedCorpusContentDigestsFromSnapshots(allRoots, before) - : undefined; - if (deepBefore !== undefined) - await assertFixedCorpusContentBaseline(context, deepBefore); - const assetsDirectory = corpusAssetsDirectory(context); - assertScopesBelongToCommonCorpusRoot(assetsDirectory, allRoots); - stateByMode.set(stateKey(context), { - corpusAssetsDirectory: assetsDirectory, - allRoots, - unsupportedRoots, - before, - ...(deepBefore === undefined ? {} : { deepBefore }), - }); - }, - commands(context) { - const { corpusAssetsDirectory, unsupportedRoots } = currentState(context); - const data = context.productDataDirectory; - return [ - { - name: "register fixed corpus root", - args: [ - "library-create", - "固定语料", - "--directory", - corpusAssetsDirectory, - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - haltOnFailure: true, - }, - ...unsupportedRoots.map((root) => ({ - name: `observe unresolved ${root.logicalRoot}`, - args: (results: readonly CliJourneyResult[]) => [ - "preview", - registeredObservedRootId(results[0]), - "--scope", - root.logicalRoot, - "--json", - ], - env: { DIGITAL_PRODUCT_DATA: data }, - haltOnFailure: true, - })), - ]; - }, - async assertions({ context, results }) { - const state = currentState(context); - assert.equal(results.length, state.unsupportedRoots.length + 1); - const [rootCreateResult, ...previewResults] = results; - assert.equal(rootCreateResult?.exitCode, 0, rootCreateResult?.stderr); - registeredObservedRootId(rootCreateResult); - for (const [index, root] of state.unsupportedRoots.entries()) { - const result: CliJourneyResult | undefined = previewResults[index]; - assert.equal(result?.exitCode, 0, result?.stderr); - const inspection = JSON.parse(result?.stdout ?? "") as { - readonly execution: { - readonly outcome?: string; - readonly snapshot: { - readonly complete: boolean; - readonly files: readonly { readonly relativePath: string }[]; - readonly directories?: readonly { readonly relativePath: string }[]; - }; - }; - readonly sourceRecords: readonly unknown[]; - readonly recognition?: unknown; - readonly accepted?: unknown; - readonly unresolvedObservation?: { - readonly observedRoot: string; - readonly fileCount: number; - readonly totalSize: number; - readonly representativePaths: readonly string[]; - }; - }; - assert.equal( - inspection.execution.outcome, - "unresolved", - `${root.logicalRoot} must remain unresolved until its own integration exists`, - ); - const before = state.before.get(root.logicalRoot)?.entries; - assert.notEqual(before, undefined); - assert.deepEqual( - inspection.execution.snapshot.directories?.map( - (directory) => directory.relativePath, - ), - expectedDirectoryPaths(before ?? []), - `${root.logicalRoot} must retain complete directory observations including empty directories`, - ); - assert.equal(inspection.accepted, undefined); - assert.equal(inspection.recognition, undefined); - assert.deepEqual(inspection.sourceRecords, []); - assert.equal( - inspection.execution.snapshot.complete, - true, - `${root.logicalRoot} must retain a complete Filesystem Package Snapshot`, - ); - assert.equal( - inspection.execution.snapshot.files.length, - expectedFileCount(before ?? []), - `${root.logicalRoot} must retain a complete regular-file observation`, - ); - assert.deepEqual( - inspection.unresolvedObservation, - { - observedRoot: root.directory, - fileCount: expectedFileCount(before ?? []), - totalSize: expectedTotalSize(before ?? []), - representativePaths: expectedRepresentativePaths(before ?? []), - }, - `${root.logicalRoot} must expose an inspectable unresolved file summary`, - ); - } - - const after = await captureFixedCorpusSnapshots( - state.allRoots, - state.deepBefore !== undefined, - ); - assert.deepEqual(after, state.before); - if (state.deepBefore !== undefined) { - const deepAfter = fixedCorpusContentDigestsFromSnapshots( - state.allRoots, - after, - ); - await assertFixedCorpusContentBaseline(context, deepAfter); - assert.deepEqual(deepAfter, state.deepBefore); - } - - const allFingerprints = [...state.before.values()].flatMap( - (snapshot) => snapshot.entries, - ); - assert.ok( - allFingerprints.some((entry) => entry.relativePath.endsWith(".nfo")), - "legacy NFO must remain observable material", - ); - assert.ok( - allFingerprints.some((entry) => - entry.relativePath.endsWith("poster.jpg"), - ), - "legacy manager poster must remain observable material", - ); - assert.ok( - allFingerprints.some((entry) => entry.relativePath.endsWith(".epub")), - "container-based book must remain one opaque observation", - ); - assert.equal( - allFingerprints.some((entry) => entry.relativePath.includes("OEBPS/")), - false, - "the observer must not recursively inspect EPUB archive entries", - ); - assert.equal( - state.unsupportedRoots.some((root) => - (state.before.get(root.logicalRoot)?.entries ?? []).some((entry) => - entry.relativePath.endsWith(".nfo"), - ), - ), - false, - "the legacy NFO counterexample belongs to the dedicated TMDB/OMDb recognition journey rather than the unresolved safety gate", - ); - assert.equal( - state.unsupportedRoots.some((root) => - (state.before.get(root.logicalRoot)?.entries ?? []).some((entry) => - entry.relativePath.endsWith("poster.jpg"), - ), - ), - false, - "the legacy manager poster belongs to the dedicated TMDB/OMDb recognition journey rather than the unresolved safety gate", - ); - }, -}; - -export default journey; diff --git a/apps/cli/test/e2e/pack-cli.ts b/apps/cli/test/e2e/pack-cli.ts deleted file mode 100644 index 81291c5..0000000 --- a/apps/cli/test/e2e/pack-cli.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * 打包模式交付物:用包管理器的官方 deploy 生成只含生产依赖的闭包,再返回其中的 CLI - * 可执行入口。闭包必须不含环境包——正式业务不依赖验收环境(合同⑥),打包模式因此 - * 与源码、构建产物模式共用同一个场景,却走真正不同的启动方式。 - */ -import { spawn } from "node:child_process"; -import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const e2eRoot = path.dirname(fileURLToPath(import.meta.url)); -const workspaceRoot = path.resolve(e2eRoot, "..", "..", "..", ".."); - -export type PackedCli = { - readonly deployDirectory: string; - readonly binPath: string; - /** 生产闭包里的 `@digital` 成员:用于证明环境包没有被打包进正式业务。 */ - readonly digitalPackages: readonly string[]; -}; - -async function runPnpm(args: readonly string[]): Promise { - await new Promise((resolve, reject) => { - const child = spawn("pnpm", args, { - cwd: workspaceRoot, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - child.once("error", reject); - child.once("close", (code) => { - if (code === 0) resolve(); - else - reject( - new Error( - `pnpm ${args.join(" ")} 以退出码 ${String(code)} 失败\n${stdout}\n${stderr}`, - ), - ); - }); - }); -} - -async function digitalPackages( - deployDirectory: string, -): Promise { - const scope = path.join(deployDirectory, "node_modules", "@digital"); - const entries = await readdir(scope, { withFileTypes: true }).catch( - () => undefined, - ); - if (entries === undefined) return []; - return entries.filter((entry) => entry.isDirectory()).map((e) => e.name); -} - -/** 生成一份新的打包闭包;调用方负责在用完后删除该目录。 */ -export async function packCli(options: { - readonly runId: string; -}): Promise { - const outputRoot = path.join(workspaceRoot, "run", "02-packed"); - await mkdir(outputRoot, { recursive: true }); - const deployDirectory = await mkdtemp( - path.join(outputRoot, `deploy-${options.runId}-`), - ); - await runPnpm([ - "--config.inject-workspace-packages=true", - "--filter", - "./apps/cli", - "deploy", - "--prod", - deployDirectory, - ]); - // 部署目录就是可执行包本身:官方 bin 入口带 shebang 与可执行位。 - const binPath = path.join(deployDirectory, "dist", "cli.js"); - const members = await digitalPackages(deployDirectory); - if (members.includes("environment")) - throw new Error( - `打包闭包不应包含验收环境包:${deployDirectory}\n@digital=${members.join("、")}`, - ); - return { deployDirectory, binPath, digitalPackages: members }; -} - -export async function removePackedCli(deployDirectory: string): Promise { - await rm(deployDirectory, { recursive: true, force: true }); -} - -if (import.meta.url === `file://${process.argv[1]}`) { - const packed = await packCli({ runId: Date.now().toString(36) }); - process.stdout.write(`${packed.binPath}\n`); -} diff --git a/apps/cli/test/e2e/run-journeys.ts b/apps/cli/test/e2e/run-journeys.ts deleted file mode 100644 index e3a89ff..0000000 --- a/apps/cli/test/e2e/run-journeys.ts +++ /dev/null @@ -1,1027 +0,0 @@ -import { spawn } from "node:child_process"; -import { - appendFile, - mkdir, - mkdtemp, - readdir, - rm, - stat, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -import { - mergeCommandEnvironment, - prepareScenarioEnvironment, - recycleOwnedEnvironment, - reserveFreePort, - retainProvidedEnvironment, - scenarioEnvironmentName, - scenarioRunId, - type AcceptanceEnvironment, - type OwnedRecycling, - type ProvidedEnvironment, - type ScenarioSource, -} from "./environment-runtime.ts"; -import { fixedCorpusRoots } from "./fixed-corpus.ts"; -import type { - CliJourney, - CliJourneyCommand, - CliJourneyContext, - CliJourneyMode, - CliJourneyResult, -} from "./journey.ts"; - -const e2eRoot = path.dirname(fileURLToPath(import.meta.url)); -const packageRoot = path.resolve(e2eRoot, "..", ".."); -const workspaceRoot = path.resolve(packageRoot, "..", ".."); - -function assertNever(value: never): never { - throw new Error(`unsupported journey mode: ${String(value)}`); -} - -type JourneyRunTarget = { - readonly mode: CliJourneyMode; - readonly packedBin?: string; -}; - -const journeyRunnerUsage = [ - "Usage:", - " run-journeys.ts [source|distribution ...]", - " run-journeys.ts packed ", - " 可选:--retain-on-success --handoff-ledger --evidence-directory ", -].join("\n"); - -function invalidArguments(message: string): never { - throw new Error(`${message}\n${journeyRunnerUsage}`); -} - -const privateEnvironmentName = /(?:TOKEN|KEY|SECRET|OAUTH|COOKIE|CREDENTIAL)/iu; - -function privateValues( - commands: readonly CliJourneyCommand[], -): readonly string[] { - return [ - ...new Set( - commands.flatMap((command) => - Object.entries(command.env ?? {}).flatMap(([name, value]) => - privateEnvironmentName.test(name) && value.trim().length > 0 - ? [value] - : [], - ), - ), - ), - ].toSorted((left, right) => right.length - left.length); -} - -function escaped(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); -} - -/** 绝不让旅程配置的凭据值进入失败诊断。 */ -export function redactJourneyDiagnostic( - value: string, - commands: readonly CliJourneyCommand[], -): string { - let redacted = value; - for (const privateValue of privateValues(commands)) { - redacted = redacted.replaceAll( - new RegExp(escaped(privateValue), "gu"), - "[REDACTED]", - ); - } - return redacted - .replace(/(Bearer\s+)[^\s]+/giu, "$1[REDACTED]") - .replace( - /([?&;](?:api[-_]?key|token|key|secret|oauth|cookie|credential)=[^&\s;]+)/giu, - "[REDACTED]", - ) - .replace( - /((?:authorization|cookie)\s*:\s*(?:Bearer\s+)?)[^\s]+/giu, - "$1[REDACTED]", - ); -} - -function diagnosticText( - value: string, - commands: readonly CliJourneyCommand[], -): string { - const redacted = redactJourneyDiagnostic(value, commands); - const limit = 2_000; - return redacted.length <= limit - ? redacted - : `${redacted.slice(0, limit)}\n… ${redacted.length - limit} diagnostic characters omitted`; -} - -/** - * 某些实时命令会生成后续每条命令都需要的标识符。如果其中一项前置条件失败, - * 应显示其自身的安全诊断,而不是让依赖命令在解码缺失标识符时失败。 - */ -export function assertHaltedJourneyCommand( - command: CliJourneyCommand, - result: CliJourneyResult, -): void { - if (command.haltOnFailure !== true || result.exitCode === 0) return; - throw new Error( - `${result.commandName} failed with exit ${result.exitCode}\nstdout: ${diagnosticText(result.stdout, [command])}\nstderr: ${diagnosticText(result.stderr, [command])}`, - ); -} - -export function formatJourneyAssertionFailure(input: { - readonly mode: CliJourneyMode; - readonly journeyName: string; - readonly commands: readonly CliJourneyCommand[]; - readonly results: readonly CliJourneyResult[]; - readonly error: unknown; -}): Error { - const message = - input.error instanceof Error ? input.error.message : String(input.error); - const sanitizedMessage = redactJourneyDiagnostic(message, input.commands); - const sanitizedCause = new Error(sanitizedMessage); - return new Error( - `${input.mode}:${input.journeyName} assertion failed\n${input.results - .map((result, index) => { - const command = input.commands[index]; - return `${result.commandName}: exit ${result.exitCode}\nstdout: ${diagnosticText(result.stdout, command === undefined ? input.commands : [command])}\nstderr: ${diagnosticText(result.stderr, command === undefined ? input.commands : [command])}`; - }) - .join("\n")}` + `\n${sanitizedMessage}`, - { cause: sanitizedCause }, - ); -} - -function parseJourneyRunTargets( - args: readonly string[], -): readonly JourneyRunTarget[] { - const first = args[0]; - if (first === undefined) { - invalidArguments("missing journey mode"); - } - - if (first === "packed") { - if (args.length === 1) { - invalidArguments("packed journey mode requires exactly one bin path"); - } - if (args.length !== 2) { - invalidArguments( - "packed journey mode accepts exactly one bin path and cannot be combined with other modes", - ); - } - const packedBin = args[1]!; - if ( - packedBin === "source" || - packedBin === "distribution" || - packedBin === "packed" - ) { - invalidArguments( - `packed bin path cannot be journey mode ${JSON.stringify(packedBin)}`, - ); - } - return [{ mode: "packed", packedBin }]; - } - - const seen = new Set(); - const targets: JourneyRunTarget[] = []; - for (const argument of args) { - switch (argument) { - case "source": - case "distribution": - if (seen.has(argument)) { - invalidArguments( - `duplicate journey mode ${JSON.stringify(argument)}; each mode may be specified once`, - ); - } - seen.add(argument); - targets.push({ mode: argument }); - break; - case "packed": - invalidArguments( - "packed journey mode cannot be combined with source or distribution", - ); - default: - invalidArguments( - `unknown journey mode ${JSON.stringify(argument)}; expected source, distribution, or packed`, - ); - } - } - return targets; -} - -async function discoverJourneys( - selection?: "default" | "real", -): Promise { - const journeyRoot = path.join(e2eRoot, "journeys"); - const entries = await readdir(journeyRoot, { withFileTypes: true }); - const journeyFiles = entries - .filter( - (entry) => - entry.isFile() && - (entry.name.endsWith(".journey.ts") || - entry.name.endsWith(".journey.js")), - ) - .map((entry) => entry.name) - .toSorted(); - const journeys = await Promise.all( - journeyFiles.map(async (fileName) => { - const loaded = (await import( - pathToFileURL(path.join(journeyRoot, fileName)).href - )) as { default: CliJourney }; - return loaded.default; - }), - ); - const requiresRealInputs = (journey: CliJourney): boolean => - journey.realCorpus === true || journey.source?.mode === "real"; - if (selection !== undefined) - return journeys.filter((journey) => - selection === "real" - ? requiresRealInputs(journey) - : !requiresRealInputs(journey), - ); - const named = process.env.DIGITAL_E2E_JOURNEY; - if (named === undefined) - return journeys.filter((journey) => !requiresRealInputs(journey)); - const selected = journeys.filter((journey) => journey.name === named); - if (selected.length === 0) - throw new Error(`unknown DIGITAL_E2E_JOURNEY ${JSON.stringify(named)}`); - return selected; -} - -function invocation( - mode: CliJourneyMode, - packedBin: string | undefined, -): { readonly executable: string; readonly prefix: readonly string[] } { - switch (mode) { - case "source": - return { - executable: process.execPath, - prefix: [ - "--conditions=digital-source", - path.join(packageRoot, "src", "cli.ts"), - ], - }; - case "distribution": - return { - executable: process.execPath, - prefix: [path.join(packageRoot, "dist", "cli.js")], - }; - case "packed": - if (packedBin === undefined) { - throw new Error( - "packed journey mode requires a package-manager bin path", - ); - } - return { executable: packedBin, prefix: [] }; - default: - return assertNever(mode); - } -} - -async function runCommand(options: { - readonly mode: CliJourneyMode; - readonly packedBin: string | undefined; - readonly command: CliJourneyCommand; - readonly previousResults: readonly CliJourneyResult[]; - readonly cwd: string; - /** 正式 CLI 子进程的环境完全来自本次场景环境;不继承父终端。 */ - readonly environment: Readonly>; - readonly signal?: AbortSignal; -}): Promise { - const target = invocation(options.mode, options.packedBin); - const args = - typeof options.command.args === "function" - ? options.command.args(options.previousResults) - : options.command.args; - return await new Promise((resolve, reject) => { - const child = spawn(target.executable, [...target.prefix, ...args], { - cwd: options.cwd, - env: options.environment, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - const signal = options.signal; - if (signal !== undefined) { - const abort = () => { - child.kill("SIGINT"); - }; - if (signal.aborted) abort(); - else signal.addEventListener("abort", abort, { once: true }); - child.once("close", () => { - signal.removeEventListener("abort", abort); - }); - } - child.once("error", reject); - child.once("close", (exitCode) => { - resolve({ - commandName: options.command.name, - exitCode: exitCode ?? 1, - stdout, - stderr, - }); - }); - }); -} - -type JourneyCommandExecutor = typeof runCommand; - -function errorDiagnostic( - error: unknown, - commands: readonly CliJourneyCommand[], -): string { - return diagnosticText( - error instanceof Error ? error.message : String(error), - commands, - ); -} - -function formatJourneyLifecycleFailure(input: { - readonly mode: CliJourneyMode; - readonly journeyName: string; - readonly commands: readonly CliJourneyCommand[]; - readonly results: readonly CliJourneyResult[]; - readonly primaryFailed: boolean; - readonly primaryError?: unknown; - readonly teardownFailed: boolean; - readonly teardownError?: unknown; - /** 信号中断独立可见:自有环境只停止并保留,绝不报告为成功。 */ - readonly interrupted: boolean; - /** 自有环境收尾各环节的独立问题:与主失败并列,不替换首因。 */ - readonly recyclingProblems: readonly string[]; - /** 证据保存环节自身的独立问题:与收尾错误可辨。 */ - readonly evidenceProblems: readonly string[]; - readonly cleanupFailed: boolean; - readonly cleanupError?: unknown; -}): Error { - const failures = [ - ...(!input.primaryFailed - ? [] - : [ - `main failure: ${errorDiagnostic(input.primaryError, input.commands)}`, - ]), - ...(!input.teardownFailed - ? [] - : [ - `teardown failure: ${errorDiagnostic(input.teardownError, input.commands)}`, - ]), - ...(!input.interrupted - ? [] - : [ - "interruption failure: 场景被信号中断,自有环境只停止并保留可重启数据", - ]), - ...input.recyclingProblems.map( - (problem) => `environment recycling failure: ${problem}`, - ), - ...input.evidenceProblems.map( - (problem) => `evidence persistence failure: ${problem}`, - ), - ...(!input.cleanupFailed - ? [] - : [ - `workDir cleanup failure: ${errorDiagnostic(input.cleanupError, input.commands)}`, - ]), - ]; - const commandDiagnostics = input.results.map((result, index) => { - const command = input.commands[index]; - const applicable = command === undefined ? input.commands : [command]; - return `${result.commandName}: exit ${result.exitCode}\nstdout: ${diagnosticText(result.stdout, applicable)}\nstderr: ${diagnosticText(result.stderr, applicable)}`; - }); - const sanitized = [...commandDiagnostics, ...failures].join("\n"); - return new Error( - `${input.mode}:${input.journeyName} journey failed${sanitized.length === 0 ? "" : `\n${sanitized}`}`, - { cause: new Error(failures.join("\n")) }, - ); -} - -/** 旅程未声明来源即离线:绝不因父终端遗留配置使用真实来源。 */ -function journeySource(journey: CliJourney): ScenarioSource { - return journey.source ?? { mode: "offline" }; -} - -/** 一次旅程世代的环境生命周期:准备由环境包公开接口完成,回收同样复用公开能力。 */ -export type JourneyEnvironmentLifecycle = { - open(options: { - readonly journey: CliJourney; - readonly mode: CliJourneyMode; - readonly runId: string; - }): Promise; - close(options: { - readonly environment: AcceptanceEnvironment; - readonly outcome: OwnedRecycling["outcome"]; - /** 清理上下文:收尾期间的取消必须阻止删除,只停止并保留现场。 */ - readonly signal?: AbortSignal; - }): Promise; -}; - -const evidenceRoot = path.join(workspaceRoot, "run", "02-evidence"); - -/** - * 自有场景世代:环境容器由环境包公开布局放在本树工作区内(`.digital/environments/`), - * 名字始终带 completion-02- 前缀;调用方只在 `run/` 下保存证据,绝不把工作区当成临时目录删除。 - * 世代记录里的 Web 端口一律先在本票区间内实际探测空闲:宁可显式给一个探测过的端口, - * 也不让环境包按名字哈希算出区间外的监听口。 - */ -export function ownedEnvironmentLifecycle( - options: { - readonly webPort?: number; - /** 成功后停止并保留,交给调用方在实际上传后显式删除。 */ - readonly retainOnSuccess?: boolean; - /** prepare 之前保存归属,取消时仍能用公开操作收尾。 */ - readonly handoffLedger?: string; - } = {}, -): JourneyEnvironmentLifecycle { - return { - async open({ journey, mode, runId }) { - const environment = scenarioEnvironmentName({ - purpose: `${journey.name}-${mode}`, - runId, - }); - if (options.handoffLedger !== undefined) { - await mkdir(path.dirname(options.handoffLedger), { recursive: true }); - await appendFile( - options.handoffLedger, - `${JSON.stringify({ - workspacePath: workspaceRoot, - environment, - runId, - journey: journey.name, - mode, - issuedAt: new Date().toISOString(), - })}\n`, - ); - } - return await prepareScenarioEnvironment({ - workspacePath: workspaceRoot, - environment, - source: journeySource(journey), - webPort: options.webPort ?? (await reserveFreePort({ runId })), - }); - }, - async close({ environment, outcome, signal }) { - return await recycleOwnedEnvironment({ - workspacePath: environment.workspacePath, - environment: environment.environment, - outcome, - ...(options.retainOnSuccess === undefined - ? {} - : { retainOnSuccess: options.retainOnSuccess }), - ...(signal === undefined ? {} : { signal }), - }); - }, - }; -} - -/** - * 使用调用方提供的环境:场景操作与断言完全相同,但世代归属留在提供者。成功、失败 - * 或中断都只执行公开查看,绝不停止、重建或删除;无法确认归属时才报告问题, - * 绝不把未确认写成已回收。 - */ -export function providedEnvironmentLifecycle( - provided: ProvidedEnvironment, -): JourneyEnvironmentLifecycle { - return { - async open() { - return provided; - }, - async close({ outcome }) { - return await retainProvidedEnvironment({ - workspacePath: provided.workspacePath, - environment: provided.environment, - outcome, - }); - }, - }; -} - -type JourneyEvidence = { - readonly journey: string; - readonly mode: CliJourneyMode; - readonly environment: string; - readonly workspacePath: string; - readonly productDataRoot: string; - readonly sourceMode: string; - readonly callerWorkDir: string; - readonly outcome: OwnedRecycling["outcome"]; - readonly stopStatus: string | null; - readonly exitConfirmation: { - readonly confirmed: boolean; - readonly state: string; - readonly processLiveness: string; - readonly serviceReadiness: string; - readonly detail: string; - } | null; - readonly deleteStatus: string | null; - readonly recyclingProblems: readonly string[]; - /** 公开环境操作给出的收尾叙述:如实记录,不据此判定失败。 */ - readonly recyclingDiagnostics: readonly string[]; - readonly retainedDataRoot: string | null; - readonly retainedLogs: readonly string[]; - readonly overriddenAuthoritative: readonly string[]; - readonly commands: readonly { - readonly name: string; - readonly exitCode: number; - readonly stdout: string; - readonly stderr: string; - }[]; - readonly failures: readonly string[]; -}; - -/** 保存结果早于任何收尾动作:失败诊断一律先脱敏再落盘。 */ -async function writeJourneyEvidence( - evidence: JourneyEvidence, - commands: readonly CliJourneyCommand[], - directory: string, -): Promise { - await mkdir(directory, { recursive: true }); - const sanitized: JourneyEvidence = { - ...evidence, - commands: evidence.commands.map((command) => ({ - ...command, - stdout: redactJourneyDiagnostic(command.stdout, commands), - stderr: redactJourneyDiagnostic(command.stderr, commands), - })), - failures: evidence.failures.map((failure) => - redactJourneyDiagnostic(failure, commands), - ), - recyclingProblems: evidence.recyclingProblems.map((problem) => - redactJourneyDiagnostic(problem, commands), - ), - recyclingDiagnostics: evidence.recyclingDiagnostics.map((diagnostic) => - redactJourneyDiagnostic(diagnostic, commands), - ), - }; - await writeFile( - path.join(directory, `${evidence.environment}.json`), - `${JSON.stringify(sanitized, null, 2)}\n`, - "utf8", - ); -} - -/** - * 取消状态的实时重读:`await` 之后取消才到达也要读得出来。先前 `interruptedNow` - * 的别名条件会把 `signal.aborted` 收窄成永不为 true,所以这里隔一层函数调用, - * 而不是放弃晚到取消的收尾。 - */ -function cancellationReceived(signal: AbortSignal | undefined): boolean { - return signal?.aborted === true; -} - -export async function runJourney(options: { - readonly journey: CliJourney; - readonly mode: CliJourneyMode; - readonly packedBin?: string; - readonly executeCommand?: JourneyCommandExecutor; - readonly lifecycle?: JourneyEnvironmentLifecycle; - readonly retainOnSuccess?: boolean; - readonly handoffLedger?: string; - readonly evidenceDirectory?: string; - readonly signal?: AbortSignal; -}): Promise { - const lifecycle = - options.lifecycle ?? - ownedEnvironmentLifecycle({ - ...(options.retainOnSuccess === undefined - ? {} - : { retainOnSuccess: options.retainOnSuccess }), - ...(options.handoffLedger === undefined - ? {} - : { handoffLedger: options.handoffLedger }), - }); - const evidenceDirectory = options.evidenceDirectory ?? evidenceRoot; - const workDir = await mkdtemp( - path.join( - tmpdir(), - `digital-disposable-cli-${options.journey.name}-${options.mode}-`, - ), - ); - const runId = scenarioRunId(); - const results: CliJourneyResult[] = []; - const overriddenAuthoritative: string[] = []; - let commands: readonly CliJourneyCommand[] = []; - let primaryFailed = false; - let primaryError: unknown; - let interruptedDuringRun = false; - let environment: AcceptanceEnvironment | undefined; - let context: CliJourneyContext | undefined; - try { - environment = await lifecycle.open({ - journey: options.journey, - mode: options.mode, - runId, - }); - const scenario: CliJourneyContext = { - mode: options.mode, - packageRoot, - workDir, - productDataDirectory: environment.productDataRoot, - environmentName: environment.environment, - commandEnvironment: environment.baseEnvironment, - }; - context = scenario; - await options.journey.setup(scenario); - commands = options.journey.commands(scenario); - for (const command of commands) { - // 取消一旦到达就不再追加新的正式命令:中断结局由收尾与证据表达。 - if (options.signal?.aborted === true) { - interruptedDuringRun = true; - break; - } - const merged = mergeCommandEnvironment(environment, command.env); - for (const key of merged.overridden) - if (!overriddenAuthoritative.includes(key)) - overriddenAuthoritative.push(key); - const result = await (options.executeCommand ?? runCommand)({ - mode: options.mode, - packedBin: options.packedBin, - command, - previousResults: results, - cwd: workDir, - environment: merged.env, - ...(options.signal === undefined ? {} : { signal: options.signal }), - }); - results.push(result); - assertHaltedJourneyCommand(command, result); - } - // 中断到达后不再执行断言:缺失的结果只会伪造出一个掩盖中断的“主失败”。 - if (!interruptedDuringRun) - await options.journey.assertions({ context: scenario, results }); - } catch (error) { - primaryFailed = true; - primaryError = error; - } - - let teardownFailed = false; - let teardownError: unknown; - if (context !== undefined) - try { - await options.journey.teardown?.({ context, results }); - } catch (error) { - teardownFailed = true; - teardownError = error; - } - - // 先保存结果,再停止、由独立查看确认退出、按结局删除或保留。 - // 取消可能在保存与收尾的等待期间到达:每个环节前重读,绝不沿用过期快照。 - const interrupted = options.signal?.aborted === true || interruptedDuringRun; - const succeeded = !primaryFailed && !teardownFailed && !interrupted; - const outcome: OwnedRecycling["outcome"] = interrupted - ? "interrupted" - : succeeded - ? "success" - : "failure"; - const commandEvidence = commands.map((command, index) => ({ - name: command.name, - exitCode: results[index]?.exitCode ?? -1, - stdout: results[index]?.stdout ?? "", - stderr: results[index]?.stderr ?? "", - })); - const buildFailureEvidence = (interruptedNow: boolean): string[] => [ - ...(!primaryFailed - ? [] - : [`main failure: ${errorDiagnostic(primaryError, commands)}`]), - ...(!teardownFailed - ? [] - : [`teardown failure: ${errorDiagnostic(teardownError, commands)}`]), - ...(interruptedNow ? ["journey interrupted by signal"] : []), - ]; - let recycling: OwnedRecycling | undefined; - let recyclingError: unknown; - const evidenceSaveProblems: string[] = []; - if (environment !== undefined) { - const draft: JourneyEvidence = { - journey: options.journey.name, - mode: options.mode, - environment: environment.environment, - workspacePath: environment.workspacePath, - productDataRoot: environment.productDataRoot, - sourceMode: environment.facts.sourceMode, - callerWorkDir: workDir, - outcome, - stopStatus: null, - exitConfirmation: null, - deleteStatus: null, - recyclingProblems: [], - recyclingDiagnostics: [], - retainedDataRoot: null, - retainedLogs: [], - overriddenAuthoritative, - commands: commandEvidence, - failures: buildFailureEvidence(interrupted), - }; - const saveJourneyEvidence = async ( - evidence: JourneyEvidence, - ): Promise => { - try { - await writeJourneyEvidence(evidence, commands, evidenceDirectory); - return true; - } catch (error) { - evidenceSaveProblems.push( - `证据保存失败:${errorDiagnostic(error, commands)}`, - ); - return false; - } - }; - const savedDraft = await saveJourneyEvidence(draft); - // 结果未能落盘时绝不交出可删除的 success 结局:证据载体只停止并保留。 - const outcomeAtClose = - savedDraft || outcome !== "success" ? outcome : "failure"; - try { - recycling = await lifecycle.close({ - environment, - outcome: outcomeAtClose, - ...(options.signal === undefined ? {} : { signal: options.signal }), - }); - } catch (error) { - recyclingError = error; - } - const interruptedNow = - options.signal?.aborted === true || interruptedDuringRun; - const finalEvidence: JourneyEvidence = { - ...draft, - outcome: interruptedNow ? "interrupted" : outcomeAtClose, - failures: buildFailureEvidence(interruptedNow), - stopStatus: recycling?.stop?.status ?? null, - exitConfirmation: recycling?.exitConfirmation ?? null, - deleteStatus: recycling?.deletion?.status ?? null, - recyclingProblems: recycling?.problems ?? [], - recyclingDiagnostics: recycling?.recyclingDiagnostics ?? [], - retainedDataRoot: recycling?.retainedDataRoot ?? null, - retainedLogs: recycling?.retainedLogs ?? [], - }; - await saveJourneyEvidence(finalEvidence); - // 取消允许在最终保存的异步写入期间才到达,此时上面那份载荷已按未取消构造。 - // 磁盘上的最终结局必须与调用方收到的中断一致:重写为 interrupted, - // 已提交的公开删除不假定可逆转,这里只纠正证据,不新增删除动作。 - if ( - !interruptedNow && - (cancellationReceived(options.signal) || interruptedDuringRun) - ) - await saveJourneyEvidence({ - ...finalEvidence, - outcome: "interrupted", - failures: [ - ...buildFailureEvidence(true), - "取消在最终证据保存期间到达:持久结局按中断记录", - ], - }); - } - - let cleanupFailed = false; - let cleanupError: unknown; - try { - // 只回收调用方自己的临时工作目录;环境容器由公开删除操作负责。 - await rm(workDir, { recursive: true, force: true }); - } catch (error) { - cleanupFailed = true; - cleanupError = error; - } - - const recyclingProblems = [ - ...(recycling === undefined ? [] : recycling.problems), - ...(recyclingError === undefined - ? [] - : [`环境收尾抛出异常:${errorDiagnostic(recyclingError, commands)}`]), - ]; - const evidenceProblems = [...new Set(evidenceSaveProblems)]; - const interruptedFinal = - options.signal?.aborted === true || interruptedDuringRun; - if ( - primaryFailed || - teardownFailed || - cleanupFailed || - interruptedFinal || - recyclingProblems.length > 0 || - evidenceProblems.length > 0 - ) - throw formatJourneyLifecycleFailure({ - mode: options.mode, - journeyName: options.journey.name, - commands, - results, - primaryFailed, - primaryError, - teardownFailed, - teardownError, - interrupted: interruptedFinal, - recyclingProblems, - evidenceProblems, - cleanupFailed, - cleanupError, - }); - return `${options.mode}:${options.journey.name}:passed`; -} - -export async function runJourneys(options: { - readonly retainOnSuccess?: boolean; - readonly handoffLedger?: string; - readonly evidenceDirectory?: string; - readonly mode: CliJourneyMode; - readonly packedBin?: string; - readonly signal?: AbortSignal; - readonly selection?: "default" | "real"; -}): Promise { - const passed: string[] = []; - const journeys = (await discoverJourneys(options.selection)).filter( - (journey) => journey.modes.includes(options.mode), - ); - for (const [index, journey] of journeys.entries()) { - try { - passed.push( - await runJourney({ - journey, - mode: options.mode, - ...(options.retainOnSuccess === undefined - ? {} - : { retainOnSuccess: options.retainOnSuccess }), - ...(options.handoffLedger === undefined - ? {} - : { handoffLedger: options.handoffLedger }), - ...(options.evidenceDirectory === undefined - ? {} - : { evidenceDirectory: options.evidenceDirectory }), - ...(options.packedBin === undefined - ? {} - : { packedBin: options.packedBin }), - ...(options.signal === undefined ? {} : { signal: options.signal }), - }), - ); - } catch (error) { - if (options.selection === "real") - process.stderr.write( - `${JSON.stringify({ - mode: options.mode, - status: "failed", - journey: journey.name, - sourceMode: journeySource(journey).mode, - passed, - notExecuted: journeys.slice(index + 1).map((entry) => entry.name), - })}\n`, - ); - throw error; - } - } - return passed; -} - -/** 显式全量真实入口先报告缺失素材;不准备无从执行的世代,也不把未执行计为通过。 */ -async function requireRealCorpus( - targets: readonly JourneyRunTarget[], -): Promise { - const roots = await fixedCorpusRoots({ - mode: targets[0]!.mode, - packageRoot, - workDir: "", - productDataDirectory: "", - environmentName: "", - commandEnvironment: {}, - }); - const missing: string[] = []; - for (const root of roots) { - try { - if (!(await stat(root.directory)).isDirectory()) - missing.push(root.logicalRoot); - } catch (error) { - if ( - !(error instanceof Error && "code" in error && error.code === "ENOENT") - ) - throw error; - missing.push(root.logicalRoot); - } - } - if (missing.length === 0) return; - const journeys = await discoverJourneys("real"); - process.stderr.write( - `${JSON.stringify({ - status: "condition-missing", - condition: "private-corpus-directories", - missingRoots: missing, - notExecuted: targets.flatMap((target) => - journeys - .filter((journey) => journey.modes.includes(target.mode)) - .map((journey) => ({ mode: target.mode, journey: journey.name })), - ), - })}\n`, - ); - throw new Error( - "显式真实验收缺少材料,未执行来源采集或产品验收;请按 fixtures/live-corpus/files-from.txt 准备目录。", - ); -} - -async function runJourneyTargets( - targets: readonly JourneyRunTarget[], - signal: AbortSignal | undefined, - selection: "default" | "real" | undefined, - retention: { - retainOnSuccess?: boolean; - handoffLedger?: string; - evidenceDirectory?: string; - } = {}, -): Promise { - const passed: string[] = []; - for (const [index, target] of targets.entries()) { - const options = - target.packedBin === undefined ? {} : { packedBin: target.packedBin }; - try { - switch (target.mode) { - case "source": - case "distribution": - passed.push( - ...(await runJourneys({ - mode: target.mode, - ...(selection === undefined ? {} : { selection }), - ...options, - ...retention, - ...(signal === undefined ? {} : { signal }), - })), - ); - break; - case "packed": - passed.push( - ...(await runJourneys({ - mode: target.mode, - ...(selection === undefined ? {} : { selection }), - ...options, - ...retention, - ...(signal === undefined ? {} : { signal }), - })), - ); - break; - default: - assertNever(target.mode); - } - } catch (error) { - if (selection === "real" && index + 1 < targets.length) - process.stderr.write( - `${JSON.stringify({ - status: "not-executed", - modes: targets.slice(index + 1).map((entry) => entry.mode), - })}\n`, - ); - throw error; - } - } - return passed; -} - -if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { - const cancellation = new AbortController(); - for (const signalName of ["SIGINT", "SIGTERM"] as const) { - process.on(signalName, () => { - // 中断只取消进行中的操作;自有环境的停止、确认与保留仍由收尾路径完成。 - cancellation.abort(new Error(`received ${signalName}`)); - }); - } - try { - const args = process.argv.slice(2); - const selection = - args[0] === "--default" - ? "default" - : args[0] === "--real" - ? "real" - : undefined; - if (selection !== undefined) args.shift(); - const retention: { - retainOnSuccess?: boolean; - handoffLedger?: string; - evidenceDirectory?: string; - } = {}; - const modes: string[] = []; - for (let index = 0; index < args.length; index++) { - const argument = args[index]!; - if (argument === "--retain-on-success") retention.retainOnSuccess = true; - else if ( - argument === "--handoff-ledger" || - argument === "--evidence-directory" - ) { - const value = args[++index]; - if (value === undefined || value.startsWith("--")) - invalidArguments(argument + " requires a path"); - if (argument === "--handoff-ledger") - retention.handoffLedger = path.resolve(value); - else retention.evidenceDirectory = path.resolve(value); - } else modes.push(argument); - } - const targets = parseJourneyRunTargets(modes); - if (selection === "real") await requireRealCorpus(targets); - const passed = await runJourneyTargets( - targets, - cancellation.signal, - selection, - retention, - ); - process.stdout.write(`${passed.join("\n")}\n`); - if (cancellation.signal.aborted) process.exitCode = 1; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - process.stderr.write(`error: ${message}\n`); - process.exitCode = 1; - } -} diff --git a/apps/cli/test/integration/asset-refresh.test.ts b/apps/cli/test/integration/asset-refresh.test.ts deleted file mode 100644 index d1ee008..0000000 --- a/apps/cli/test/integration/asset-refresh.test.ts +++ /dev/null @@ -1,1489 +0,0 @@ -import { - lstat, - mkdir, - mkdtemp, - rm, - stat, - symlink, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -import { runProduct } from "@digital/composition"; -import { captureRawFilesystemSnapshot } from "@digital/test-support"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { runCli, type CliRuntime } from "../../src/main.ts"; -import { prepareCliProductData } from "../prepare-catalogue.ts"; - -vi.mock("@digital/fanbox", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createFanboxProductAssembly: () => ({ - async validatedBrowserCredential(input: { - readonly browserCredential: unknown; - }) { - const credential = input.browserCredential as { - readonly userAgent: string; - readonly cookies: readonly unknown[]; - }; - return JSON.stringify({ - format: "fanbox-browser-request-context/2", - userAgent: credential.userAgent, - cookies: credential.cookies, - }); - }, - async verifyStoredCredential() {}, - createCollector: () => ({ - capability: actual.fanboxOfficialCollectionCapability, - canCollect: () => false, - coverageScopeIds: () => [], - collect: async () => ({ records: [], failures: [] }), - }), - async close() {}, - }), - }; -}); - -const cleanupPaths: string[] = []; - -async function temporaryDirectory(prefix: string): Promise { - const directory = await mkdtemp(path.join(tmpdir(), prefix)); - cleanupPaths.push(directory); - return directory; -} - -async function disposableProductDataDirectory(): Promise { - return path.join( - await temporaryDirectory("digital-disposable-cli-"), - "product-data", - ); -} - -afterEach(async () => { - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - await Promise.all( - cleanupPaths - .splice(0) - .map( - async (directory) => - await rm(directory, { recursive: true, force: true }), - ), - ); -}); - -function runtime( - args: readonly string[], - options: { readonly cwd: string; readonly env?: Record }, -) { - let stdout = ""; - let stderr = ""; - return { - runtime: { - argv: ["node", "cli", ...args], - streams: { - stdin: {}, - stdout: { write: (chunk: string) => (stdout += chunk) }, - stderr: { write: (chunk: string) => (stderr += chunk) }, - }, - cwd: options.cwd, - env: options.env ?? {}, - tty: { stdin: false, stdout: false, stderr: false }, - version: "1.2.3", - } satisfies CliRuntime, - stdout: () => stdout, - stderr: () => stderr, - }; -} - -async function registerRoot(input: { - readonly source: string; - readonly cwd: string; - readonly env: Record; -}): Promise { - const command = runtime( - ["library-create", "测试文件位置", "--directory", input.source, "--json"], - { - cwd: input.cwd, - env: input.env, - }, - ); - await expect(runCli(command.runtime)).resolves.toBe(0); - return ( - JSON.parse(command.stdout()) as { readonly rootIds: readonly string[] } - ).rootIds[0]!; -} - -/** 通过公开的资产库入口登记单一目录,返回该目录对应的观察根编号。 */ -async function seedLibraryRoot( - workflow: { - readonly assetLibraries: { - save(input: { - readonly displayName: string; - readonly directories: readonly string[]; - readonly recognitionProfileIds: readonly string[]; - readonly sourceIdentities: readonly string[]; - }): Promise<{ readonly rootIds: readonly string[] }>; - readonly recognitionProfiles: readonly { readonly id: string }[]; - readonly sources: readonly { readonly id: string }[]; - }; - }, - displayName: string, - directory: string, -): Promise { - const libraries = workflow.assetLibraries; - const saved = await libraries.save({ - displayName, - directories: [directory], - recognitionProfileIds: libraries.recognitionProfiles.map( - (profile) => profile.id, - ), - sourceIdentities: libraries.sources.map((source) => source.id), - }); - return saved.rootIds[0]!; -} - -async function tree( - root: string, - options: { readonly ignoreDirectoryModifiedAt?: readonly string[] } = {}, -): Promise { - const ignoredDirectoryModifiedAt = new Set( - options.ignoreDirectoryModifiedAt ?? [], - ); - return ( - await captureRawFilesystemSnapshot(root, { contentDigests: true }) - ).entries - .filter( - (entry) => - entry.relativePath !== "." && - !entry.relativePath.split("/").includes("runtime-state"), - ) - .map((entry) => { - const modifiedAtNanoseconds = - entry.kind === "directory" && - ignoredDirectoryModifiedAt.has(entry.relativePath) - ? "" - : entry.modifiedAtNanoseconds; - return `${entry.relativePath}:${entry.kind}:${entry.byteLength}:${modifiedAtNanoseconds}:${entry.symbolicLinkTarget ?? ""}:${entry.contentDigest ?? ""}`; - }); -} - -describe("Asset Refresh CLI", () => { - it("directs new material through a source-owned structured input", async () => { - const source = await temporaryDirectory("digital-cli-directed-input-"); - const data = await disposableProductDataDirectory(); - await writeFile(path.join(source, "new.pdf"), syntheticPdf()); - await prepareCliProductData(data); - vi.stubGlobal( - "fetch", - async () => - new Response( - JSON.stringify({ - docs: [ - { - pnx: { - display: { title: ["CLI 定向书名"] }, - addata: { isbn: ["9789864738281"] }, - }, - }, - ], - }), - { headers: { "content-type": "application/json" } }, - ), - ); - const rootId = await registerRoot({ - source, - cwd: source, - env: { DIGITAL_PRODUCT_DATA: data }, - }); - const directed = runtime( - [ - "scrape-source", - rootId, - "nbinet", - JSON.stringify({ isbn: "9789864738281" }), - "--json", - ], - { cwd: source, env: { DIGITAL_PRODUCT_DATA: data } }, - ); - expect(await runCli(directed.runtime), directed.stderr()).toBe(0); - const directedPage = JSON.parse(directed.stdout()) as { - execution: { outcome?: string }; - itemResults: { - outcome?: string; - accepted?: { - entity: { id: string }; - facts: { - id: string; - descriptions: { - metadata: { - property: string; - result: { - status: string; - values?: { value: string; sourceIdentity: string }[]; - }; - }[]; - }[]; - }; - sourceAssociations: { sourceIdentity: string }[]; - }; - }[]; - }; - expect(directedPage.execution.outcome).toBe("accepted"); - expect(directedPage.itemResults).toHaveLength(1); - const directedAccepted = directedPage.itemResults[0]!.accepted; - expect(directedAccepted?.sourceAssociations).toEqual([ - expect.objectContaining({ sourceIdentity: "nbinet" }), - ]); - expect(directedAccepted?.entity.id).toBeTruthy(); - expect(directedAccepted?.facts.id).toBe(directedAccepted?.entity.id); - expect( - directedAccepted?.facts.descriptions - .flatMap((description) => description.metadata) - .map((entry) => [ - entry.property, - entry.result.status, - entry.result.values?.[0]?.value, - entry.result.values?.[0]?.sourceIdentity, - ]), - ).toEqual([ - ["asset.title", "resolved", "CLI 定向书名", "nbinet"], - ["book.isbn-13", "resolved", "9789864738281", "nbinet"], - ]); - }); - - it("uses the public product use case for source requirements and explicit enrichment", async () => { - const source = await temporaryDirectory("digital-cli-source-enrichment-"); - const data = await disposableProductDataDirectory(); - await writeFile(path.join(source, "manual.pdf"), syntheticPdf()); - await prepareCliProductData(data); - const product = runProduct( - { productDataDirectory: data, environment: {} }, - { start: (application) => application.workflow }, - ); - const workflow = product.result; - let assetId: string; - try { - const rootId = await seedLibraryRoot(workflow, "明确来源材料", source); - const scan = await workflow.scanObservedScope({ - registrationId: rootId, - relativeScope: ".", - acceptTrusted: false, - }); - const review = await workflow.prepareLocalContent({ - executionId: scan.execution.id, - relativePaths: ["manual.pdf"], - name: "CLI 本地书", - classification: "book", - }); - assetId = ( - await workflow.confirmLocalContent({ executionId: review.executionId }) - ).content.entity.id; - } finally { - await product.close(); - } - vi.stubGlobal( - "fetch", - async () => - new Response( - JSON.stringify({ - docs: [ - { - pnx: { - display: { title: ["CLI 来源书名"] }, - addata: { isbn: ["9789864738281"] }, - }, - }, - { - pnx: { - display: { title: ["CLI 更换来源书名"] }, - addata: { isbn: ["9786264151726"] }, - }, - }, - ], - }), - { headers: { "content-type": "application/json" } }, - ), - ); - const inputs = runtime(["source-inputs", "--json"], { - cwd: source, - env: { DIGITAL_PRODUCT_DATA: data }, - }); - await expect(runCli(inputs.runtime)).resolves.toBe(0); - expect( - ( - JSON.parse(inputs.stdout()) as readonly { - readonly sourceIdentity: string; - }[] - ).map(({ sourceIdentity }) => sourceIdentity), - ).toEqual(["dlsite", "nbinet", "tmdb", "taiwan-kadokawa", "kadokawa-jp"]); - const invalid = runtime( - [ - "enrich-source", - assetId, - "nbinet", - JSON.stringify({ isbn: "not-an-isbn" }), - "--json", - ], - { cwd: source, env: { DIGITAL_PRODUCT_DATA: data } }, - ); - expect(await runCli(invalid.runtime)).toBe(1); - expect(invalid.stderr()).toContain("ISBN-13"); - const enriched = runtime( - [ - "enrich-source", - assetId, - "nbinet", - JSON.stringify({ isbn: "9789864738281" }), - "--json", - ], - { cwd: source, env: { DIGITAL_PRODUCT_DATA: data } }, - ); - await expect(runCli(enriched.runtime)).resolves.toBe(0); - const enrichedInspection = JSON.parse(enriched.stdout()) as { - readonly accepted: { - readonly sourceAssociations: readonly { - readonly id: string; - readonly sourceIdentity: string; - }[]; - }; - }; - expect(enrichedInspection).toMatchObject({ - execution: { outcome: "accepted", acceptedAssetId: assetId }, - accepted: { - entity: { id: assetId }, - sourceAssociations: [ - expect.objectContaining({ sourceIdentity: "nbinet" }), - ], - }, - }); - const beforeAudit = runtime( - ["inspect-correction-subject", assetId, "--json"], - { cwd: source, env: { DIGITAL_PRODUCT_DATA: data } }, - ); - await expect(runCli(beforeAudit.runtime)).resolves.toBe(0); - const nbinetAssociation = ( - JSON.parse(beforeAudit.stdout()) as { - readonly current: readonly { - readonly id: string; - readonly sourceIdentity: string; - }[]; - } - ).current.find(({ sourceIdentity }) => sourceIdentity === "nbinet")!; - const replace = runtime( - [ - "source-decision", - "replace", - assetId, - nbinetAssociation.id, - JSON.stringify({ isbn: "9786264151726" }), - "--actor", - "cli-reviewer", - "--json", - ], - { cwd: source, env: { DIGITAL_PRODUCT_DATA: data } }, - ); - expect(await runCli(replace.runtime), replace.stderr()).toBe(0); - const replacementJson = replace.stdout(); - const replacementInspection = JSON.parse(replacementJson) as { - readonly candidates: readonly unknown[]; - readonly evidenceComparison: { - readonly sourceCandidates: readonly unknown[]; - }; - }; - expect(replacementInspection.candidates.length).toBeGreaterThan(0); - expect( - replacementInspection.evidenceComparison.sourceCandidates.length, - ).toBeGreaterThan(0); - for (const forbidden of [ - source, - "interpretation", - "sourceRecordIds", - "producer", - "capabilityId", - "capabilityVersion", - "contractVersion", - ]) - expect(replacementJson).not.toContain(forbidden); - const retract = runtime( - [ - "source-decision", - "retract", - assetId, - nbinetAssociation.id, - "错误来源关联", - "--actor", - "cli-reviewer", - "--json", - ], - { cwd: source, env: { DIGITAL_PRODUCT_DATA: data } }, - ); - expect(await runCli(retract.runtime), retract.stderr()).toBe(0); - expect(retract.stdout()).not.toContain(source); - expect(retract.stdout()).not.toContain("capabilityId"); - const retractionInspection = JSON.parse(retract.stdout()) as { - readonly executionId: string; - readonly preview: { readonly id: string; readonly status: string }; - }; - expect(retractionInspection).toMatchObject({ - state: "awaiting-confirmation", - preview: { status: "awaiting-confirmation" }, - }); - const apply = runtime( - [ - "apply-preview", - retractionInspection.preview.id, - "--actor", - "cli-reviewer", - "--json", - ], - { cwd: source, env: { DIGITAL_PRODUCT_DATA: data } }, - ); - expect(await runCli(apply.runtime), apply.stderr()).toBe(0); - expect(JSON.parse(apply.stdout())).toMatchObject({ - preview: { status: "applied" }, - }); - const audit = runtime(["inspect-correction-subject", assetId, "--json"], { - cwd: source, - env: { DIGITAL_PRODUCT_DATA: data }, - }); - await expect(runCli(audit.runtime)).resolves.toBe(0); - expect(JSON.parse(audit.stdout())).toMatchObject({ - current: [], - historical: expect.arrayContaining([ - expect.objectContaining({ - association: expect.objectContaining({ id: nbinetAssociation.id }), - status: "retracted", - authorizedBy: expect.objectContaining({ - kind: "source-association-decision", - }), - }), - ]), - }); - }); - - it("默认普通重扫和重开保留 DLsite 官方封面及 PDF 精确文件图片", async () => { - const fixture = await temporaryDirectory("digital-cli-file-image-"); - const source = path.join(fixture, "RJ01668691"); - const data = await disposableProductDataDirectory(); - await mkdir(path.join(source, "台本"), { recursive: true }); - await writeFile(path.join(source, "track.wav"), new Uint8Array(44)); - await writeFile(path.join(source, "台本", "script.pdf"), syntheticPdf()); - await prepareCliProductData(data); - vi.stubGlobal("fetch", async (input: string | URL | Request) => { - const address = - typeof input === "string" - ? input - : input instanceof URL - ? input.href - : input.url; - if (address.includes("/product/info/ajax")) { - return new Response( - JSON.stringify({ - RJ01668691: { - site_id: "maniax", - work_name: "带台本的音声作品", - regist_date: "2026-07-17", - work_image: "//img.dlsite.jp/official.jpg", - }, - }), - { headers: { "content-type": "application/json" } }, - ); - } - if ( - address === - "https://www.dlsite.com/maniax/work/=/product_id/RJ01668691.html" - ) { - return new Response("", { - headers: { "content-type": "text/html" }, - }); - } - if (address === "https://img.dlsite.jp/official.jpg") { - return new Response(new Uint8Array([1, 2, 3, 4]), { - headers: { "content-type": "image/jpeg" }, - }); - } - return new Response("not found", { status: 404 }); - }); - const env = { DIGITAL_PRODUCT_DATA: data }; - const rootId = await registerRoot({ source, cwd: source, env }); - const initialScan = runtime( - [ - "scan", - rootId, - "--require-artifact-role", - "asset.primary-cover", - "asset.material-image", - "--json", - ], - { cwd: source, env }, - ); - const initialScanExit = await runCli(initialScan.runtime); - expect(initialScanExit, initialScan.stderr()).toBe(0); - const initialScanResult = JSON.parse(initialScan.stdout()) as { - execution: { id: string; outcome?: string }; - itemResults: { - accepted?: { - entity: { id: string }; - facts: { - id: string; - descriptions: { artifactRoles: { role: string }[] }[]; - locations: { kind: string; content: { status: string } }[]; - }; - }; - }[]; - }; - const initialAccepted = initialScanResult.itemResults[0]!.accepted; - expect(initialScanResult.execution.outcome).toBe("accepted"); - expect(initialAccepted?.entity.id).toBeTruthy(); - expect(initialAccepted?.facts.id).toBe(initialAccepted?.entity.id); - expect( - initialAccepted?.facts.descriptions - .flatMap((description) => - description.artifactRoles.map((entry) => entry.role), - ) - .includes("asset.primary-cover"), - ).toBe(true); - expect( - initialAccepted?.facts.locations.some( - (location) => - location.kind === "managed" && - location.content.status === "confirmed", - ), - ).toBe(true); - - // PDF 精确文件图片是原始观察部件,按 ADR 0086 只经显式检查入口核验, - // 不在普通消费投影里另开第二条旁路。 - const initialAssociations = await inspectedArtifactAssociations( - initialScanResult.execution.id, - source, - env, - ); - expect( - initialAssociations.filter( - (association) => association.role === "asset.material-image", - ), - ).toEqual([ - expect.objectContaining({ - observedArtifactPart: expect.objectContaining({ - revisionId: expect.any(String), - locator: "pdf:page[1]", - }), - }), - ]); - const initialMaterialRevisionId = initialAssociations.find( - (association) => association.role === "asset.material-image", - )?.observedArtifactPart?.revisionId; - - const initialSurface = runtime( - ["catalogue-surface", "--surface", "current-catalogue"], - { cwd: source, env }, - ); - const initialSurfaceExit = await runCli(initialSurface.runtime); - expect(initialSurfaceExit, initialSurface.stderr()).toBe(0); - const [initial] = ( - JSON.parse(initialSurface.stdout()) as { - items: { - id: string; - descriptions: { - artifactRoles: { role: string; artifactId: string }[]; - }[]; - locations: unknown[]; - }[]; - } - ).items; - expect(initial?.id).toBe(initialAccepted?.entity.id); - expect( - initial?.descriptions.flatMap((description) => - description.artifactRoles.map((entry) => entry.role), - ), - ).toEqual( - expect.arrayContaining(["asset.primary-cover", "asset.material-image"]), - ); - // 主封面的“恰一项”在现行有界公开语义里由共同单值裁决表达:每个角色只 - // 投影一条生效条目;未采用与历史关联的原始计数仍按上一条在显式 inspect 入口可查。 - expect( - initial?.descriptions.flatMap((description) => - description.artifactRoles.filter( - (entry) => entry.role === "asset.primary-cover", - ), - ), - ).toHaveLength(1); - expect(initial?.locations.length).toBeGreaterThan(0); - - const defaultScan = runtime(["scan", rootId, "--json"], { - cwd: source, - env, - }); - await expect(runCli(defaultScan.runtime)).resolves.toBe(0); - const defaultScanResult = JSON.parse(defaultScan.stdout()) as { - execution: { id: string; outcome?: string }; - }; - // 初次扫描已完成材料确认,未变化重扫保留同一结果。 - expect(defaultScanResult.execution.outcome).toBe("no-change"); - const reopenedSurface = runtime( - ["catalogue-surface", "--surface", "current-catalogue"], - { cwd: source, env }, - ); - await expect(runCli(reopenedSurface.runtime)).resolves.toBe(0); - const reopened = ( - JSON.parse(reopenedSurface.stdout()) as { - items: (typeof initial)[]; - } - ).items[0]; - expect(reopened?.id).toBe(initial?.id); - expect(reopened?.descriptions).toEqual(initial?.descriptions); - expect(reopened?.locations).toEqual(initial?.locations); - expect( - reopened?.descriptions.flatMap((description) => - description.artifactRoles.filter( - (entry) => entry.role === "asset.primary-cover", - ), - ), - ).toHaveLength(1); - // 普通重扫后必须重新经显式入口核验同一精确部件:普通 facts 的身份、角色与 - // 位置一致不能替代它,部件丢失或改页在此处必须可见。 - const reopenedAssociations = await inspectedArtifactAssociations( - defaultScanResult.execution.id, - source, - env, - ); - expect( - reopenedAssociations.filter( - (association) => association.role === "asset.material-image", - ), - ).toEqual([ - expect.objectContaining({ - observedArtifactPart: expect.objectContaining({ - revisionId: expect.any(String), - locator: "pdf:page[1]", - }), - }), - ]); - expect( - reopenedAssociations.filter( - (association) => association.role === "asset.material-image", - )[0]?.observedArtifactPart?.revisionId, - ).toBe(initialMaterialRevisionId); - - const repeatedScan = runtime(["scan", rootId, "--json"], { - cwd: source, - env, - }); - await expect(runCli(repeatedScan.runtime)).resolves.toBe(0); - const repeatedScanResult = JSON.parse(repeatedScan.stdout()) as { - execution: { id: string; outcome?: string }; - }; - expect(repeatedScanResult.execution.outcome).toBe("no-change"); - const repeatedSurface = runtime( - ["catalogue-surface", "--surface", "current-catalogue"], - { cwd: source, env }, - ); - await expect(runCli(repeatedSurface.runtime)).resolves.toBe(0); - expect(JSON.parse(repeatedSurface.stdout())).toEqual( - JSON.parse(reopenedSurface.stdout()), - ); - // no-change 之后的稳定比较仍要覆盖同一材料语义:重读同一显式入口,精确部件 - // 的载体修订与页定位必须与重扫时一致,才构成原断言要求的稳定证明。 - const stableAssociations = await inspectedArtifactAssociations( - defaultScanResult.execution.id, - source, - env, - ); - expect(stableAssociations).toEqual(reopenedAssociations); - }, 30_000); - - it("starts, completes, and revokes a Bangumi OAuth connection without printing private grants", async () => { - const data = await disposableProductDataDirectory(); - await prepareCliProductData(data); - const accessToken = "cli-bangumi-private-access"; - const refreshToken = "cli-bangumi-private-refresh"; - vi.stubGlobal("fetch", async () => ({ - ok: true, - status: 200, - headers: { get: () => "application/json" }, - arrayBuffer: async () => - new TextEncoder().encode( - JSON.stringify({ - access_token: accessToken, - refresh_token: refreshToken, - expires_in: 3600, - }), - ).buffer, - })); - const env = { - DIGITAL_PRODUCT_DATA: data, - DIGITAL_BANGUMI_CLIENT_ID: "digital-cli-client", - DIGITAL_BANGUMI_CLIENT_SECRET: "digital-cli-secret", - DIGITAL_BANGUMI_REDIRECT_URI: - "https://digital.example.test/api/source-connections/bangumi/callback", - }; - - const started = runtime(["bangumi-connection-authorize"], { - cwd: "/workspace", - env, - }); - await expect(runCli(started.runtime)).resolves.toBe(0); - const authorization = JSON.parse(started.stdout()) as { - readonly authorizationUrl: string; - }; - const callback = new URL(env.DIGITAL_BANGUMI_REDIRECT_URI); - callback.searchParams.set("code", "cli-callback-code"); - callback.searchParams.set( - "state", - new URL(authorization.authorizationUrl).searchParams.get("state")!, - ); - - const completed = runtime( - ["bangumi-connection-callback", callback.toString()], - { cwd: "/workspace", env }, - ); - await expect(runCli(completed.runtime)).resolves.toBe(0); - expect(JSON.parse(completed.stdout())).toMatchObject({ - id: "bangumi", - version: 1, - credentialStatus: "configured", - availability: "available", - }); - const revoked = runtime( - ["bangumi-connection-revoke", "--expected-version", "1"], - { cwd: "/workspace", env }, - ); - await expect(runCli(revoked.runtime)).resolves.toBe(0); - expect(JSON.parse(revoked.stdout())).toMatchObject({ - version: 2, - credentialStatus: "revoked", - availability: "unavailable", - }); - expect( - `${started.stdout()}${completed.stdout()}${revoked.stdout()}`, - ).not.toContain(accessToken); - expect( - `${started.stdout()}${completed.stdout()}${revoked.stdout()}`, - ).not.toContain(refreshToken); - }); - - it("imports, replaces, and revokes one scoped FANBOX browser connection without reflecting its credentials", async () => { - const data = await disposableProductDataDirectory(); - await prepareCliProductData(data); - const credentialDirectory = await temporaryDirectory("digital-fanbox-cli-"); - const firstFile = path.join(credentialDirectory, "first.json"); - const secondFile = path.join(credentialDirectory, "second.json"); - const firstSecret = "cli-fanbox-first-private-session"; - const secondSecret = "cli-fanbox-second-private-session"; - await writeFile( - firstFile, - JSON.stringify({ - userAgent: "test-browser/1", - cookies: [ - { - name: "FANBOXSESSID", - value: firstSecret, - domain: ".fanbox.cc", - path: "/", - }, - ], - }), - ); - await writeFile( - secondFile, - JSON.stringify({ - userAgent: "test-browser/1", - cookies: [ - { - name: "FANBOXSESSID", - value: secondSecret, - domain: ".fanbox.cc", - path: "/", - }, - ], - }), - ); - - const imported = runtime( - ["fanbox-connection-import", "customudon", firstFile], - { cwd: "/workspace", env: { DIGITAL_PRODUCT_DATA: data } }, - ); - await expect(runCli(imported.runtime)).resolves.toBe(0); - const first = JSON.parse(imported.stdout()) as { readonly version: number }; - expect(first.version).toBe(1); - - const replaced = runtime( - [ - "fanbox-connection-replace", - "customudon", - secondFile, - "--expected-version", - String(first.version), - ], - { cwd: "/workspace", env: { DIGITAL_PRODUCT_DATA: data } }, - ); - await expect(runCli(replaced.runtime)).resolves.toBe(0); - const second = JSON.parse(replaced.stdout()) as { - readonly version: number; - }; - expect(second.version).toBe(2); - - const revoked = runtime( - [ - "fanbox-connection-revoke", - "customudon", - "--expected-version", - String(second.version), - ], - { cwd: "/workspace", env: { DIGITAL_PRODUCT_DATA: data } }, - ); - await expect(runCli(revoked.runtime)).resolves.toBe(0); - expect(JSON.parse(revoked.stdout())).toMatchObject({ - version: 3, - credentialStatus: "revoked", - availability: "unavailable", - }); - expect( - `${imported.stdout()}${replaced.stdout()}${revoked.stdout()}`, - ).not.toContain(firstSecret); - expect( - `${imported.stdout()}${replaced.stdout()}${revoked.stdout()}`, - ).not.toContain(secondSecret); - }); - - it("records an unsupported directory read-only and makes the execution inspectable later", async () => { - const sourceDirectory = await temporaryDirectory("digital-source-"); - const dataDirectory = await disposableProductDataDirectory(); - const sourceFile = path.join(sourceDirectory, "ordinary.txt"); - await writeFile(sourceFile, "unchanged source bytes\n"); - await prepareCliProductData(dataDirectory); - const before = await tree(sourceDirectory); - - const rootId = await registerRoot({ - source: sourceDirectory, - cwd: "/workspace", - env: { DIGITAL_PRODUCT_DATA: dataDirectory }, - }); - const scan = runtime(["scan", rootId, "--json"], { - cwd: "/workspace", - env: { DIGITAL_PRODUCT_DATA: dataDirectory }, - }); - await expect(runCli(scan.runtime)).resolves.toBe(0); - const result = JSON.parse(scan.stdout()) as { - readonly executionId: string; - readonly execution: { readonly outcome: string }; - }; - expect(result.execution.outcome).toBe("unresolved"); - expect(scan.stderr()).toContain(`刷新记录:${result.executionId}`); - - const inspected = runtime(["inspect", result.executionId, "--json"], { - cwd: "/workspace", - env: { DIGITAL_PRODUCT_DATA: dataDirectory }, - }); - await expect(runCli(inspected.runtime)).resolves.toBe(0); - expect(JSON.parse(inspected.stdout())).toMatchObject({ - executionId: result.executionId, - execution: { - outcome: "unresolved", - snapshot: { complete: true, files: [{ relativePath: "ordinary.txt" }] }, - }, - unresolvedObservation: { - observedRoot: sourceDirectory, - fileCount: 1, - totalSize: Buffer.byteLength("unchanged source bytes\n"), - representativePaths: ["ordinary.txt"], - }, - }); - expect(inspected.stdout()).not.toMatch( - /stagedReference|managedSourceArtifacts|sourceArtifactAcquisitions/u, - ); - - const humanInspection = runtime(["inspect", result.executionId], { - cwd: "/workspace", - env: { DIGITAL_PRODUCT_DATA: dataDirectory }, - }); - await expect(runCli(humanInspection.runtime)).resolves.toBe(0); - expect(humanInspection.stdout()).toContain(`观察范围:${sourceDirectory}`); - expect(humanInspection.stdout()).toContain("总大小:23 字节"); - expect(humanInspection.stdout()).toContain("代表文件:ordinary.txt"); - expect(await tree(sourceDirectory)).toEqual(before); - }); - - it("uses an OS data root for scan . and leaves the complete observed tree unchanged", async () => { - const source = await temporaryDirectory("digital-cwd-"); - const xdgData = await temporaryDirectory("digital-disposable-xdg-"); - const productDataDirectory = path.join(xdgData, "digital"); - await writeFile(path.join(source, "ordinary.txt"), "source"); - await writeFile(path.join(source, "nested.txt"), "nested"); - const before = await tree(source); - await prepareCliProductData(productDataDirectory); - - const rootId = await registerRoot({ - source, - cwd: source, - env: { XDG_DATA_HOME: xdgData }, - }); - const scan = runtime(["scan", rootId, "--json"], { - cwd: source, - env: { XDG_DATA_HOME: xdgData }, - }); - await expect(runCli(scan.runtime)).resolves.toBe(0); - - expect(await tree(source)).toEqual(before); - await expect(lstat(path.join(source, ".digital"))).rejects.toMatchObject({ - code: "ENOENT", - }); - expect( - (await stat(path.join(xdgData, "digital", "catalogue.sqlite"))).isFile(), - ).toBe(true); - }); - - it("rejects an explicit product-data descendant before source observation can create it", async () => { - const source = await temporaryDirectory("digital-disposable-overlap-"); - await writeFile(path.join(source, "ordinary.txt"), "source"); - const inside = path.join(source, "product-data"); - await prepareCliProductData(inside); - // 打开已存在的产品数据库会短暂创建并移除 SQLite WAL,从而只改变这个已知可写目录的 - // mtime;这里仍完整比较其他目录、所有条目及文件摘要。 - const snapshotOptions = { - ignoreDirectoryModifiedAt: ["product-data"], - } as const; - const before = await tree(source, snapshotOptions); - - const scan = runtime( - ["library-create", "重叠位置", "--directory", source, "--json"], - { - cwd: source, - env: { DIGITAL_PRODUCT_DATA: inside }, - }, - ); - await expect(runCli(scan.runtime)).resolves.toBe(1); - - expect(scan.stderr()).toContain("不能与产品数据或托管产物目录重叠"); - expect(await tree(source, snapshotOptions)).toEqual(before); - }); - - it("rejects a symlinked product-data alias before any source mutation", async () => { - const source = await temporaryDirectory("digital-disposable-alias-source-"); - const aliasParent = await temporaryDirectory("digital-alias-parent-"); - await writeFile(path.join(source, "ordinary.txt"), "source"); - const alias = path.join(aliasParent, "observed-alias"); - await symlink(source, alias, "dir"); - const canonicalProductData = path.join(source, "product-data"); - await prepareCliProductData(canonicalProductData); - const productData = path.join(alias, "product-data"); - const snapshotOptions = { - ignoreDirectoryModifiedAt: ["product-data"], - } as const; - const before = await tree(source, snapshotOptions); - - const scan = runtime( - ["library-create", "链接重叠位置", "--directory", source, "--json"], - { - cwd: source, - env: { DIGITAL_PRODUCT_DATA: productData }, - }, - ); - await expect(runCli(scan.runtime)).resolves.toBe(1); - - expect(scan.stderr()).toContain("不能与产品数据或托管产物目录重叠"); - expect(await tree(source, snapshotOptions)).toEqual(before); - }); - - it("does not treat legacy FANBOX environment variables as startup configuration", async () => { - const data = await disposableProductDataDirectory(); - await prepareCliProductData(data); - const connections = runtime(["source-connection-list"], { - cwd: "/workspace", - env: { - DIGITAL_PRODUCT_DATA: data, - DIGITAL_FANBOX_CREATOR_ID: "customudon", - }, - }); - - await expect(runCli(connections.runtime)).resolves.toBe(0); - expect(JSON.parse(connections.stdout())).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "fanbox/customudon" }), - ]), - ); - }); - - it("does not echo malformed FANBOX credential-file contents through CLI import failure", async () => { - const data = await disposableProductDataDirectory(); - await prepareCliProductData(data); - const credentialRoot = await temporaryDirectory( - "digital-secret-credential-", - ); - const credentialFile = path.join(credentialRoot, "fanbox.json"); - const secret = "cli-private-session-token"; - await writeFile( - credentialFile, - `[{"name":"FANBOXSESSID","value":"${secret}"}`, - ); - const before = await tree(data); - const imported = runtime( - ["fanbox-connection-import", "customudon", credentialFile], - { - cwd: "/workspace", - env: { - DIGITAL_PRODUCT_DATA: data, - }, - }, - ); - - await expect(runCli(imported.runtime)).resolves.toBe(1); - expect(`${imported.stdout()}\n${imported.stderr()}`).not.toContain(secret); - expect(await tree(data)).toEqual(before); - }); - - it("prepares, inspects, and confirms observed material through the real CLI product", async () => { - const admitted = await temporaryDirectory("digital-cli-material-target-"); - const material = await temporaryDirectory("digital-cli-material-source-"); - const data = await disposableProductDataDirectory(); - await writeFile(path.join(admitted, "initial.pdf"), syntheticPdf()); - await writeFile(path.join(material, "appendix.txt"), "CLI whole material"); - await prepareCliProductData(data); - const product = runProduct( - { productDataDirectory: data, environment: {} }, - { start: (application) => application.workflow }, - ); - let assetId: string; - let rootId: string; - try { - const targetRootId = await seedLibraryRoot( - product.result, - "CLI target", - admitted, - ); - await product.result.scanObservedScope({ - registrationId: targetRootId, - relativeScope: ".", - acceptTrusted: true, - }); - assetId = (await product.result.listCurrentCataloguePage()).items[0]!.id; - rootId = await seedLibraryRoot(product.result, "CLI material", material); - } finally { - await product.close(); - } - const env = { DIGITAL_PRODUCT_DATA: data }; - const prepared = runtime( - [ - "material-association-prepare", - "--asset", - assetId, - "--root", - rootId, - "--scope", - ".", - "--file", - "appendix.txt", - "--initiated-by", - "cli-prepare", - ], - { cwd: material, env }, - ); - expect(await runCli(prepared.runtime), prepared.stderr()).toBe(0); - const preparedResult = JSON.parse(prepared.stdout()) as { - readonly executionId: string; - readonly status: string; - }; - expect(preparedResult.status).toBe("awaiting-confirmation"); - const inspected = runtime( - ["material-association-inspect", preparedResult.executionId], - { cwd: material, env }, - ); - expect(await runCli(inspected.runtime), inspected.stderr()).toBe(0); - expect(JSON.parse(inspected.stdout())).toMatchObject({ - status: "awaiting-confirmation", - target: { id: assetId }, - files: [{ relativePath: "appendix.txt" }], - }); - const confirmed = runtime( - [ - "material-association-confirm", - preparedResult.executionId, - "--confirmed-by", - "cli-confirm", - ], - { cwd: material, env }, - ); - expect(await runCli(confirmed.runtime), confirmed.stderr()).toBe(0); - expect(JSON.parse(confirmed.stdout())).toMatchObject({ - status: "accepted", - confirmedBy: "cli-confirm", - target: { id: assetId }, - }); - }); - - it("uses the shared current catalogue query after a successful scan confirms material is gone", async () => { - const source = await temporaryDirectory( - "digital-cli-current-catalogue-membership-", - ); - const data = await disposableProductDataDirectory(); - await writeFile(path.join(source, "local.txt"), "CLI 本地内容"); - await prepareCliProductData(data); - const product = runProduct( - { productDataDirectory: data, environment: {} }, - { start: (application) => application.workflow }, - ); - try { - const rootId = await seedLibraryRoot( - product.result, - "CLI 当前目录材料", - source, - ); - const initial = await product.result.scanObservedScope({ - registrationId: rootId, - relativeScope: ".", - acceptTrusted: false, - }); - const review = await product.result.prepareLocalContent({ - executionId: initial.execution.id, - relativePaths: ["local.txt"], - name: "CLI 本地内容", - classification: "book", - }); - await product.result.confirmLocalContent({ - executionId: review.executionId, - }); - await rm(path.join(source, "local.txt")); - await product.result.scanObservedScope({ - registrationId: rootId, - relativeScope: ".", - acceptTrusted: false, - }); - } finally { - await product.close(); - } - - const command = runtime( - ["catalogue-surface", "--surface", "current-catalogue"], - { - cwd: source, - env: { DIGITAL_PRODUCT_DATA: data }, - }, - ); - await expect(runCli(command.runtime), command.stderr()).resolves.toBe(0); - expect(JSON.parse(command.stdout())).toMatchObject({ items: [] }); - }); - - it("keeps a directory package current from the directory itself until a completed scan confirms it is gone", async () => { - const media = await temporaryDirectory("digital-cli-directory-package-"); - const data = await disposableProductDataDirectory(); - const packageDirectory = path.join(media, "film"); - await mkdir(path.join(packageDirectory, "BDMV", "PLAYLIST"), { - recursive: true, - }); - await mkdir(path.join(packageDirectory, "BDMV", "STREAM"), { - recursive: true, - }); - await writeFile(path.join(packageDirectory, "BDMV", "index.bdmv"), "index"); - await writeFile( - path.join(packageDirectory, "BDMV", "MovieObject.bdmv"), - "movie-object", - ); - await writeFile( - path.join(packageDirectory, "BDMV", "STREAM", "00000.m2ts"), - "stream", - ); - // 观察根必须继续可读:删除整个根会被当作位置不可访问,而不是材料消失。 - await writeFile(path.join(media, "note.txt"), "同级材料"); - await prepareCliProductData(data); - const env = { DIGITAL_PRODUCT_DATA: data }; - vi.stubGlobal( - "fetch", - async () => - new Response( - JSON.stringify({ - docs: [ - { - pnx: { - display: { title: ["CLI 目录影片"] }, - addata: { isbn: ["9789864738281"] }, - }, - }, - ], - }), - { headers: { "content-type": "application/json" } }, - ), - ); - - const rootId = await registerRoot({ source: media, cwd: media, env }); - const scrape = runtime( - [ - "scrape-source", - rootId, - "nbinet", - JSON.stringify({ isbn: "9789864738281" }), - "--scope", - "film", - "--json", - ], - { cwd: media, env }, - ); - expect(await runCli(scrape.runtime), scrape.stderr()).toBe(0); - expect( - (JSON.parse(scrape.stdout()) as { execution: { outcome: string } }) - .execution.outcome, - ).toBe("accepted"); - const admitted = await currentCatalogueIds(media, env); - expect(admitted).toHaveLength(1); - const assetId = admitted[0]!; - - const database = new DatabaseSync(path.join(data, "catalogue.sqlite"), { - readOnly: true, - }); - try { - // 目录材料由目录本身支持,而不是伪造一条文件观察。 - expect( - ( - database - .prepare("SELECT COUNT(*) AS count FROM current_file_observations") - .get() as { readonly count: number } - ).count, - ).toBe(0); - } finally { - database.close(); - } - - await rm(path.join(packageDirectory, "BDMV", "STREAM", "00000.m2ts")); - await rm(path.join(packageDirectory, "BDMV", "index.bdmv")); - await rm(path.join(packageDirectory, "BDMV", "MovieObject.bdmv")); - await expect(scanRoot(media, env, rootId)).resolves.toMatchObject({ - execution: { status: "completed" }, - }); - await expect(currentCatalogueIds(media, env)).resolves.toEqual([assetId]); - - await rm(packageDirectory, { recursive: true, force: true }); - await expect(scanRoot(media, env, rootId)).resolves.toMatchObject({ - execution: { status: "completed" }, - }); - await expect(currentCatalogueIds(media, env)).resolves.toEqual([]); - // 重复扫描保持退出结果稳定。 - await expect(scanRoot(media, env, rootId)).resolves.toMatchObject({ - execution: { status: "completed" }, - }); - await expect(currentCatalogueIds(media, env)).resolves.toEqual([]); - - const history = new DatabaseSync(path.join(data, "catalogue.sqlite"), { - readOnly: true, - }); - try { - // 退出当前查询不删除历史身份。 - expect( - history - .prepare("SELECT id FROM entities WHERE id = ?") - .get(assetId) as { readonly id: string }, - ).toMatchObject({ id: assetId }); - } finally { - history.close(); - } - const historyPage = runtime( - ["catalogue-surface", "--surface", "workflow-history"], - { cwd: media, env }, - ); - await expect( - runCli(historyPage.runtime), - historyPage.stderr(), - ).resolves.toBe(0); - expect( - (JSON.parse(historyPage.stdout()) as { items: readonly unknown[] }).items - .length, - ).toBeGreaterThan(0); - }); - - it("主封面不提供当前成员资格,但另一个仍存在的交付材料提供", async () => { - const fixture = await temporaryDirectory("digital-cli-mixed-material-"); - const source = path.join(fixture, "RJ01668691"); - const data = await disposableProductDataDirectory(); - await mkdir(path.join(source, "台本"), { recursive: true }); - await writeFile(path.join(source, "track.wav"), new Uint8Array(44)); - await writeFile(path.join(source, "台本", "script.pdf"), syntheticPdf()); - await prepareCliProductData(data); - vi.stubGlobal("fetch", async (input: string | URL | Request) => { - const address = - typeof input === "string" - ? input - : input instanceof URL - ? input.href - : input.url; - if (address.includes("/product/info/ajax")) { - return new Response( - JSON.stringify({ - RJ01668691: { - site_id: "maniax", - work_name: "带台本的音声作品", - regist_date: "2026-07-17", - work_image: "//img.dlsite.jp/official.jpg", - }, - }), - { headers: { "content-type": "application/json" } }, - ); - } - if (address === "https://img.dlsite.jp/official.jpg") { - return new Response(new Uint8Array([1, 2, 3, 4]), { - headers: { "content-type": "image/jpeg" }, - }); - } - return new Response("", { - headers: { "content-type": "text/html" }, - }); - }); - const env = { DIGITAL_PRODUCT_DATA: data }; - const rootId = await registerRoot({ source, cwd: source, env }); - const admitted = runtime( - [ - "scan", - rootId, - "--require-artifact-role", - "asset.primary-cover", - "--json", - ], - { cwd: source, env }, - ); - expect(await runCli(admitted.runtime), admitted.stderr()).toBe(0); - const ids = await currentCatalogueIds(source, env); - expect(ids).toHaveLength(1); - const assetId = ids[0]!; - - // 限定范围的完整扫描不能撤销范围外材料的支持。 - await rm(path.join(source, "台本", "script.pdf")); - await expect(scanRoot(source, env, rootId, "台本")).resolves.toMatchObject({ - execution: { status: "completed" }, - }); - await expect(currentCatalogueIds(source, env)).resolves.toEqual([assetId]); - - // 全部交付材料消失后,托管主封面不提供普通当前集合的成员资格。 - await rm(path.join(source, "track.wav")); - await expect(scanRoot(source, env, rootId)).resolves.toMatchObject({ - execution: { status: "completed" }, - }); - await expect(currentCatalogueIds(source, env)).resolves.toEqual([]); - - const history = new DatabaseSync(path.join(data, "catalogue.sqlite"), { - readOnly: true, - }); - try { - expect( - history - .prepare( - `SELECT COUNT(*) AS count FROM artifact_associations - WHERE subject_id = ? AND role = 'asset.primary-cover'`, - ) - .get(assetId), - ).toEqual({ count: 1 }); - } finally { - history.close(); - } - const historyPage = runtime( - ["catalogue-surface", "--surface", "workflow-history"], - { cwd: source, env }, - ); - await expect( - runCli(historyPage.runtime), - historyPage.stderr(), - ).resolves.toBe(0); - expect( - (JSON.parse(historyPage.stdout()) as { items: readonly unknown[] }).items - .length, - ).toBeGreaterThan(0); - }, 60_000); -}); - -async function currentCatalogueIds( - cwd: string, - env: Record, -): Promise { - const command = runtime( - ["catalogue-surface", "--surface", "current-catalogue"], - { - cwd, - env, - }, - ); - const exit = await runCli(command.runtime); - expect(exit, `${command.stderr()}\n${command.stdout()}`).toBe(0); - const page = JSON.parse(command.stdout()) as { - readonly items: readonly { readonly id: string }[]; - }; - return page.items.map((item) => item.id); -} - -// ADR 0086 的显式检查入口:原始观察部件只在这里公开,普通消费投影不另开旁路。 -async function inspectedArtifactAssociations( - executionId: string, - cwd: string, - env: Record, -): Promise< - readonly { - readonly role: string; - readonly observedArtifactPart?: { - readonly revisionId: string; - readonly locator: string; - }; - }[] -> { - const command = runtime(["inspect", executionId, "--json"], { cwd, env }); - const exit = await runCli(command.runtime); - expect(exit, `${command.stderr()}\n${command.stdout()}`).toBe(0); - type Accepted = { - entity: { id: string }; - artifactAssociations: { - role: string; - observedArtifactPart?: { revisionId: string; locator: string }; - }[]; - }; - const inspected = JSON.parse(command.stdout()) as { - accepted?: Accepted; - itemResults?: { key: string; accepted?: Accepted }[]; - }; - return inspected.itemResults === undefined - ? (inspected.accepted?.artifactAssociations ?? []) - : inspected.itemResults.flatMap( - (item) => item.accepted?.artifactAssociations ?? [], - ); -} - -async function scanRoot( - cwd: string, - env: Record, - rootId: string, - scope?: string, -) { - const command = runtime( - [ - "scan", - rootId, - ...(scope === undefined ? [] : ["--scope", scope]), - "--json", - ], - { cwd, env }, - ); - await expect(runCli(command.runtime), command.stderr()).resolves.toBe(0); - return JSON.parse(command.stdout()) as { - readonly execution: { readonly status: string; readonly outcome: string }; - }; -} - -function syntheticPdf(): Uint8Array { - const objects = [ - "<< /Type /Catalog /Pages 2 0 R >>", - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", - "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>", - ]; - let source = "%PDF-1.7\n"; - const offsets = [0]; - for (const [index, object] of objects.entries()) { - offsets.push(new TextEncoder().encode(source).byteLength); - source += `${index + 1} 0 obj\n${object}\nendobj\n`; - } - const xref = new TextEncoder().encode(source).byteLength; - source += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; - source += offsets - .slice(1) - .map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`) - .join(""); - source += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; - return new TextEncoder().encode(source); -} diff --git a/apps/cli/test/integration/bangumi-episodic-live-journey.test.ts b/apps/cli/test/integration/bangumi-episodic-live-journey.test.ts deleted file mode 100644 index cfaade7..0000000 --- a/apps/cli/test/integration/bangumi-episodic-live-journey.test.ts +++ /dev/null @@ -1,667 +0,0 @@ -import { - chmod, - copyFile, - mkdir, - mkdtemp, - readFile, - rm, - stat, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import type { AssetRefreshInspection } from "@digital/application"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { - assertBangumiAcceptedEpisodeSet, - assertBangumiReopenedEpisodeSet, - type BangumiEpisodicRootOracle, - default as journey, -} from "../e2e/journeys/bangumi-episodic-live.journey.ts"; - -const temporaryDirectories: string[] = []; - -afterEach(async () => { - vi.unstubAllEnvs(); - await Promise.all( - temporaryDirectories.splice(0).map(async (directory) => { - await rm(directory, { recursive: true, force: true }); - }), - ); -}); - -const oracle: BangumiEpisodicRootOracle = { - title: "示例作品", - subjects: [{ season: 1, sourceAddress: "100" }], - episodes: [ - { - season: 1, - seasonPosition: 1, - continuousPosition: 1, - sourceAddress: "1001", - }, - { - season: 1, - seasonPosition: 2, - continuousPosition: 2, - sourceAddress: "1002", - }, - ], -}; - -const multiSeasonOracle: BangumiEpisodicRootOracle = { - title: "示例作品", - subjects: [ - { season: 1, sourceAddress: "100" }, - { season: 2, sourceAddress: "200" }, - ], - episodes: [ - { - season: 1, - seasonPosition: 1, - continuousPosition: 1, - sourceAddress: "1001", - }, - { - season: 2, - seasonPosition: 1, - continuousPosition: 2, - sourceAddress: "1002", - }, - ], -}; - -function inspection( - addresses: readonly ["1001" | "1002", "1001" | "1002"], -): AssetRefreshInspection { - const sourceRecords = [ - { - id: "search-record", - sourceIdentity: "bangumi", - nativeAddress: "bangumi:search:%E7%A4%BA%E4%BE%8B%E4%BD%9C%E5%93%81:2026", - coverage: { - id: "bangumi.subject-search", - completeness: "complete" as const, - authority: "official" as const, - }, - acquiredAt: "2026-08-29T00:00:00.000Z", - digest: "search", - }, - { - id: "subject-record", - sourceIdentity: "bangumi", - nativeAddress: "https://bgm.tv/subject/100", - coverage: { - id: "bangumi.subject:100", - completeness: "complete" as const, - authority: "official" as const, - }, - acquiredAt: "2026-08-29T00:00:00.000Z", - digest: "subject", - }, - { - id: "relationship-record", - sourceIdentity: "bangumi", - nativeAddress: "https://bgm.tv/subject/100", - coverage: { - id: "bangumi.relationships:100", - completeness: "complete" as const, - authority: "official" as const, - }, - acquiredAt: "2026-08-29T00:00:00.000Z", - digest: "relationships", - }, - { - id: "episode-page-record", - sourceIdentity: "bangumi", - nativeAddress: "https://bgm.tv/subject/100", - coverage: { - id: "bangumi.ordinary-episodes:100:0", - completeness: "complete" as const, - authority: "official" as const, - }, - acquiredAt: "2026-08-29T00:00:00.000Z", - digest: "episodes", - }, - ...["1001", "1002"].map((address) => ({ - id: `episode-record-${address}`, - sourceIdentity: "bangumi", - nativeAddress: `https://bgm.tv/episode/${address}`, - coverage: { - id: `bangumi.episode:${address}`, - completeness: "complete" as const, - authority: "official" as const, - }, - acquiredAt: "2026-08-29T00:00:00.000Z", - digest: address, - })), - ]; - const itemResults = addresses.map((address) => { - const position = Number(address) - 1000; - const entityId = `episode-${address}`; - const artifactId = `artifact-${address}`; - const subjectId = "subject-100"; - return { - key: `bangumi:episode:${JSON.stringify([".", 1, position])}`, - relativePaths: [ - `Season 1/示例作品 - S01E${String(position).padStart(2, "0")} - 第 ${position} 集.mkv`, - ], - outcome: "accepted" as const, - accepted: { - entity: { id: entityId, kind: "digital-asset" as const }, - recognitionSourceAssociation: { - sourceIdentity: "bangumi", - nativeAddress: `https://bgm.tv/episode/${address}`, - }, - sourceAssociations: [ - { - sourceIdentity: "bangumi", - nativeAddress: `https://bgm.tv/episode/${address}`, - }, - ], - metadata: [ - { - id: `season-position-${address}`, - target: { kind: "entity", entityId }, - property: "bangumi.episode.season-position", - value: String(position), - sourceSlot: "bangumi.episode.ep", - sourceRecordIds: ["episode-page-record"], - }, - { - id: `continuous-position-${address}`, - target: { kind: "entity", entityId }, - property: "bangumi.episode.continuous-position", - value: String(position), - sourceSlot: "bangumi.episode.sort", - sourceRecordIds: ["episode-page-record"], - }, - ], - currentRelatedEntities: [ - { - id: subjectId, - kind: { status: "ordinary" as const }, - sourceIdentity: "bangumi", - identityNamespace: "bangumi.subject", - sourceKey: "100", - label: "示例作品", - sourceRecordIds: ["subject-record"], - }, - ], - relationships: [ - { - id: `membership-${address}`, - participants: [ - { entityId, role: "asset" }, - { - entityId: subjectId, - role: "bangumi.subject.ordinary-episode", - }, - ], - type: "asset.membership", - sourceSlot: `bangumi.subject.ordinary-episode:100:${address}`, - sourceRecordIds: ["subject-record", "episode-page-record"], - qualifiers: [ - { - key: "bangumi.episode.season-position", - value: String(position), - }, - { - key: "bangumi.episode.continuous-position", - value: String(position), - }, - ], - }, - ], - artifacts: [{ id: artifactId }], - locations: [ - { - id: `location-${address}`, - artifactId, - address: `/observed/${address}.mkv`, - kind: "observed" as const, - }, - ], - blobs: [], - artifactRevisions: [], - artifactAssociations: [ - { - id: `association-${address}`, - target: { kind: "entity", entityId }, - artifactId, - role: "asset.material-delivered", - }, - ], - effectiveMetadata: [], - metadataPolicyConflicts: [], - recognitionExplanation: "Bangumi v1", - }, - }; - }); - return { - execution: { - id: "execution", - selection: { intent: "scan-now" }, - status: "completed", - stage: "completed", - outcome: "accepted", - sourceRecordIds: sourceRecords.map((record) => record.id), - dispositions: [], - }, - sourceRecords, - itemResults, - } as unknown as AssetRefreshInspection; -} - -function multiSeasonInspection(): AssetRefreshInspection { - const value = structuredClone(inspection(["1001", "1002"])); - ( - value as unknown as { - sourceRecords: AssetRefreshInspection["sourceRecords"][number][]; - } - ).sourceRecords.push( - { - id: "subject-record-200", - sourceIdentity: "bangumi", - nativeAddress: "https://bgm.tv/subject/200", - coverage: { - id: "bangumi.subject:200", - completeness: "complete", - authority: "official", - }, - acquiredAt: "2026-08-30T00:00:00.000Z", - digest: "subject-200", - }, - { - id: "relationship-record-200", - sourceIdentity: "bangumi", - nativeAddress: "https://bgm.tv/subject/200", - coverage: { - id: "bangumi.relationships:200", - completeness: "complete", - authority: "official", - }, - acquiredAt: "2026-08-30T00:00:00.000Z", - digest: "relationships-200", - }, - { - id: "episode-page-record-200", - sourceIdentity: "bangumi", - nativeAddress: "https://bgm.tv/subject/200", - coverage: { - id: "bangumi.ordinary-episodes:200:0", - completeness: "complete", - authority: "official", - }, - acquiredAt: "2026-08-30T00:00:00.000Z", - digest: "episodes-200", - }, - ); - const [season1, season2] = value.itemResults ?? []; - for (const item of [season1, season2]) { - if (item?.accepted === undefined) continue; - ( - item.accepted as unknown as { - currentRelatedEntities: Record[]; - } - ).currentRelatedEntities.push({ - id: "subject-200", - kind: { status: "ordinary" }, - sourceIdentity: "bangumi", - identityNamespace: "bangumi.subject", - sourceKey: "200", - label: "示例作品 第二季", - sourceRecordIds: ["subject-record-200"], - }); - ( - item.accepted as unknown as { - relationships: Record[]; - } - ).relationships.push({ - id: "sequel-100-200", - participants: [ - { entityId: "subject-100", role: "bangumi.subject.predecessor" }, - { entityId: "subject-200", role: "bangumi.subject.sequel" }, - ], - type: "bangumi.subject.related", - sourceSlot: "bangumi.subject.sequel:100:200", - sourceRecordIds: ["relationship-record"], - }); - } - if (season2?.accepted !== undefined) { - (season2 as { key: string; relativePaths: string[] }).key = - `bangumi:episode:${JSON.stringify([".", 2, 1])}`; - (season2 as { key: string; relativePaths: string[] }).relativePaths = [ - "Season 2/示例作品 - S02E01 - 第 1 集.mkv", - ]; - for (const metadata of season2.accepted.metadata) { - const mutableMetadata = metadata as unknown as { - value: string; - sourceRecordIds: string[]; - }; - if (metadata.property === "bangumi.episode.season-position") - mutableMetadata.value = "1"; - if (metadata.property === "bangumi.episode.continuous-position") - mutableMetadata.value = "2"; - mutableMetadata.sourceRecordIds = ["episode-page-record-200"]; - } - const membership = season2.accepted.relationships?.find( - (relationship) => relationship.type === "asset.membership", - ); - if (membership !== undefined) { - const mutableMembership = membership as unknown as { - participants: { entityId: string; role: string }[]; - sourceSlot: string; - sourceRecordIds: string[]; - qualifiers: { key: string; value: string }[]; - }; - const seasonParticipant = mutableMembership.participants.find( - (participant) => - participant.role === "bangumi.subject.ordinary-episode", - ); - if (seasonParticipant !== undefined) - seasonParticipant.entityId = "subject-200"; - mutableMembership.sourceSlot = - "bangumi.subject.ordinary-episode:200:1002"; - mutableMembership.sourceRecordIds = [ - "subject-record-200", - "episode-page-record-200", - ]; - const seasonPosition = membership.qualifiers?.find( - (qualifier) => qualifier.key === "bangumi.episode.season-position", - ); - const continuousPosition = membership.qualifiers?.find( - (qualifier) => qualifier.key === "bangumi.episode.continuous-position", - ); - if (seasonPosition !== undefined) - (seasonPosition as { value: string }).value = "1"; - if (continuousPosition !== undefined) - (continuousPosition as { value: string }).value = "2"; - } - } - return value; -} - -describe("Bangumi episodic live journey contract", () => { - it("requires the complete reviewed season chain in every final episode projection", () => { - expect(() => - assertBangumiAcceptedEpisodeSet( - multiSeasonInspection(), - multiSeasonOracle, - ), - ).not.toThrow(); - - const thirdSubject = multiSeasonInspection(); - const firstAccepted = thirdSubject.itemResults?.[0]?.accepted; - if (firstAccepted !== undefined) - ( - firstAccepted as unknown as { - currentRelatedEntities: Record[]; - } - ).currentRelatedEntities.push({ - id: "subject-300", - kind: { status: "ordinary" }, - sourceIdentity: "bangumi", - identityNamespace: "bangumi.subject", - sourceKey: "300", - label: "非法第三季", - sourceRecordIds: ["subject-record-200"], - }); - expect(() => - assertBangumiAcceptedEpisodeSet(thirdSubject, multiSeasonOracle), - ).toThrow(/exact reviewed Bangumi subjects/u); - - const missingSequel = multiSeasonInspection(); - const missingRelationships = - missingSequel.itemResults?.[0]?.accepted?.relationships; - if (missingRelationships !== undefined) - ( - missingRelationships as unknown as { - splice(start: number, count: number): void; - } - ).splice(1, 1); - expect(() => - assertBangumiAcceptedEpisodeSet(missingSequel, multiSeasonOracle), - ).toThrow(/sequel/u); - - const reversedSequel = multiSeasonInspection(); - const reversed = - reversedSequel.itemResults?.[0]?.accepted?.relationships?.find( - (relationship) => - relationship.participants.some( - (participant) => participant.role === "bangumi.subject.sequel", - ), - ); - if (reversed !== undefined) { - const predecessor = reversed.participants.find( - (participant) => participant.role === "bangumi.subject.predecessor", - ); - const successor = reversed.participants.find( - (participant) => participant.role === "bangumi.subject.sequel", - ); - if (predecessor !== undefined && successor !== undefined) { - const entityId = predecessor.entityId; - (predecessor as { entityId: string }).entityId = successor.entityId; - (successor as { entityId: string }).entityId = entityId; - } - } - expect(() => - assertBangumiAcceptedEpisodeSet(reversedSequel, multiSeasonOracle), - ).toThrow(/sequel/u); - - const wrongMembership = multiSeasonInspection(); - const membership = - wrongMembership.itemResults?.[0]?.accepted?.relationships?.find( - (relationship) => relationship.type === "asset.membership", - ); - if (membership !== undefined) { - const participant = membership.participants.find( - (item) => item.role === "bangumi.subject.ordinary-episode", - ); - if (participant !== undefined) - (participant as { entityId: string }).entityId = "subject-200"; - } - expect(() => - assertBangumiAcceptedEpisodeSet(wrongMembership, multiSeasonOracle), - ).toThrow(); - }); - - it("binds every episode by exact address instead of response order", () => { - expect(() => - assertBangumiAcceptedEpisodeSet(inspection(["1002", "1001"]), oracle), - ).not.toThrow(); - - expect(() => - assertBangumiAcceptedEpisodeSet(inspection(["1001", "1001"]), oracle), - ).toThrow(/exact Bangumi episode address/u); - }); - - it("requires both scoped episode positions and one observed episode artifact", () => { - const wrongPosition = structuredClone(inspection(["1001", "1002"])); - const first = wrongPosition.itemResults?.[0]?.accepted; - const continuous = first?.metadata.find( - (item) => item.property === "bangumi.episode.continuous-position", - ); - if (continuous !== undefined) (continuous as { value: string }).value = "2"; - - expect(() => - assertBangumiAcceptedEpisodeSet(wrongPosition, oracle), - ).toThrow(/continuous position/u); - - const extraObservedArtifact = structuredClone(inspection(["1001", "1002"])); - const accepted = extraObservedArtifact.itemResults?.[0]?.accepted; - if (accepted !== undefined) { - (accepted.artifacts as { id: string }[]).push({ id: "poster" }); - ( - accepted.locations as { - id: string; - artifactId: string; - address: string; - kind: "observed"; - }[] - ).push({ - id: "poster-location", - artifactId: "poster", - address: "/observed/poster.jpg", - kind: "observed", - }); - } - expect(() => - assertBangumiAcceptedEpisodeSet(extraObservedArtifact, oracle), - ).toThrow(/one observed episode artifact/u); - - const missingRelationshipOrder = structuredClone( - inspection(["1001", "1002"]), - ); - const membership = - missingRelationshipOrder.itemResults?.[0]?.accepted?.relationships?.find( - (relationship) => relationship.type === "asset.membership", - ); - if (membership !== undefined) - ( - membership as unknown as { - qualifiers: { key: string; value: string }[]; - } - ).qualifiers = [{ key: "bangumi.episode.season-position", value: "1" }]; - expect(() => - assertBangumiAcceptedEpisodeSet(missingRelationshipOrder, oracle), - ).toThrow(/relationship ordering qualifiers/u); - - const changedRelationshipOrder = structuredClone( - inspection(["1001", "1002"]), - ); - const changedMembership = - changedRelationshipOrder.itemResults?.[0]?.accepted?.relationships?.find( - (relationship) => relationship.type === "asset.membership", - ); - const continuousQualifier = changedMembership?.qualifiers?.find( - (qualifier) => qualifier.key === "bangumi.episode.continuous-position", - ); - if (continuousQualifier !== undefined) - (continuousQualifier as { value: string }).value = "2"; - expect(() => - assertBangumiAcceptedEpisodeSet(changedRelationshipOrder, oracle), - ).toThrow(/relationship ordering qualifiers/u); - }); - - it("compares stable identities by exact address after SQLite reopen", () => { - const initial = inspection(["1002", "1001"]); - const reopened = structuredClone(inspection(["1001", "1002"])); - (reopened.execution as { outcome: string }).outcome = "no-change"; - (reopened as unknown as { sourceRecords: unknown[] }).sourceRecords = []; - for (const item of reopened.itemResults ?? []) - (item as { outcome: string }).outcome = "no-change"; - - expect(() => - assertBangumiReopenedEpisodeSet(initial, reopened, oracle), - ).not.toThrow(); - - const drifted = structuredClone(reopened); - const accepted = drifted.itemResults?.[0]?.accepted; - if (accepted !== undefined) - (accepted.entity as { id: string }).id = "rebuilt-episode"; - expect(() => - assertBangumiReopenedEpisodeSet(initial, drifted, oracle), - ).toThrow(/stable identity/u); - }); - - it("fails without a Digital-owned connection and copies only a dummy connection at mode 0600", async () => { - let packageRoot = path.resolve(import.meta.dirname, "../.."); - vi.stubEnv("DIGITAL_BANGUMI_LIVE_PRODUCT_DATA", ""); - await expect( - journey.setup({ - mode: "source", - packageRoot, - workDir: "/unused", - productDataDirectory: "/unused/product-data", - environmentName: "completion-02-fixture-context", - commandEnvironment: {}, - }), - ).rejects.toThrow(/authorized Bangumi connection/u); - - const temporary = await mkdtemp( - path.join(tmpdir(), "digital-bangumi-journey-contract-"), - ); - temporaryDirectories.push(temporary); - const workspace = path.join(temporary, "workspace"); - const references = path.join(workspace, "fixtures/live-corpus/references"); - await mkdir(references, { recursive: true }); - await copyFile( - path.resolve( - packageRoot, - "../../fixtures/live-corpus/references/reviewed-oracle.json", - ), - path.join(references, "reviewed-oracle.json"), - ); - // 合成文件只供连接复制和命令计划契约使用,不运行真实来源采集。 - for (const [logicalRoot, count] of [ - ["media/番剧/终将成为你 (2018)", 13], - ["media/番剧/葬送的芙莉莲 (2023)", 38], - ] as const) { - const directory = path.join( - workspace, - ".scratch/acceptance-corpus/assets", - logicalRoot, - ); - await mkdir(directory, { recursive: true }); - await Promise.all( - Array.from({ length: count }, (_, index) => - writeFile( - path.join(directory, `episode-${index + 1}.mkv`), - "synthetic-contract-input", - ), - ), - ); - } - packageRoot = path.join(workspace, "apps/cli"); - const productData = path.join(temporary, "digital-product-data"); - const connectionDirectory = path.join( - productData, - "private-source-connections", - ); - await mkdir(connectionDirectory, { recursive: true }); - const connection = path.join(connectionDirectory, "bangumi-oauth.json"); - await writeFile(connection, "dummy-private-contract-value", { - mode: 0o600, - }); - await chmod(connection, 0o600); - const workDir = path.join(temporary, "runner-work"); - vi.stubEnv("DIGITAL_BANGUMI_LIVE_PRODUCT_DATA", productData); - const context = { - mode: "source" as const, - packageRoot, - workDir, - productDataDirectory: path.join(workDir, "product-data"), - environmentName: "completion-02-fixture-context", - commandEnvironment: {}, - }; - await journey.setup(context); - - const seededDirectory = path.join( - context.productDataDirectory, - "private-source-connections", - ); - const seeded = path.join(seededDirectory, "bangumi-oauth.json"); - expect((await stat(seededDirectory)).mode & 0o777).toBe(0o700); - expect((await stat(seeded)).mode & 0o777).toBe(0o600); - expect(await readFile(seeded, "utf8")).toBe("dummy-private-contract-value"); - const commands = journey.commands(context); - expect(commands.map((command) => command.name)).toEqual([ - "register live Bloom root", - "register live Frieren root", - "live accept Bloom", - "live no-change Bloom after SQLite reopen", - "live metadata refresh Bloom episode", - "live accept Frieren", - "live no-change Frieren after SQLite reopen", - ]); - expect( - commands.every( - (command) => - command.env === undefined || Object.keys(command.env).length === 0, - ), - ).toBe(true); - }); -}); diff --git a/apps/cli/test/integration/command.test.ts b/apps/cli/test/integration/command.test.ts deleted file mode 100644 index 626bfe9..0000000 --- a/apps/cli/test/integration/command.test.ts +++ /dev/null @@ -1,901 +0,0 @@ -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { runProduct } from "@digital/composition"; -import { inspectProductionCatalogueReadiness } from "@digital/db-migrations"; -import { describe, expect, it } from "vitest"; - -import { - runCli, - type CliProductApplication, - type CliRuntime, -} from "../../src/main.ts"; -import { prepareCliProductData } from "../prepare-catalogue.ts"; - -function legacyCliApplication(input: unknown): CliProductApplication { - const legacy = input as { - readonly openAssetRefresh: () => unknown; - readonly openAutomationHost: () => unknown; - readonly createOfflineBackup?: (backupDirectory: string) => Promise; - }; - let workflow: unknown; - let automation: unknown; - return { - get workflow() { - return (workflow ??= legacy.openAssetRefresh()) as never; - }, - get automation() { - return (automation ??= legacy.openAutomationHost()) as never; - }, - async startAutomation() { - await ( - (automation ??= legacy.openAutomationHost()) as { - readonly start?: () => Promise; - } - ).start?.(); - }, - async createOfflineBackup(backupDirectory) { - await legacy.createOfflineBackup?.(backupDirectory); - }, - }; -} - -let commandName = "demo-cli"; -commandName = "cli"; - -function testRuntime( - args: readonly string[], - version = "1.2.3", -): { - readonly runtime: CliRuntime; - readonly stdout: () => string; - readonly stderr: () => string; -} { - let stdout = ""; - let stderr = ""; - return { - runtime: { - argv: ["node", commandName, ...args], - streams: { - stdin: {}, - stdout: { write: (chunk) => (stdout += chunk) }, - stderr: { write: (chunk) => (stderr += chunk) }, - }, - cwd: "/workspace", - env: { MODE: "test" }, - tty: { stdin: false, stdout: false, stderr: false }, - version, - }, - stdout: () => stdout, - stderr: () => stderr, - }; -} - -describe("CLI command control", () => { - it("prepares disposable product data through the reviewed production migrations", async () => { - const productData = await mkdtemp( - path.join(tmpdir(), "digital-disposable-cli-prepare-"), - ); - try { - await prepareCliProductData(productData); - - await expect( - inspectProductionCatalogueReadiness({ - productDataDirectory: productData, - }), - ).resolves.toMatchObject({ state: "ready" }); - } finally { - await rm(productData, { recursive: true, force: true }); - } - }); - - it("parses greet and writes business output to the injected stream", async () => { - const output = testRuntime(["greet", " Ada Lovelace "]); - - await expect(runCli(output.runtime)).resolves.toBe(0); - expect(output.stdout()).toBe("Hello, Ada Lovelace\n"); - expect(output.stderr()).toBe(""); - }); - - it("renders the injected package version", async () => { - const output = testRuntime(["--version"], "9.8.7"); - - await expect(runCli(output.runtime)).resolves.toBe(0); - expect(output.stdout()).toBe("9.8.7\n"); - expect(output.stderr()).toBe(""); - }); - - it("renders Commander help without exiting the process", async () => { - const output = testRuntime(["--help"]); - - await expect(runCli(output.runtime)).resolves.toBe(0); - expect(output.stdout()).toContain( - `Usage: ${commandName} [options] [command]`, - ); - expect(output.stdout()).toContain("greet "); - expect(output.stderr()).toBe(""); - }); - - it("keeps directed re-scrape input source-neutral", async () => { - const output = testRuntime(["rescrape", "--help"]); - - await expect(runCli(output.runtime)).resolves.toBe(0); - expect(output.stdout()).toContain(""); - expect(output.stdout()).toContain(""); - expect(output.stdout()).not.toContain("DLsite"); - expect(output.stderr()).toBe(""); - }); - - it("reports missing arguments as Commander usage errors", async () => { - const output = testRuntime(["greet"]); - - await expect(runCli(output.runtime)).resolves.toBe(1); - expect(output.stdout()).toBe(""); - expect(output.stderr()).toContain( - "error: missing required argument 'name'", - ); - expect(output.stderr()).toContain(`Usage: ${commandName} greet`); - }); - - it("adapts business validation into a testable command error", async () => { - const output = testRuntime(["greet", " "]); - - await expect(runCli(output.runtime)).resolves.toBe(1); - expect(output.stdout()).toBe(""); - expect(output.stderr()).toContain("error: Name must not be empty"); - }); - - it("returns a command failure while the executable root retains lifecycle ownership", async () => { - const productData = await mkdtemp( - path.join(tmpdir(), "digital-disposable-cli-close-"), - ); - await prepareCliProductData(productData); - const output = testRuntime(["inspect", "missing"]); - const product = runProduct( - { productDataDirectory: productData, environment: {} }, - { start: (application) => application }, - ); - try { - await expect(runCli(output.runtime, product.result)).resolves.toBe(1); - expect( - output.stderr().match(/error: 未找到刷新记录 missing/gu), - ).toHaveLength(1); - } finally { - await product.close(); - await rm(productData, { recursive: true, force: true }); - } - }); - - it("applies one saved change-impact preview and renders its safe JSON inspection", async () => { - const output = testRuntime([ - "apply-preview", - "preview-1", - "--actor", - "reviewer", - "--json", - ]); - const calls: string[] = []; - let closes = 0; - const application = { - openAssetRefresh() { - return { - async applyChangeImpactPreview(input: { - readonly previewId: string; - }) { - const { previewId } = input; - calls.push(previewId); - return { status: "applied" }; - }, - async inspectChangeImpactPreview(previewId: string) { - return { id: previewId, status: "applied" }; - }, - close() { - closes += 1; - }, - }; - }, - openAutomationHost() { - throw new Error("not used"); - }, - } as unknown as CliProductApplication; - - await expect( - runCli(output.runtime, legacyCliApplication(application)), - ).resolves.toBe(0); - expect(calls).toEqual(["preview-1"]); - expect(JSON.parse(output.stdout())).toMatchObject({ - format: "temporary-change-impact-preview/v1", - preview: { id: "preview-1", status: "applied" }, - }); - expect(output.stderr()).toBe(""); - expect(closes).toBe(0); - }); - - it("rejects one saved change-impact preview and renders its safe JSON inspection", async () => { - const output = testRuntime([ - "reject-preview", - "preview-2", - "--actor", - "reviewer", - "--json", - ]); - const calls: string[] = []; - let closes = 0; - const application = { - openAssetRefresh() { - return { - async rejectChangeImpactPreview(input: { - readonly previewId: string; - }) { - const { previewId } = input; - calls.push(previewId); - return { id: previewId, status: "rejected" }; - }, - close() { - closes += 1; - }, - }; - }, - openAutomationHost() { - throw new Error("not used"); - }, - } as unknown as CliProductApplication; - - await expect( - runCli(output.runtime, legacyCliApplication(application)), - ).resolves.toBe(0); - expect(calls).toEqual(["preview-2"]); - expect(JSON.parse(output.stdout())).toMatchObject({ - format: "temporary-change-impact-preview/v1", - preview: { id: "preview-2", status: "rejected" }, - }); - expect(output.stderr()).toBe(""); - expect(closes).toBe(0); - }); - - it("continues an ambiguous source decision only through its opaque candidate reference", async () => { - const reference = "a".repeat(64); - const output = testRuntime([ - "select-source-candidate", - "execution-ambiguous", - reference, - "--json", - ]); - const calls: unknown[] = []; - const application = { - openAssetRefresh() { - return { - async selectSourceAssociationCandidate(input: unknown) { - calls.push(input); - return { - executionId: "execution-ambiguous", - state: "awaiting-confirmation", - candidates: [ - { - reference, - outcome: "accepted", - sourceIdentity: "source", - nativeAddress: "opaque:work", - ruleResults: [], - explanation: "候选已选择", - }, - ], - }; - }, - close() {}, - }; - }, - openAutomationHost() { - throw new Error("not used"); - }, - } as unknown as CliProductApplication; - - await expect( - runCli(output.runtime, legacyCliApplication(application)), - ).resolves.toBe(0); - expect(calls).toEqual([ - { executionId: "execution-ambiguous", candidateReference: reference }, - ]); - expect(output.stdout()).not.toContain("sourceRecordIds"); - expect(output.stderr()).toBe(""); - }); - - it("reports a missing change-impact preview without taking root ownership", async () => { - const output = testRuntime([ - "apply-preview", - "missing", - "--actor", - "reviewer", - "--json", - ]); - let closes = 0; - const application = { - openAssetRefresh() { - return { - async applyChangeImpactPreview() { - throw new Error("未找到变更影响预演"); - }, - close() { - closes += 1; - }, - }; - }, - openAutomationHost() { - throw new Error("not used"); - }, - } as unknown as CliProductApplication; - - await expect( - runCli(output.runtime, legacyCliApplication(application)), - ).resolves.toBe(1); - expect(output.stdout()).toBe(""); - expect(output.stderr().match(/error: 未找到变更影响预演/gu)).toHaveLength( - 1, - ); - expect(closes).toBe(0); - }); - - it("reports a stale applied preview explicitly after rendering its inspection", async () => { - const output = testRuntime([ - "apply-preview", - "stale-preview", - "--actor", - "reviewer", - "--json", - ]); - const application = { - openAssetRefresh() { - return { - async applyChangeImpactPreview() { - return { status: "stale" }; - }, - async inspectChangeImpactPreview(previewId: string) { - return { id: previewId, status: "stale" }; - }, - close() {}, - }; - }, - openAutomationHost() { - throw new Error("not used"); - }, - } as unknown as CliProductApplication; - - await expect( - runCli(output.runtime, legacyCliApplication(application)), - ).resolves.toBe(1); - expect(JSON.parse(output.stdout())).toMatchObject({ - preview: { id: "stale-preview", status: "stale" }, - }); - expect(output.stderr().match(/error: 变更影响预演已过期/gu)).toHaveLength( - 1, - ); - }); - - it("reports a missing preview from rejection without taking root ownership", async () => { - const output = testRuntime([ - "reject-preview", - "missing", - "--actor", - "reviewer", - "--json", - ]); - let closes = 0; - const application = { - openAssetRefresh() { - return { - async rejectChangeImpactPreview() { - throw new Error("未找到变更影响预演"); - }, - close() { - closes += 1; - }, - }; - }, - openAutomationHost() { - throw new Error("not used"); - }, - } as unknown as CliProductApplication; - - await expect( - runCli(output.runtime, legacyCliApplication(application)), - ).resolves.toBe(1); - expect(output.stdout()).toBe(""); - expect(output.stderr().match(/error: 未找到变更影响预演/gu)).toHaveLength( - 1, - ); - expect(closes).toBe(0); - }); - - it("reports a stale rejected preview explicitly after rendering its inspection", async () => { - const output = testRuntime([ - "reject-preview", - "stale-preview", - "--actor", - "reviewer", - "--json", - ]); - const application = { - openAssetRefresh() { - return { - async rejectChangeImpactPreview(input: { - readonly previewId: string; - }) { - const { previewId } = input; - return { id: previewId, status: "stale" }; - }, - close() {}, - }; - }, - openAutomationHost() { - throw new Error("not used"); - }, - } as unknown as CliProductApplication; - - await expect( - runCli(output.runtime, legacyCliApplication(application)), - ).resolves.toBe(1); - expect(JSON.parse(output.stdout())).toMatchObject({ - preview: { id: "stale-preview", status: "stale" }, - }); - expect(output.stderr().match(/error: 变更影响预演已过期/gu)).toHaveLength( - 1, - ); - }); - - it("returns nonzero after rendering an inspectable partially succeeded metadata refresh", async () => { - const output = testRuntime(["refresh-metadata", "asset", "--json"]); - const application = { - openAssetRefresh() { - return { - async refreshMetadata() { - return { - execution: { - id: "partial", - selection: { - intent: "metadata-refresh", - }, - operationKind: "refresh-metadata", - requestedSelection: { - version: "asset-refresh-selection/1", - subjects: [], - filesystemScopes: [], - }, - resolvedSelection: { - version: "asset-refresh-selection/1", - subjects: [], - filesystemScopes: [], - }, - targetDispositions: [], - filesystemSnapshots: [], - status: "completed", - stage: "completed", - outcome: "partially-succeeded", - sourceRecordIds: [], - reason: "可选来源未完成", - dispositions: [], - }, - sourceRecords: [], - }; - }, - close() {}, - }; - }, - openAutomationHost() { - throw new Error("not used"); - }, - } as unknown as CliProductApplication; - - await expect( - runCli(output.runtime, legacyCliApplication(application)), - ).resolves.toBe(1); - expect(JSON.parse(output.stdout())).toMatchObject({ - execution: { - id: "partial", - outcome: "partially-succeeded", - reason: "可选来源未完成", - }, - }); - expect(output.stderr()).toContain("error: 可选来源未完成"); - expect(output.stderr().match(/error: 可选来源未完成/gu)).toHaveLength(1); - }); - - it("renders every source and the effective metadata audit for each work item", async () => { - const output = testRuntime(["refresh-metadata", "asset"]); - const jsonOutput = testRuntime(["refresh-metadata", "asset", "--json"]); - const entityTarget = { kind: "entity", entityId: "asset" } as const; - const accepted = { - entity: { id: "asset", kind: "digital-asset" }, - recognitionSourceAssociation: { - sourceIdentity: "tmdb", - nativeAddress: "tmdb:9509", - }, - sourceAssociations: [ - { sourceIdentity: "tmdb", nativeAddress: "tmdb:9509" }, - { sourceIdentity: "omdb", nativeAddress: "omdb:tt0328107" }, - ], - // 原始逐项来源断言保留全部候选,包括未被采用的 tmdb 评分候选。 - metadata: [ - { - target: entityTarget, - property: "asset.rating", - sourceSlot: "omdb.rating", - value: "7.7", - sourceRecordIds: ["omdb-record"], - }, - { - target: entityTarget, - property: "asset.rating", - sourceSlot: "tmdb.vote-average", - value: "7.2", - sourceRecordIds: ["tmdb-record"], - }, - { - target: entityTarget, - property: "asset.title", - sourceSlot: "tmdb.title", - value: "怒火救援", - sourceRecordIds: ["tmdb-record"], - }, - { - target: entityTarget, - property: "asset.title", - sourceSlot: "omdb.title", - value: "怒火救援:完全版", - sourceRecordIds: ["omdb-record"], - }, - ], - entities: [], - relationships: [ - { - id: "rel-director", - type: "credit.directed_by", - sourceSlot: "tmdb.director", - participants: [ - { entityId: "asset", role: "subject" }, - { entityId: "person-director", role: "object" }, - ], - sourceRecordIds: ["tmdb-record"], - }, - ], - recognitionExplanation: "已由严格目录规则识别", - artifacts: [{ id: "cover-artifact" }], - blobs: [ - { - digest: "cover-digest", - mediaType: "image/jpeg", - byteLength: 4, - }, - ], - artifactRevisions: [ - { - id: "cover-revision", - artifactId: "cover-artifact", - blobDigest: "cover-digest", - }, - ], - locations: [ - { - id: "cover-location", - artifactId: "cover-artifact", - kind: "managed", - address: "poster.jpg", - }, - ], - artifactAssociations: [ - { - id: "cover-association", - target: entityTarget, - artifactId: "cover-artifact", - role: "asset.primary-cover", - sourceRecordIds: ["tmdb-record"], - }, - ], - currentRelatedEntities: [ - { - id: "person-director", - sourceIdentity: "tmdb", - sourceKey: "p-1", - identityNamespace: "tmdb:person", - label: "导演甲", - sourceRecordIds: ["tmdb-record"], - kind: { status: "ordinary" }, - }, - ], - // 共同生效读取:普通消费路径唯一的事实契约。 - facts: { - id: "asset", - recognitionSourceAssociation: { - sourceIdentity: "tmdb", - nativeAddress: "tmdb:9509", - }, - sourceAssociations: [ - { sourceIdentity: "tmdb", nativeAddress: "tmdb:9509" }, - { sourceIdentity: "omdb", nativeAddress: "omdb:tt0328107" }, - ], - descriptions: [ - { - target: entityTarget, - metadataUsageContext: "asset.ordinary", - metadata: [ - { - property: "asset.rating", - result: { - status: "resolved", - values: [ - { - property: "asset.rating", - value: "7.7", - sourceIdentity: "omdb", - sourceSlot: "omdb.rating", - sourceRecordIds: ["omdb-record"], - assertions: [ - { - target: entityTarget, - sourceSlot: "omdb.rating", - sourceRecordIds: ["omdb-record"], - }, - ], - reasoning: { - kind: "source-preference", - preferredSource: "omdb", - }, - }, - ], - }, - }, - { - property: "asset.title", - result: { - status: "conflict", - conflict: { - kind: "policy-conflict", - property: "asset.title", - references: [ - { - kind: "metadata", - target: entityTarget, - sourceSlot: "tmdb.title", - sourceRecordIds: ["tmdb-record"], - }, - { - kind: "metadata", - target: entityTarget, - sourceSlot: "omdb.title", - sourceRecordIds: ["omdb-record"], - }, - ], - explanation: "规则 first、second 尚未确定先后", - }, - }, - }, - { property: "asset.runtime", result: { status: "missing" } }, - ], - metadataScope: { kind: "all-available" }, - artifactRoles: [ - { - role: "asset.primary-cover", - artifactId: "cover-artifact", - associationIds: ["cover-association"], - locationId: "cover-location", - address: "poster.jpg", - }, - ], - missingArtifactRoles: ["asset.material-image"], - conflicts: [ - { - kind: "policy-conflict", - property: "asset.title", - references: [ - { - kind: "metadata", - target: entityTarget, - sourceSlot: "tmdb.title", - sourceRecordIds: ["tmdb-record"], - }, - { - kind: "metadata", - target: entityTarget, - sourceSlot: "omdb.title", - sourceRecordIds: ["omdb-record"], - }, - ], - explanation: "规则 first、second 尚未确定先后", - }, - ], - }, - ], - relatedEntities: [ - { - id: "person-director", - kind: { status: "ordinary" }, - label: "导演甲", - sourceIdentity: "tmdb", - identityNamespace: "tmdb:person", - sourceKey: "p-1", - }, - ], - relationships: [ - { - id: "rel-director", - type: "credit.directed_by", - participants: [ - { entityId: "asset", role: "subject" }, - { entityId: "person-director", role: "object" }, - ], - sourceRecordIds: ["tmdb-record"], - }, - ], - locations: [ - { - id: "cover-location", - artifactId: "cover-artifact", - kind: "managed", - address: "poster.jpg", - content: { - status: "confirmed", - target: { - kind: "artifact-revision", - artifactId: "cover-artifact", - revisionId: "cover-revision", - }, - }, - }, - ], - }, - }; - const inspectionResult = { - execution: { - id: "itemized", - selection: { intent: "metadata-refresh" }, - operationKind: "refresh-metadata", - requestedSelection: { - version: "asset-refresh-selection/1", - subjects: [], - filesystemScopes: [], - }, - resolvedSelection: { - version: "asset-refresh-selection/1", - subjects: [], - filesystemScopes: [], - }, - targetDispositions: [], - filesystemSnapshots: [], - status: "completed", - stage: "completed", - outcome: "accepted", - sourceRecordIds: ["tmdb-record", "omdb-record"], - dispositions: [], - }, - sourceRecords: [], - accepted, - itemResults: [ - { - key: "movie", - relativePaths: ["怒火救援 (2004)/怒火救援 (2004).mkv"], - outcome: "accepted", - recognition: { - status: "accepted", - explanation: "已由严格目录规则识别", - }, - accepted, - }, - ], - }; - const application = { - openAssetRefresh() { - return { - async refreshMetadata() { - return inspectionResult; - }, - close() {}, - }; - }, - openAutomationHost() { - throw new Error("not used"); - }, - } as unknown as CliProductApplication; - - await expect( - runCli(output.runtime, legacyCliApplication(application)), - output.stderr(), - ).resolves.toBe(0); - expect( - await runCli(jsonOutput.runtime, legacyCliApplication(application)), - jsonOutput.stderr(), - ).toBe(0); - expect(output.stdout()).toContain("\n tmdb:tmdb:9509"); - expect(output.stdout()).toContain("\n omdb:omdb:tt0328107"); - expect(output.stdout()).toContain( - "\n当前采用:\n asset.rating:7.7(omdb;记录 omdb-record;来源位置 omdb.rating)\n 选择依据:采用首选来源 omdb", - ); - expect(output.stdout()).toContain( - "\n元数据规则冲突:\n asset.title:存在尚未解决的资料选择,当前没有采用值", - ); - expect(output.stdout()).toContain(" 导演甲(entity)"); - expect(output.stdout()).toContain( - " credit.directed_by:subject:asset;object:导演甲(记录 tmdb-record)", - ); - expect(output.stdout()).toContain("识别说明:已由严格目录规则识别"); - expect(output.stdout()).toContain( - " asset.primary-cover:poster.jpg(已托管)", - ); - expect(output.stdout()).toContain("边界 movie:accepted"); - expect(output.stdout()).toContain("\n tmdb:tmdb:9509"); - expect(output.stdout()).toContain( - "\n 当前采用:\n asset.rating:7.7(omdb;记录 omdb-record;来源位置 omdb.rating)\n 选择依据:采用首选来源 omdb", - ); - expect(output.stdout()).toContain( - "\n 元数据规则冲突:\n asset.title:存在尚未解决的资料选择,当前没有采用值", - ); - expect(output.stdout()).not.toContain("7.2"); - expect(output.stdout()).not.toContain("tmdb.vote-average"); - expect(output.stdout()).not.toContain("first、second"); - - const json = JSON.parse(jsonOutput.stdout()) as typeof inspectionResult; - expect(json.accepted?.entity.id).toBe("asset"); - expect(json.accepted?.facts.id).toBe("asset"); - expect(json.accepted?.metadata).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - property: "asset.rating", - value: "7.2", - sourceSlot: "tmdb.vote-average", - sourceRecordIds: ["tmdb-record"], - }), - ]), - ); - const [description] = json.accepted?.facts.descriptions ?? []; - expect(description?.metadata).toEqual([ - expect.objectContaining({ - property: "asset.rating", - result: expect.objectContaining({ - status: "resolved", - values: [ - expect.objectContaining({ - value: "7.7", - sourceIdentity: "omdb", - sourceSlot: "omdb.rating", - }), - ], - }), - }), - expect.objectContaining({ - property: "asset.title", - result: expect.objectContaining({ - status: "conflict", - conflict: expect.objectContaining({ - kind: "policy-conflict", - property: "asset.title", - explanation: "规则 first、second 尚未确定先后", - }), - }), - }), - expect.objectContaining({ - property: "asset.runtime", - result: { status: "missing" }, - }), - ]); - expect(description?.artifactRoles).toEqual([ - expect.objectContaining({ - role: "asset.primary-cover", - artifactId: "cover-artifact", - associationIds: ["cover-association"], - }), - ]); - expect(description?.missingArtifactRoles).toEqual(["asset.material-image"]); - expect(json.accepted?.facts.locations).toEqual([ - expect.objectContaining({ - id: "cover-location", - kind: "managed", - content: expect.objectContaining({ - status: "confirmed", - target: expect.objectContaining({ revisionId: "cover-revision" }), - }), - }), - ]); - expect( - json.itemResults?.[0]?.accepted?.facts.descriptions[0]?.metadata, - ).toEqual(description?.metadata); - }); -}); diff --git a/apps/cli/test/integration/controlled-fanbox-session.test.ts b/apps/cli/test/integration/controlled-fanbox-session.test.ts deleted file mode 100644 index e130bb0..0000000 --- a/apps/cli/test/integration/controlled-fanbox-session.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { execFile } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; - -import { loadControlledSourceIo } from "@digital/test-support/source-io"; -import { expect, it } from "vitest"; - -import { prepareCliProductData } from "../prepare-catalogue.ts"; - -const execFileAsync = promisify(execFile); - -const workspaceRoot = path.resolve( - fileURLToPath(new URL(".", import.meta.url)), - "..", - "..", - "..", - "..", -); -const cliEntry = path.join(workspaceRoot, "apps", "cli", "dist", "cli.js"); -const fixtureDirectory = path.join( - workspaceRoot, - "fixtures", - "controlled-fanbox-session", -); -const materialDirectory = path.join(fixtureDirectory, "material"); -const credentialFile = path.join( - fixtureDirectory, - "connections", - "synthetic-customudon.json", -); - -const creatorId = "customudon"; -const postId = "7225781"; -const postTitle = "Movie | コスプレ4"; -const publicationAddress = `https://www.fanbox.cc/@${creatorId}/posts/${postId}`; - -type ChildResult = { - readonly code: number; - readonly stdout: string; - readonly stderr: string; -}; - -/** - * 正式构建的 CLI 可执行文件在独立操作系统子进程中运行:每次命令都自己完成受控来源选择、 - * 打开产品根、再关闭。子进程环境只给出白名单变量,不继承父进程的任何来源秘密。 - */ -async function runFormalCli( - args: readonly string[], - env: Readonly>, -): Promise { - try { - const { stdout, stderr } = await execFileAsync( - process.execPath, - [cliEntry, ...args], - { - cwd: workspaceRoot, - env, - maxBuffer: 64 * 1024 * 1024, - timeout: 300_000, - }, - ); - return { code: 0, stdout, stderr }; - } catch (failure) { - const error = failure as { - code?: unknown; - stdout?: unknown; - stderr?: unknown; - }; - return { - code: typeof error.code === "number" ? error.code : 1, - stdout: typeof error.stdout === "string" ? error.stdout : "", - stderr: typeof error.stderr === "string" ? error.stderr : "", - }; - } -} - -async function controlledEnvironment(input: { - readonly manifest: string; - readonly data: string; -}): Promise> { - const manifestPath = path.join(fixtureDirectory, input.manifest); - const { contentIdentity } = await loadControlledSourceIo({ manifestPath }); - return { - PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", - HOME: process.env.HOME ?? "/root", - TMPDIR: process.env.TMPDIR ?? tmpdir(), - DIGITAL_PRODUCT_DATA: input.data, - DIGITAL_SOURCE_IO_MODE: "controlled", - DIGITAL_SOURCE_IO_CONFIG: manifestPath, - DIGITAL_SOURCE_IO_IDENTITY: contentIdentity, - }; -} - -async function disposableRoot(marker: string): Promise { - return await mkdtemp(path.join(tmpdir(), `digital-disposable-${marker}-`)); -} - -function jsonValues(stdout: string): any[] { - const values: any[] = []; - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) continue; - values.push(JSON.parse(trimmed) as any); - } - return values; -} - -/** 正式检查投影里的已接受主体视角:单项执行与多边界执行都覆盖。 */ -function acceptedView(inspection: any): any { - return inspection?.accepted ?? inspection?.itemResults?.[0]?.accepted; -} - -it("正式 CLI 子进程经产品保存合成会话连接、由目录识别形成帖子并托管来源主图", async () => { - const temporary = await disposableRoot("t09-cli-fanbox"); - const data = path.join(temporary, "data"); - const managedDirectory = path.join(data, "managed-artifacts"); - try { - await prepareCliProductData(data); - const env = await controlledEnvironment({ - manifest: "manifest.json", - data, - }); - const imported = await runFormalCli( - ["fanbox-connection-import", creatorId, credentialFile], - env, - ); - expect(imported.code, imported.stderr).toBe(0); - const connection = jsonValues(imported.stdout).at(-1); - expect(connection).toMatchObject({ - creatorId, - version: 1, - credentialStatus: "configured", - availability: "available", - }); - // 合成凭据不回显在 CLI 的任何输出通道里。 - const credential = JSON.parse(await readFile(credentialFile, "utf8")) as { - userAgent: string; - cookies: { name: string; value: string }[]; - }; - const echoed = `${imported.stdout}${imported.stderr}`; - for (const secret of [ - "FANBOXSESSID", - credential.cookies[0]!.value, - credential.userAgent, - ]) - expect(echoed).not.toContain(secret); - - const created = await runFormalCli( - [ - "library-create", - "受控 FANBOX 会话库", - "--directory", - materialDirectory, - "--profile", - "fanbox", - "--source", - "fanbox", - "--json", - ], - env, - ); - expect(created.code, created.stderr).toBe(0); - const rootIds = jsonValues(created.stdout).at(-1)!.rootIds as string[]; - expect(rootIds).toHaveLength(1); - - // 帖子只经正式扫描与识别工作流形成:任何命令都没有收到 postId。 - const scanned = await runFormalCli( - ["scan", rootIds[0]!, "--scope", ".", "--json"], - env, - ); - expect(scanned.code, scanned.stderr).toBe(0); - const inspection = jsonValues(scanned.stdout).at(-1); - expect(inspection.execution.outcome).toBe("accepted"); - const accepted = acceptedView(inspection); - expect(accepted.entity.id).toMatch(/^entity_/u); - expect(accepted.sourceAssociations).toEqual([ - { sourceIdentity: "fanbox", nativeAddress: publicationAddress }, - ]); - - const facts = JSON.stringify(accepted.facts); - expect(facts).toContain(postTitle); - expect(facts).toContain("2023-12-31T17:10:55+09:00"); - // 交付视图不含受控拒绝的边界标识,也不含合成凭据。 - const delivered = JSON.stringify(inspection); - expect(delivered).not.toContain("受控来源拒绝"); - expect(delivered).not.toContain("请求键"); - expect(delivered).not.toContain('"fanbox-session"'); - expect(delivered).not.toContain(credential.cookies[0]!.value); - - // CLI 观察本次受控响应托管到当前产品根的主图:位置、摘要、媒体与来源证据。 - const revision = accepted.artifactRevisions.find( - (item: any) => item.artifactId === accepted.primaryCoverArtifactId, - ); - expect(revision.producer.capabilityId).toBe( - "fanbox.source-artifact-acquirer", - ); - const cover = new Uint8Array( - await readFile( - path.join(fixtureDirectory, "responses", "fanbox-cover.png"), - ), - ); - const coverDigest = createHash("sha256").update(cover).digest("hex"); - expect(revision.blobDigest).toBe(coverDigest); - const blob = accepted.blobs.find( - (item: any) => item.digest === coverDigest, - ); - expect(blob.mediaType).toBe("image/png"); - expect(blob.byteLength).toBe(cover.byteLength); - const managed = accepted.locations.find( - (item: any) => - item.kind === "managed" && - item.artifactId === accepted.primaryCoverArtifactId, - ); - expect(managed.address).toBe( - path.join(managedDirectory, coverDigest.slice(0, 2), coverDigest), - ); - const managedBytes = new Uint8Array(await readFile(managed.address)); - expect(managedBytes).toEqual(cover); - // 独立解码:托管字节确为一张有效的 8×8 PNG。 - const managedBuffer = Buffer.from(managedBytes); - expect(managedBuffer.subarray(0, 8).toString("hex")).toBe( - "89504e470d0a1a0a", - ); - expect(managedBuffer.readUInt32BE(16)).toBe(8); - expect(managedBuffer.readUInt32BE(20)).toBe(8); - // 主图的来源证据指向同一篇帖子的官方来源记录。 - const coverAssociation = accepted.artifactAssociations.find( - (item: any) => - item.role === "asset.primary-cover" && - item.artifactId === accepted.primaryCoverArtifactId, - ); - const recordedIds = new Set( - inspection.sourceRecords.map((item: any) => item.id), - ); - expect( - coverAssociation.sourceRecordIds.every((id: string) => - recordedIds.has(id), - ), - ).toBe(true); - - // 重启后连接由产品私有存储恢复,且仍然不含真实凭据。 - const listed = await runFormalCli(["source-connection-list"], env); - expect(listed.code, listed.stderr).toBe(0); - expect(JSON.stringify(jsonValues(listed.stdout).at(-1))).toContain( - creatorId, - ); - expect(listed.stdout).not.toContain(credential.cookies[0]!.value); - } finally { - await rm(temporary, { recursive: true, force: true }); - } -}, 600_000); - -it("缺少会话条目的清单让正式 CLI 连接导入失败且不落库", async () => { - const temporary = await disposableRoot("t09-cli-fanbox-refusal"); - const data = path.join(temporary, "data"); - try { - await prepareCliProductData(data); - const env = await controlledEnvironment({ - manifest: "manifest-without-session.json", - data, - }); - const imported = await runFormalCli( - ["fanbox-connection-import", creatorId, credentialFile], - env, - ); - expect(imported.code).toBe(1); - const output = `${imported.stdout}${imported.stderr}`; - expect(output).toContain("FANBOX 来源连接验证失败"); - // 受控拒绝的诊断标识与合成凭据都不进入 CLI 输出。 - expect(output).not.toContain("受控来源拒绝"); - expect(output).not.toContain("请求键"); - expect(output).not.toContain("fanbox-session"); - expect(output).not.toContain("FANBOXSESSID"); - - const listed = await runFormalCli(["source-connection-list"], env); - expect(listed.code, listed.stderr).toBe(0); - expect(JSON.stringify(jsonValues(listed.stdout).at(-1))).not.toContain( - creatorId, - ); - } finally { - await rm(temporary, { recursive: true, force: true }); - } -}, 300_000); diff --git a/apps/cli/test/integration/fanbox-five-live-diagnostic.test.ts b/apps/cli/test/integration/fanbox-five-live-diagnostic.test.ts deleted file mode 100644 index d58e600..0000000 --- a/apps/cli/test/integration/fanbox-five-live-diagnostic.test.ts +++ /dev/null @@ -1,1332 +0,0 @@ -import { - appendFile, - chmod, - lstat, - mkdir, - mkdtemp, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import type { CliJourneyContext, CliJourneyResult } from "../e2e/journey.ts"; -import journey, { - assertFanboxAcceptedPost, - assertFanboxRefreshedPost, - type FanboxJourneyInspection, - type FanboxPostOracle, -} from "../e2e/journeys/fanbox-five-live.journey.ts"; - -const evidenceEnvironment = "DIGITAL_FANBOX_LIVE_FAILURE_EVIDENCE_PATH"; -const cleanup: string[] = []; - -afterEach(async () => { - delete process.env[evidenceEnvironment]; - await Promise.all( - cleanup - .splice(0) - .map(async (directory) => await rm(directory, { recursive: true })), - ); -}); - -function reviewedPost(index: number): FanboxPostOracle { - return { - logicalRoot: `fanbox/post-${index}`, - directoryDate: `2026-01-0${index}`, - sourceNativeAddress: String(1000 + index), - title: `reviewed post ${index}`, - publishedDatetime: `2026-01-0${index}T00:00:00Z`, - postType: "text", - creatorId: "customudon", - entitlement: "restricted", - cover: "absent", - body: { status: "restricted", blocks: [] }, - attachments: [], - }; -} - -async function syntheticJourney( - options: { - readonly ownerTicket?: string; - } = {}, -): Promise<{ - readonly context: CliJourneyContext; - readonly posts: readonly FanboxPostOracle[]; - readonly mediaFile: string; - readonly evidencePath: string; -}> { - const directory = await mkdtemp( - path.join(tmpdir(), "digital-fanbox-diagnostic-"), - ); - cleanup.push(directory); - const packageRoot = path.join(directory, "apps", "cli"); - const corpus = path.join(directory, ".scratch", "acceptance-corpus"); - const reviewedCorpus = path.join(directory, "fixtures", "live-corpus"); - const references = path.join(reviewedCorpus, "references"); - const assets = path.join(corpus, "assets"); - const evidenceParent = path.join(directory, "evidence"); - const workDir = path.join(directory, "work"); - await Promise.all([ - mkdir(packageRoot, { recursive: true }), - mkdir(references, { recursive: true }), - mkdir(evidenceParent, { recursive: true }), - mkdir(workDir, { recursive: true }), - ]); - await chmod(evidenceParent, 0o700); - - const posts = Array.from({ length: 5 }, (_, index) => - reviewedPost(index + 1), - ); - const otherOwned = Array.from( - { length: 19 }, - (_, index) => `owned/root-${index + 1}`, - ); - const unowned = Array.from( - { length: 5 }, - (_, index) => `unowned/root-${index + 1}`, - ); - const logicalRoots = [ - ...posts.map((post) => post.logicalRoot), - ...otherOwned, - ...unowned, - ]; - for (const post of posts) { - const root = path.join(assets, ...post.logicalRoot.split("/")); - await mkdir(root, { recursive: true }); - await writeFile(path.join(root, "media.bin"), `media-${post.logicalRoot}`); - } - await writeFile( - path.join(reviewedCorpus, "files-from.txt"), - `${logicalRoots.join("\n")}\n`, - ); - let ownedOffset = 0; - const ownership = [ - ["01-dlsite-eight-live-gate.md", "dlsite-eight-live", "implemented", 8], - ["02-fanbox-five-live-gate.md", "fanbox-five-live", "external-gated", 0], - [ - "03-epub-publication-live-gate.md", - "epub-publication-live", - "implemented", - 6, - ], - ["04-pdf-series-live-gate.md", "pdf-publication-live", "implemented", 1], - [ - "05-bangumi-episodic-live-gate.md", - "bangumi-episodic-live", - "implemented", - 2, - ], - ["06-film-tmdb-omdb-live-gate.md", "tmdb-omdb-live", "implemented", 2], - ].map(([ticket, ownerJourney, status, rawCount]) => { - const count = rawCount as number; - const roots = - count === 0 - ? posts.map((post) => post.logicalRoot) - : otherOwned.slice(ownedOffset, (ownedOffset += count)); - return { - ownerTicket: `v1-live-acceptance/issues/${ticket}`, - journey: ownerJourney, - status, - roots, - }; - }); - if (options.ownerTicket !== undefined) - ownership[1]!.ownerTicket = options.ownerTicket; - await writeFile( - path.join(references, "reviewed-oracle.json"), - JSON.stringify({ - reviews: { fanbox: { status: "reviewed-current-official-source" } }, - creatorPosts: posts, - dedicatedIntegrationOwnershipGroups: ownership, - }), - ); - - const context = { - mode: "source", - packageRoot, - workDir, - productDataDirectory: path.join(workDir, "product-data"), - environmentName: "completion-02-fixture-context", - commandEnvironment: {}, - } as const; - await journey.setup(context); - return { - context, - posts, - mediaFile: path.join( - assets, - ...posts[0]!.logicalRoot.split("/"), - "media.bin", - ), - evidencePath: path.join(evidenceParent, "failure.json"), - }; -} - -function commandResults( - context: CliJourneyContext, - posts: readonly FanboxPostOracle[], - throughOrdinal: number, -): CliJourneyResult[] { - return journey - .commands(context) - .slice(0, throughOrdinal) - .map((command, index) => ({ - commandName: command.name, - exitCode: 0, - stdout: - index >= 1 && index <= 5 - ? JSON.stringify({ rootIds: [`root-${index}`] }) - : "", - stderr: "", - })); -} - -function acceptedInspection(post: FanboxPostOracle, index: number): unknown { - const sourceRecordIds = [`summary-${index}`, `detail-${index}`]; - const artifactId = `observed-${index}`; - const managedCoverArtifactId = `managed-cover-${index}`; - return { - format: "temporary-asset-refresh-inspection/v1", - execution: { outcome: "accepted" }, - sourceRecords: [ - { - id: sourceRecordIds[0], - sourceIdentity: "fanbox", - coverage: { id: "fanbox.creator-post-summaries" }, - }, - { - id: sourceRecordIds[1], - sourceIdentity: "fanbox", - coverage: { id: "fanbox.post-detail" }, - }, - ], - accepted: { - entity: { id: `asset-${index}` }, - sourceAssociations: [ - { - sourceIdentity: "fanbox", - nativeAddress: `https://www.fanbox.cc/@customudon/posts/${post.sourceNativeAddress}`, - }, - ], - recognitionSourceAssociation: { - sourceIdentity: "fanbox", - nativeAddress: `https://www.fanbox.cc/@customudon/posts/${post.sourceNativeAddress}`, - }, - artifacts: [ - { id: artifactId }, - ...(post.cover === "present" ? [{ id: managedCoverArtifactId }] : []), - ], - artifactAssociations: [ - { artifactId, role: "asset.material-delivered" }, - ...(post.cover === "present" - ? [ - { - artifactId: managedCoverArtifactId, - role: "asset.primary-cover", - sourceRecordIds, - }, - ] - : []), - ], - locations: [ - { artifactId, kind: "observed" }, - ...(post.cover === "present" - ? [{ artifactId: managedCoverArtifactId, kind: "managed" }] - : []), - ], - blobs: [ - { - digest: `sha256:observed-${index}`, - mediaType: "application/json", - byteLength: 1, - }, - ], - artifactRevisions: [ - { - id: `revision-${index}`, - artifactId, - blobDigest: `sha256:observed-${index}`, - }, - ], - metadata: [ - ...( - [ - [ - "asset.title", - post.title, - "fanbox.post.title", - [sourceRecordIds[1]!], - ], - [ - "asset.publication-datetime", - post.publishedDatetime, - "fanbox.post.publication-datetime", - [sourceRecordIds[1]!], - ], - [ - "fanbox.post.form", - post.postType, - "fanbox.post.form", - [sourceRecordIds[1]!], - ], - [ - "fanbox.post.entitlement", - post.entitlement, - "fanbox.post.entitlement", - [sourceRecordIds[1]!], - ], - ] as const - ).map(([property, value, sourceSlot, factSourceRecordIds]) => ({ - property, - value, - sourceSlot, - sourceRecordIds: factSourceRecordIds, - })), - { - property: "asset.local-observation", - value: `observed-${index}`, - sourceSlot: "local.observation", - sourceRecordIds: [], - observedArtifactPart: { - artifactId, - revisionId: `revision-${index}`, - locator: "metadata.json", - }, - }, - ], - sourceArtifactCandidates: [ - { - sourceRecordId: sourceRecordIds[1], - role: "asset.primary-cover", - mediaType: "image/jpeg", - }, - ], - entities: [ - { id: "creator-1", sourceKey: post.creatorId, sourceRecordIds }, - ], - entityMentions: [{ sourceRecordIds }], - entityResolutionDecisions: [{ sourceRecordIds }], - relationships: [{ sourceRecordIds }], - }, - }; -} - -function refreshedInspectionWithRetainedFacts( - post: FanboxPostOracle, - index: number, -): unknown { - const refreshed = structuredClone(acceptedInspection(post, index)) as { - sourceRecords: Array<{ id: string }>; - accepted: { relationships: MutableSourcedFact[] }; - }; - refreshed.sourceRecords[0]!.id = `refresh-summary-${index}`; - refreshed.sourceRecords[1]!.id = `refresh-detail-${index}`; - refreshed.accepted.relationships.push({ - sourceRecordIds: [`refresh-detail-${index}`], - }); - return refreshed; -} - -type MutableSourcedFact = { sourceRecordIds: string[] }; -type MutableInspection = FanboxJourneyInspection & { - sourceRecords: Array<{ - id: string; - sourceIdentity: string; - coverage: { id: string }; - }>; - accepted: NonNullable & { - metadata: Array< - MutableSourcedFact & { - property: string; - sourceSlot: string; - value: string; - } - >; - entities: MutableSourcedFact[]; - entityMentions: MutableSourcedFact[]; - entityResolutionDecisions: MutableSourcedFact[]; - relationships: MutableSourcedFact[]; - sourceArtifactCandidates: Array<{ sourceRecordId: string }>; - artifactAssociations: Array<{ sourceRecordIds?: string[] }>; - }; -}; - -function mutableInspection(value: unknown): MutableInspection { - return structuredClone(value) as MutableInspection; -} - -function malformedMetadataInspections( - post: FanboxPostOracle, -): readonly unknown[] { - const withoutObservedPart = structuredClone(acceptedInspection(post, 1)) as { - accepted: { metadata: Record[] }; - }; - delete withoutObservedPart.accepted.metadata.at(-1)!.observedArtifactPart; - - const unknownRevision = structuredClone(acceptedInspection(post, 1)) as { - accepted: { metadata: Record[] }; - }; - unknownRevision.accepted.metadata.at(-1)!.observedArtifactPart = { - artifactId: "observed-1", - revisionId: "unknown-revision", - locator: "metadata.json", - }; - - const forgedSourceFact = structuredClone(acceptedInspection(post, 1)) as { - accepted: { metadata: Record[] }; - }; - forgedSourceFact.accepted.metadata.push({ - property: "fanbox.post.like-count", - value: "10", - sourceSlot: "local.observation", - sourceRecordIds: [], - observedArtifactPart: { - artifactId: "observed-1", - revisionId: "revision-1", - locator: "metadata.json", - }, - }); - - const mixedProvenance = structuredClone(acceptedInspection(post, 1)) as { - accepted: { metadata: Array<{ sourceRecordIds: string[] }> }; - }; - mixedProvenance.accepted.metadata.at(-1)!.sourceRecordIds = ["detail-1"]; - - return [ - withoutObservedPart, - unknownRevision, - forgedSourceFact, - mixedProvenance, - ]; -} - -function successfulResults( - context: CliJourneyContext, - posts: readonly FanboxPostOracle[], -): readonly CliJourneyResult[] { - const results = commandResults(context, posts, 21); - for (const [index, post] of posts.entries()) { - const initial = acceptedInspection(post, index + 1) as Record< - string, - unknown - >; - results[6 + index] = { - ...results[6 + index]!, - stdout: JSON.stringify(initial), - }; - results[11 + index] = { - ...results[11 + index]!, - stdout: JSON.stringify({ - ...initial, - execution: { outcome: "no-change" }, - sourceRecords: [], - }), - }; - results[16 + index] = { - ...results[16 + index]!, - stdout: JSON.stringify( - refreshedInspectionWithRetainedFacts(post, index + 1), - ), - }; - } - return results; -} - -async function evidenceLines( - pathname: string, -): Promise[]> { - const contents = await readFile(pathname, "utf8"); - if (!contents.endsWith("\n")) throw new Error("证据日志缺少末尾换行"); - return contents - .slice(0, -1) - .split("\n") - .map((line) => JSON.parse(line) as Record); -} - -async function recoverableCause( - pathname: string, -): Promise> { - const contents = await readFile(pathname, "utf8"); - const boundary = contents.indexOf("\n"); - if (boundary < 0) throw new Error("证据日志没有完整首因行"); - return JSON.parse(contents.slice(0, boundary)) as Record; -} - -function errorContains(caught: unknown, sentinel: string): boolean { - expect(caught).toBeInstanceOf(Error); - const assertion = caught as Error & { - actual?: unknown; - expected?: unknown; - cause?: unknown; - }; - return [ - assertion.message, - assertion.stack, - JSON.stringify(assertion.actual), - JSON.stringify(assertion.expected), - JSON.stringify(assertion.cause), - assertion.cause instanceof Error ? assertion.cause.message : undefined, - ] - .join("\n") - .includes(sentinel); -} - -describe("FANBOX 实时失败诊断", () => { - it("setup ownership routing 错误不会携带 ownerTicket 原值", async () => { - const sentinel = "private-owner-ticket-sentinel"; - let caught: unknown; - try { - await syntheticJourney({ ownerTicket: sentinel }); - } catch (error) { - caught = error; - } - - expect(errorContains(caught, sentinel)).toBe(false); - const routingError = caught as Error & { cause?: unknown }; - expect(routingError.message).toBe( - "FANBOX fixed-corpus ownership routing is invalid", - ); - expect(routingError.cause).toBeUndefined(); - }); - - it("允许 refresh 同值事实保留 initial 来源证明", () => { - const post = reviewedPost(1); - const initial = acceptedInspection(post, 1); - const refreshed = refreshedInspectionWithRetainedFacts(post, 1); - - expect(() => - assertFanboxRefreshedPost( - initial as Parameters[0], - refreshed as Parameters[1], - post, - ), - ).not.toThrow(); - }); - - it("拒绝 refresh 重复消费同一个 initial 来源事实", () => { - const post = reviewedPost(1); - const initial = acceptedInspection(post, 1); - const refreshed = refreshedInspectionWithRetainedFacts(post, 1); - const mutableRefreshed = refreshed as { - accepted: { relationships: MutableSourcedFact[] }; - }; - mutableRefreshed.accepted.relationships.push( - structuredClone(mutableRefreshed.accepted.relationships[0]!), - ); - - expect(() => - assertFanboxRefreshedPost( - initial as Parameters[0], - refreshed as Parameters[1], - post, - ), - ).toThrow("FANBOX refresh relationship provenance is invalid"); - }); - - it("拒绝无效的 FANBOX 两代来源记录与投影事实证明", () => { - const post = reviewedPost(1); - const invalidInitialRecords: Array<(value: MutableInspection) => void> = [ - (value) => { - value.sourceRecords[0]!.id = " "; - }, - (value) => { - value.sourceRecords[1]!.id = value.sourceRecords[0]!.id; - }, - (value) => { - value.sourceRecords[0]!.sourceIdentity = "other-source"; - }, - (value) => { - value.sourceRecords[1]!.coverage.id = "fanbox.creator-post-summaries"; - }, - (value) => { - expect(Reflect.deleteProperty(value.sourceRecords, 0)).toBe(true); - }, - ]; - for (const mutate of invalidInitialRecords) { - const value = mutableInspection(acceptedInspection(post, 1)); - mutate(value); - expect(() => - assertFanboxAcceptedPost( - value as Parameters[0], - post, - ), - ).toThrow(); - } - - const invalidInitialFacts: Array< - readonly [string, (value: MutableInspection) => void] - > = [ - [ - "metadata", - (value) => { - value.accepted.metadata[0]!.sourceRecordIds = [ - "detail-1", - "unknown-record", - ]; - }, - ], - [ - "entity", - (value) => { - value.accepted.entities[0]!.sourceRecordIds = ["unknown-record"]; - }, - ], - [ - "entity-mention", - (value) => { - value.accepted.entityMentions[0]!.sourceRecordIds = [" "]; - }, - ], - [ - "entity-resolution-decision", - (value) => { - value.accepted.entityResolutionDecisions[0]!.sourceRecordIds = [ - "unknown-record", - ]; - }, - ], - [ - "relationship", - (value) => { - value.accepted.relationships[0]!.sourceRecordIds = ["unknown-record"]; - }, - ], - [ - "source-artifact-candidate", - (value) => { - value.accepted.sourceArtifactCandidates[0]!.sourceRecordId = - "unknown-record"; - }, - ], - ]; - for (const [kind, mutate] of invalidInitialFacts) { - const value = mutableInspection(acceptedInspection(post, 1)); - mutate(value); - expect(() => - assertFanboxAcceptedPost( - value as Parameters[0], - post, - ), - ).toThrow(`FANBOX initial ${kind} provenance is invalid`); - } - - const sparseFact = mutableInspection(acceptedInspection(post, 1)); - expect( - Reflect.deleteProperty( - sparseFact.accepted.entities[0]!.sourceRecordIds, - 0, - ), - ).toBe(true); - expect(() => - assertFanboxAcceptedPost( - sparseFact as Parameters[0], - post, - ), - ).toThrow("must not be sparse"); - - const coverPost = { ...post, cover: "present" as const }; - const invalidAssociation = mutableInspection( - acceptedInspection(coverPost, 1), - ); - const associationIndex = - invalidAssociation.accepted.artifactAssociations.length - 1; - invalidAssociation.accepted.artifactAssociations[associationIndex] = { - ...invalidAssociation.accepted.artifactAssociations[associationIndex]!, - sourceRecordIds: ["unknown-record"], - }; - expect(() => - assertFanboxAcceptedPost( - invalidAssociation as Parameters[0], - coverPost, - ), - ).toThrow("FANBOX initial artifact-association provenance is invalid"); - - const reusedGeneration = mutableInspection( - refreshedInspectionWithRetainedFacts(post, 1), - ); - reusedGeneration.sourceRecords[0]!.id = "summary-1"; - expect(() => - assertFanboxRefreshedPost( - acceptedInspection(post, 1) as Parameters< - typeof assertFanboxRefreshedPost - >[0], - reusedGeneration as Parameters[1], - post, - ), - ).toThrow("must not reuse initial ids"); - - for (const mutate of [ - (value: MutableInspection) => { - value.accepted.metadata.push({ - property: "fanbox.post.like-count", - sourceSlot: "fanbox.post.like-count", - value: "10", - sourceRecordIds: ["detail-1"], - }); - }, - (value: MutableInspection) => { - value.accepted.metadata[0]!.sourceRecordIds = [ - "detail-1", - "refresh-detail-1", - ]; - }, - ]) { - const invalidRefresh = mutableInspection( - refreshedInspectionWithRetainedFacts(post, 1), - ); - mutate(invalidRefresh); - expect(() => - assertFanboxRefreshedPost( - acceptedInspection(post, 1) as Parameters< - typeof assertFanboxRefreshedPost - >[0], - invalidRefresh as Parameters[1], - post, - ), - ).toThrow("FANBOX refresh metadata provenance is invalid"); - } - }); - - it("将 metadata-refresh 的非 accepted 结果归属到原 initial-accept", async () => { - for (const scenario of [ - { accepted: undefined, prefixMutation: "none" }, - { - accepted: { entity: { id: "stale-asset" } }, - prefixMutation: "none", - }, - { accepted: undefined, prefixMutation: "sparse" }, - { accepted: undefined, prefixMutation: "truncated" }, - ]) { - const subject = await syntheticJourney(); - process.env[evidenceEnvironment] = subject.evidencePath; - const results = commandResults(subject.context, subject.posts, 16); - results[6] = { - ...results[6]!, - stdout: JSON.stringify({ - format: "temporary-asset-refresh-inspection/v1", - execution: { - outcome: "partially-succeeded", - stage: "failed", - dispositions: [ - { - key: "official-collection", - sourceFailureCodes: [ - { - capability: { - capabilityId: "fanbox.official-collection", - capabilityVersion: "2", - contractVersion: "1", - identity: "fanbox.official-collection", - implementationVersion: "2", - kind: "source-collector", - interpretationContractVersions: ["1"], - }, - code: "http:post-detail:503", - }, - ], - }, - ], - }, - ...(scenario.accepted === undefined - ? {} - : { accepted: scenario.accepted }), - }), - }; - const refresh = journey.commands(subject.context)[16]!; - expect(typeof refresh.args).toBe("function"); - expect(() => - (refresh.args as (previous: readonly CliJourneyResult[]) => unknown)( - results, - ), - ).toThrow(); - if (scenario.prefixMutation === "sparse") - expect(Reflect.deleteProperty(results, 2)).toBe(true); - if (scenario.prefixMutation === "truncated") results.length = 7; - - await journey.teardown?.({ context: subject.context, results }); - - const cause = (await evidenceLines(subject.evidencePath))[0]; - if (scenario.prefixMutation !== "none") { - expect(cause).toMatchObject({ - stage: "teardown", - cause: { category: "journey-output-invalid" }, - }); - continue; - } - expect(cause).toMatchObject({ - stage: "initial-accept", - command: { - ordinal: 7, - name: "live accept FANBOX post 1001", - post: { ordinal: 1, nativeAddress: "1001" }, - exitCode: 0, - }, - inspection: { - outcome: "partially-succeeded", - workflowStage: "failed", - }, - cause: { category: "http-status", status: 503, class: "5xx" }, - }); - } - }); - - it("接受同一结构化原因的重复观察并拒绝带额外字段的观察", async () => { - const capability = { - capabilityId: "fanbox.official-collection", - capabilityVersion: "2", - contractVersion: "1", - identity: "fanbox.official-collection", - implementationVersion: "2", - kind: "source-collector", - interpretationContractVersions: ["1"], - }; - const run = async ( - sourceFailureCodes: unknown, - trailingSourceFailureCodes?: unknown, - ) => { - const subject = await syntheticJourney(); - process.env[evidenceEnvironment] = subject.evidencePath; - const results = commandResults(subject.context, subject.posts, 7); - results[6] = { - ...results[6]!, - exitCode: 7, - stdout: JSON.stringify({ - format: "temporary-asset-refresh-inspection/v1", - execution: { - outcome: "failed", - stage: "failed", - dispositions: [ - { - key: "official-collection", - status: "failed", - sourceFailureCodes, - }, - ...(trailingSourceFailureCodes === undefined - ? [] - : [ - { - key: "official-collection:trailing", - status: "failed", - sourceFailureCodes: trailingSourceFailureCodes, - }, - ]), - ], - }, - }), - }; - await journey.teardown?.({ context: subject.context, results }); - return (await evidenceLines(subject.evidencePath))[0]!.cause; - }; - - await expect( - run([ - { capability, code: "http:creator-summaries:403" }, - { capability, code: "http:creator-summaries:403" }, - ]), - ).resolves.toEqual({ category: "http-status", status: 403, class: "4xx" }); - await expect( - run([ - { - capability: { ...capability, adjacent: "must-not-be-ignored" }, - code: "http:creator-summaries:403", - }, - ]), - ).resolves.toEqual({ category: "unknown" }); - await expect( - run([ - { - capability: { - ...capability, - capabilityVersion: "1", - implementationVersion: "1", - }, - code: "http:creator-summaries:403", - }, - ]), - ).resolves.toEqual({ category: "unknown" }); - for (const malformed of [ - {}, - [], - Array.from({ length: 17 }, () => ({ - capability, - code: "http:creator-summaries:403", - })), - ]) { - await expect( - run(malformed, [{ capability, code: "http:creator-summaries:403" }]), - ).resolves.toEqual({ category: "unknown" }); - } - }); - - it("使用超过 2,000 字符后的完整检查结果分类且不复制原始材料", async () => { - const subject = await syntheticJourney(); - process.env[evidenceEnvironment] = subject.evidencePath; - const results = commandResults(subject.context, subject.posts, 7); - const reason = "FANBOX official source request failed: HTTP 403"; - const stdout = JSON.stringify({ - format: "temporary-asset-refresh-inspection/v1", - discarded: - "FANBOXSESSID=fake-secret /private/source/path authorization cookie 正文".padEnd( - 2_200, - "x", - ), - execution: { - outcome: "failed", - stage: "failed", - reason, - dispositions: [ - { - key: "official-collection", - status: "failed", - sourceFailureCodes: [ - { - capability: { - capabilityId: "fanbox.official-collection", - capabilityVersion: "2", - contractVersion: "1", - identity: "fanbox.official-collection", - implementationVersion: "2", - kind: "source-collector", - interpretationContractVersions: ["1"], - }, - code: "http:creator-summaries:403", - }, - ], - }, - ], - }, - sourceRecords: [{ body: "must never be copied" }], - }); - expect(stdout.indexOf(reason)).toBeGreaterThan(2_000); - results[6] = { ...results[6]!, exitCode: 7, stdout }; - - await journey.teardown?.({ context: subject.context, results }); - - const serialized = await readFile(subject.evidencePath, "utf8"); - expect(Buffer.byteLength(serialized)).toBeLessThanOrEqual(4_096); - expect( - serialized - .slice(0, -1) - .split("\n") - .every((line) => Buffer.byteLength(`${line}\n`) <= 2_048), - ).toBe(true); - expect(serialized).not.toMatch( - /FANBOXSESSID|fake-secret|private\/source|authorization|cookie|正文|discarded|sourceRecords|must never/iu, - ); - const [cause, finished] = await evidenceLines(subject.evidencePath); - expect(cause).toEqual({ - format: "fanbox-live-failure/1", - evidenceState: "cause-persisted", - mode: "source", - journey: "fanbox-five-live", - stage: "initial-accept", - command: { - ordinal: 7, - name: "live accept FANBOX post 1001", - post: { ordinal: 1, nativeAddress: "1001" }, - exitCode: 7, - }, - inspection: { outcome: "failed", workflowStage: "failed" }, - cause: { category: "http-status", status: 403, class: "4xx" }, - teardown: "pending", - stateReleased: "pending", - }); - expect(finished).toEqual({ - format: "fanbox-live-failure/1", - evidenceState: "teardown-finished", - mode: "source", - journey: "fanbox-five-live", - stage: "initial-accept", - command: { - ordinal: 7, - name: "live accept FANBOX post 1001", - post: { ordinal: 1, nativeAddress: "1001" }, - exitCode: 7, - }, - inspection: { outcome: "failed", workflowStage: "failed" }, - cause: { category: "http-status", status: 403, class: "4xx" }, - teardown: "passed", - stateReleased: "yes", - }); - expect((await lstat(subject.evidencePath)).mode & 0o777).toBe(0o600); - }); - - it("在媒体核对前持久化原因,并在漂移导致收尾失败时释放状态", async () => { - const subject = await syntheticJourney(); - process.env[evidenceEnvironment] = subject.evidencePath; - const results = commandResults(subject.context, subject.posts, 7); - results[6] = { - ...results[6]!, - exitCode: 9, - stdout: JSON.stringify({ - format: "temporary-asset-refresh-inspection/v1", - execution: { - outcome: "failed", - stage: "failed", - reason: "FANBOX official source request failed: HTTP 503", - dispositions: [ - { - key: "official-collection", - status: "failed", - sourceFailureCodes: [ - { - capability: { - capabilityId: "fanbox.official-collection", - capabilityVersion: "2", - contractVersion: "1", - identity: "fanbox.official-collection", - implementationVersion: "2", - kind: "source-collector", - interpretationContractVersions: ["1"], - }, - code: "http:creator-summaries:503", - }, - ], - }, - ], - }, - }), - }; - - const mutateAfterCause = (async () => { - for (let attempt = 0; attempt < 1_000; attempt += 1) { - try { - if ( - (await recoverableCause(subject.evidencePath)).evidenceState === - "cause-persisted" - ) { - await writeFile(subject.mediaFile, "media-drift-after-cause"); - return; - } - } catch {} - await new Promise((resolve) => setImmediate(resolve)); - } - throw new Error("媒体核对前未观察到 cause-persisted"); - })(); - const teardownResult = journey - .teardown?.({ context: subject.context, results }) - .then( - () => undefined, - (error: unknown) => error, - ); - - await mutateAfterCause; - expect(await teardownResult).toBeInstanceOf(Error); - expect((await evidenceLines(subject.evidencePath))[1]).toMatchObject({ - evidenceState: "teardown-finished", - cause: { category: "http-status", status: 503, class: "5xx" }, - teardown: "failed", - stateReleased: "yes", - }); - await expect( - journey.teardown?.({ context: subject.context, results }), - ).resolves.toBeUndefined(); - - delete process.env[evidenceEnvironment]; - const missingPath = await syntheticJourney(); - const missingPathResults = commandResults( - missingPath.context, - missingPath.posts, - 7, - ); - missingPathResults[6] = { ...missingPathResults[6]!, exitCode: 1 }; - await writeFile(missingPath.mediaFile, "drift-without-evidence-path"); - const missingPathFailure = await journey - .teardown?.({ context: missingPath.context, results: missingPathResults }) - .then( - () => undefined, - (error: unknown) => error, - ); - expect(missingPathFailure).toBeInstanceOf(AggregateError); - expect( - (missingPathFailure as AggregateError).errors.map(String).join("\n"), - ).toMatch(/必须提供 FANBOX 失败证据路径[\s\S]*单根条目签名/u); - await expect( - journey.teardown?.({ - context: missingPath.context, - results: missingPathResults, - }), - ).resolves.toBeUndefined(); - - const interrupted = await syntheticJourney(); - process.env[evidenceEnvironment] = interrupted.evidencePath; - const interruptedResults = commandResults( - interrupted.context, - interrupted.posts, - 7, - ); - interruptedResults[6] = { - ...interruptedResults[6]!, - exitCode: 8, - stdout: JSON.stringify({ - format: "temporary-asset-refresh-inspection/v1", - execution: { - outcome: "failed", - reason: "FANBOX official source request failed: HTTP 502", - dispositions: [ - { - key: "official-collection", - status: "failed", - sourceFailureCodes: [ - { - capability: { - capabilityId: "fanbox.official-collection", - capabilityVersion: "2", - contractVersion: "1", - identity: "fanbox.official-collection", - implementationVersion: "2", - kind: "source-collector", - interpretationContractVersions: ["1"], - }, - code: "http:creator-summaries:502", - }, - ], - }, - ], - }, - }), - }; - const injectTrailingBytes = (async () => { - for (let attempt = 0; attempt < 1_000; attempt += 1) { - try { - if ( - (await recoverableCause(interrupted.evidencePath)).evidenceState === - "cause-persisted" - ) { - await appendFile(interrupted.evidencePath, '{"partial":'); - await chmod(interrupted.evidencePath, 0o400); - return; - } - } catch {} - await new Promise((resolve) => setImmediate(resolve)); - } - throw new Error("追加前未观察到 cause-persisted"); - })(); - const interruptedTeardown = journey - .teardown?.({ context: interrupted.context, results: interruptedResults }) - .then( - () => undefined, - (error: unknown) => error, - ); - await injectTrailingBytes; - expect(await interruptedTeardown).toBeInstanceOf(Error); - await chmod(interrupted.evidencePath, 0o600); - expect(await recoverableCause(interrupted.evidencePath)).toMatchObject({ - evidenceState: "cause-persisted", - cause: { category: "http-status", status: 502, class: "5xx" }, - teardown: "pending", - stateReleased: "pending", - }); - await expect(evidenceLines(interrupted.evidencePath)).rejects.toThrow( - "证据日志缺少末尾换行", - ); - await expect( - journey.teardown?.({ - context: interrupted.context, - results: interruptedResults, - }), - ).resolves.toBeUndefined(); - }); - - it("将结果错序和命令名称漂移映射为 journey-output-invalid", async () => { - const subject = await syntheticJourney(); - process.env[evidenceEnvironment] = subject.evidencePath; - const results = commandResults(subject.context, subject.posts, 7); - [results[1], results[2]] = [results[2]!, results[1]!]; - results[6] = { - ...results[6]!, - commandName: "drifted command name with /private/path", - exitCode: 3, - stdout: "not trusted", - }; - - await journey.teardown?.({ context: subject.context, results }); - - expect((await evidenceLines(subject.evidencePath))[1]).toEqual({ - format: "fanbox-live-failure/1", - evidenceState: "teardown-finished", - mode: "source", - journey: "fanbox-five-live", - stage: "teardown", - cause: { category: "journey-output-invalid" }, - teardown: "passed", - stateReleased: "yes", - }); - - for (const malformed of ["empty", "short", "extra", "drift"] as const) { - const malformedSubject = await syntheticJourney(); - process.env[evidenceEnvironment] = malformedSubject.evidencePath; - const all = commandResults( - malformedSubject.context, - malformedSubject.posts, - 21, - ); - const malformedResults = - malformed === "empty" - ? [] - : malformed === "short" - ? all.slice(0, 6) - : malformed === "extra" - ? [...all, { ...all[20]!, commandName: "extra command" }] - : all.map((result, index) => - index === 3 - ? { ...result, commandName: "drifted zero command" } - : result, - ); - await journey.teardown?.({ - context: malformedSubject.context, - results: malformedResults, - }); - expect((await evidenceLines(malformedSubject.evidencePath))[1]).toEqual({ - format: "fanbox-live-failure/1", - evidenceState: "teardown-finished", - mode: "source", - journey: "fanbox-five-live", - stage: "teardown", - cause: { category: "journey-output-invalid" }, - teardown: "passed", - stateReleased: "yes", - }); - } - }); - - it("拒绝缺失或伪造的 FANBOX metadata 证据链", () => { - const post = reviewedPost(1); - - for (const inspection of malformedMetadataInspections(post)) - expect(() => - assertFanboxAcceptedPost( - inspection as Parameters[0], - post, - ), - ).toThrow(); - }); - - it.each([ - [ - "outcome", - (value: MutableInspection, sentinel: string) => { - (value.execution as { outcome: string }).outcome = sentinel; - }, - ], - [ - "source identity", - (value: MutableInspection, sentinel: string) => { - value.sourceRecords[0]!.sourceIdentity = sentinel; - }, - ], - [ - "coverage", - (value: MutableInspection, sentinel: string) => { - value.sourceRecords[0]!.coverage.id = sentinel; - }, - ], - ] as const)("%s 错误对象不会携带 inspection 原值", (_label, mutate) => { - const sentinel = "private-inspection-sentinel"; - const post = reviewedPost(1); - const value = mutableInspection(acceptedInspection(post, 1)); - mutate(value, sentinel); - - let caught: unknown; - try { - assertFanboxAcceptedPost(value, post); - } catch (error) { - caught = error; - } - expect(errorContains(caught, sentinel)).toBe(false); - }); - - it("业务断言错误对象不会携带来源值", async () => { - const titleSentinel = "private-title-sentinel"; - const post = reviewedPost(1); - const value = acceptedInspection(post, 1) as MutableInspection; - const titleFact = value.accepted.metadata.find( - (fact) => fact.property === "asset.title", - ) as unknown as { value: string }; - titleFact.value = titleSentinel; - - let titleError: unknown; - try { - assertFanboxAcceptedPost(value, post); - } catch (error) { - titleError = error; - } - expect(errorContains(titleError, titleSentinel)).toBe(false); - - const stderrSentinel = "private-stderr-sentinel"; - const subject = await syntheticJourney(); - process.env[evidenceEnvironment] = subject.evidencePath; - const results = successfulResults(subject.context, subject.posts).map( - (result) => ({ ...result }), - ); - results[0] = { - ...results[0]!, - exitCode: 1, - stderr: stderrSentinel, - }; - let commandError: unknown; - try { - await journey.assertions({ context: subject.context, results }); - } catch (error) { - commandError = error; - } - expect(errorContains(commandError, stderrSentinel)).toBe(false); - await journey.teardown?.({ context: subject.context, results }); - }); - - it("没有证据路径时仍保持成功业务断言与收尾正常运行", async () => { - const failedAssertions = await syntheticJourney(); - process.env[evidenceEnvironment] = failedAssertions.evidencePath; - const invalidResults = successfulResults( - failedAssertions.context, - failedAssertions.posts, - ).map((result) => ({ ...result })); - const invalidInspection = JSON.parse(invalidResults[6]!.stdout) as Record< - string, - unknown - >; - invalidInspection.execution = { outcome: "no-change" }; - invalidResults[6] = { - ...invalidResults[6]!, - stdout: JSON.stringify(invalidInspection), - }; - await expect( - journey.assertions({ - context: failedAssertions.context, - results: invalidResults, - }), - ).rejects.toThrow(); - await journey.teardown?.({ - context: failedAssertions.context, - results: invalidResults, - }); - expect((await evidenceLines(failedAssertions.evidencePath))[1]).toEqual({ - format: "fanbox-live-failure/1", - evidenceState: "teardown-finished", - mode: "source", - journey: "fanbox-five-live", - stage: "assertions", - cause: { category: "journey-output-invalid" }, - teardown: "passed", - stateReleased: "yes", - }); - - delete process.env[evidenceEnvironment]; - const subject = await syntheticJourney(); - const results = successfulResults(subject.context, subject.posts); - - await expect( - journey.assertions({ context: subject.context, results }), - ).resolves.toBeUndefined(); - await expect( - journey.teardown?.({ context: subject.context, results }), - ).resolves.toBeUndefined(); - await expect(lstat(subject.evidencePath)).rejects.toMatchObject({ - code: "ENOENT", - }); - }); -}); diff --git a/apps/cli/test/integration/fixed-corpus-baseline.test.ts b/apps/cli/test/integration/fixed-corpus-baseline.test.ts deleted file mode 100644 index 2fe52b3..0000000 --- a/apps/cli/test/integration/fixed-corpus-baseline.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { - assertContentDigestMatchesBaseline, - assertInventoryMatchesBaseline, - fixedCorpusContentDigest, - fixedCorpusDedicatedIntegrationOwnershipGroups, - fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle, - fixedCorpusHistoricallyReviewedRoots, - fixedCorpusInventory, - fixedCorpusRoots, - unsupportedFixedCorpusRoots, -} from "../e2e/fixed-corpus.ts"; - -const cleanup: string[] = []; - -afterEach(async () => { - await Promise.all( - cleanup - .splice(0) - .map( - async (directory) => - await rm(directory, { recursive: true, force: true }), - ), - ); -}); - -describe("fixed corpus inventory baseline", () => { - it("routes owned roots to their dedicated integration journeys and statuses", async () => { - const context = { - mode: "source", - packageRoot: path.resolve(import.meta.dirname, "../.."), - workDir: "/unused", - productDataDirectory: "/unused/product-data", - environmentName: "completion-02-fixture-context", - commandEnvironment: {}, - } as const; - const [allRoots, ownershipGroups, historicallyReviewedRoots, safetyRoots] = - await Promise.all([ - fixedCorpusRoots(context), - fixedCorpusDedicatedIntegrationOwnershipGroups(context), - fixedCorpusHistoricallyReviewedRoots(context), - unsupportedFixedCorpusRoots(context), - ]); - - expect(allRoots).toHaveLength(29); - expect( - ownershipGroups.map((group) => ({ - ownerTicket: group.ownerTicket, - journey: group.journey, - status: group.status, - rootCount: group.roots.length, - })), - ).toEqual([ - { - ownerTicket: "v1-live-acceptance/issues/01-dlsite-eight-live-gate.md", - journey: "dlsite-eight-live", - status: "implemented", - rootCount: 8, - }, - { - ownerTicket: "v1-live-acceptance/issues/02-fanbox-five-live-gate.md", - journey: "fanbox-five-live", - status: "external-gated", - rootCount: 5, - }, - { - ownerTicket: - "v1-live-acceptance/issues/03-epub-publication-live-gate.md", - journey: "epub-publication-live", - status: "implemented", - rootCount: 6, - }, - { - ownerTicket: "v1-live-acceptance/issues/04-pdf-series-live-gate.md", - journey: "pdf-publication-live", - status: "implemented", - rootCount: 1, - }, - { - ownerTicket: - "v1-live-acceptance/issues/05-bangumi-episodic-live-gate.md", - journey: "bangumi-episodic-live", - status: "implemented", - rootCount: 2, - }, - { - ownerTicket: "v1-live-acceptance/issues/06-film-tmdb-omdb-live-gate.md", - journey: "tmdb-omdb-live", - status: "implemented", - rootCount: 2, - }, - ]); - const ownedRoots = ownershipGroups.flatMap((group) => group.roots); - expect(ownedRoots).toHaveLength(24); - expect(new Set(ownedRoots.map((root) => root.logicalRoot)).size).toBe(24); - expect(historicallyReviewedRoots).toHaveLength(15); - expect( - historicallyReviewedRoots.some((root) => - root.logicalRoot.startsWith("appdata/booklore/books/"), - ), - ).toBe(false); - expect(safetyRoots).toHaveLength(5); - expect(safetyRoots.map((root) => root.logicalRoot)).toEqual([ - "media/music/25時、ナイトコードで。 — 25時、ナイトコードで。 SEKAI ALBUM vol.3 (2025) {BRMM-10894} [CD-FLAC]", - "media/music/[EAC][110612][ボーマス16][同人][Baguettes Ensemble][The Vocaloid Jazz sessions Vol.3][FLAC+CUE+LOG]", - "media/music/『プロジェクトセカイ カラフルステージ! feat. 初音ミク』25時、ナイトコードで。 8th Single「演劇/25時の情熱」[FLAC]", - "media/pics/プロジェクトセカイ カラフルステージ! feat. 初音ミク 公式ビジュアルファンブック Vol.3[332P][JPG]", - "media/Boku no Risou no Isekai Seikatsu - 04", - ]); - }); - - it("rejects inventory drift instead of accepting a freshly observed tree as expected", async () => { - const directory = await mkdtemp(path.join(tmpdir(), "digital-baseline-")); - cleanup.push(directory); - await writeFile(path.join(directory, "ordinary.txt"), "ordinary"); - const actual = await fixedCorpusInventory({ - logicalRoot: "sample", - directory, - }); - - expect(() => - assertInventoryMatchesBaseline(actual, { - ...actual, - fileCount: actual.fileCount + 1, - }), - ).toThrow("fixed corpus inventory baseline drifted: sample"); - }); - - it("rejects duplicate owned roots instead of silently changing the unresolved complement", async () => { - const context = { - mode: "source", - packageRoot: path.resolve(import.meta.dirname, "../.."), - workDir: "/unused", - productDataDirectory: "/unused/product-data", - environmentName: "completion-02-fixture-context", - commandEnvironment: {}, - } as const; - const [roots, rawOracle] = await Promise.all([ - fixedCorpusRoots(context), - readFile( - path.resolve( - context.packageRoot, - "../..", - "fixtures/live-corpus/references/reviewed-oracle.json", - ), - "utf8", - ), - ]); - const oracle = JSON.parse(rawOracle) as { - reviewedSupportedRoots: string[]; - dedicatedIntegrationOwnershipGroups: { - roots: string[]; - }[]; - }; - oracle.reviewedSupportedRoots = ["not-an-owned-root"]; - oracle.dedicatedIntegrationOwnershipGroups[1]!.roots[0] = - oracle.dedicatedIntegrationOwnershipGroups[0]!.roots[0]!; - - expect(() => - fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle(oracle, roots), - ).toThrow("fixed corpus routing has a duplicate owned root"); - }); - - it("rejects unknown roots and incomplete ownership-group contracts", async () => { - const context = { - mode: "source", - packageRoot: path.resolve(import.meta.dirname, "../.."), - workDir: "/unused", - productDataDirectory: "/unused/product-data", - environmentName: "completion-02-fixture-context", - commandEnvironment: {}, - } as const; - const [roots, rawOracle] = await Promise.all([ - fixedCorpusRoots(context), - readFile( - path.resolve( - context.packageRoot, - "../..", - "fixtures/live-corpus/references/reviewed-oracle.json", - ), - "utf8", - ), - ]); - type OwnershipGroup = { - ownerTicket?: string; - journey?: string; - status?: string; - roots?: string[]; - }; - type Oracle = { - dedicatedIntegrationOwnershipGroups: OwnershipGroup[]; - }; - const original = JSON.parse(rawOracle) as Oracle; - const cases: readonly { - readonly expected: string; - readonly mutate: (oracle: Oracle) => void; - }[] = [ - { - expected: "contains an unknown root", - mutate: (oracle) => { - oracle.dedicatedIntegrationOwnershipGroups[0]!.roots![0] = - "not-an-owned-root"; - }, - }, - { - expected: "ownership group count drifted", - mutate: (oracle) => { - oracle.dedicatedIntegrationOwnershipGroups.pop(); - }, - }, - { - expected: "ownerTicket must be a non-empty string", - mutate: (oracle) => { - delete oracle.dedicatedIntegrationOwnershipGroups[0]!.ownerTicket; - }, - }, - { - expected: "has an unknown status", - mutate: (oracle) => { - oracle.dedicatedIntegrationOwnershipGroups[0]!.status = "completed"; - }, - }, - { - expected: "status must be implemented", - mutate: (oracle) => { - oracle.dedicatedIntegrationOwnershipGroups[0]!.status = "pending"; - }, - }, - { - expected: "journey must be a non-empty string", - mutate: (oracle) => { - delete oracle.dedicatedIntegrationOwnershipGroups[3]!.journey; - }, - }, - ]; - - for (const testCase of cases) { - const oracle = structuredClone(original); - testCase.mutate(oracle); - expect(() => - fixedCorpusDedicatedIntegrationOwnershipGroupsFromOracle(oracle, roots), - ).toThrow(testCase.expected); - } - }); - - it("rejects same-path same-size content drift against the approved digest", async () => { - const directory = await mkdtemp( - path.join(tmpdir(), "digital-content-baseline-"), - ); - cleanup.push(directory); - const file = path.join(directory, "ordinary.bin"); - await writeFile(file, "AAAA"); - const approved = await fixedCorpusContentDigest({ - logicalRoot: "sample", - directory, - }); - - await writeFile(file, "BBBB"); - const changed = await fixedCorpusContentDigest({ - logicalRoot: "sample", - directory, - }); - - expect(changed.contentDigest).not.toBe(approved.contentDigest); - expect(() => assertContentDigestMatchesBaseline(changed, approved)).toThrow( - "fixed corpus content baseline drifted: sample", - ); - }); -}); diff --git a/apps/cli/test/integration/journey-runner.test.ts b/apps/cli/test/integration/journey-runner.test.ts deleted file mode 100644 index 7edd553..0000000 --- a/apps/cli/test/integration/journey-runner.test.ts +++ /dev/null @@ -1,749 +0,0 @@ -import { - access, - mkdir, - mkdtemp, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it } from "vitest"; - -import { - stubDeletion, - stubRecycling, - stubScenarioEnvironment, - type OwnedRecycling, - type AcceptanceEnvironment, -} from "../e2e/environment-runtime.ts"; -import type { CliJourney } from "../e2e/journey.ts"; -import { - assertHaltedJourneyCommand, - formatJourneyAssertionFailure, - redactJourneyDiagnostic, - runJourney, - type JourneyEnvironmentLifecycle, -} from "../e2e/run-journeys.ts"; - -/** - * 单元层不重复支付真实环境准备:只核验调用方的归属与收尾分支。真实环境包的公开 - * 接口由 test/e2e/environment-contracts.ts 的正式入口覆盖。 - */ -type UnitRecycle = ( - environment: AcceptanceEnvironment, - outcome: OwnedRecycling["outcome"], -) => Promise; - -/** 默认收尾:确认退出并删除,等价于一次成功的自有环境回收。 */ -const confirmedRecycle: UnitRecycle = async (environment, outcome) => - stubRecycling(outcome, { - exitConfirmation: { - confirmed: true, - state: "prepared", - processLiveness: "exited", - serviceReadiness: "not-evaluated", - detail: "unit stub", - }, - deletion: stubDeletion(environment.facts), - }); - -const retained = new Set(); - -afterEach(async () => { - const pending = [...retained]; - retained.clear(); - for (const directory of pending) - await rm(directory, { recursive: true, force: true }); -}); - -/** 单元层的一次虚拟场景世代:环境不来自环境包,只用于核验调用方归属分支。 */ -async function unitTargets(recycle: UnitRecycle = confirmedRecycle): Promise<{ - readonly lifecycle: JourneyEnvironmentLifecycle; - readonly closed: OwnedRecycling["outcome"][]; - readonly evidenceDirectory: string; - readonly workspaceRoot: string; -}> { - const workspaceRoot = await mkdtemp( - path.join(tmpdir(), "digital-cli-journey-runner-unit-"), - ); - retained.add(workspaceRoot); - const evidenceDirectory = path.join(workspaceRoot, "evidence"); - const closed: OwnedRecycling["outcome"][] = []; - let index = 0; - return { - evidenceDirectory, - workspaceRoot, - closed, - lifecycle: { - async open() { - const directory = path.join(workspaceRoot, `scenario-${index++}`); - await mkdir(directory, { recursive: true }); - return stubScenarioEnvironment({ workspacePath: directory }); - }, - async close({ environment, outcome }) { - closed.push(outcome); - return await recycle(environment, outcome); - }, - }, - }; -} - -describe("CLI journey runner", () => { - it("stops a credentialed live journey at its first unexpected command failure with that command's diagnostic", () => { - expect(() => - assertHaltedJourneyCommand( - { name: "live TMDB scan", args: [], haltOnFailure: true }, - { - commandName: "live TMDB scan", - exitCode: 1, - stdout: "", - stderr: "TMDB 官方来源请求失败:HTTP 401", - }, - ), - ).toThrow("live TMDB scan failed with exit 1"); - expect(() => - assertHaltedJourneyCommand( - { name: "live TMDB scan", args: [], haltOnFailure: true }, - { - commandName: "live TMDB scan", - exitCode: 1, - stdout: "", - stderr: "TMDB 官方来源请求失败:HTTP 401", - }, - ), - ).toThrow("TMDB 官方来源请求失败:HTTP 401"); - expect(() => - assertHaltedJourneyCommand( - { name: "expected business failure", args: [] }, - { - commandName: "expected business failure", - exitCode: 1, - stdout: "", - stderr: "expected", - }, - ), - ).not.toThrow(); - }); - - it("redacts every configured private value from halted-command diagnostics", () => { - const command = { - name: "live private source", - args: [], - haltOnFailure: true as const, - env: { - DIGITAL_TMDB_ACCESS_TOKEN: "placeholder-tmdb-token", - DIGITAL_OMDB_API_KEY: "placeholder-omdb-key", - DIGITAL_SESSION_COOKIE: "placeholder-session-cookie", - }, - }; - const leaked = [ - "token=placeholder-tmdb-token", - "apikey=placeholder-omdb-key", - "Cookie: placeholder-session-cookie", - ].join("\n"); - - expect(redactJourneyDiagnostic(leaked, [command])).toContain("[REDACTED]"); - expect(redactJourneyDiagnostic(leaked, [command])).not.toContain( - "placeholder-tmdb-token", - ); - expect(redactJourneyDiagnostic(leaked, [command])).not.toContain( - "placeholder-omdb-key", - ); - expect(redactJourneyDiagnostic(leaked, [command])).not.toContain( - "placeholder-session-cookie", - ); - expect(() => - assertHaltedJourneyCommand(command, { - commandName: command.name, - exitCode: 1, - stdout: leaked, - stderr: leaked, - }), - ).toThrow("[REDACTED]"); - expect(() => - assertHaltedJourneyCommand(command, { - commandName: command.name, - exitCode: 1, - stdout: leaked, - stderr: leaked, - }), - ).not.toThrow("placeholder-tmdb-token"); - }); - - it("does not retain original private diagnostics through an assertion failure cause or stack", () => { - const command = { - name: "live private source", - args: [], - env: { DIGITAL_TMDB_ACCESS_TOKEN: "placeholder-tmdb-token" }, - }; - const original = new Error("assertion saw placeholder-tmdb-token"); - original.stack = `Error: assertion saw placeholder-tmdb-token\n at private`; - const error = formatJourneyAssertionFailure({ - mode: "source", - journeyName: "private-live", - commands: [command], - results: [ - { - commandName: command.name, - exitCode: 1, - stdout: "stdout placeholder-tmdb-token", - stderr: "stderr placeholder-tmdb-token", - }, - ], - error: original, - }); - const cause = (error as Error & { readonly cause?: unknown }).cause; - const observable = JSON.stringify({ - message: error.message, - stack: error.stack, - cause: - cause instanceof Error - ? { message: cause.message, stack: cause.stack } - : cause, - }); - - expect(observable).toContain("[REDACTED]"); - expect(observable).not.toContain("placeholder-tmdb-token"); - }); - - it.each([ - ["主路径", "main", "assertions"], - ["收尾", "teardown", "teardown"], - ] as const)( - "%s bare rejection 仍拒绝并清理工作目录", - async (_, label, stage) => { - let capturedWorkDir = ""; - const journey: CliJourney = { - name: `bare-${label}-rejection`, - modes: ["source"], - async setup(context) { - capturedWorkDir = context.workDir; - }, - commands() { - return []; - }, - async assertions() { - if (stage === "assertions") await Promise.reject(); - }, - async teardown() { - if (stage === "teardown") await Promise.reject(); - }, - }; - const unit = await unitTargets(); - - await expect( - runJourney({ - journey, - mode: "source", - lifecycle: unit.lifecycle, - evidenceDirectory: unit.evidenceDirectory, - }), - ).rejects.toThrow(`${label} failure: undefined`); - await expect(access(capturedWorkDir)).rejects.toThrow(); - }, - ); - - it("命令改写媒体后失败仍执行收尾并同时报告主失败与漂移", async () => { - const baselines = new Map(); - let capturedWorkDir = ""; - let mediaFile = ""; - let stateReleased = false; - const journey: CliJourney = { - name: "teardown-on-halt", - modes: ["source"], - async setup(context) { - capturedWorkDir = context.workDir; - const mediaRoot = path.join(context.workDir, "test-media"); - mediaFile = path.join(mediaRoot, "publication.bin"); - await mkdir(mediaRoot); - await writeFile(mediaFile, "before"); - baselines.set(context.workDir, "before"); - }, - commands() { - return [ - { - name: "mutating command", - args: [], - haltOnFailure: true, - }, - ]; - }, - async assertions() { - throw new Error("非零 halt 后不应执行普通断言"); - }, - async teardown({ context }) { - const baseline = baselines.get(context.workDir); - if (baseline === undefined) return; - try { - const after = await readFile(mediaFile, "utf8"); - if (after !== baseline) throw new Error("测试媒体根发生漂移"); - } finally { - baselines.delete(context.workDir); - stateReleased = true; - } - }, - }; - const unit = await unitTargets(); - - await expect( - runJourney({ - journey, - mode: "source", - lifecycle: unit.lifecycle, - evidenceDirectory: unit.evidenceDirectory, - async executeCommand({ command }) { - await writeFile(mediaFile, "after"); - return { - commandName: command.name, - exitCode: 7, - stdout: "", - stderr: "主命令失败", - }; - }, - }), - ).rejects.toThrow( - /mutating command failed with exit 7[\s\S]*测试媒体根发生漂移/u, - ); - expect(stateReleased).toBe(true); - expect(baselines).toHaveProperty("size", 0); - await expect(access(capturedWorkDir)).rejects.toThrow(); - }); - - it("自有环境成功时保存结果后停止并按结局删除", async () => { - const unit = await unitTargets(); - const journey: CliJourney = { - name: "owned-success", - modes: ["source"], - async setup() {}, - commands: () => [{ name: "no-op", args: [] }], - async assertions() {}, - }; - - await expect( - runJourney({ - journey, - mode: "source", - lifecycle: unit.lifecycle, - evidenceDirectory: unit.evidenceDirectory, - async executeCommand({ command }) { - return { - commandName: command.name, - exitCode: 0, - stdout: "", - stderr: "", - }; - }, - }), - ).resolves.toBe("source:owned-success:passed"); - expect(unit.closed).toEqual(["success"]); - const evidence = JSON.parse( - await readFile( - path.join(unit.evidenceDirectory, "completion-02-stub.json"), - "utf8", - ), - ) as Record; - expect(evidence).toMatchObject({ - outcome: "success", - deleteStatus: "deleted", - sourceMode: "offline", - }); - }); - - it("自有环境失败时保留数据并把停止与删除问题与主失败并列报告", async () => { - const unit = await unitTargets(async (environment, outcome) => ({ - ...stubRecycling(outcome, { - exitConfirmation: { - confirmed: false, - state: "prepared", - processLiveness: "unknown", - serviceReadiness: "unknown", - detail: "无法确认退出", - }, - retainedDataRoot: environment.productDataRoot, - retainedLogs: ["logs/stop.log"], - }), - problems: ["收尾停止未确认:stop=not-confirmed 无法确认退出"], - })); - const journey: CliJourney = { - name: "owned-failure", - modes: ["source"], - async setup() {}, - commands: () => [{ name: "failing", args: [], haltOnFailure: true }], - async assertions() {}, - }; - - await expect( - runJourney({ - journey, - mode: "source", - lifecycle: unit.lifecycle, - evidenceDirectory: unit.evidenceDirectory, - async executeCommand({ command }) { - return { - commandName: command.name, - exitCode: 4, - stdout: "", - stderr: "业务失败", - }; - }, - }), - ).rejects.toThrow( - /main failure:[\s\S]*收尾停止未确认[\s\S]*stop=not-confirmed/u, - ); - expect(unit.closed).toEqual(["failure"]); - const evidence = JSON.parse( - await readFile( - path.join(unit.evidenceDirectory, "completion-02-stub.json"), - "utf8", - ), - ) as Record; - expect(evidence).toMatchObject({ - outcome: "failure", - deleteStatus: null, - retainedDataRoot: expect.any(String), - }); - }); - - it("信号中断按 interrupted 收尾,不删除自有数据也不伪造退出确认", async () => { - const unit = await unitTargets(); - const cancellation = new AbortController(); - const journey: CliJourney = { - name: "owned-interrupt", - modes: ["source"], - async setup() {}, - commands: () => [{ name: "interrupted command", args: [] }], - async assertions() {}, - }; - - await expect( - runJourney({ - journey, - mode: "source", - lifecycle: unit.lifecycle, - evidenceDirectory: unit.evidenceDirectory, - signal: cancellation.signal, - async executeCommand({ command, signal }) { - signal?.addEventListener("abort", () => {}, { once: true }); - cancellation.abort(new Error("received SIGINT")); - return { - commandName: command.name, - exitCode: 130, - stdout: "", - stderr: "", - }; - }, - }), - ).rejects.toThrow(/interruption failure/u); - expect(unit.closed).toEqual(["interrupted"]); - }); - - it("最终证据保存期间到达的取消改写持久结局为中断,且保存错误仍独立可辨", async () => { - const unit = await unitTargets(); - const cancellation = new AbortController(); - const journey: CliJourney = { - name: "cancel-during-final-persistence", - modes: ["source"], - async setup() {}, - commands: () => [{ name: "no-op", args: [] }], - async assertions() {}, - }; - - // 取消刻意落在收尾之后、最终证据写入完成之前:调用方会收到中断, - // 但按保存开始前那一刻构造的载荷仍是 success,磁盘结局必须被纠正。 - await expect( - runJourney({ - journey, - mode: "source", - lifecycle: { - async open() { - return stubScenarioEnvironment({ - workspacePath: unit.workspaceRoot, - }); - }, - async close({ environment, outcome }) { - unit.closed.push(outcome); - const recycling = await confirmedRecycle(environment, outcome); - setImmediate(() => - cancellation.abort(new Error("received SIGINT")), - ); - return recycling; - }, - }, - evidenceDirectory: unit.evidenceDirectory, - signal: cancellation.signal, - async executeCommand({ command }) { - return { - commandName: command.name, - exitCode: 0, - stdout: "", - stderr: "", - }; - }, - }), - ).rejects.toThrow(/interruption failure/u); - expect(unit.closed).toEqual(["success"]); - - const evidence = JSON.parse( - await readFile( - path.join(unit.evidenceDirectory, "completion-02-stub.json"), - "utf8", - ), - ) as { outcome: string; failures: readonly string[] }; - expect(evidence.outcome).toBe("interrupted"); - expect(evidence.failures.join("\n")).toMatch(/取消在最终证据保存期间到达/u); - // 中断不是保存错误:两者必须分别可辨,不能把取消写成证据保存失败。 - expect(evidence.failures.join("\n")).not.toMatch(/证据保存失败/u); - }); - - it("环境收尾自身抛出异常时仍报告主失败并保留自有工作区", async () => { - const workspaceRoot = await mkdtemp( - path.join(tmpdir(), "digital-cli-journey-runner-throwing-"), - ); - retained.add(workspaceRoot); - const journey: CliJourney = { - name: "recycling-throw", - modes: ["source"], - async setup() {}, - commands: () => [{ name: "failing", args: [], haltOnFailure: true }], - async assertions() {}, - }; - - await expect( - runJourney({ - journey, - mode: "source", - evidenceDirectory: path.join(workspaceRoot, "evidence"), - lifecycle: { - async open() { - return stubScenarioEnvironment({ workspacePath: workspaceRoot }); - }, - async close() { - throw new Error("收尾公开调用失败:secret-token"); - }, - }, - async executeCommand({ command }) { - return { - commandName: command.name, - exitCode: 9, - stdout: "secret-token", - stderr: "", - }; - }, - }), - ).rejects.toThrow(/main failure[\s\S]*环境收尾抛出异常/u); - }); - - it("首次证据保存失败仍执行收尾,原始主失败与保存错误分别可辨", async () => { - const workspaceRoot = await mkdtemp( - path.join(tmpdir(), "digital-cli-journey-runner-evidence-blocked-"), - ); - retained.add(workspaceRoot); - // 普通文件占据证据目录位置:mkdir/write 一律 ENOTDIR。 - await writeFile(path.join(workspaceRoot, "occupied"), "占位"); - const closed: OwnedRecycling["outcome"][] = []; - let capturedWorkDir = ""; - const journey: CliJourney = { - name: "evidence-blocked", - modes: ["source"], - async setup(context) { - capturedWorkDir = context.workDir; - }, - commands: () => [{ name: "no-op", args: [] }], - async assertions() { - throw new Error("ORIGINAL_BUSINESS_FAILURE"); - }, - }; - - const failure = await runJourney({ - journey, - mode: "source", - lifecycle: { - async open() { - return stubScenarioEnvironment({ workspacePath: workspaceRoot }); - }, - async close({ environment, outcome }) { - closed.push(outcome); - return await confirmedRecycle(environment, outcome); - }, - }, - evidenceDirectory: path.join(workspaceRoot, "occupied", "evidence"), - async executeCommand({ command }) { - return { - commandName: command.name, - exitCode: 0, - stdout: "", - stderr: "", - }; - }, - }).catch((error: unknown) => error); - - expect(failure).toBeInstanceOf(Error); - const message = (failure as Error).message; - expect(message).toMatch(/main failure:[\s\S]*ORIGINAL_BUSINESS_FAILURE/u); - expect(message).toMatch(/证据保存失败/u); - expect(message).not.toMatch(/环境收尾抛出异常/u); - expect(closed).toEqual(["failure"]); - await expect(access(capturedWorkDir)).rejects.toThrow(); - }); - - it("证据保存失败时成功结局降级为保留,绝不删除证据载体", async () => { - const workspaceRoot = await mkdtemp( - path.join(tmpdir(), "digital-cli-journey-runner-evidence-degrade-"), - ); - retained.add(workspaceRoot); - await writeFile(path.join(workspaceRoot, "occupied"), "占位"); - const closed: OwnedRecycling["outcome"][] = []; - const journey: CliJourney = { - name: "evidence-blocked-success", - modes: ["source"], - async setup() {}, - commands: () => [{ name: "no-op", args: [] }], - async assertions() {}, - }; - - await expect( - runJourney({ - journey, - mode: "source", - lifecycle: { - async open() { - return stubScenarioEnvironment({ workspacePath: workspaceRoot }); - }, - async close({ environment, outcome }) { - closed.push(outcome); - // 公开收尾按结局决定删除:success 才删除,这里核验调用方不再交出 success。 - return outcome === "success" - ? await confirmedRecycle(environment, outcome) - : stubRecycling(outcome, { - retainedDataRoot: environment.productDataRoot, - retainedLogs: ["logs/stop.log"], - }); - }, - }, - evidenceDirectory: path.join(workspaceRoot, "occupied", "evidence"), - async executeCommand({ command }) { - return { - commandName: command.name, - exitCode: 0, - stdout: "", - stderr: "", - }; - }, - }), - ).rejects.toThrow(/证据保存失败/u); - expect(closed).toEqual(["failure"]); - }); - - it("收尾后的第二次证据保存失败报告为保存失败而非收尾异常", async () => { - const workspaceRoot = await mkdtemp( - path.join(tmpdir(), "digital-cli-journey-runner-evidence-second-"), - ); - retained.add(workspaceRoot); - const evidenceDirectory = path.join(workspaceRoot, "evidence"); - const journey: CliJourney = { - name: "evidence-second-fail", - modes: ["source"], - async setup() {}, - commands: () => [{ name: "no-op", args: [] }], - async assertions() {}, - }; - - const failure = await runJourney({ - journey, - mode: "source", - lifecycle: { - async open() { - return stubScenarioEnvironment({ workspacePath: workspaceRoot }); - }, - async close({ environment, outcome }) { - const recycling = await confirmedRecycle(environment, outcome); - // 收尾成功后破坏证据文件位置:只让第二次保存失败。 - await rm(path.join(evidenceDirectory, "completion-02-stub.json"), { - force: true, - }); - await mkdir(path.join(evidenceDirectory, "completion-02-stub.json")); - return recycling; - }, - }, - evidenceDirectory, - async executeCommand({ command }) { - return { - commandName: command.name, - exitCode: 0, - stdout: "", - stderr: "", - }; - }, - }).catch((error: unknown) => error); - - expect(failure).toBeInstanceOf(Error); - const secondSaveMessage = (failure as Error).message; - expect(secondSaveMessage).toMatch(/证据保存失败/u); - expect(secondSaveMessage).not.toMatch(/环境收尾抛出异常/u); - }); - - it("收尾期间到达的取消不报告成功,最终证据记为 interrupted 且不删除", async () => { - const workspaceRoot = await mkdtemp( - path.join(tmpdir(), "digital-cli-journey-runner-late-cancel-"), - ); - retained.add(workspaceRoot); - const evidenceDirectory = path.join(workspaceRoot, "evidence"); - const cancellation = new AbortController(); - const journey: CliJourney = { - name: "late-cancel", - modes: ["source"], - async setup() {}, - commands: () => [{ name: "no-op", args: [] }], - async assertions() {}, - }; - - await expect( - runJourney({ - journey, - mode: "source", - signal: cancellation.signal, - lifecycle: { - async open() { - return stubScenarioEnvironment({ workspacePath: workspaceRoot }); - }, - async close({ environment }) { - // 取消在异步收尾期间到达:真实收尾据此停止而不删除。 - cancellation.abort(new Error("received SIGTERM")); - return stubRecycling("success", { - exitConfirmation: { - confirmed: true, - state: "prepared", - processLiveness: "exited", - serviceReadiness: "not-evaluated", - detail: "unit stub", - }, - retainedDataRoot: environment.productDataRoot, - retainedLogs: ["logs/stop.log"], - }); - }, - }, - evidenceDirectory, - async executeCommand({ command }) { - return { - commandName: command.name, - exitCode: 0, - stdout: "", - stderr: "", - }; - }, - }), - ).rejects.toThrow(/interruption failure/u); - const evidence = JSON.parse( - await readFile( - path.join(evidenceDirectory, "completion-02-stub.json"), - "utf8", - ), - ) as Record; - expect(evidence).toMatchObject({ - outcome: "interrupted", - deleteStatus: null, - }); - }); -}); diff --git a/apps/cli/test/integration/offline-backup.test.ts b/apps/cli/test/integration/offline-backup.test.ts deleted file mode 100644 index 0ba9ac7..0000000 --- a/apps/cli/test/integration/offline-backup.test.ts +++ /dev/null @@ -1,568 +0,0 @@ -import { spawn } from "node:child_process"; -import { - chmod, - lstat, - mkdir, - mkdtemp, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { runProduct } from "@digital/composition"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import { runCli as runCliEntry, type CliRuntime } from "../../src/main.ts"; - -const cleanupPaths: string[] = []; - -/** - * 自有收尾账本:清理要等「整条测试体异步链」真正落定,而不是只等当时已经登记的叶子 Promise - * 快照。被超时放弃的用例之后仍会继续 mkdir/runProduct/scan/restore,此时撤 fetch 替身或 - * 删除自有临时根都会与仍在写入的链交错,留下与业务无关的 ENOTEMPTY 次生失败。 - * 判据复用用例自己声明的预算:那条链在预算内没有落定,就既不撤替身也不删除(宁可留下自有根), - * 不做无限重试,也不新增产品锁或计时框架。 - */ -const ownCaseBudgetMilliseconds = 120_000; -let ownChain: - | { readonly givenUp: Promise; readonly settled: Promise } - | undefined; - -function runWithOwnChain(body: () => Promise): Promise { - let giveUp: (settled: false) => void = () => {}; - const givenUp = new Promise((resolve) => { - giveUp = resolve; - }); - const timer = setTimeout(() => giveUp(false), ownCaseBudgetMilliseconds); - const settled = body().finally(() => clearTimeout(timer)); - ownChain = { givenUp, settled }; - return settled; -} - -function runCli(runtime: CliRuntime): Promise { - return runCliEntry(runtime); -} - -const repositoryRoot = path.resolve(import.meta.dirname, "../../.."); - -async function temporaryDirectory(prefix: string): Promise { - const directory = await mkdtemp(path.join(tmpdir(), prefix)); - cleanupPaths.push(directory); - return directory; -} - -function runtime( - args: readonly string[], - productDataDirectory: string, - environment: Record = {}, -) { - let stdout = ""; - let stderr = ""; - return { - runtime: { - argv: ["node", "cli", ...args], - streams: { - stdin: {}, - stdout: { write: (chunk: string) => (stdout += chunk) }, - stderr: { write: (chunk: string) => (stderr += chunk) }, - }, - cwd: repositoryRoot, - env: { DIGITAL_PRODUCT_DATA: productDataDirectory, ...environment }, - tty: { stdin: false, stdout: false, stderr: false }, - version: "1.2.3", - } satisfies CliRuntime, - stdout: () => stdout, - stderr: () => stderr, - }; -} - -/** `workflow-history` 公开面的最小可读形状。 */ -type SurfaceHistory = { - readonly items: readonly { - readonly execution: { readonly id: string; readonly outcome?: string }; - }[]; -}; - -/** - * `scan … --json` 的公开检查(`temporary-asset-refresh-inspection/v1`)在本用例里需要读到的 - * 父子位置。已接受资产身份挂在 `itemResults[i].accepted.entity` 上;父执行顶层的 - * `acceptedAssetId`/`reusedExecutionId` 仅在整执行级复用命中时发出,因此都是可选的。 - */ -type ScanInspection = { - readonly executionId: string; - readonly execution: { - readonly outcome: string; - readonly status?: string; - readonly acceptedAssetId?: string; - readonly reusedExecutionId?: string; - readonly workItems?: readonly { - readonly key: string; - readonly relativePaths: readonly string[]; - readonly outcome?: string; - readonly reason?: string; - readonly acceptedAssetId?: string; - readonly retainedAssetId?: string; - readonly reuseAcceptedBoundary?: boolean; - }[]; - }; - readonly itemResults?: readonly { - readonly key: string; - readonly relativePaths: readonly string[]; - readonly outcome?: string; - readonly reason?: string; - readonly accepted?: { - readonly entity?: { readonly id: string; readonly kind: string }; - readonly sourceAssociations?: readonly { - readonly sourceIdentity: string; - readonly nativeAddress: string; - }[]; - }; - }[]; -}; - -/** - * 恢复后复扫的复用位置(真实脱敏公开 JSON 留档: - * `R/current/07/evidence/07-scan-inspection-reopened.json`/ - * `07-scan-inspection-resumed.json`):命中的是「按已接受边界逐项复用」这条终态,父执行顶层 - * 不发出 `acceptedAssetId`/`reusedExecutionId`;被复用的资产身份挂在子项 `retainedAssetId`/ - * `acceptedAssetId` 上,并与备份前按父子位置取到的同一条已接受资产互指, - * `itemResults[i].accepted.entity` 也仍在。 - */ -function expectRetainedAcceptedAsset( - scan: ScanInspection, - acceptedAssetId: string, - backedUpSourceAssociation: { - readonly sourceIdentity: string; - readonly nativeAddress: string; - }, -) { - expect(scan.execution).toMatchObject({ - outcome: "no-change", - status: "completed", - }); - const [reusedWorkItem] = scan.execution.workItems ?? []; - expect(reusedWorkItem).toMatchObject({ - relativePaths: ["track.wav"], - outcome: "no-change", - reason: "保留已经确认的资产范围", - reuseAcceptedBoundary: true, - retainedAssetId: acceptedAssetId, - acceptedAssetId, - }); - const itemResult = scan.itemResults?.find( - ({ key }) => key === reusedWorkItem?.key, - ); - expect(itemResult?.accepted?.entity).toMatchObject({ - id: acceptedAssetId, - kind: "digital-asset", - }); - expect(itemResult?.accepted?.sourceAssociations).toEqual([ - backedUpSourceAssociation, - ]); -} - -async function prepareWithFormalMigration( - productDataDirectory: string, -): Promise { - const result = await new Promise((resolve, reject) => { - const child = spawn( - "pnpm", - ["--filter", "@digital/db-migrations", "prepare-only"], - { - cwd: repositoryRoot, - env: { ...process.env, DIGITAL_PRODUCT_DATA: productDataDirectory }, - stdio: "pipe", - }, - ); - child.once("error", reject); - child.once("close", (code) => resolve(code ?? 1)); - }); - expect(result).toBe(0); -} - -/** - * 清理顺序:先等整条自有测试体链落定(其中每一步都在写自己的产品数据根),再撤全局替身, - * 最后删除自有临时根;未落定时两者都跳过,避免与仍在写入的链并发 `rm -r`。 - */ -afterEach(async () => { - const chain = ownChain; - ownChain = undefined; - if ( - chain !== undefined && - (await Promise.race([ - chain.settled.then( - () => true, - () => true, - ), - chain.givenUp, - ])) === false - ) - return; - vi.unstubAllGlobals(); - for (const directory of cleanupPaths.splice(0)) - await rm(directory, { recursive: true, force: true }); -}); - -describe("离线备份与恢复维护命令", () => { - it( - "拒绝运行中产品的备份,并只恢复允许的产品数据区域", - () => - runWithOwnChain(async () => { - const workspace = await temporaryDirectory( - "completion-07-offline-backup-", - ); - const sourceProductData = path.join(workspace, "source-product-data"); - const backupDirectory = path.join(workspace, "backup"); - const restoredProductData = path.join( - workspace, - "restored-product-data", - ); - const observedRoot = path.join(workspace, "RJ01668691"); - const secret = "offline-backup-test-secret-must-never-be-printed"; - await prepareWithFormalMigration(sourceProductData); - await mkdir(observedRoot); - await writeFile(path.join(observedRoot, "track.wav"), "observed bytes"); - vi.stubGlobal("fetch", async (input: string | URL | Request) => { - const address = - typeof input === "string" - ? input - : input instanceof URL - ? input.href - : input.url; - if (address.includes("/product/info/ajax")) { - return new Response( - JSON.stringify({ - RJ01668691: { - site_id: "maniax", - work_name: "离线恢复去重演练作品", - regist_date: "2026-08-01", - work_image: "//img.dlsite.jp/offline-rehearsal.jpg", - }, - }), - { headers: { "content-type": "application/json" } }, - ); - } - if (address === "https://img.dlsite.jp/offline-rehearsal.jpg") { - return new Response(new Uint8Array([1, 2, 3, 4]), { - headers: { "content-type": "image/jpeg" }, - }); - } - return new Response("", { - headers: { "content-type": "text/html" }, - }); - }); - - const activeProduct = runProduct( - { productDataDirectory: sourceProductData, environment: {} }, - { start: (application) => application }, - ); - void activeProduct.result.workflow; - const whileRunning = runtime( - ["offline-backup", backupDirectory], - sourceProductData, - ); - await expect(runCli(whileRunning.runtime)).resolves.toBe(1); - expect(whileRunning.stderr()).toContain("产品正在运行"); - await activeProduct.close(); - - const registered = runtime( - [ - "library-create", - "恢复演练文件位置", - "--directory", - observedRoot, - "--json", - ], - sourceProductData, - ); - await expect(runCli(registered.runtime)).resolves.toBe(0); - const rootId = ( - JSON.parse(registered.stdout()) as { - readonly rootIds: readonly string[]; - } - ).rootIds[0]!; - - const scanned = runtime(["scan", rootId, "--json"], sourceProductData); - await expect(runCli(scanned.runtime)).resolves.toBe(0); - const initialScan = JSON.parse(scanned.stdout()) as ScanInspection; - const executionId = initialScan.executionId; - expect(initialScan.execution).toMatchObject({ outcome: "accepted" }); - - // 真实公开 JSON(完整脱敏留档:R/current/07/evidence/07-scan-inspection-initial.json) - // 钉住这次目录包扫描的父子位置:父执行顶层没有 `accepted`,也没有 - // `execution.acceptedAssetId`;子项已接受资产只落在同 key 的 - // `execution.workItems[i].acceptedAssetId` 上,而 `accepted.entity` 只挂在 - // `itemResults[i].accepted.entity` —— 父子里侧量必须互指同一个资产身份。 - const [acceptedWorkItem] = - initialScan.execution.workItems?.filter( - ({ outcome, acceptedAssetId }) => - outcome === "accepted" && acceptedAssetId !== undefined, - ) ?? []; - expect(acceptedWorkItem?.key).toBe("dlsite-directory:."); - expect(acceptedWorkItem?.relativePaths).toEqual(["track.wav"]); - const acceptedItemResult = initialScan.itemResults?.find( - ({ key }) => key === acceptedWorkItem?.key, - ); - expect(acceptedItemResult?.accepted?.entity).toBeDefined(); - const acceptedAssetId = acceptedItemResult!.accepted!.entity!.id; - expect(acceptedAssetId).toEqual(expect.any(String)); - expect(acceptedAssetId).toBe(acceptedWorkItem!.acceptedAssetId); - expect(acceptedItemResult!.accepted!.entity!.kind).toBe( - "digital-asset", - ); - // 被备份的资产身份就是这条 DLsite 目录包所声明的数字资产及其来源关联。 - const backedUpSourceAssociation = { - sourceIdentity: "dlsite", - nativeAddress: - "https://www.dlsite.com/maniax/work/=/product_id/RJ01668691.html", - } as const; - expect(acceptedItemResult!.accepted!.sourceAssociations).toEqual([ - backedUpSourceAssociation, - ]); - - const connections = runtime( - ["source-connection-list"], - sourceProductData, - { - DIGITAL_TMDB_ACCESS_TOKEN: secret, - }, - ); - await expect(runCli(connections.runtime)).resolves.toBe(0); - expect(connections.stdout()).not.toContain(secret); - const sourceConnectionInspection = JSON.parse(connections.stdout()); - - const privateSecretPath = path.join( - sourceProductData, - "private-source-connections", - "tmdb.json", - ); - const privateSecretBytes = await readFile(privateSecretPath); - await expect(lstat(privateSecretPath)).resolves.toMatchObject({ - mode: expect.any(Number), - }); - await chmod(privateSecretPath, 0o600); - await mkdir(path.join(sourceProductData, "cache")); - await writeFile( - path.join(sourceProductData, "cache", "rebuildable"), - "cache", - ); - await mkdir(path.join(sourceProductData, "future-durable-area")); - await writeFile( - path.join(sourceProductData, "future-durable-area", "state"), - "future durable state", - ); - const managedArtifactPath = path.join( - sourceProductData, - "managed-artifacts", - "maintenance-rehearsal.bin", - ); - const managedArtifactBytes = new Uint8Array([7, 6, 5, 4]); - await writeFile(managedArtifactPath, managedArtifactBytes); - await chmod(managedArtifactPath, 0o640); - const surfaces = [ - "current-catalogue", - "current-attention", - "workflow-history", - ] as const; - const sourceSurfaces = new Map(); - for (const surface of surfaces) { - const sourceSurface = runtime( - ["catalogue-surface", "--surface", surface], - sourceProductData, - ); - await expect(runCli(sourceSurface.runtime)).resolves.toBe(0); - sourceSurfaces.set(surface, JSON.parse(sourceSurface.stdout())); - } - - const backup = runtime( - ["offline-backup", backupDirectory], - sourceProductData, - ); - await expect(runCli(backup.runtime)).resolves.toBe(0); - expect(backup.stdout()).not.toContain(secret); - await expect( - lstat(path.join(backupDirectory, "cache")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - await expect( - readFile( - path.join(backupDirectory, "future-durable-area", "state"), - "utf8", - ), - ).resolves.toBe("future durable state"); - await expect( - lstat(path.join(backupDirectory, "observed-media")), - ).rejects.toMatchObject({ - code: "ENOENT", - }); - expect( - await readFile( - path.join( - backupDirectory, - "private-source-connections", - "tmdb.json", - ), - ), - ).toEqual(privateSecretBytes); - expect( - ( - await lstat( - path.join( - backupDirectory, - "private-source-connections", - "tmdb.json", - ), - ) - ).mode & 0o777, - ).toBe(0o600); - expect( - await readFile( - path.join( - backupDirectory, - "managed-artifacts", - "maintenance-rehearsal.bin", - ), - ), - ).toEqual(Buffer.from(managedArtifactBytes)); - expect( - ( - await lstat( - path.join( - backupDirectory, - "managed-artifacts", - "maintenance-rehearsal.bin", - ), - ) - ).mode & 0o777, - ).toBe(0o640); - - await mkdir(restoredProductData); - const restored = runtime( - ["offline-restore", backupDirectory], - restoredProductData, - ); - await expect(runCli(restored.runtime)).resolves.toBe(0); - expect(restored.stdout()).not.toContain(secret); - expect( - await readFile( - path.join( - restoredProductData, - "managed-artifacts", - "maintenance-rehearsal.bin", - ), - ), - ).toEqual(Buffer.from(managedArtifactBytes)); - expect( - ( - await lstat( - path.join( - restoredProductData, - "managed-artifacts", - "maintenance-rehearsal.bin", - ), - ) - ).mode & 0o777, - ).toBe(0o640); - - const restoredRoot = runtime( - ["scan", rootId, "--scope", ".", "--json"], - restoredProductData, - ); - await expect(runCli(restoredRoot.runtime)).resolves.toBe(0); - const reopenedScan = JSON.parse( - restoredRoot.stdout(), - ) as ScanInspection; - expectRetainedAcceptedAsset( - reopenedScan, - acceptedAssetId, - backedUpSourceAssociation, - ); - const reopenedExecutionId = reopenedScan.executionId; - - const restoredExecution = runtime( - ["inspect", executionId, "--json"], - restoredProductData, - ); - await expect(runCli(restoredExecution.runtime)).resolves.toBe(0); - expect(JSON.parse(restoredExecution.stdout())).toMatchObject({ - executionId, - execution: { status: "completed" }, - }); - - // 真实指纹(R/current/07/logs/07-batch-d-drift.log 的 SURFACE-FP 行):恢复后的 - // current-catalogue 与 current-attention 与源库逐字段相等;workflow-history 是只增 - // 不减的执行台账,重开扫描自身追加一条 no-change 执行,源库那条已接受执行原样保留。 - for (const surface of [ - "current-catalogue", - "current-attention", - ] as const) { - const restoredSurface = runtime( - ["catalogue-surface", "--surface", surface], - restoredProductData, - ); - await expect(runCli(restoredSurface.runtime)).resolves.toBe(0); - expect(JSON.parse(restoredSurface.stdout())).toEqual( - sourceSurfaces.get(surface), - ); - } - - const restoredHistorySurface = runtime( - ["catalogue-surface", "--surface", "workflow-history"], - restoredProductData, - ); - await expect(runCli(restoredHistorySurface.runtime)).resolves.toBe(0); - const restoredHistory = JSON.parse( - restoredHistorySurface.stdout(), - ) as SurfaceHistory; - const sourceHistory = sourceSurfaces.get( - "workflow-history", - ) as SurfaceHistory; - expect(sourceHistory.items).toHaveLength(1); - expect( - restoredHistory.items.map(({ execution }) => [ - execution.id, - execution.outcome, - ]), - ).toEqual([ - [reopenedExecutionId, "no-change"], - ...sourceHistory.items.map(({ execution }) => [ - execution.id, - execution.outcome, - ]), - ]); - // 被备份的已接受执行本身逐字段不变(含 sourceRecords、recognitionResults、 - // resultReferences 与 sourceRefreshRuns),历史面除重开自身外没有第二处新增。 - expect(restoredHistory.items[1]).toEqual(sourceHistory.items[0]); - - const restoredConnections = runtime( - ["source-connection-list"], - restoredProductData, - ); - await expect(runCli(restoredConnections.runtime)).resolves.toBe(0); - expect(restoredConnections.stdout()).toContain('"id":"tmdb"'); - expect(restoredConnections.stdout()).not.toContain(secret); - expect(JSON.parse(restoredConnections.stdout())).toEqual( - sourceConnectionInspection, - ); - - const refreshed = runtime( - ["scan", rootId, "--json"], - restoredProductData, - ); - await expect(runCli(refreshed.runtime)).resolves.toBe(0); - const resumedScan = JSON.parse(refreshed.stdout()) as ScanInspection; - // 逐项核对与公开面检查之后再次复扫仍是同一条被复用的已接受资产(没有第二次接受)。 - expectRetainedAcceptedAsset( - resumedScan, - acceptedAssetId, - backedUpSourceAssociation, - ); - expect(resumedScan.executionId).not.toBe(executionId); - expect(resumedScan.executionId).not.toBe(reopenedExecutionId); - }), - ownCaseBudgetMilliseconds, - ); -}); diff --git a/apps/cli/test/integration/pdf-publication-live-journey.test.ts b/apps/cli/test/integration/pdf-publication-live-journey.test.ts deleted file mode 100644 index 63f6972..0000000 --- a/apps/cli/test/integration/pdf-publication-live-journey.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; - -import { expect, it } from "vitest"; - -import { assertApprovedPdfOracleSlice } from "../e2e/journeys/pdf-publication-live.journey.ts"; - -it("PDF live journey 只接受已审核的本地格式 oracle 切片", async () => { - const oracle = JSON.parse( - await readFile( - path.resolve( - import.meta.dirname, - "../../../..", - "fixtures/live-corpus/references/reviewed-oracle.json", - ), - "utf8", - ), - ) as { - pdfPublications: Array<{ pageCount: number }>; - }; - - expect(() => assertApprovedPdfOracleSlice(oracle)).not.toThrow(); - oracle.pdfPublications[0]!.pageCount += 1; - expect(() => assertApprovedPdfOracleSlice(oracle)).toThrow( - "PDF reviewed oracle slice digest drifted", - ); -}); diff --git a/apps/cli/test/integration/tmdb-omdb-live-journey.test.ts b/apps/cli/test/integration/tmdb-omdb-live-journey.test.ts deleted file mode 100644 index 23dd9af..0000000 --- a/apps/cli/test/integration/tmdb-omdb-live-journey.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -import { mkdir, mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { afterEach, describe, expect, it, vi } from "vitest"; - -import journey, { - acceptedProjection, - assertSourceEnrichedFilm, -} from "../e2e/journeys/tmdb-omdb-live.journey.ts"; - -const temporaryDirectories: string[] = []; - -afterEach(async () => { - vi.unstubAllEnvs(); - await Promise.all( - temporaryDirectories - .splice(0) - .map((directory) => rm(directory, { recursive: true, force: true })), - ); -}); - -describe("TMDB and OMDb live journey contract", () => { - it("fails before it can run when either required private provider credential is absent", async () => { - for (const [tmdb, omdb] of [ - [undefined, "test-omdb-key"], - ["test-tmdb-token", undefined], - ["", ""], - ] as const) { - vi.stubEnv("DIGITAL_TMDB_ACCESS_TOKEN", tmdb); - vi.stubEnv("DIGITAL_OMDB_API_KEY", omdb); - await expect( - journey.setup({ - mode: "source", - packageRoot: path.resolve(import.meta.dirname, "../.."), - workDir: "/unused", - productDataDirectory: "/unused/product-data", - environmentName: "completion-02-fixture-context", - commandEnvironment: {}, - }), - ).rejects.toThrow( - "TMDB and OMDb live journey requires DIGITAL_TMDB_ACCESS_TOKEN and DIGITAL_OMDB_API_KEY", - ); - vi.unstubAllEnvs(); - } - }); - - it("plans strict recognition, exact directed recognition, reuse, and metadata refresh through one SQLite catalogue", async () => { - vi.stubEnv("DIGITAL_TMDB_ACCESS_TOKEN", "test-tmdb-token"); - vi.stubEnv("DIGITAL_OMDB_API_KEY", "test-omdb-key"); - const workspace = await mkdtemp( - path.join(tmpdir(), "digital-film-contract-"), - ); - temporaryDirectories.push(workspace); - const packageRoot = path.join(workspace, "apps", "cli"); - // 本例只验证命令计划,两个合成目录不代表真实影片验收通过。 - await Promise.all( - [ - "media/电影/恐怖直播 (2013)", - "media/电影/[怒火救援].Man.on.Fire.2004.Blu-ray.1080p.AVC.DTS-HD.MA5.1-CMCT", - ].map((logicalRoot) => - mkdir( - path.join( - workspace, - ".scratch/acceptance-corpus/assets", - logicalRoot, - ), - { recursive: true }, - ), - ), - ); - const context = { - mode: "source", - packageRoot, - workDir: path.join(workspace, "work"), - productDataDirectory: path.join(workspace, "work/product-data"), - environmentName: "completion-02-fixture-context", - commandEnvironment: {}, - } as const; - await journey.setup(context); - const commands = journey.commands(context); - - expect(commands.map((command) => command.name)).toEqual([ - "register strict TMDB film root", - "register directed BDMV film root", - "live accept strict TMDB film with OMDb enrichment", - "live no-change strict TMDB film after SQLite restart", - "live refresh strict film metadata from current sources", - "live inspect refreshed strict film projection", - "live accept directed BDMV film with OMDb enrichment", - "live inspect directed BDMV film projection after SQLite restart", - "live refresh directed BDMV film metadata from current sources", - "live inspect refreshed directed BDMV film projection", - ]); - const result = (stdout: string) => ({ - commandName: "fixture", - exitCode: 0, - stdout, - stderr: "", - }); - const strictRoot = result(JSON.stringify({ rootIds: ["strict-root"] })); - const directedRoot = result(JSON.stringify({ rootIds: ["directed-root"] })); - const strictAccepted = result( - JSON.stringify({ accepted: { entity: { id: "strict-asset" } } }), - ); - const plannedArgs = ( - index: number, - previous: readonly (typeof strictRoot)[], - ) => { - const args = commands[index]?.args; - expect(typeof args).toBe("function"); - return typeof args === "function" ? args(previous) : args; - }; - expect(plannedArgs(2, [strictRoot, directedRoot])).toEqual( - expect.arrayContaining(["scan", "strict-root", "--scope", ".", "--json"]), - ); - expect(plannedArgs(4, [strictRoot, directedRoot, strictAccepted])).toEqual( - expect.arrayContaining(["refresh-metadata", "strict-asset", "--json"]), - ); - expect(plannedArgs(6, [strictRoot, directedRoot])).toEqual( - expect.arrayContaining([ - "scrape-source", - "directed-root", - "tmdb", - JSON.stringify({ - movie: "https://www.themoviedb.org/movie/9509", - }), - "--scope", - ".", - "--json", - ]), - ); - expect(commands.every((command) => command.env !== undefined)).toBe(true); - }); - - it("uses the public legacy and itemized inspection projections without treating them as interchangeable", () => { - const accepted = { - entity: { id: "asset-1", kind: "digital-asset" }, - recognitionSourceAssociation: { - sourceIdentity: "tmdb", - nativeAddress: "https://www.themoviedb.org/movie/209764", - }, - sourceAssociations: [ - { - sourceIdentity: "tmdb", - nativeAddress: "https://www.themoviedb.org/movie/209764", - }, - { - sourceIdentity: "omdb", - nativeAddress: "omdb:imdb:tt2990738", - }, - ], - metadata: [ - { - target: { kind: "entity", entityId: "asset-1" }, - property: "asset.title", - value: "The Terror Live", - sourceSlot: "omdb.movie.title", - sourceRecordIds: ["omdb-record"], - }, - ], - entities: [], - entityMentions: [], - entityResolutionDecisions: [], - relationships: [], - artifacts: [{ id: "managed-cover" }], - locations: [ - { - artifactId: "managed-cover", - kind: "managed", - address: "/managed-cover", - }, - ], - artifactAssociations: [ - { artifactId: "managed-cover", role: "asset.primary-cover" }, - ], - effectiveMetadata: [ - { - property: "asset.title", - value: "恐怖直播", - sourceIdentity: "tmdb", - sourceRecordIds: ["tmdb-record"], - sourceSlot: "tmdb.movie.title", - selectedAssertion: { - id: "assertion-tmdb-title", - property: "asset.title", - value: "恐怖直播", - sourceRecordIds: ["tmdb-record"], - sourceSlot: "tmdb.movie.title", - }, - alternatives: [ - { - assertion: { - id: "assertion-omdb-title", - property: "asset.title", - value: "The Terror Live", - sourceRecordIds: ["omdb-record"], - sourceSlot: "omdb.movie.title", - }, - value: "The Terror Live", - sourceIdentity: "omdb", - sourceRecordIds: ["omdb-record"], - sourceSlot: "omdb.movie.title", - exclusion: { - kind: "source-preference", - selectedSource: "tmdb", - disagreesWithSelected: true, - }, - }, - ], - reasoning: { kind: "source-preference", preferredSource: "tmdb" }, - }, - { - property: "rating.imdb", - value: "6.6/10", - sourceIdentity: "omdb", - sourceRecordIds: ["omdb-record"], - sourceSlot: "omdb.rating.rating.imdb", - selectedAssertion: { - id: "assertion-omdb-rating", - property: "rating.imdb", - value: "6.6/10", - sourceRecordIds: ["omdb-record"], - sourceSlot: "omdb.rating.rating.imdb", - qualifiers: [{ key: "rating.unit", value: "score" }], - }, - qualifiers: [{ key: "rating.unit", value: "score" }], - alternatives: [], - reasoning: { kind: "unranked-source-assertion" }, - }, - ], - metadataPolicyConflicts: [], - recognitionExplanation: "strict title and year", - }; - // 普通消费读取采用当前 facts;原始来源断言仍单独保留未采用的 OMDb 标题。 - const projected = { - ...accepted, - facts: { - id: "asset-1", - descriptions: [ - { - target: { kind: "entity", entityId: "asset-1" }, - metadata: accepted.effectiveMetadata.map((entry) => ({ - property: entry.property, - result: { - status: "resolved", - values: [ - { - ...entry, - assertions: [ - { - target: { kind: "entity", entityId: "asset-1" }, - sourceSlot: entry.sourceSlot, - sourceRecordIds: entry.sourceRecordIds, - }, - ], - }, - ], - }, - })), - }, - ], - }, - }; - const common = { - format: "temporary-asset-refresh-inspection/v1", - unstable: true, - sourceRecords: [ - { - id: "tmdb-record", - sourceIdentity: "tmdb", - nativeAddress: "https://www.themoviedb.org/movie/209764", - coverage: { - id: "tmdb.movie", - completeness: "complete", - authority: "official", - }, - acquiredAt: "2026-01-01T00:00:00.000Z", - digest: "tmdb", - }, - { - id: "omdb-record", - sourceIdentity: "omdb", - nativeAddress: "omdb:imdb:tt2990738", - coverage: { - id: "omdb.movie", - completeness: "complete", - authority: "official", - }, - acquiredAt: "2026-01-01T00:00:00.000Z", - digest: "omdb", - }, - ], - } as const; - const legacy = { - ...common, - execution: { - id: "legacy-execution", - selection: { directory: "/movie" }, - status: "completed", - stage: "completed", - outcome: "accepted", - sourceRecordIds: ["tmdb-record", "omdb-record"], - compatibility: { version: "1" }, - dispositions: [], - }, - accepted: projected, - } as unknown as Parameters[0]; - const itemized = { - ...common, - execution: { - id: "itemized-execution", - selection: { intent: "directed-recognition" }, - status: "completed", - stage: "completed", - outcome: "accepted", - sourceRecordIds: ["tmdb-record", "omdb-record"], - compatibility: { version: "1" }, - dispositions: [], - }, - itemResults: [ - { - key: "directed-recognition", - relativePaths: ["."], - outcome: "accepted", - accepted: projected, - }, - ], - } as unknown as Parameters[0]; - - expect(acceptedProjection(legacy, "legacy").entity.id).toBe("asset-1"); - expect(acceptedProjection(itemized, "itemized").entity.id).toBe("asset-1"); - expect(() => acceptedProjection(legacy, "itemized")).toThrow( - "explicit work-item", - ); - expect(() => acceptedProjection(itemized, "legacy")).toThrow( - "legacy single-boundary", - ); - expect(() => - assertSourceEnrichedFilm( - legacy, - "https://www.themoviedb.org/movie/209764", - "omdb:imdb:tt2990738", - "legacy", - ), - ).not.toThrow(); - expect(() => - assertSourceEnrichedFilm( - itemized, - "https://www.themoviedb.org/movie/209764", - "omdb:imdb:tt2990738", - "itemized", - ), - ).not.toThrow(); - }); -}); diff --git a/apps/cli/test/integration/turbo-private-environment.test.ts b/apps/cli/test/integration/turbo-private-environment.test.ts deleted file mode 100644 index b88b672..0000000 --- a/apps/cli/test/integration/turbo-private-environment.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; - -import { describe, expect, it } from "vitest"; - -describe("root Turbo private integration environment", () => { - it("passes credentials only to the live E2E task that needs them", async () => { - const configuration = JSON.parse( - await readFile( - path.resolve(import.meta.dirname, "../../../../turbo.json"), - "utf8", - ), - ) as { - readonly tasks?: { - readonly "test:e2e"?: { - readonly cache?: boolean; - readonly passThroughEnv?: readonly string[]; - }; - readonly "test:e2e:tmdb-omdb-live"?: { - readonly cache?: boolean; - readonly dependsOn?: readonly string[]; - readonly passThroughEnv?: readonly string[]; - }; - readonly "test:e2e:bangumi-live"?: { - readonly cache?: boolean; - readonly dependsOn?: readonly string[]; - readonly passThroughEnv?: readonly string[]; - }; - }; - }; - - expect("globalPassThroughEnv" in configuration).toBe(false); - expect(configuration.tasks?.["test:e2e"]).toEqual({ - dependsOn: ["build"], - cache: false, - passThroughEnv: ["TMPDIR", "TMP", "TEMP"], - }); - expect(configuration.tasks?.["test:e2e:tmdb-omdb-live"]).toEqual({ - dependsOn: ["build"], - cache: false, - passThroughEnv: ["DIGITAL_TMDB_ACCESS_TOKEN", "DIGITAL_OMDB_API_KEY"], - }); - expect(configuration.tasks?.["test:e2e:bangumi-live"]).toEqual({ - dependsOn: ["build"], - cache: false, - passThroughEnv: [ - "DIGITAL_BANGUMI_LIVE_PRODUCT_DATA", - "DIGITAL_BANGUMI_CLIENT_ID", - "DIGITAL_BANGUMI_CLIENT_SECRET", - "DIGITAL_BANGUMI_REDIRECT_URI", - ], - }); - - const cliPackage = JSON.parse( - await readFile( - path.resolve(import.meta.dirname, "../../package.json"), - "utf8", - ), - ) as { readonly scripts?: Readonly> }; - expect(cliPackage.scripts?.["test:e2e:tmdb-omdb-live"]).toBe( - "DIGITAL_E2E_JOURNEY=tmdb-omdb-live node --conditions=digital-source test/e2e/run-journeys.ts source distribution", - ); - expect(cliPackage.scripts?.["test:e2e:bangumi-live"]).toBe( - "DIGITAL_E2E_JOURNEY=bangumi-episodic-live node --conditions=digital-source test/e2e/run-journeys.ts source distribution", - ); - }); -}); diff --git a/apps/cli/test/prepare-catalogue.ts b/apps/cli/test/prepare-catalogue.ts deleted file mode 100644 index 98c5640..0000000 --- a/apps/cli/test/prepare-catalogue.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { prepareProductionCatalogue } from "@digital/db-migrations"; - -/** - * 仅供端到端测试使用的产品数据准备。旅程复用正式迁移入口,使源码态与 - * 构建产物态接受完全相同的迁移账本;运行时 CLI 仍然不了解迁移实现。 - */ -export async function prepareCliProductData( - productDataDirectory: string, -): Promise { - await prepareProductionCatalogue({ productDataDirectory }); -} diff --git a/apps/cli/test/unit/greet.test.ts b/apps/cli/test/unit/greet.test.ts deleted file mode 100644 index 1d1cb6a..0000000 --- a/apps/cli/test/unit/greet.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { greet } from "../../src/main.ts"; - -describe("greet", () => { - it("normalizes a name and returns a deterministic greeting", () => { - expect(greet(" Ada Lovelace ")).toEqual({ - message: "Hello, Ada Lovelace", - }); - }); - - it("rejects an empty normalized name", () => { - expect(() => greet(" ")).toThrow("Name must not be empty"); - }); -}); diff --git a/apps/cli/test/unit/process-temporary-root.test.ts b/apps/cli/test/unit/process-temporary-root.test.ts deleted file mode 100644 index 108d911..0000000 --- a/apps/cli/test/unit/process-temporary-root.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { lstat, mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { claimProcessTemporaryRoot } from "@digital/composition/process-temporary-root"; -import { afterEach, expect, it } from "vitest"; - -const cleanup: string[] = []; - -afterEach(async () => { - await Promise.all( - cleanup - .splice(0) - .map((entry) => rm(entry, { recursive: true, force: true })), - ); -}); - -it("CLI process owner sets its environment to one 0700 root and closes it idempotently", async () => { - const parent = await mkdtemp(path.join(tmpdir(), "digital-cli-owner-test-")); - cleanup.push(parent); - const environment: NodeJS.ProcessEnv = { TMPDIR: "/previous" }; - - const owner = await claimProcessTemporaryRoot(environment, { - prefix: "digital-cli-process-", - parentDirectory: parent, - }); - - expect(environment.TMPDIR).toBe(owner.directory); - expect((await lstat(owner.directory)).mode & 0o777).toBe(0o700); - const firstClose = owner.close(); - expect(owner.close()).toBe(firstClose); - await firstClose; - expect(environment.TMPDIR).toBe("/previous"); - await expect(lstat(owner.directory)).rejects.toMatchObject({ - code: "ENOENT", - }); -}); - -it("CLI process owner is deleted only after its child operation succeeds", async () => { - const parent = await mkdtemp(path.join(tmpdir(), "digital-cli-parent-test-")); - cleanup.push(parent); - const environment: NodeJS.ProcessEnv = {}; - const failedOwner = await claimProcessTemporaryRoot(environment, { - prefix: "digital-cli-process-", - parentDirectory: parent, - }); - const failure = new Error("product close failed"); - - await expect( - failedOwner.closeAfterSuccessful(Promise.reject(failure)), - ).rejects.toBe(failure); - await expect(lstat(failedOwner.directory)).resolves.toBeDefined(); - expect(environment.TMPDIR).toBe(failedOwner.directory); - - await failedOwner.close(); - const successfulOwner = await claimProcessTemporaryRoot(environment, { - prefix: "digital-cli-process-", - parentDirectory: parent, - }); - await expect( - successfulOwner.closeAfterSuccessful(Promise.resolve(7)), - ).resolves.toBe(7); - await expect(lstat(successfulOwner.directory)).rejects.toMatchObject({ - code: "ENOENT", - }); -}); diff --git a/apps/cli/turbo.json b/apps/cli/turbo.json deleted file mode 100644 index 53ee16c..0000000 --- a/apps/cli/turbo.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": ["//"], - "tags": ["composition-root"], - "tasks": { - "build": { "outputs": ["dist/**"] }, - "test": { "dependsOn": ["build", "@digital/sqlite#build"] }, - "test:e2e": { - "dependsOn": ["build", "@digital/sqlite#build"], - "cache": false - }, - "test:e2e:environment": { - "dependsOn": ["build", "@digital/sqlite#build"], - "cache": false - } - } -} diff --git a/apps/fanbox-extension/README.md b/apps/fanbox-extension/README.md new file mode 100644 index 0000000..5103eb4 --- /dev/null +++ b/apps/fanbox-extension/README.md @@ -0,0 +1,19 @@ +# FANBOX 浏览器连接器 + +## 安装 + +1. 在仓库根目录运行 `pnpm --filter @digital/fanbox-extension build`。 +2. 在 Chromium 打开 `chrome://extensions`,启用“开发者模式”。 +3. 选择“加载已解压的扩展程序”,选中 `apps/fanbox-extension/dist`。 + +Chromium 在加载时会说明扩展读取浏览器 Cookie 所需的权限。扩展只会在用户明确启动连接后读取 FANBOX 会话;目标服务和 FANBOX 站点访问权限会在连接步骤中分别申请。 + +## 连接 + +1. 在 Digital TUI 打开菜单 7“来源连接”,按 `f` 输入 FANBOX 创作者编号。 +2. 保持 TUI 中的连接请求等待,记下服务地址、目标实例编号、请求编号和限时配对码。 +3. 确认当前 Chromium 配置已登录 FANBOX,点击工具栏扩展图标打开独立连接页,填写 TUI 显示的信息并选择“检查目标与创作者范围”。四项输入会保存在当前浏览器会话中;重新打开页面后仍需手动检查目标。 +4. 核对服务实例和创作者范围,再选择“使用此浏览器登录状态连接”。Chromium 会分别询问目标服务与 FANBOX 的访问权限。 +5. 扩展只读取 FANBOX 域名 Cookie,并自动附上当前浏览器 User-Agent;目标服务完成正式 FANBOX 登录验证后才会私密保存连接。 + +服务地址需使用完整的 HTTP 或 HTTPS 地址。扩展会请求该地址的精确来源权限;是否允许 HTTP 地址由当前 Chromium 版本与部署环境的浏览器权限规则决定。连接请求有时限,取消、替换或过期后,旧请求不能再写入连接。 diff --git a/apps/fanbox-extension/options.html b/apps/fanbox-extension/options.html new file mode 100644 index 0000000..12a2894 --- /dev/null +++ b/apps/fanbox-extension/options.html @@ -0,0 +1,78 @@ + + + + + + 连接 FANBOX + + + +

连接 FANBOX

+ + + + + + + + + + + +
+
+ + + diff --git a/apps/fanbox-extension/package.json b/apps/fanbox-extension/package.json new file mode 100644 index 0000000..449bc42 --- /dev/null +++ b/apps/fanbox-extension/package.json @@ -0,0 +1,36 @@ +{ + "name": "@digital/fanbox-extension", + "version": "0.0.0", + "private": true, + "files": [ + "dist", + "README.md" + ], + "type": "module", + "exports": { + "./extension": "./dist" + }, + "scripts": { + "build": "vite build", + "format:check": "oxfmt --list-different --config ../../oxfmt.config.ts .", + "format:write": "oxfmt --write --config ../../oxfmt.config.ts .", + "lint": "oxlint --quiet --format=unix --config ../../oxlint.config.ts --ignore-pattern node_modules .", + "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", + "typecheck": "tsc --noEmit --pretty false" + }, + "dependencies": { + "@digital/public-api": "workspace:*" + }, + "devDependencies": { + "@digital/typescript-config": "workspace:*", + "@types/node": "catalog:", + "oxfmt": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", + "typescript-7": "catalog:", + "vite": "catalog:" + }, + "engines": { + "node": ">=22" + } +} diff --git a/apps/fanbox-extension/public/manifest.json b/apps/fanbox-extension/public/manifest.json new file mode 100644 index 0000000..0720700 --- /dev/null +++ b/apps/fanbox-extension/public/manifest.json @@ -0,0 +1,20 @@ +{ + "manifest_version": 3, + "name": "Digital FANBOX 连接器", + "version": "1.0.1", + "minimum_chrome_version": "102", + "description": "使用当前 Chromium 浏览器中的 FANBOX 登录状态连接受信任的 Digital 服务。", + "permissions": ["cookies", "storage"], + "optional_host_permissions": ["http://*/*", "https://*/*"], + "action": { + "default_title": "连接 FANBOX" + }, + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "background": { + "service_worker": "assets/background.js", + "type": "module" + } +} diff --git a/apps/fanbox-extension/src/background.ts b/apps/fanbox-extension/src/background.ts new file mode 100644 index 0000000..b4308c9 --- /dev/null +++ b/apps/fanbox-extension/src/background.ts @@ -0,0 +1,5 @@ +chrome.runtime.onInstalled.addListener(() => {}); + +chrome.action.onClicked.addListener(() => { + void chrome.runtime.openOptionsPage(); +}); diff --git a/apps/fanbox-extension/src/chrome.d.ts b/apps/fanbox-extension/src/chrome.d.ts new file mode 100644 index 0000000..25b8cd6 --- /dev/null +++ b/apps/fanbox-extension/src/chrome.d.ts @@ -0,0 +1,44 @@ +declare namespace chrome { + namespace permissions { + function request(permissions: { + readonly origins?: readonly string[]; + }): Promise; + } + + namespace action { + const onClicked: { + addListener(listener: () => void): void; + }; + } + + namespace storage { + namespace session { + function get(keys: string): Promise>; + function set(items: Record): Promise; + } + } + + namespace cookies { + type SameSite = "unspecified" | "no_restriction" | "lax" | "strict"; + type Cookie = { + readonly name: string; + readonly value: string; + readonly domain: string; + readonly path: string; + readonly secure: boolean; + readonly httpOnly: boolean; + readonly sameSite: SameSite; + readonly expirationDate?: number; + }; + function getAll(details: { + readonly domain: string; + }): Promise; + } + + namespace runtime { + const onInstalled: { + addListener(listener: () => void): void; + }; + function openOptionsPage(): Promise; + } +} diff --git a/apps/fanbox-extension/src/options.ts b/apps/fanbox-extension/src/options.ts new file mode 100644 index 0000000..d95e708 --- /dev/null +++ b/apps/fanbox-extension/src/options.ts @@ -0,0 +1,474 @@ +import { + createPublicApiClient, + PublicApiClientError, +} from "@digital/public-api"; +import type { PublicFanboxConnectionRequest } from "@digital/public-api/source-connection-contract"; + +const serviceUrlInput = requiredInput("service-url"); +const instanceIdInput = requiredInput("instance-id"); +const requestIdInput = requiredInput("request-id"); +const pairingCodeInput = requiredInput("pairing-code"); +const reviewButton = requiredButton("review"); +const connectButton = requiredButton("connect"); +const scope = requiredElement("scope"); +const status = requiredElement("status"); +const draftStatus = requiredElement("draft-status"); + +const draftStorageKey = "fanboxConnectionDraft"; +type ConnectionDraft = { + readonly serviceAddress: string; + readonly instanceId: string; + readonly requestId: string; + readonly pairingCode: string; +}; +type DraftField = keyof ConnectionDraft; +const draftEditedFields = new Set(); +let draftWriteQueue: Promise = Promise.resolve(); + +let reviewedRequest: PublicFanboxConnectionRequest | undefined; +type ConnectionTarget = NonNullable>; +let reviewedTarget: ConnectionTarget | undefined; +let busy = false; + +for (const [field, input] of draftInputs()) { + input.addEventListener("input", () => { + draftEditedFields.add(field); + saveDraft(); + }); +} + +setStatus("正在恢复当前浏览器会话草稿…"); +void restoreDraft(); +reviewButton.addEventListener("click", () => void reviewTarget()); +connectButton.addEventListener("click", () => void connectBrowserSession()); + +function draftInputs(): readonly (readonly [DraftField, HTMLInputElement])[] { + return [ + ["serviceAddress", serviceUrlInput], + ["instanceId", instanceIdInput], + ["requestId", requestIdInput], + ["pairingCode", pairingCodeInput], + ]; +} + +async function restoreDraft(): Promise { + try { + const saved = (await chrome.storage.session.get(draftStorageKey))[ + draftStorageKey + ]; + if (!isConnectionDraft(saved)) return; + for (const [field, input] of draftInputs()) { + if (!draftEditedFields.has(field)) input.value = saved[field]; + } + } catch { + showDraftStorageFailure(); + } finally { + for (const [, input] of draftInputs()) input.disabled = false; + reviewButton.disabled = false; + setStatus(""); + } +} + +function saveDraft(): void { + const draft: ConnectionDraft = { + serviceAddress: serviceUrlInput.value, + instanceId: instanceIdInput.value, + requestId: requestIdInput.value, + pairingCode: pairingCodeInput.value, + }; + draftWriteQueue = draftWriteQueue + .catch(() => {}) + .then(async () => { + await chrome.storage.session.set({ [draftStorageKey]: draft }); + draftStatus.textContent = ""; + }) + .catch(showDraftStorageFailure); +} + +function showDraftStorageFailure(): void { + draftStatus.textContent = + "表单草稿无法保存在当前浏览器会话中;关闭页面后输入可能丢失。"; +} + +function isConnectionDraft(value: unknown): value is ConnectionDraft { + if (typeof value !== "object" || value === null) return false; + return ( + "serviceAddress" in value && + typeof value.serviceAddress === "string" && + "instanceId" in value && + typeof value.instanceId === "string" && + "requestId" in value && + typeof value.requestId === "string" && + "pairingCode" in value && + typeof value.pairingCode === "string" + ); +} + +function clearPairingCodeIfTerminal( + statusValue: PublicFanboxConnectionRequest["status"], + target: ConnectionTarget, +): void { + if ( + !isTerminalRequestStatus(statusValue) || + !targetStillMatchesInputs(target) + ) + return; + pairingCodeInput.value = ""; + draftEditedFields.add("pairingCode"); + saveDraft(); +} + +function targetStillMatchesInputs(target: ConnectionTarget): boolean { + let serviceAddress: string; + try { + serviceAddress = new URL(serviceUrlInput.value.trim()).href; + } catch { + return false; + } + return ( + serviceAddress === target.serviceAddress && + instanceIdInput.value.trim() === target.expectedInstanceId && + requestIdInput.value.trim() === target.requestId && + pairingCodeInput.value.trim() === target.pairingCode + ); +} + +function isTerminalRequestStatus( + statusValue: PublicFanboxConnectionRequest["status"], +): boolean { + return ( + statusValue === "succeeded" || + statusValue === "failed" || + statusValue === "cancelled" || + statusValue === "expired" || + statusValue === "superseded" + ); +} + +async function reviewTarget(): Promise { + if (busy) return; + const target = readTarget(); + if (target === undefined) return; + busy = true; + setBusy(true); + reviewedRequest = undefined; + reviewedTarget = undefined; + connectButton.disabled = true; + scope.hidden = true; + setStatus("正在请求访问目标服务的权限…"); + try { + if (!(await requestOrigins([target.permissionOrigin]))) { + setStatus("未授予目标服务权限,连接请求保持等待状态。"); + return; + } + if (!sameTarget(target, readTarget())) { + rejectChangedReview(); + return; + } + const request = await target.client.getFanboxConnectionRequestForPairing({ + requestId: target.requestId, + pairingCode: target.pairingCode, + }); + if (!sameTarget(target, readTarget())) { + rejectChangedReview(); + return; + } + if (request.instanceId !== target.expectedInstanceId) { + setScope(request); + setStatus("目标实例编号不一致。请核对 TUI 显示的服务地址与实例编号。"); + return; + } + setScope(request); + if (request.status !== "waiting") { + setStatus(`此连接请求当前状态为:${requestStatus(request.status)}。`); + clearPairingCodeIfTerminal(request.status, target); + return; + } + reviewedRequest = request; + reviewedTarget = target; + connectButton.disabled = false; + setStatus( + "请核对上方服务实例和创作者范围,再选择是否发送当前浏览器登录状态。", + ); + } catch (error) { + setStatus(clientFailure(error)); + } finally { + busy = false; + setBusy(false); + if (reviewedRequest !== undefined) connectButton.disabled = false; + } +} + +async function connectBrowserSession(): Promise { + if (busy || reviewedRequest === undefined || reviewedTarget === undefined) + return; + const target = readTarget(); + if (target === undefined) return; + const confirmedTarget = reviewedTarget; + const confirmedRequest = reviewedRequest; + if (!sameTarget(confirmedTarget, target)) { + rejectChangedReview(); + return; + } + busy = true; + setBusy(true); + connectButton.disabled = true; + setStatus("正在请求读取 FANBOX 登录状态的权限…"); + const fanboxOrigins = ["https://fanbox.cc/*", "https://*.fanbox.cc/*"]; + try { + if (!(await requestOrigins(fanboxOrigins))) { + setStatus("未授予 FANBOX 权限。尚未读取或发送浏览器登录状态。"); + return; + } + if (!reviewIsCurrent(confirmedTarget, confirmedRequest)) { + rejectChangedReview(); + return; + } + const current = await target.client.getFanboxConnectionRequestForPairing({ + requestId: target.requestId, + pairingCode: target.pairingCode, + }); + if (!reviewIsCurrent(confirmedTarget, confirmedRequest)) { + rejectChangedReview(); + return; + } + if ( + current.status !== "waiting" || + current.instanceId !== target.expectedInstanceId || + current.creatorId !== confirmedRequest.creatorId + ) { + reviewedRequest = undefined; + reviewedTarget = undefined; + setScope(current); + setStatus( + `连接请求已变化,当前状态为:${requestStatus(current.status)}。请重新检查目标。`, + ); + clearPairingCodeIfTerminal(current.status, confirmedTarget); + return; + } + + const cookies = await getFanboxCookies(); + if (!reviewIsCurrent(confirmedTarget, confirmedRequest)) { + rejectChangedReview(); + return; + } + if (!cookies.some((cookie) => cookie.name === "FANBOXSESSID")) { + setStatus( + "当前 Chromium 配置中没有 FANBOX 登录会话。请先在此浏览器登录 FANBOX,再重试。", + ); + connectButton.disabled = false; + return; + } + setStatus("已捕获当前浏览器会话,正在由目标服务验证…"); + const result = await target.client.completeFanboxConnectionRequest({ + requestId: target.requestId, + pairingCode: target.pairingCode, + browserCredential: { cookies, userAgent: navigator.userAgent }, + }); + setScope(result); + reviewedRequest = undefined; + reviewedTarget = undefined; + clearPairingCodeIfTerminal(result.status, confirmedTarget); + setStatus( + result.status === "succeeded" + ? "FANBOX 连接成功。浏览器会话已由目标服务验证并私密保存。" + : `目标服务未保存此连接,状态为:${requestStatus(result.status)}。`, + ); + } catch (error) { + setStatus(clientFailure(error)); + connectButton.disabled = false; + } finally { + busy = false; + setBusy(false); + if (reviewedRequest !== undefined) connectButton.disabled = false; + } +} + +function readTarget(): + | { + readonly client: ReturnType; + readonly serviceAddress: string; + readonly permissionOrigin: string; + readonly expectedInstanceId: string; + readonly requestId: string; + readonly pairingCode: string; + } + | undefined { + let serviceUrl: URL; + try { + serviceUrl = new URL(serviceUrlInput.value.trim()); + } catch { + setStatus("请输入完整的 HTTP 或 HTTPS 服务地址。"); + return undefined; + } + if ( + (serviceUrl.protocol !== "http:" && serviceUrl.protocol !== "https:") || + serviceUrl.username.length > 0 || + serviceUrl.password.length > 0 + ) { + setStatus("服务地址必须使用 HTTP 或 HTTPS,且不能包含用户名或密码。"); + return undefined; + } + const expectedInstanceId = instanceIdInput.value.trim(); + const requestId = requestIdInput.value.trim(); + const pairingCode = pairingCodeInput.value.trim(); + if ( + !isUuid(expectedInstanceId) || + !isUuid(requestId) || + !isPairingCode(pairingCode) + ) { + setStatus("请填写 TUI 显示的目标实例编号、连接请求编号和限时配对码。"); + return undefined; + } + return { + client: createPublicApiClient({ baseUrl: serviceUrl }), + serviceAddress: serviceUrl.href, + permissionOrigin: `${serviceUrl.origin}/*`, + expectedInstanceId, + requestId, + pairingCode, + }; +} + +function sameTarget( + expected: ConnectionTarget, + actual: ConnectionTarget | undefined, +): boolean { + return ( + actual !== undefined && + expected.serviceAddress === actual.serviceAddress && + expected.expectedInstanceId === actual.expectedInstanceId && + expected.requestId === actual.requestId && + expected.pairingCode === actual.pairingCode + ); +} + +function reviewIsCurrent( + target: ConnectionTarget, + request: PublicFanboxConnectionRequest, +): boolean { + return ( + reviewedTarget === target && + reviewedRequest === request && + sameTarget(target, readTarget()) + ); +} + +function rejectChangedReview(): void { + reviewedRequest = undefined; + reviewedTarget = undefined; + connectButton.disabled = true; + scope.hidden = true; + setStatus("目标信息已更改,请重新检查目标与创作者范围。"); +} + +function requestOrigins(origins: readonly string[]): Promise { + return chrome.permissions.request({ origins: [...origins] }); +} + +async function getFanboxCookies(): Promise[]> { + const browserCookies = await chrome.cookies.getAll({ domain: "fanbox.cc" }); + return browserCookies.flatMap((cookie) => { + if (cookie.domain !== "fanbox.cc" && cookie.domain !== ".fanbox.cc") + return []; + const sameSite = + cookie.sameSite === "unspecified" + ? undefined + : cookie.sameSite === "no_restriction" + ? "None" + : cookie.sameSite === "lax" + ? "Lax" + : "Strict"; + return [ + { + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + ...(sameSite === undefined ? {} : { sameSite }), + ...(cookie.expirationDate === undefined + ? {} + : { expirationDate: cookie.expirationDate }), + }, + ]; + }); +} + +function setScope(request: PublicFanboxConnectionRequest): void { + scope.hidden = false; + scope.textContent = `目标实例:${request.instanceId}\n创作者范围:${request.creatorId}\n请求状态:${requestStatus(request.status)}`; +} + +function setBusy(value: boolean): void { + reviewButton.disabled = value; + if (value) connectButton.disabled = true; +} + +function setStatus(message: string): void { + status.textContent = message; +} + +function requestStatus( + statusValue: PublicFanboxConnectionRequest["status"], +): string { + switch (statusValue) { + case "waiting": + return "等待配对"; + case "verifying": + return "正在验证"; + case "succeeded": + return "连接成功"; + case "failed": + return "验证失败"; + case "cancelled": + return "已取消"; + case "expired": + return "已过期"; + case "superseded": + return "已替代"; + } +} + +function clientFailure(error: unknown): string { + if (error instanceof PublicApiClientError) { + if (error.kind === "not-found") + return "服务地址、实例编号或配对信息无效;请核对目标并重新发起请求。"; + if (error.kind === "conflict") + return "连接状态已变化,请回到 TUI 重新发起连接。"; + if (error.kind === "transport") + return "无法连接目标服务,请检查服务地址和浏览器权限。"; + } + return "连接未完成。请核对目标服务状态后重试;浏览器会话不会显示在扩展中。"; +} + +function isUuid(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test( + value, + ); +} + +function isPairingCode(value: string): boolean { + return /^[A-Za-z0-9_-]{43}$/u.test(value); +} + +function requiredElement(id: string): HTMLElement { + const element = document.getElementById(id); + if (element === null) throw new Error("扩展界面没有完成加载"); + return element; +} + +function requiredInput(id: string): HTMLInputElement { + const element = requiredElement(id); + if (!(element instanceof HTMLInputElement)) + throw new Error("扩展界面输入项无效"); + return element; +} + +function requiredButton(id: string): HTMLButtonElement { + const element = requiredElement(id); + if (!(element instanceof HTMLButtonElement)) + throw new Error("扩展界面按钮无效"); + return element; +} diff --git a/apps/fanbox-extension/tsconfig.json b/apps/fanbox-extension/tsconfig.json new file mode 100644 index 0000000..313c345 --- /dev/null +++ b/apps/fanbox-extension/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "@digital/typescript-config/base.json", + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "customConditions": ["digital-source"], + "rewriteRelativeImportExtensions": true, + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/apps/fanbox-extension/turbo.json b/apps/fanbox-extension/turbo.json new file mode 100644 index 0000000..93a1dec --- /dev/null +++ b/apps/fanbox-extension/turbo.json @@ -0,0 +1,24 @@ +{ + "extends": ["//"], + "tags": ["client"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "format:check": { "outputs": [], "tags": ["quality:light"] }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { "outputs": [], "tags": ["quality:light"] }, + "lint:fix": { "cache": false, "tags": ["work:repair"] }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + } + } +} diff --git a/apps/fanbox-extension/vite.config.ts b/apps/fanbox-extension/vite.config.ts new file mode 100644 index 0000000..a89bbcc --- /dev/null +++ b/apps/fanbox-extension/vite.config.ts @@ -0,0 +1,21 @@ +import { resolve } from "node:path"; + +import { defineConfig } from "vite"; + +export default defineConfig({ + root: resolve(import.meta.dirname), + publicDir: "public", + build: { + outDir: "dist", + emptyOutDir: true, + rollupOptions: { + input: { + background: resolve(import.meta.dirname, "src/background.ts"), + options: resolve(import.meta.dirname, "options.html"), + }, + output: { + entryFileNames: "assets/[name].js", + }, + }, + }, +}); diff --git a/apps/web/Dockerfile b/apps/service/Dockerfile similarity index 65% rename from apps/web/Dockerfile rename to apps/service/Dockerfile index 35674eb..1b37838 100644 --- a/apps/web/Dockerfile +++ b/apps/service/Dockerfile @@ -1,21 +1,23 @@ # syntax=docker/dockerfile:1.7 +# @template-mirror NODE_VERSION +ARG NODE_VERSION="24.21.0" +# @end-template-mirror + ARG DEPLOYMENT_BUILD_ID="local" -FROM node:24-bookworm-slim AS base -# 与根 package.json 的 packageManager 保持一致:pruner 的 `pnpm fetch` 在 package.json 就位前 -# 运行(用此激活版本),`pnpm install --offline` 在其后运行(corepack 读 packageManager)。 -# 两者若跨 pnpm 主版本,fetch 写入的离线元数据命名空间与 install 读取的不兼容,报 -# ERR_PNPM_NO_OFFLINE_META。统一为工作区声明的 pnpm@12.3.4 以保证同一版本与裁剪一致性。 -ARG PACKAGE_MANAGER_PIN="pnpm@12.3.4" +FROM node:${NODE_VERSION}-bookworm-slim AS base ENV COREPACK_HOME="/corepack" ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME/bin:$PNPM_HOME:$PATH" WORKDIR /repo -RUN install -d -m 0755 "$COREPACK_HOME" "$PNPM_HOME" \ +COPY package.json /root-manifest.json +RUN install -d -m 0755 "$COREPACK_HOME" "$PNPM_HOME" "$PNPM_HOME/bin" \ && corepack enable --install-directory "$PNPM_HOME" \ + && package_manager="$(node -p "require('/root-manifest.json').packageManager")" \ + && rm /root-manifest.json \ && for attempt in 1 2 3; do \ - if corepack prepare "$PACKAGE_MANAGER_PIN" --activate; then break; fi; \ + if corepack prepare "$package_manager" --activate; then break; fi; \ [ "$attempt" -lt 3 ] || exit 1; \ sleep 2; \ done \ @@ -24,11 +26,11 @@ RUN install -d -m 0755 "$COREPACK_HOME" "$PNPM_HOME" \ FROM base AS pruner COPY pnpm-lock.yaml pnpm-workspace.yaml ./ RUN pnpm fetch -# Web 的生产依赖闭包包含工作区包(尤其是 application)。在为 Turbo prune +# 服务的生产依赖闭包包含工作区包。在为 Turbo prune # 提供冻结安装前,先向 pnpm 提供完整的工作区清单图。 COPY . . RUN pnpm install --offline --frozen-lockfile -RUN pnpm exec turbo prune --scope=@digital/web --scope=@digital/db-migrations --docker +RUN pnpm exec turbo prune --scope=@digital/service --scope=@digital/db-migrations --docker FROM base AS build COPY --from=pruner /repo/out/json/pnpm-lock.yaml ./pnpm-lock.yaml @@ -37,46 +39,47 @@ COPY --from=pruner /repo/tsconfig.json /repo/tsconfig.config.json ./ RUN pnpm fetch COPY --from=pruner /repo/out/json/ ./ RUN pnpm install --offline --frozen-lockfile -COPY --from=pruner /repo/out/full/ ./ # FANBOX production transport 与 package 中固定的 Playwright 版本共用同一 Chromium revision。 ENV PLAYWRIGHT_BROWSERS_PATH="/ms-playwright" RUN pnpm --filter @digital/fanbox exec playwright install chromium +COPY --from=pruner /repo/out/full/ ./ # 由 Turbo 按正式 workspace 依赖图构建交付所需包,再由 deploy 在隔离的部署目录中定点 # 注入其发布文件。裁剪只决定包的存在,构建顺序与闭包由 Turbo 计算。 RUN TURBO_TELEMETRY_DISABLED=1 pnpm exec turbo run build \ - --filter=@digital/web --filter=@digital/db-migrations -RUN pnpm --config.inject-workspace-packages=true --filter ./apps/web deploy --prod /runtime-deploy + --filter=@digital/service --filter=@digital/db-migrations +RUN pnpm --config.inject-workspace-packages=true --filter ./apps/service deploy --prod /runtime-deploy RUN pnpm --config.inject-workspace-packages=true --filter ./packages/db-migrations deploy --prod /migration-deploy -FROM node:24-bookworm-slim AS application-runtime +FROM node:${NODE_VERSION}-bookworm-slim AS application-runtime ARG DEPLOYMENT_BUILD_ID LABEL org.opencontainers.image.version="$DEPLOYMENT_BUILD_ID" ENV NODE_ENV="production" \ DIGITAL_PRODUCT_DATA="/data" \ + HOST="0.0.0.0" \ PLAYWRIGHT_BROWSERS_PATH="/ms-playwright" WORKDIR /app RUN groupadd --system --gid 1001 nodejs \ && useradd --system --uid 1001 --gid nodejs --home-dir /app --shell /usr/sbin/nologin app \ && install -d -o app -g nodejs /app /data -COPY --chmod=755 apps/web/scripts/compose-entrypoint.sh /usr/local/bin/compose-entrypoint +COPY --chmod=755 apps/service/scripts/compose-entrypoint.sh /usr/local/bin/compose-entrypoint COPY --from=build --chown=app:nodejs /runtime-deploy/node_modules ./node_modules -COPY --from=build --chown=app:nodejs /repo/apps/web/dist ./dist +COPY --from=build --chown=app:nodejs /repo/apps/service/dist ./dist COPY --from=build /ms-playwright /ms-playwright RUN node /app/node_modules/.pnpm/playwright@1.62.0/node_modules/playwright/cli.js install-deps chromium \ && rm -rf /var/lib/apt/lists/* \ && chmod -R a-w /ms-playwright -# 仅启动部署刻意不携带迁移 CLI、迁移 SQL 或准备入口。未准备的数据卷必须拒绝 -# readiness,而不是自行迁移。 +# 仅启动部署的服务进程不会自行迁移;明确维护 bin 随服务生产依赖安装,可独立停止服务后 +# 执行离线备份或恢复。未准备的数据卷仍必须拒绝 readiness。 FROM application-runtime AS runtime USER app EXPOSE 3000 -CMD ["node", "/app/dist/server/index.mjs"] +CMD ["node", "/app/dist/main.js"] # 组合部署是唯一携带正式准备能力的镜像。 FROM application-runtime AS standalone COPY --from=build --chown=app:nodejs /migration-deploy /migration -COPY --chmod=755 apps/web/scripts/container-entrypoint.sh /usr/local/bin/container-entrypoint +COPY --chmod=755 apps/service/scripts/container-entrypoint.sh /usr/local/bin/container-entrypoint USER app EXPOSE 3000 ENTRYPOINT ["/usr/local/bin/container-entrypoint"] diff --git a/apps/web/Dockerfile.dockerignore b/apps/service/Dockerfile.dockerignore similarity index 70% rename from apps/web/Dockerfile.dockerignore rename to apps/service/Dockerfile.dockerignore index 8243776..0ddfb0f 100644 --- a/apps/web/Dockerfile.dockerignore +++ b/apps/service/Dockerfile.dockerignore @@ -1,18 +1,30 @@ .git +.digital +.env +.env.local +.env.*.local +*.env .template node_modules **/node_modules **/.turbo +**/*.tsbuildinfo **/dist +**/cache +**/log +**/logs +*.log +**/*.log **/playwright-report **/test-results -apps/web/data -apps/web/node_modules/.tmp +apps/service/data *.sqlite *.sqlite-* # 本地机密、agent 上下文与教学资料不得进入生产构建上下文。 .scratch/ +credentials/ +secrets/ .agents/ AGENTS.md GLOSSARY*.md diff --git a/apps/service/MAINTENANCE.md b/apps/service/MAINTENANCE.md new file mode 100644 index 0000000..0cada47 --- /dev/null +++ b/apps/service/MAINTENANCE.md @@ -0,0 +1,29 @@ +# 离线备份与恢复 + +`@digital/service` 的生产依赖提供 `digital-product-maintenance`。在仓库根目录用 +`pnpm product:maintenance` 调用;容器部署使用 `/app/node_modules/.bin/digital-product-maintenance`。 +命令只对显式给出的产品数据根操作,不会从当前目录或服务环境猜测路径。 + +离线操作前,停止 `digital-product-service` 及所有可能写入同一产品数据根的自动化宿主、 +工作进程和旧客户端;确认没有其他产品写入者。维护命令仍会获取产品数据维护租约,若根 +仍被占用会失败退出,不读取不一致状态,也不与活动服务竞争。备份目标和恢复目标必须是 +空目录;恢复不会覆盖现有产品数据。 + +```sh +pnpm product:maintenance offline-backup \ + --product-data /data/digital \ + --backup-directory /backups/digital-2026-10-18 + +pnpm product:maintenance offline-restore \ + --backup-directory /backups/digital-2026-10-18 \ + --product-data /data/digital-restored +``` + +备份包含完整产品数据根中的耐久数据,包括 Catalogue 及其 WAL/SHM、持久配置、私密来源 +连接、托管素材和其他耐久目录项;只排除 `cache`、`runtime-state` 与 `staging`。产品观察 +到的原始媒体目录位于产品数据根之外,不会复制或改写。恢复后必须让服务在原素材仍可用的 +路径上运行。私密来源凭据会随产品数据备份保留,命令输出不会打印凭据内容。 + +成功时命令以零退出码输出完成信息;任何准备、复制、迁移、发布或收尾失败都以非零退出码 +报告,恢复未完成时不会输出成功信息。恢复失败会留下中断标记并阻止该目标根被当作可用 +产品启动。保留原备份;确认失败目标可丢弃后,使用一个新的空目标目录重试恢复。 diff --git a/apps/service/package.json b/apps/service/package.json new file mode 100644 index 0000000..6d7afc1 --- /dev/null +++ b/apps/service/package.json @@ -0,0 +1,57 @@ +{ + "name": "@digital/service", + "version": "0.0.0", + "private": true, + "bin": { + "digital-product-service": "./dist/main.js" + }, + "files": [ + "dist" + ], + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.build.json --pretty false", + "format:check": "oxfmt --list-different --config ../../oxfmt.config.ts .", + "format:write": "oxfmt --write --config ../../oxfmt.config.ts .", + "lint": "oxlint --quiet --format=unix --config ../../oxlint.config.ts --ignore-pattern node_modules . && shellcheck scripts/container-entrypoint.sh scripts/compose-entrypoint.sh", + "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix && shellcheck scripts/container-entrypoint.sh scripts/compose-entrypoint.sh", + "deployment": "node --conditions=digital-source scripts/check-deployment.ts", + "deployment:prepare": "node --conditions=digital-source scripts/prepare-deployment.ts", + "prepare:verify": "node ./dist/verify-prepared.js", + "start": "node ./dist/main.js", + "test": "vitest run --reporter=verbose --passWithNoTests --exclude=test/fanbox-extension-browser.test.ts --testNamePattern='^(?!.*安装客户端).*$'", + "test:installed-clients": "pnpm exec playwright install chromium && vitest run --reporter=verbose test/public-http.test.ts test/automation-http.test.ts test/execution-recovery-http.test.ts test/asset-inspection-http.test.ts test/source-connections-http.test.ts test/fanbox-extension-browser.test.ts test/development-session.test.ts --testNamePattern=安装客户端", + "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", + "dev:serve": "pnpm --workspace-root run development:serve" + }, + "dependencies": { + "@digital/composition": "workspace:*", + "@digital/http-delivery": "workspace:*", + "@digital/product-maintenance": "workspace:*", + "@digital/service-host": "workspace:*", + "@digital/test-support": "workspace:*", + "hono": "catalog:", + "srvx": "catalog:" + }, + "devDependencies": { + "@digital/db-migrations": "workspace:*", + "@digital/environment": "workspace:*", + "@digital/fanbox": "workspace:*", + "@digital/fanbox-extension": "workspace:*", + "@digital/preview-corpus": "workspace:*", + "@digital/product-application": "workspace:*", + "@digital/public-api": "workspace:*", + "@digital/tui": "workspace:*", + "@digital/typescript-config": "workspace:*", + "@playwright/test": "catalog:", + "@types/node": "catalog:", + "oxfmt": "catalog:", + "oxlint": "catalog:", + "oxlint-tsgolint": "catalog:", + "typescript-7": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": "24" + } +} diff --git a/apps/service/scripts/check-deployment.ts b/apps/service/scripts/check-deployment.ts new file mode 100644 index 0000000..57f2d65 --- /dev/null +++ b/apps/service/scripts/check-deployment.ts @@ -0,0 +1,1221 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { + appendFile, + chmod, + cp, + mkdir, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { createServer } from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + manageContainerEnvironment, + type ContainerObject, + type ContainerOperationResult, + type ContainerReference, + type ContainerRequest, +} from "@digital/environment/container"; +import { createPublicApiClient } from "@digital/public-api"; + +type PreparedDeployment = { + readonly workspacePath: string; + readonly revision: string; + readonly endpoint: string; + readonly daemonId: string; + readonly composeVersion: string; + readonly standaloneImage: string; + readonly runtimeImage: string; + readonly images: readonly { + readonly reference: string; + readonly id: string; + readonly version: string | null; + }[]; +}; + +type OwnedEnvironment = ContainerReference & { + readonly mode: "standalone" | "compose"; +}; + +type ProductState = { + readonly assetLibraryId: string; + readonly executionId: string; + readonly assetIds: readonly string[]; +}; + +const workspacePath = path.resolve(import.meta.dirname, "../../.."); +const preparedPath = path.resolve( + process.env.DIGITAL_DEPLOYMENT_ARTIFACTS ?? + process.env.DIGITAL_CI_EVIDENCE ?? + path.join(import.meta.dirname, "../.turbo/deployment"), + "prepared.json", +); +const explicitStandaloneImage = process.env.DIGITAL_DEPLOYMENT_STANDALONE_IMAGE; +const explicitRuntimeImage = process.env.DIGITAL_DEPLOYMENT_RUNTIME_IMAGE; +if ( + (explicitStandaloneImage === undefined) !== + (explicitRuntimeImage === undefined) +) + throw new Error("显式部署需要同时提供独立准备与仅运行镜像"); + +const runId = `${process.pid}-${Date.now()}-${randomUUID().slice(0, 8)}`; +const artifacts = path.resolve( + process.env.DIGITAL_DEPLOYMENT_ARTIFACTS ?? + process.env.DIGITAL_CI_EVIDENCE ?? + path.join(import.meta.dirname, `../.turbo/deployment/run-${runId}`), +); +const sourceMaterialRoot = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/material"), +); +const endpoint = { + command: process.env.DIGITAL_DEPLOYMENT_DOCKER_COMMAND ?? "docker", + arguments: JSON.parse( + process.env.DIGITAL_DEPLOYMENT_DOCKER_ARGUMENTS ?? "[]", + ) as string[], + host: process.env.DOCKER_HOST ?? "unix:///var/run/docker.sock", +}; +const controller = new AbortController(); +for (const signal of ["SIGINT", "SIGTERM"] as const) + process.once(signal, () => controller.abort(new Error(`收到 ${signal}`))); +const environments: OwnedEnvironment[] = []; +const operations: Array<{ + readonly request: ContainerRequest; + readonly startedAt: string; + readonly finishedAt: string; + readonly result: ContainerOperationResult; +}> = []; +const saveErrors: string[] = []; +const stopErrors: string[] = []; +const cleanupErrors: string[] = []; +let mainError: string | undefined; +let prepared: PreparedDeployment | undefined; +let mediaRoot: string | undefined; + +function currentDeployment(): PreparedDeployment { + assert.ok(prepared, "服务部署镜像尚未解析"); + return prepared; +} + +function requireSuccess( + result: ContainerOperationResult, +): ContainerOperationResult { + assert.equal(result.status, "ok", JSON.stringify(result)); + return result; +} + +async function saveOperations(): Promise { + try { + await writeFile( + path.join(artifacts, "operations.json"), + JSON.stringify( + { operations, mainError, saveErrors, stopErrors, cleanupErrors }, + null, + 2, + ), + { mode: 0o600 }, + ); + } catch (error) { + saveErrors.push(String(error)); + console.error("保存容器操作证据失败", error); + } +} + +async function operate( + request: ContainerRequest, + cleanup = false, + signal: AbortSignal | undefined = controller.signal, +): Promise { + const startedAt = new Date().toISOString(); + const result = await manageContainerEnvironment( + request, + cleanup || signal === undefined ? {} : { signal }, + ); + const finishedAt = new Date().toISOString(); + operations.push({ request, startedAt, finishedAt, result }); + console.log( + JSON.stringify({ + environment: request.environment, + action: request.action, + status: result.status, + failure: result.failure, + finishedAt, + }), + ); + if (result.saveErrors.length > 0) saveErrors.push(...result.saveErrors); + if (result.cleanupErrors.length > 0) + cleanupErrors.push(...result.cleanupErrors); + await saveOperations(); + return result; +} + +function newReference( + label: string, + mode: OwnedEnvironment["mode"], +): OwnedEnvironment { + const reference: OwnedEnvironment = { + workspacePath, + environment: `ewc22-${runId}-${label}`, + mode, + }; + environments.push(reference); + return reference; +} + +async function registerOwner(reference: ContainerReference): Promise { + const directory = process.env.DIGITAL_CI_EVIDENCE; + if (directory === undefined) return; + await mkdir(directory, { recursive: true, mode: 0o700 }); + await appendFile( + path.join(directory, "container-owners.ndjson"), + `${JSON.stringify({ at: new Date().toISOString(), ...reference })}\n`, + { mode: 0o600 }, + ); +} + +async function prepareStandalone( + label: string, + mounts: readonly { + readonly source: string; + readonly target: string; + readonly readonly: true; + }[], +): Promise { + const reference = newReference(label, "standalone"); + await registerOwner(reference); + requireSuccess( + await operate({ + ...reference, + action: "prepare", + endpoint, + image: currentDeployment().standaloneImage, + mounts, + }), + ); + return reference; +} + +async function prepareCompose( + label: string, + observedPath: string, + uid = Number(process.env.DIGITAL_COMPOSE_CHECK_UID ?? "1001"), + gid = Number(process.env.DIGITAL_COMPOSE_CHECK_GID ?? "1001"), + hostDataOwner: { readonly uid: number; readonly gid: number } = { uid, gid }, +): Promise { + const reference = newReference(label, "compose"); + await registerOwner(reference); + const port = + process.env.DIGITAL_COMPOSE_CHECK_PORT === undefined + ? await claimFreePort() + : Number(process.env.DIGITAL_COMPOSE_CHECK_PORT); + requireSuccess( + await operate({ + ...reference, + action: "prepare-compose", + endpoint, + image: currentDeployment().standaloneImage, + compose: { + file: path.join(workspacePath, "docker-compose.yml"), + configuration: { + DIGITAL_SERVICE_MIGRATION_IMAGE: currentDeployment().standaloneImage, + DIGITAL_SERVICE_RUNTIME_IMAGE: currentDeployment().runtimeImage, + DIGITAL_UID: String(uid), + DIGITAL_GID: String(gid), + DIGITAL_OBSERVED_ROOT_HOST_PATH: observedPath, + DIGITAL_BIND_ADDRESS: "127.0.0.1", + DIGITAL_PORT: String(port), + }, + services: [ + { name: "migrate", role: "task" }, + { name: "service", role: "service" }, + ], + dataVariable: "DIGITAL_PRODUCT_DATA_HOST_PATH", + dataOwner: hostDataOwner, + }, + }), + ); + return reference; +} + +function serviceObject(result: ContainerOperationResult): ContainerObject { + const service = result.observation?.objects.findLast( + (object) => object.role === "service" && object.state === "present", + ); + assert.ok(service?.id, JSON.stringify(result)); + return service; +} + +function taskObject(result: ContainerOperationResult): ContainerObject { + const task = result.observation?.objects.findLast( + (object) => object.role === "task" && object.state === "present", + ); + assert.ok(task, JSON.stringify(result)); + return task; +} + +async function startStandalone( + reference: ContainerReference, + image: string, + waitFor: "ready" | "live" = "ready", +): Promise { + const port = + process.env.DIGITAL_DEPLOYMENT_PORT === undefined + ? await claimFreePort() + : Number(process.env.DIGITAL_DEPLOYMENT_PORT); + return requireSuccess( + await operate({ + ...reference, + action: "start", + image, + publish: { hostIp: "127.0.0.1", hostPort: port, containerPort: 3000 }, + waitFor, + }), + ); +} + +async function startCompose( + reference: ContainerReference, +): Promise { + const result = requireSuccess( + await operate({ ...reference, action: "compose-up" }), + ); + const migration = taskObject(result); + assert.equal(migration.running, false, JSON.stringify(migration)); + assert.equal(migration.exitCode, 0, JSON.stringify(migration)); + assert.equal(result.observation?.serviceReady, true, JSON.stringify(result)); + const service = serviceObject(result); + assert.equal(service.running, true, JSON.stringify(service)); + requireSuccess( + await operate({ + ...reference, + action: "wait", + objectId: service.id!, + condition: "live", + }), + ); + requireSuccess( + await operate({ + ...reference, + action: "wait", + objectId: service.id!, + condition: "healthy", + timeoutMilliseconds: 90_000, + }), + ); + return result; +} + +async function verifyServiceAddress(address: string): Promise { + const live = await fetch(new URL("/api/live", address), { + signal: AbortSignal.timeout(5_000), + }); + assert.equal(live.status, 200); + assert.equal(((await live.json()) as { readonly ok: boolean }).ok, true); + const ready = await fetch(new URL("/api/ready", address), { + signal: AbortSignal.timeout(5_000), + }); + assert.equal(ready.status, 200); + assert.equal(((await ready.json()) as { readonly ok: boolean }).ok, true); + const client = createPublicApiClient({ baseUrl: address }); + await client.listAssetLibraries(); +} + +async function verifyRuntimeIdentity( + reference: ContainerReference, + service: ContainerObject, + uid = 1001, + gid = 1001, +): Promise { + const command = await operate({ + ...reference, + action: "exec", + objectId: service.id!, + command: [ + "node", + "--input-type=module", + "--eval", + "console.log(JSON.stringify({uid:process.getuid(),gid:process.getgid()}));", + ], + }); + requireSuccess(command); + const identity = JSON.parse(command.command?.stdout ?? "null") as { + readonly uid: number; + readonly gid: number; + } | null; + assert.deepEqual(identity, { + uid, + gid, + }); +} + +async function verifyReadOnlyMaterials( + reference: ContainerReference, + service: ContainerObject, + paths: readonly string[], + uid: number, + gid: number, +): Promise { + const encodedPaths = JSON.stringify(paths); + const script = `import fs from 'node:fs'; const paths=${encodedPaths}; const uid=process.getuid(); const gid=process.getgid(); if(uid!==${uid}||gid!==${gid}) throw Error('服务身份不符合部署配置'); fs.writeFileSync('/data/.ewc22-write-check','ok'); fs.unlinkSync('/data/.ewc22-write-check'); const failures=[]; for(const root of paths){ try {fs.writeFileSync(root+'/.ewc22-forbidden-write','bad'); fs.unlinkSync(root+'/.ewc22-forbidden-write'); failures.push(root+':writable')} catch(error){ if(error.code!=='EROFS'&&error.code!=='EACCES') throw error; }} if(failures.length) throw Error('观察素材可写:'+failures.join(',')); console.log(JSON.stringify({uid,gid,dataWritable:true,materialsReadonly:paths}));`; + requireSuccess( + await operate({ + ...reference, + action: "exec", + objectId: service.id!, + command: ["node", "--input-type=module", "--eval", script], + }), + ); +} + +async function scanProduct( + address: string, + displayName: string, + directories: readonly string[], +): Promise { + const client = createPublicApiClient({ baseUrl: address }); + const library = await client.saveAssetLibrary({ + displayName, + directories: [...directories], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: [], + }); + assert.deepEqual( + library.directories.map(({ path: directory }) => directory).toSorted(), + [...directories].toSorted(), + ); + const started = await client.startAssetLibraryScan(library.id); + const deadline = Date.now() + 300_000; + let scan = await client.inspectAssetLibraryScan(started.parentExecutionId); + while (scan.status === "running" && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + scan = await client.inspectAssetLibraryScan(started.parentExecutionId); + } + assert.equal(scan.status, "completed", JSON.stringify(scan)); + assert.equal( + scan.directories.length, + directories.length, + JSON.stringify(scan), + ); + assert( + scan.directories.every((directory) => directory.stage === "settled"), + JSON.stringify(scan.directories), + ); + const scannedIds = new Set( + scan.directories.flatMap((directory) => + directory.files.flatMap((file) => + file.assetId === undefined ? [] : [file.assetId], + ), + ), + ); + assert(scannedIds.size > 0, "正式扫描应产生可核查的资产身份"); + + const assets: string[] = []; + let cursor: string | undefined; + do { + const page = await client.listAssets({ + assetLibraryId: library.id, + limit: 100, + ...(cursor === undefined ? {} : { cursor }), + }); + assets.push(...page.items.map(({ id }) => id)); + cursor = page.nextCursor; + } while (cursor !== undefined); + assert(assets.length > 0); + assert( + assets.some((assetId) => scannedIds.has(assetId)), + "资产列表应读回本次正式目录扫描产生的资产", + ); + + const unrecognized = await client.listUnrecognizedMaterials({ + assetLibraryId: library.id, + limit: 100, + }); + assert( + unrecognized.items.some((item) => + item.relativePaths.includes("普通文件夹/杂项/材料列表.txt"), + ), + "扫描结果应保留普通目录中的未识别说明材料", + ); + + const cancellation = await client.cancelExecution(started.parentExecutionId, { + reason: "容器验收确认已结束的扫描保持原结局", + }); + assert.equal(cancellation.requestStatus, "already-finished"); + assert.equal(cancellation.inspection.status, "completed"); + return { + assetLibraryId: library.id, + executionId: started.parentExecutionId, + assetIds: assets.toSorted(), + }; +} + +async function verifyPersistedProduct( + address: string, + displayName: string, + state: ProductState, +): Promise { + const client = createPublicApiClient({ baseUrl: address }); + const libraries = await client.listAssetLibraries(); + assert( + libraries.items.some( + ({ id, displayName: currentName }) => + id === state.assetLibraryId && currentName === displayName, + ), + ); + const scan = await client.inspectAssetLibraryScan(state.executionId); + assert.equal(scan.status, "completed"); + assert.equal(scan.assetLibraryId, state.assetLibraryId); + const assets = await client.listAssets({ + assetLibraryId: state.assetLibraryId, + limit: 100, + }); + assert.deepEqual(assets.items.map(({ id }) => id).toSorted(), state.assetIds); +} + +async function stopService( + reference: ContainerReference, + normal = true, +): Promise { + const result = requireSuccess( + await operate({ ...reference, action: "stop" }, true), + ); + assert( + result.observation?.objects.every((object) => object.running !== true), + JSON.stringify(result), + ); + if (normal) { + const service = serviceObject(result); + assert.equal(service.running, false); + assert.equal(service.pid, 0); + assert.equal(service.exitCode, 143); + assert.equal(service.oomKilled, false); + assert( + (service.stopDurationMilliseconds ?? Infinity) < 10_000, + JSON.stringify(service), + ); + } + return result; +} + +async function assertServiceTemporaryRootsClosed( + reference: ContainerReference, + service: ContainerObject, +): Promise { + const archive = path.join(artifacts, `${reference.environment}-stopped.tar`); + requireSuccess( + await operate( + { + ...reference, + action: "export", + objectId: service.id!, + destination: archive, + }, + true, + ), + ); + const result = await runCommand( + "tar", + [ + "--list", + "--file", + archive, + "--wildcards", + "--no-anchored", + "tmp/digital-product-service-process-*", + ], + workspacePath, + ); + assert.equal(result.stdout.trim(), "", "服务停止后不应保留进程临时根"); + assert( + result.exitCode === 0 || + (result.exitCode === 2 && result.stderr.includes("Not found in archive")), + JSON.stringify(result), + ); +} + +async function saveEnvironment( + reference: ContainerReference, + suffix: string, +): Promise { + const result = await operate( + { + ...reference, + action: "save", + directory: path.join( + artifacts, + "saved", + `${reference.environment}-${suffix}`, + ), + includeData: true, + }, + true, + ); + const saved = result.status === "ok" && result.evidence?.dataSaved === true; + if (!saved) + saveErrors.push(`${reference.environment}: ${JSON.stringify(result)}`); + return saved; +} + +async function buildObservedMaterial(): Promise<{ + readonly root: string; + readonly directories: readonly string[]; + readonly digest: string; +}> { + const visibleRoot = path.resolve( + process.env.DIGITAL_COMPOSE_CHECK_ROOT ?? + path.join(import.meta.dirname, "../.turbo/deployment"), + ); + const root = path.join(visibleRoot, `ewc22-${runId}-observed`); + const first = path.join(root, "one"); + const second = path.join(root, "two"); + const inputs = [ + path.join(sourceMaterialRoot, "RJ01029383"), + path.join(sourceMaterialRoot, "RJ01196709"), + ]; + const inputDigest = await Promise.all(inputs.map(digestDirectory)); + await mkdir(path.join(first, "作者甲", "2024"), { + recursive: true, + mode: 0o755, + }); + await mkdir(path.join(second, "作者乙", "2025", "已归档"), { + recursive: true, + mode: 0o755, + }); + await Promise.all([ + cp(inputs[0]!, path.join(first, "作者甲", "2024", "RJ01029383"), { + recursive: true, + }), + cp( + inputs[1]!, + path.join(second, "作者乙", "2025", "已归档", "RJ01196709"), + { + recursive: true, + }, + ), + ]); + const notes = path.join(second, "普通文件夹", "杂项"); + await mkdir(notes, { recursive: true, mode: 0o755 }); + await writeFile(path.join(notes, "材料列表.txt"), "用户自己的说明\n"); + await chmod(root, 0o755); + const directories = [first, second]; + assert.deepEqual(await Promise.all(inputs.map(digestDirectory)), inputDigest); + return { root, directories, digest: await digestDirectory(root) }; +} + +async function verifyMaterialUnchanged( + material: Awaited>, +): Promise { + assert.equal(await digestDirectory(material.root), material.digest); +} + +async function verifyEmptyDefaultStart( + material: Awaited>, +): Promise { + const mounts = [ + { + source: path.join(material.root, "one"), + target: "/observed/one", + readonly: true as const, + }, + { + source: path.join(material.root, "two"), + target: "/observed/two", + readonly: true as const, + }, + ]; + const reference = await prepareStandalone("combined", mounts); + const running = await startStandalone( + reference, + currentDeployment().standaloneImage, + ); + assert.equal(running.observation?.serviceReady, true); + await verifyServiceAddress(running.observation!.address!); + const service = serviceObject(running); + await verifyRuntimeIdentity(reference, service); + await stopService(reference); + await assertServiceTemporaryRootsClosed(reference, service); + await saveEnvironment(reference, "combined"); +} + +async function verifyPreparedStandalone( + material: Awaited>, +): Promise { + const reference = await prepareStandalone("prepared", [ + { + source: path.join(material.root, "one"), + target: "/observed/one", + readonly: true, + }, + { + source: path.join(material.root, "two"), + target: "/observed/two", + readonly: true, + }, + ]); + const preparedTask = requireSuccess( + await operate({ + ...reference, + action: "start", + image: currentDeployment().standaloneImage, + role: "task", + command: ["prepare-only"], + waitFor: "exit", + }), + ); + const task = taskObject(preparedTask); + assert.equal(task.running, false); + assert.equal(task.exitCode, 0); + assert.equal(Object.keys(task.ports ?? {}).length, 0); + + const running = await startStandalone( + reference, + currentDeployment().runtimeImage, + ); + assert.equal(running.observation?.serviceReady, true); + await verifyServiceAddress(running.observation!.address!); + const service = serviceObject(running); + await verifyRuntimeIdentity(reference, service); + await verifyReadOnlyMaterials( + reference, + service, + ["/observed/one", "/observed/two"], + 1001, + 1001, + ); + const displayName = "Standalone 多目录验收"; + const state = await scanProduct(running.observation!.address!, displayName, [ + "/observed/one", + "/observed/two", + ]); + await verifyMaterialUnchanged(material); + const stopped = await stopService(reference); + await assertServiceTemporaryRootsClosed(reference, serviceObject(stopped)); + + const restarted = await startStandalone( + reference, + currentDeployment().runtimeImage, + ); + assert.equal(restarted.observation?.serviceReady, true); + await verifyPersistedProduct( + restarted.observation!.address!, + displayName, + state, + ); + await verifyMaterialUnchanged(material); + const finalStop = await stopService(reference); + await assertServiceTemporaryRootsClosed(reference, serviceObject(finalStop)); + await saveEnvironment(reference, "persistent"); +} + +async function verifyStandaloneRejectsUnpreparedData(): Promise { + const reference = await prepareStandalone("unprepared", []); + const result = await operate({ + ...reference, + action: "start", + image: currentDeployment().runtimeImage, + publish: { + hostIp: "127.0.0.1", + hostPort: + process.env.DIGITAL_DEPLOYMENT_PORT === undefined + ? await claimFreePort() + : Number(process.env.DIGITAL_DEPLOYMENT_PORT), + containerPort: 3000, + }, + waitFor: "live", + }); + assert.notEqual(result.status, "cancelled", JSON.stringify(result)); + assert.equal(result.observation?.serviceReady, false, JSON.stringify(result)); + if (result.status === "ok") { + const response = await fetch( + new URL("/api/ready", result.observation!.address!), + { signal: AbortSignal.timeout(5_000) }, + ); + assert.equal(response.status, 503); + } + const service = serviceObject(result); + const cancellation = new AbortController(); + const cancellationTimer = setTimeout( + () => cancellation.abort(new Error("验证未准备数据的就绪等待可取消")), + 500, + ); + let cancelled: ContainerOperationResult; + try { + cancelled = await operate( + { + ...reference, + action: "wait", + objectId: service.id!, + condition: "ready", + timeoutMilliseconds: 120_000, + }, + false, + cancellation.signal, + ); + } finally { + clearTimeout(cancellationTimer); + } + assert.equal(cancelled.status, "cancelled", JSON.stringify(cancelled)); + assert( + cancelled.observation?.objects.every( + (object) => object.running !== true && object.state !== "unknown", + ), + JSON.stringify(cancelled), + ); + await stopService(reference, false); + await saveEnvironment(reference, "unprepared"); +} + +async function verifyComposeProduct( + material: Awaited>, +): Promise { + const uid = Number(process.env.DIGITAL_COMPOSE_CHECK_UID ?? "1001"); + const gid = Number(process.env.DIGITAL_COMPOSE_CHECK_GID ?? "1001"); + assert(uid > 0 && gid > 0, "Compose 服务必须使用非 root UID/GID"); + const reference = await prepareCompose( + "compose-product", + material.root, + uid, + gid, + ); + const running = await startCompose(reference); + await verifyServiceAddress(running.observation!.address!); + const service = serviceObject(running); + await verifyRuntimeIdentity(reference, service, uid, gid); + await verifyReadOnlyMaterials( + reference, + service, + ["/observed/media"], + uid, + gid, + ); + const displayName = "Compose 多目录验收"; + const state = await scanProduct(running.observation!.address!, displayName, [ + "/observed/media/one", + "/observed/media/two", + ]); + await verifyMaterialUnchanged(material); + const stopped = await stopService(reference); + await assertServiceTemporaryRootsClosed(reference, serviceObject(stopped)); + assert( + await saveEnvironment(reference, "compose-stopped"), + "Compose 首次关闭前必须保存完整证据与数据", + ); + requireSuccess(await operate({ ...reference, action: "compose-down" }, true)); + + const restarted = await startCompose(reference); + await verifyPersistedProduct( + restarted.observation!.address!, + displayName, + state, + ); + await verifyMaterialUnchanged(material); + const finalStop = await stopService(reference); + await assertServiceTemporaryRootsClosed(reference, serviceObject(finalStop)); + assert( + await saveEnvironment(reference, "persistent"), + "Compose 持久化复验后关闭前必须保存完整证据与数据", + ); + requireSuccess(await operate({ ...reference, action: "compose-down" }, true)); +} + +async function verifyComposeRejectsPreparationFailures( + material: Awaited>, +): Promise { + const missingMount = path.join(material.root, `missing-${runId}`); + const missing = await prepareCompose("compose-missing-mount", missingMount); + const missingResult = await operate({ ...missing, action: "compose-up" }); + assert.notEqual(missingResult.status, "ok", JSON.stringify(missingResult)); + assert.equal(missingResult.observation?.serviceReady, false); + assert( + missingResult.observation?.objects.every( + (object) => object.running !== true, + ) ?? true, + ); + await stopService(missing, false); + await saveEnvironment(missing, "missing-mount"); + + for (const [label, uid, gid] of [ + ["uid-zero", 0, 1001], + ["gid-zero", 1001, 0], + ] as const) { + const hostUid = process.getuid?.(); + const hostGid = process.getgid?.(); + if (hostUid === undefined || hostGid === undefined) + throw new Error("Compose 权限验收需要 POSIX 宿主身份"); + const reference = await prepareCompose(label, material.root, uid, gid, { + uid: hostUid, + gid: hostGid, + }); + const result = await operate({ ...reference, action: "compose-up" }); + assert.notEqual(result.status, "ok", JSON.stringify(result)); + assert.equal(result.observation?.serviceReady, false); + assert.equal(taskObject(result).exitCode, 64, JSON.stringify(result)); + assert( + result.observation?.objects.every((object) => object.running !== true) ?? + true, + ); + await stopService(reference, false); + await saveEnvironment(reference, label); + } + + const broken = await prepareCompose( + "compose-migration-failure", + material.root, + ); + const before = requireSuccess( + await operate({ ...broken, action: "inspect" }, true), + ); + const dataDirectory = before.observation?.dataDirectory; + assert(dataDirectory); + await mkdir(path.join(dataDirectory, "catalogue.sqlite")); + const failedMigration = await operate({ ...broken, action: "compose-up" }); + assert.notEqual( + failedMigration.status, + "ok", + JSON.stringify(failedMigration), + ); + assert.equal(failedMigration.observation?.serviceReady, false); + assert( + failedMigration.observation?.objects.every( + (object) => object.running !== true, + ) ?? true, + ); + assert(failedMigration.failure?.message); + await stopService(broken, false); + await saveEnvironment(broken, "migration-failure"); + await verifyMaterialUnchanged(material); +} + +async function verifyStandalonePreparationFailure( + material: Awaited>, +): Promise { + const reference = await prepareStandalone("migration-failure", [ + { + source: path.join(material.root, "one"), + target: "/observed/one", + readonly: true, + }, + { + source: path.join(material.root, "two"), + target: "/observed/two", + readonly: true, + }, + ]); + const seed = requireSuccess( + await operate({ + ...reference, + action: "start", + role: "client", + image: currentDeployment().standaloneImage, + entrypoint: "node", + command: [ + "--input-type=module", + "--eval", + "import fs from 'node:fs'; fs.mkdirSync('/data/catalogue.sqlite')", + ], + useData: true, + waitFor: "exit", + }), + ); + const seededVolume = seed.observation?.objects.findLast( + (object) => object.role === "client" && object.state === "present", + ); + assert.equal(seededVolume?.usesData, true, JSON.stringify(seed)); + assert.equal(seededVolume?.running, false, JSON.stringify(seed)); + assert.equal(seededVolume?.exitCode, 0, JSON.stringify(seed)); + const failedMigration = requireSuccess( + await operate({ + ...reference, + action: "start", + image: currentDeployment().standaloneImage, + role: "task", + command: ["prepare-only"], + waitFor: "exit", + }), + ); + const failedTask = taskObject(failedMigration); + assert.equal(failedTask.running, false, JSON.stringify(failedTask)); + assert.notEqual(failedTask.exitCode, undefined, JSON.stringify(failedTask)); + assert.notEqual(failedTask.exitCode, 0, JSON.stringify(failedTask)); + await stopService(reference, false); + await saveEnvironment(reference, "migration-failure"); + await verifyMaterialUnchanged(material); +} + +async function cleanupOwnedEnvironments(): Promise { + for (const reference of environments) { + try { + const stopped = await operate({ ...reference, action: "stop" }, true); + if (stopped.status !== "ok") + stopErrors.push( + `${reference.environment}: ${stopped.failure?.message ?? stopped.status}`, + ); + } catch (error) { + stopErrors.push(`${reference.environment}: ${String(error)}`); + } + } + + for (const reference of environments) { + try { + const observed = requireSuccess( + await operate({ ...reference, action: "inspect" }, true), + ); + if (observed.remaining.length === 0 && !observed.observation?.dataPresent) + continue; + + if ( + observed.observation?.objects.some( + (object) => object.running === true || object.state === "unknown", + ) + ) { + cleanupErrors.push( + `${reference.environment}: 退出状态未确认,保留载体`, + ); + continue; + } + const includeData = + observed.observation?.dataPresent === true || + observed.remaining.some( + (object) => object.kind === "volume" && object.state === "present", + ); + const saved = await operate( + { + ...reference, + action: "save", + directory: path.join( + artifacts, + "saved", + `${reference.environment}-final`, + ), + includeData, + }, + true, + ); + if ( + saved.status !== "ok" || + (includeData && saved.evidence?.dataSaved !== true) + ) { + saveErrors.push( + `${reference.environment}: 失败证据或产品数据未完整保存,保留载体`, + ); + continue; + } + if (process.env.DIGITAL_CI_EVIDENCE !== undefined) continue; + + const deleted = await operate( + { ...reference, action: "delete", deleteData: includeData }, + true, + ); + if ( + deleted.status !== "ok" || + deleted.remaining.length > 0 || + deleted.observation?.dataPresent + ) + cleanupErrors.push(`${reference.environment}: 自有资源回收未完成`); + const after = await operate({ ...reference, action: "inspect" }, true); + if ( + after.status !== "ok" || + after.remaining.length > 0 || + after.observation?.dataPresent + ) + cleanupErrors.push(`${reference.environment}: 删除后公开观察仍有资源`); + } catch (error) { + cleanupErrors.push(`${reference.environment}: ${String(error)}`); + } + } +} + +async function resolvePreparedDeployment(): Promise { + if ( + explicitStandaloneImage !== undefined && + explicitRuntimeImage !== undefined + ) { + return { + workspacePath, + revision: "caller-supplied", + endpoint: endpoint.host, + daemonId: "caller-supplied", + composeVersion: "caller-supplied", + standaloneImage: explicitStandaloneImage, + runtimeImage: explicitRuntimeImage, + images: [], + }; + } + let value: PreparedDeployment; + try { + value = JSON.parse( + await readFile(preparedPath, "utf8"), + ) as PreparedDeployment; + } catch (cause) { + throw new Error("服务镜像尚未准备;请运行正式 deployment Turbo 任务", { + cause, + }); + } + if ( + value.workspacePath !== workspacePath || + !value.standaloneImage || + !value.runtimeImage + ) + throw new Error("准备镜像与当前工作区不一致"); + return value; +} + +async function digestDirectory(root: string): Promise { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile()) + .map((entry) => path.join(entry.parentPath, entry.name)) + .toSorted(); + const digest = createHash("sha256"); + for (const file of files) { + digest.update(path.relative(root, file)); + digest.update(await readFile(file)); + } + return digest.digest("hex"); +} + +async function claimFreePort(): Promise { + const first = 56_000; + const last = 56_999; + for (let port = first; port <= last; port += 1) { + const claimed = await probePort(port); + if (claimed !== undefined) return claimed; + } + throw new Error(`自有端口段 ${first}—${last} 内没有空闲端口`); +} + +function probePort(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once("error", () => resolve(undefined)); + server.once("listening", () => { + const address = server.address(); + server.close(() => { + resolve( + typeof address === "object" && address !== null + ? address.port + : undefined, + ); + }); + }); + server.listen(port, "127.0.0.1"); + }); +} + +async function runCommand( + command: string, + args: readonly string[], + cwd: string, +): Promise<{ + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; +}> { + return await new Promise((resolve, reject) => { + const child = spawn(command, [...args], { + cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (exitCode) => resolve({ exitCode, stdout, stderr })); + }); +} + +async function main(): Promise { + prepared = await resolvePreparedDeployment(); + await mkdir(artifacts, { recursive: true, mode: 0o700 }); + const material = await buildObservedMaterial(); + mediaRoot = material.root; + const originalDigest = await digestDirectory(sourceMaterialRoot); + const port = process.env.DIGITAL_DEPLOYMENT_PORT; + if ( + port !== undefined && + (!Number.isSafeInteger(Number(port)) || Number(port) < 1) + ) + throw new Error("Standalone 部署端口无效"); + await verifyEmptyDefaultStart(material); + await verifyStandaloneRejectsUnpreparedData(); + await verifyPreparedStandalone(material); + await verifyStandalonePreparationFailure(material); + await verifyComposeProduct(material); + await verifyComposeRejectsPreparationFailures(material); + await verifyMaterialUnchanged(material); + assert.equal(await digestDirectory(sourceMaterialRoot), originalDigest); +} + +try { + await main(); +} catch (error) { + mainError = + error instanceof Error ? (error.stack ?? error.message) : String(error); + console.error("独立服务容器验收失败", mainError); + process.exitCode = controller.signal.aborted ? 130 : 1; +} finally { + await cleanupOwnedEnvironments(); + const outcomePath = path.join(artifacts, "outcome.json"); + try { + await writeFile( + outcomePath, + JSON.stringify( + { + status: + mainError === undefined && + stopErrors.length === 0 && + saveErrors.length === 0 && + cleanupErrors.length === 0 + ? "passed" + : "failed", + runId, + revision: prepared?.revision ?? null, + daemonId: prepared?.daemonId ?? null, + composeVersion: prepared?.composeVersion ?? null, + images: prepared?.images ?? [], + standaloneImage: prepared?.standaloneImage ?? null, + runtimeImage: prepared?.runtimeImage ?? null, + preparedPath, + mediaRoot: mediaRoot ?? null, + mainError: mainError ?? null, + stopErrors, + saveErrors, + cleanupErrors, + environments, + operationsPath: path.join(artifacts, "operations.json"), + finishedAt: new Date().toISOString(), + }, + null, + 2, + ), + { mode: 0o600 }, + ); + } catch (error) { + saveErrors.push(String(error)); + console.error("保存容器验收结果失败", error); + } + if ( + mainError !== undefined || + stopErrors.length > 0 || + saveErrors.length > 0 || + cleanupErrors.length > 0 + ) + process.exitCode = 1; + else if ( + process.env.DIGITAL_CI_EVIDENCE === undefined && + mediaRoot !== undefined + ) + await rm(mediaRoot, { recursive: true, force: true }); +} diff --git a/apps/web/scripts/compose-entrypoint.sh b/apps/service/scripts/compose-entrypoint.sh similarity index 96% rename from apps/web/scripts/compose-entrypoint.sh rename to apps/service/scripts/compose-entrypoint.sh index 83ba469..9a7c854 100644 --- a/apps/web/scripts/compose-entrypoint.sh +++ b/apps/service/scripts/compose-entrypoint.sh @@ -12,7 +12,7 @@ case "${DIGITAL_COMPOSE_SERVICE:?}" in migrate) exec node /migration/dist/cli.js prepare-only ;; - web) + service) exec "$@" ;; *) diff --git a/apps/web/scripts/container-entrypoint.sh b/apps/service/scripts/container-entrypoint.sh similarity index 90% rename from apps/web/scripts/container-entrypoint.sh rename to apps/service/scripts/container-entrypoint.sh index 09f123c..acedb1c 100644 --- a/apps/web/scripts/container-entrypoint.sh +++ b/apps/service/scripts/container-entrypoint.sh @@ -12,7 +12,7 @@ case "${1:-prepare-and-start}" in prepare-and-start) cd /migration node ./dist/cli.js prepare-only - exec node /app/dist/server/index.mjs + exec node /app/dist/main.js ;; *) echo "usage: container-entrypoint {prepare-only|prepare-and-start}" >&2 diff --git a/apps/service/scripts/prepare-deployment.ts b/apps/service/scripts/prepare-deployment.ts new file mode 100644 index 0000000..4cd6256 --- /dev/null +++ b/apps/service/scripts/prepare-deployment.ts @@ -0,0 +1,238 @@ +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, rename, writeFile } from "node:fs/promises"; +import path from "node:path"; + +type CommandRecord = { + readonly arguments: readonly string[]; + readonly startedAt: string; + readonly finishedAt: string; + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; + readonly stdout: string; + readonly stderr: string; +}; + +type ImageRecord = { + readonly reference: string; + readonly id: string; + readonly version: string | null; +}; + +const workspacePath = path.resolve(import.meta.dirname, "../../.."); +const deploymentDirectory = path.resolve( + process.env.DIGITAL_DEPLOYMENT_ARTIFACTS ?? + process.env.DIGITAL_CI_EVIDENCE ?? + path.join(import.meta.dirname, "../.turbo/deployment"), +); +const preparedPath = path.join(deploymentDirectory, "prepared.json"); +const dockerCommand = process.env.DIGITAL_DEPLOYMENT_DOCKER_COMMAND ?? "docker"; +const dockerArguments = JSON.parse( + process.env.DIGITAL_DEPLOYMENT_DOCKER_ARGUMENTS ?? "[]", +) as string[]; +const endpoint = process.env.DOCKER_HOST ?? "unix:///var/run/docker.sock"; +const providedStandaloneImage = process.env.DIGITAL_DEPLOYMENT_STANDALONE_IMAGE; +const providedRuntimeImage = process.env.DIGITAL_DEPLOYMENT_RUNTIME_IMAGE; +const suppliedImages = + providedStandaloneImage !== undefined || providedRuntimeImage !== undefined; +if (suppliedImages && (!providedStandaloneImage || !providedRuntimeImage)) + throw new Error("显式部署需要同时提供独立准备与仅运行镜像"); + +const runId = createHash("sha256") + .update(`${workspacePath}\0${process.pid}\0${Date.now()}\0${randomUUID()}`) + .digest("hex") + .slice(0, 16); +const imagePrefix = `digital-service-check-${runId}`; +const standaloneImage = providedStandaloneImage ?? `${imagePrefix}:standalone`; +const runtimeImage = providedRuntimeImage ?? `${imagePrefix}:runtime`; +const controller = new AbortController(); +for (const signal of ["SIGINT", "SIGTERM"] as const) + process.once(signal, () => controller.abort(new Error(`收到 ${signal}`))); +const commands: CommandRecord[] = []; + +async function docker(arguments_: readonly string[]): Promise { + if (controller.signal.aborted) throw new Error("部署准备已取消"); + const startedAt = new Date().toISOString(); + const child = spawn( + dockerCommand, + [...dockerArguments, "--host", endpoint, ...arguments_], + { + cwd: workspacePath, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + const cancel = (): void => { + child.kill("SIGTERM"); + }; + controller.signal.addEventListener("abort", cancel, { once: true }); + const result = await new Promise<{ + readonly exitCode: number | null; + readonly signal: NodeJS.Signals | null; + }>((resolve, reject) => { + child.once("error", reject); + child.once("close", (exitCode, signal) => resolve({ exitCode, signal })); + }); + controller.signal.removeEventListener("abort", cancel); + const record: CommandRecord = { + arguments: [ + dockerCommand, + ...dockerArguments, + "--host", + endpoint, + ...arguments_, + ], + startedAt, + finishedAt: new Date().toISOString(), + exitCode: result.exitCode, + signal: result.signal, + stdout, + stderr, + }; + commands.push(record); + if (result.exitCode !== 0 || controller.signal.aborted) + throw new Error( + `Docker 部署准备失败:${arguments_[0]},退出码 ${String(result.exitCode)}`, + ); + return record; +} + +async function image(reference: string): Promise { + const result = await docker([ + "image", + "inspect", + "--format", + "{{json .}}", + reference, + ]); + const inspected = JSON.parse(result.stdout) as { + readonly Id: string; + readonly Config?: { readonly Labels?: Record | null }; + }; + return { + reference, + id: inspected.Id, + version: + inspected.Config?.Labels?.["org.opencontainers.image.version"] ?? null, + }; +} + +async function main(): Promise { + await mkdir(deploymentDirectory, { recursive: true, mode: 0o700 }); + const daemon = await docker(["info", "--format", "{{.ID}}"]); + if (daemon.stdout.trim().length === 0) + throw new Error("Docker daemon 未返回实例标识"); + const compose = await docker(["compose", "version", "--short"]); + const revision = ( + await dockerFromWorkspace("git", ["rev-parse", "HEAD"]) + ).trim(); + + if (suppliedImages) { + await docker(["image", "inspect", standaloneImage, runtimeImage]); + } else { + for (const [target, tag] of [ + ["standalone", standaloneImage], + ["runtime", runtimeImage], + ] as const) { + await docker([ + "build", + ...(process.env.DIGITAL_DEPLOYMENT_BUILD_NETWORK === undefined + ? [] + : ["--network", process.env.DIGITAL_DEPLOYMENT_BUILD_NETWORK]), + "--build-arg", + `DEPLOYMENT_BUILD_ID=${revision}`, + "--file", + "apps/service/Dockerfile", + "--target", + target, + "--tag", + tag, + workspacePath, + ]); + } + } + + const images = [await image(standaloneImage), await image(runtimeImage)]; + const prepared = { + workspacePath, + revision, + endpoint, + daemonId: daemon.stdout.trim(), + composeVersion: compose.stdout.trim(), + standaloneImage, + runtimeImage, + images, + commands, + preparedAt: new Date().toISOString(), + }; + const temporary = `${preparedPath}.${process.pid}.${randomUUID()}`; + await writeFile(temporary, JSON.stringify(prepared, null, 2), { + mode: 0o600, + }); + await rename(temporary, preparedPath); +} + +function dockerFromWorkspace( + command: string, + args: readonly string[], +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, [...args], { + cwd: workspacePath, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (exitCode) => { + if (exitCode !== 0) + reject(new Error(`读取工作区版本失败:${stderr.trim()}`)); + else resolve(stdout); + }); + }); +} + +try { + await main(); +} catch (error) { + await mkdir(deploymentDirectory, { recursive: true, mode: 0o700 }); + await writeFile( + path.join(deploymentDirectory, `preparation-${runId}.json`), + JSON.stringify( + { + workspacePath, + endpoint, + standaloneImage, + runtimeImage, + commands, + error: + error instanceof Error + ? (error.stack ?? error.message) + : String(error), + finishedAt: new Date().toISOString(), + }, + null, + 2, + ), + { mode: 0o600 }, + ); + console.error("产品服务镜像准备失败", error); + process.exitCode = controller.signal.aborted ? 130 : 1; +} diff --git a/apps/service/src/main.ts b/apps/service/src/main.ts new file mode 100644 index 0000000..f3c4784 --- /dev/null +++ b/apps/service/src/main.ts @@ -0,0 +1,63 @@ +import { + combineServiceBootstrapAbortFailure, + shouldRetainServiceProcessTemporaryRoot, +} from "@digital/service-host"; +import { resolveReadyIdentity } from "@digital/service-host/environment-ready-identity"; +import { claimProductServiceProcessTemporaryRoot } from "@digital/service-host/process-temporary-root"; +import { resolveSourceIoFromEnvironment } from "@digital/test-support/source-io"; +import "@digital/service-host/process-lifecycle"; +import { serve } from "srvx"; + +import { createProductService } from "./service.ts"; + +const claim = await claimProductServiceProcessTemporaryRoot(); +let bootstrap: Awaited> | undefined; +try { + claim.beginInitialization(); + const sourceIo = await resolveSourceIoFromEnvironment(process.env); + const identityResolution = resolveReadyIdentity(); + if ("problem" in identityResolution) + console.error(identityResolution.problem); + bootstrap = await createProductService( + { + ...(process.env.DIGITAL_PRODUCT_DATA === undefined + ? {} + : { productDataDirectory: process.env.DIGITAL_PRODUCT_DATA }), + ...(process.env.DIGITAL_PRODUCT_INITIAL_CONFIGURATION === undefined + ? {} + : { + initialAssetLibraryConfigurationDirectory: + process.env.DIGITAL_PRODUCT_INITIAL_CONFIGURATION, + }), + ...(process.env.DIGITAL_PRODUCT_DATA_GENERATION === undefined + ? {} + : { dataGeneration: process.env.DIGITAL_PRODUCT_DATA_GENERATION }), + environment: process.env, + sourceIo, + }, + "identity" in identityResolution + ? { readyIdentity: identityResolution.identity } + : {}, + ); + const server = serve({ + fetch: bootstrap.app.fetch, + port: Number.parseInt(process.env.PORT ?? "3000", 10), + hostname: process.env.HOST ?? "127.0.0.1", + gracefulShutdown: false, + }); + await server.ready(); + bootstrap.publish(); + claim.commit(); +} catch (caught) { + let error = caught; + if (bootstrap !== undefined) { + try { + await bootstrap.abort(); + } catch (abortError) { + error = combineServiceBootstrapAbortFailure(error, abortError); + } + } + if (shouldRetainServiceProcessTemporaryRoot(error)) claim.retain(); + else await claim.rollback().catch(() => undefined); + throw error; +} diff --git a/apps/service/src/service.ts b/apps/service/src/service.ts new file mode 100644 index 0000000..84f27d5 --- /dev/null +++ b/apps/service/src/service.ts @@ -0,0 +1,124 @@ +import { runProduct, type ProductConfiguration } from "@digital/composition"; +import { createPublicHttpApi } from "@digital/http-delivery"; +import type { + ProductApplication, + ProductAutomationHostOptions, +} from "@digital/product-application"; +import { + createOwnedServiceApp, + type ServiceAppBootstrap, +} from "@digital/service-host"; +import type { ProcessLifecycleRegistry } from "@digital/service-host"; +import type { ReadyIdentity } from "@digital/service-host/environment-ready-identity"; +import { Hono } from "hono"; + +/** 独立服务拥有产品实例、正式 HTTP API 与自动化宿主。 */ +export async function createProductService( + configuration: ProductConfiguration, + options: { + readonly readyIdentity?: ReadyIdentity | undefined; + readonly lifecycle?: ProcessLifecycleRegistry | undefined; + } = {}, +): Promise { + return await createOwnedServiceApp( + { + serviceName: "digital-product-service", + ...(options.readyIdentity === undefined + ? {} + : { readyIdentity: options.readyIdentity }), + ...(options.lifecycle === undefined + ? {} + : { lifecycle: options.lifecycle }), + }, + undefined, + (bindClose) => { + const product = runProduct(configuration, { + start(application) { + return application; + }, + }); + bindClose(async () => await product.close()); + const automation = createServiceAutomation(product.result); + const api = new Hono().route("/v1", createPublicHttpApi(product.result)); + return { + api, + async start() { + await product.result.initialAssetLibraries?.restoreForDataGeneration(); + await automation.start(); + }, + ready: () => automation.ready(), + readinessFailure: () => automation.failure(), + }; + }, + ); +} + +/** + * 准备阶段的只读 API 探针:装配同一产品与正式 HTTP 资源,不恢复初始库或启动后台自动化。 + */ +export async function createProductPreparationProbe( + configuration: ProductConfiguration, +): Promise { + return await createOwnedServiceApp( + { serviceName: "digital-product-service-prepare-check" }, + undefined, + (bindClose) => { + const product = runProduct(configuration, { + start(application) { + return application; + }, + }); + bindClose(async () => await product.close()); + return { + api: new Hono().route("/v1", createPublicHttpApi(product.result)), + async start() {}, + ready: () => true, + readinessFailure: () => undefined, + }; + }, + ); +} + +/** + * 服务宿主负责恢复自动化并以它的就绪事实接入 readiness;该运行生命周期不属于任何客户端交付。 + */ +function createServiceAutomation(application: ProductApplication): { + start(): Promise; + ready(): boolean; + failure(): string | undefined; +} { + let started: Promise | undefined; + let startupFailure: string | undefined; + const options: ProductAutomationHostOptions = { + onError(error) { + console.error("自动刷新检查未完成", error); + }, + }; + return { + async start(): Promise { + if (!application.configured) return; + started ??= application + .startAutomation(options) + .catch((error: unknown) => { + startupFailure = describeStartupFailure(error); + started = undefined; + throw error; + }); + await started; + }, + ready(): boolean { + return started !== undefined && application.automation.recoveryReady; + }, + failure(): string | undefined { + if (startupFailure !== undefined) return startupFailure; + if (!application.configured) + return "尚未配置产品数据目录;设置 DIGITAL_PRODUCT_DATA 后运行 digital-db-migrate prepare-only"; + return undefined; + }, + }; +} + +function describeStartupFailure(error: unknown): string { + if (!(error instanceof Error)) return "内容目录启动失败;检查部署诊断后重试"; + return error.message.replaceAll(/\/[^\s]+/gu, "[路径已隐藏]"); +} diff --git a/apps/service/src/verify-prepared.ts b/apps/service/src/verify-prepared.ts new file mode 100644 index 0000000..00a0721 --- /dev/null +++ b/apps/service/src/verify-prepared.ts @@ -0,0 +1,102 @@ +import "@digital/service-host/process-lifecycle"; +import { createPublicApiClient } from "@digital/public-api"; +import { + claimProductServiceProcessTemporaryRoot, + closeProductServiceProcessTemporaryRoot, + combineServiceBootstrapAbortFailure, + shouldRetainServiceProcessTemporaryRoot, +} from "@digital/service-host"; +import { resolveSourceIoFromEnvironment } from "@digital/test-support/source-io"; +import { serve } from "srvx"; + +import { createProductPreparationProbe } from "./service.ts"; + +/** + * 环境准备的只读核验:运行不恢复、不自动扫描的正式产品 API 于短暂本地 HTTP 端口, + * 并经公开客户端读一页资产库。调用方已先完成正式迁移。 + */ +const claim = await claimProductServiceProcessTemporaryRoot(); +const arguments_ = process.argv + .slice(2) + .filter((argument) => argument !== "--"); +let bootstrap: + | Awaited> + | undefined; +let server: ReturnType | undefined; +let committed = false; + +try { + claim.beginInitialization(); + const sourceIo = await resolveSourceIoFromEnvironment(process.env); + bootstrap = await createProductPreparationProbe({ + ...(process.env.DIGITAL_PRODUCT_DATA === undefined + ? {} + : { productDataDirectory: process.env.DIGITAL_PRODUCT_DATA }), + ...(process.env.DIGITAL_PRODUCT_INITIAL_CONFIGURATION === undefined + ? {} + : { + initialAssetLibraryConfigurationDirectory: + process.env.DIGITAL_PRODUCT_INITIAL_CONFIGURATION, + }), + ...(process.env.DIGITAL_PRODUCT_DATA_GENERATION === undefined + ? {} + : { dataGeneration: process.env.DIGITAL_PRODUCT_DATA_GENERATION }), + environment: process.env, + sourceIo, + }); + server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + }); + await server.ready(); + bootstrap.publish(); + claim.commit(); + committed = true; + + const baseUrl = server.url; + if (baseUrl === undefined) + throw new Error("准备核验服务未提供本地 HTTP 地址"); + const client = createPublicApiClient({ baseUrl }); + if (arguments_[0] === "--workflow-history") { + const history = await client.listWorkflowHistory({ limit: 100 }); + process.stdout.write( + `${JSON.stringify(history.items.map(({ execution }) => execution.id))}\n`, + ); + } else { + if (arguments_.length > 0) throw new Error("准备核验参数无效"); + const page = await client.listAssetLibraries({ limit: 1 }); + if (!Array.isArray(page.items)) + throw new Error("正式产品读取未返回合法资产库分页"); + } + + await server.close(true); + await bootstrap.abort(); + await closeProductServiceProcessTemporaryRoot(); +} catch (caught) { + let error = caught; + try { + await server?.close(true); + } catch (closeError) { + error = new AggregateError( + [error, closeError], + "准备核验 HTTP 服务收尾失败", + ); + } + if (bootstrap !== undefined) { + try { + await bootstrap.abort(); + } catch (abortError) { + error = combineServiceBootstrapAbortFailure(error, abortError); + } + } + const retainRoot = shouldRetainServiceProcessTemporaryRoot(error); + if (!committed) { + if (retainRoot) claim.retain(); + else await claim.rollback(); + } else if (!retainRoot) { + await closeProductServiceProcessTemporaryRoot().catch(() => undefined); + } + throw error; +} diff --git a/apps/service/test/asset-inspection-http.test.ts b/apps/service/test/asset-inspection-http.test.ts new file mode 100644 index 0000000..3e91d45 --- /dev/null +++ b/apps/service/test/asset-inspection-http.test.ts @@ -0,0 +1,2832 @@ +import { spawn } from "node:child_process"; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; + +import type { ProductConfiguration } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { + createPublicApiClient, + type PublicCurrentAsset, +} from "@digital/public-api"; +import { + loadControlledSourceIo, + refusingSessionTransport, +} from "@digital/test-support/source-io"; +import { serve } from "srvx"; +import { afterEach, expect, it } from "vitest"; + +import { createProductService } from "../src/service.ts"; +import { + startInteractiveTerminal, + type InteractiveTerminalSession, +} from "./interactive-terminal.ts"; + +const running: Array<{ + readonly server: Awaited>; + readonly abort: () => Promise; +}> = []; +const temporaryRoots: string[] = []; +const children: Array> = []; +const materialFixture = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/material"), +); +const sourceManifest = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/manifest.json"), +); +const fanboxConnectionFixture = fileURLToPath( + import.meta + .resolve("@digital/preview-corpus/audio-posts/connections/synthetic-customudon.json"), +); +const movieMaterialFixture = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/publication-screen/material"), +); +const animeMaterialFixture = path.join(movieMaterialFixture, "anime"); +const movieSourceManifest = fileURLToPath( + import.meta + .resolve("@digital/preview-corpus/publication-screen/manifest.json"), +); +const movieSourceResponses = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/publication-screen/responses"), +); +const serviceRoot = fileURLToPath(new URL("../", import.meta.url)); + +afterEach(async () => { + for (const child of children.splice(0).reverse()) { + if (child.exitCode === null && child.signalCode === null) { + if (child.pid !== undefined && process.platform !== "win32") { + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + } else child.kill("SIGTERM"); + await new Promise((resolve) => + child.once("close", () => resolve()), + ); + } + } + for (const item of running.splice(0).reverse()) { + await item.server.close(true); + await item.abort(); + } + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); +}); + +it("经真实服务检查普通目录形成的资产及依据、修订与历史", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc08-asset-inspection-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialRoot = path.join(root, "我的收藏"); + const workDirectory = path.join(materialRoot, "声音", "2025", "RJ01029383"); + await mkdir(path.dirname(workDirectory), { recursive: true }); + await cp(path.join(materialFixture, "RJ01029383"), workDirectory, { + recursive: true, + }); + const ordinaryNotes = path.join(materialRoot, "普通文件夹", "说明.txt"); + await mkdir(path.dirname(ordinaryNotes), { recursive: true }); + await writeFile(ordinaryNotes, "这是与作品并存的普通目录说明。\n"); + + const bookRoot = path.join(root, "普通出版物"); + await mkdir(bookRoot, { recursive: true }); + await writeFile( + path.join(bookRoot, "一本书.pdf"), + syntheticPdf({ author: "普通目录中的作者", pageCount: 1 }), + ); + + await prepareProductionCatalogue({ productDataDirectory }); + const controlled = await loadControlledSourceIo({ + manifestPath: sourceManifest, + }); + const sourceIo: ProductConfiguration["sourceIo"] = { + mode: "controlled", + request: controlled.request, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + running.push({ server, abort: () => bootstrap.abort() }); + await server.ready(); + bootstrap.publish(); + const baseUrl = new URL(server.url!); + const client = createPublicApiClient({ baseUrl }); + + const library = await client.saveAssetLibrary({ + displayName: "资料检查普通目录", + directories: [materialRoot], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + const started = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(started.parentExecutionId)) + .status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + + const assetPage = await client.listAssets({ + assetLibraryId: library.id, + limit: 100, + }); + const asset = assetPage.items.find(({ locations }) => + locations.some(({ address }) => address.includes("RJ01029383")), + ); + expect(asset).toBeDefined(); + const assetId = asset!.id; + + const overview = await client.inspectAssetEvidence(assetId); + expect(overview.sections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "local-observations", + status: "available", + }), + expect.objectContaining({ kind: "source-records", status: "available" }), + expect.objectContaining({ + kind: "interpreted-facts", + status: "available", + }), + expect.objectContaining({ kind: "current-values", status: "available" }), + ]), + ); + + const readAll = async ( + kind: Parameters[1]["kind"], + ) => { + const items: Array< + Awaited>["items"][number] + > = []; + let cursor: string | undefined; + do { + const page = await client.listAssetEvidence(assetId, { + kind, + limit: 1, + ...(cursor === undefined ? {} : { cursor }), + }); + items.push(...page.items); + cursor = page.nextCursor; + } while (cursor !== undefined); + return items; + }; + const [ + observations, + formats, + sourceRecords, + interpretedFacts, + currentValues, + ] = await Promise.all([ + readAll("local-observations"), + readAll("format-results"), + readAll("source-records"), + readAll("interpreted-facts"), + readAll("current-values"), + ]); + expect(observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "local-observation", + relativePath: expect.stringContaining("RJ01029383"), + }), + ]), + ); + expect(formats).toEqual([]); + expect( + interpretedFacts.some((item) => item.kind === "interpreted-fact"), + ).toBe(true); + expect(currentValues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "current-value" }), + ]), + ); + expect(sourceRecords).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "source-record", status: "available" }), + ]), + ); + + const books = await client.saveAssetLibrary({ + displayName: "格式检查资料库", + directories: [bookRoot], + recognitionProfileIds: ["books"], + sourceIdentities: [], + }); + const bookScan = await client.startAssetLibraryScan(books.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(bookScan.parentExecutionId)) + .status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + const book = await client.listAssets({ assetLibraryId: books.id, limit: 10 }); + expect(book.items).toHaveLength(1); + const bookFormats = await client.listAssetEvidence(book.items[0]!.id, { + kind: "format-results", + limit: 100, + }); + expect(bookFormats.status).toBe("available"); + expect(bookFormats.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "format-result", + format: "artifact-format-inspection", + detail: expect.objectContaining({ kind: "metadata" }), + }), + ]), + ); + + const firstSource = sourceRecords.find( + (item) => item.kind === "source-record" && item.status === "available", + ); + expect(firstSource?.kind).toBe("source-record"); + if (firstSource?.kind !== "source-record") + throw new Error("扫描没有留下可读的来源记录"); + const firstChunk = await client.readAssetSourceRecord( + assetId, + firstSource.id, + { + offset: 0, + limit: 23, + }, + ); + expect(firstChunk.record.id).toBe(firstSource.id); + expect(firstChunk.content.length).toBeGreaterThan(0); + expect(firstChunk).not.toHaveProperty("record.body"); + + const historyEntries: Awaited< + ReturnType + >["items"][number][] = []; + let historyCursor: string | undefined; + do { + const page = await client.listWorkflowHistory({ + limit: 1, + ...(historyCursor === undefined ? {} : { cursor: historyCursor }), + }); + historyEntries.push(...page.items); + historyCursor = page.nextCursor; + } while (historyCursor !== undefined); + expect(historyEntries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + resultReferences: expect.objectContaining({ + acceptedAssetIds: expect.arrayContaining([assetId]), + }), + }), + ]), + ); + const attention = await client.listCurrentAttention({ limit: 1 }); + expect(attention.items).toBeDefined(); + + const missingAsset = await fetch( + new URL("/api/v1/assets/no-such-asset/inspection", baseUrl), + ); + expect(missingAsset.status).toBe(404); + const invalidPage = await fetch( + new URL( + `/api/v1/assets/${encodeURIComponent(assetId)}/inspection/evidence/local-observations?limit=0`, + baseUrl, + ), + ); + expect(invalidPage.status).toBe(400); + const unrelatedSource = await fetch( + new URL( + `/api/v1/assets/${encodeURIComponent(assetId)}/inspection/source-records/not-linked`, + baseUrl, + ), + ); + expect(unrelatedSource.status).toBe(404); + + const titleItem = currentValues.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + if (titleItem?.kind !== "current-value") + throw new Error("普通目录样例没有标题裁决"); + const pinDecision = await client.prepareMetadataDecision(assetId, { + kind: "pin", + property: "asset.title", + value: "普通目录人工标题", + }); + await expect( + client.confirmMetadataDecision(pinDecision.previewId), + ).resolves.toMatchObject({ status: "applied" }); + const clearDecision = await client.prepareMetadataDecision(assetId, { + kind: "clear", + property: "asset.title", + }); + await expect( + client.confirmMetadataDecision(clearDecision.previewId), + ).resolves.toMatchObject({ status: "applied" }); + await expect( + client.listAssetEvidence(assetId, { + kind: "metadata-decisions", + limit: 20, + }), + ).resolves.toMatchObject({ + items: [ + expect.objectContaining({ decision: "pin", current: false }), + expect.objectContaining({ decision: "clear", current: true }), + ], + }); +}, 180_000); + +it("从当前两季作品关系追溯来源记录,且不开放无关资产的来源资料", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc08-sequel-provenance-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialRoot = path.join(root, "anime"); + await mkdir(materialRoot, { recursive: true }); + const bloomDirectory = "终将成为你 (2018)"; + const frierenDirectory = "葬送的芙莉莲 (2023)"; + await cp( + path.join(animeMaterialFixture, bloomDirectory), + path.join(materialRoot, bloomDirectory), + { recursive: true }, + ); + await cp( + path.join(animeMaterialFixture, frierenDirectory, "Season 1"), + path.join(materialRoot, frierenDirectory, "Season 1"), + { recursive: true }, + ); + + const controlled = await loadControlledSourceIo({ + manifestPath: movieSourceManifest, + }); + const sourceIo: ProductConfiguration["sourceIo"] = { + mode: "controlled", + request: controlled.request, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + const baseUrl = await startInspectionService(productDataDirectory, sourceIo); + const client = createPublicApiClient({ baseUrl }); + const callbackUrl = new URL( + "/api/v1/source-connections/bangumi/callback", + baseUrl, + ); + await client.configureBangumiOAuthClient({ + clientId: "controlled-sequel-provenance-client", + clientSecret: "controlled-sequel-provenance-secret", + redirectUri: callbackUrl.toString(), + }); + const authorization = await client.beginBangumiAuthorization(); + const state = new URL(authorization.authorizationUrl).searchParams.get( + "state", + ); + if (state === null) throw new Error("授权请求没有返回状态值"); + callbackUrl.searchParams.set("code", "controlled-sequel-provenance-code"); + callbackUrl.searchParams.set("state", state); + expect((await fetch(callbackUrl)).status).toBe(200); + await expect(client.getSourceConnection("bangumi")).resolves.toMatchObject({ + availability: "available", + }); + + const library = await client.saveAssetLibrary({ + displayName: "两季关系来源依据", + directories: [materialRoot], + recognitionProfileIds: ["anime"], + sourceIdentities: ["bangumi"], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + await cp( + path.join(animeMaterialFixture, frierenDirectory, "Season 2"), + path.join(materialRoot, frierenDirectory, "Season 2"), + { recursive: true }, + ); + const nextScan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(nextScan.parentExecutionId)) + .status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + + const assets = await client.listAssets({ + assetLibraryId: library.id, + limit: 100, + }); + const relatedAsset = assets.items.find( + (asset) => + asset.locations.some(({ address }) => address.includes("S01E01")) && + asset.relationships.some( + (relationship) => + relationship.type === "bangumi.subject.related" && + relationship.participants.some( + ({ role }) => role === "bangumi.subject.predecessor", + ) && + relationship.participants.some( + ({ role }) => role === "bangumi.subject.sequel", + ), + ), + ); + expect(relatedAsset).toBeDefined(); + if (relatedAsset === undefined) + throw new Error("控制语料没有形成跨季作品关系"); + const sequel = relatedAsset.relationships.find( + (relationship) => + relationship.type === "bangumi.subject.related" && + relationship.participants.some( + ({ role }) => role === "bangumi.subject.predecessor", + ) && + relationship.participants.some( + ({ role }) => role === "bangumi.subject.sequel", + ), + ); + expect(sequel).toBeDefined(); + if (sequel === undefined) throw new Error("跨季作品关系没有公开投影"); + expect(sequel.sourceRecordIds.length).toBeGreaterThan(0); + + const relatedSourceRecords = await client.listAssetEvidence(relatedAsset.id, { + kind: "source-records", + limit: 100, + }); + expect(relatedSourceRecords.status).toBe("available"); + const sequelAsset = assets.items.find((asset) => + asset.locations.some(({ address }) => address.includes("S02E01")), + ); + expect(sequelAsset).toBeDefined(); + if (sequelAsset === undefined) + throw new Error("当前关系没有对应的第二季目标资产"); + const sequelEvidence = await client.listAssetEvidence(sequelAsset.id, { + kind: "source-records", + limit: 100, + }); + expect(sequelEvidence.items).toEqual( + expect.arrayContaining( + sequel.sourceRecordIds.map((id) => + expect.objectContaining({ + kind: "source-record", + id, + status: "available", + }), + ), + ), + ); + const assetSourceRecordIds = new Set( + relatedSourceRecords.items.flatMap((item) => + item.kind === "source-record" ? [item.id] : [], + ), + ); + expect([...assetSourceRecordIds]).toEqual( + expect.arrayContaining(sequel.sourceRecordIds), + ); + for (const sourceRecordId of sequel.sourceRecordIds) { + const chunk = await client.readAssetSourceRecord( + relatedAsset.id, + sourceRecordId, + { offset: 0, limit: 64 }, + ); + expect(chunk.record.id).toBe(sourceRecordId); + expect(chunk.content.length).toBeGreaterThan(0); + } + + const unrelatedAsset = assets.items.find((asset) => + asset.locations.some(({ address }) => address.includes("终将成为你")), + ); + expect(unrelatedAsset).toBeDefined(); + if (unrelatedAsset === undefined) + throw new Error("同一扫描没有形成无关作品资产"); + const unrelatedEvidence = await client.listAssetEvidence(unrelatedAsset.id, { + kind: "source-records", + limit: 100, + }); + const unrelatedRecordId = unrelatedEvidence.items.find( + (item) => + item.kind === "source-record" && !assetSourceRecordIds.has(item.id), + ); + expect(unrelatedRecordId?.kind).toBe("source-record"); + if (unrelatedRecordId?.kind !== "source-record") + throw new Error("无关资产没有独立来源记录"); + const unrelatedRead = await fetch( + new URL( + `/api/v1/assets/${encodeURIComponent(relatedAsset.id)}/inspection/source-records/${encodeURIComponent(unrelatedRecordId.id)}`, + baseUrl, + ), + ); + expect(unrelatedRead.status).toBe(404); +}, 180_000); + +it("经已验证的正式来源扫描和刷新读取当前采用依据、未采用候选与历史记录", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc08-fact-history-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const controlledDirectory = path.join(root, "controlled-source"); + await mkdir(controlledDirectory, { recursive: true }); + await cp(movieSourceResponses, path.join(controlledDirectory, "responses"), { + recursive: true, + }); + + const tmdbToken = "controlled-tmdb-fact-history-token"; + const omdbKey = "controlled-omdb-fact-history-key"; + let controlled = await loadMovieSourceIo({ + controlledDirectory, + tmdbToken, + omdbKey, + tmdbTitle: "恐怖直播", + }); + let nextSourceGate: + | { + readonly markEntered: () => void; + readonly released: Promise; + readonly release: () => void; + } + | undefined; + const sourceIo: ProductConfiguration["sourceIo"] = { + mode: "controlled", + request: async (input, init) => { + const gate = nextSourceGate; + if (gate !== undefined) { + nextSourceGate = undefined; + gate.markEntered(); + await gate.released; + } + return await controlled.request(input, init); + }, + sessionTransport: refusingSessionTransport(), + }; + + await prepareProductionCatalogue({ productDataDirectory }); + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + running.push({ server, abort: () => bootstrap.abort() }); + await server.ready(); + bootstrap.publish(); + const baseUrl = new URL(server.url!); + const client = createPublicApiClient({ baseUrl }); + + await expect( + client.configureSourceConnection({ + sourceId: "tmdb", + expectedVersion: 0, + secret: tmdbToken, + }), + ).resolves.toMatchObject({ verification: "verified" }); + await expect( + client.configureSourceConnection({ + sourceId: "omdb", + expectedVersion: 0, + secret: omdbKey, + }), + ).resolves.toMatchObject({ verification: "verified" }); + + const materialDirectory = path.join(root, "普通影视目录", "恐怖直播 (2013)"); + await cp( + path.join(movieMaterialFixture, "films", "恐怖直播 (2013)"), + materialDirectory, + { recursive: true }, + ); + const library = await client.saveAssetLibrary({ + displayName: "正式来源证据影视库", + directories: [path.dirname(materialDirectory)], + recognitionProfileIds: ["movies"], + sourceIdentities: ["tmdb", "omdb"], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + const scanInspection = await client.inspectExecution(scan.parentExecutionId); + if (scanInspection.kind !== "asset-library-scan") + throw new Error("正式扫描没有形成资产库扫描检查记录"); + const scanChild = scanInspection.children.find( + (child) => child.status === "available", + ); + if (scanChild?.status !== "available") + throw new Error("正式扫描没有可检查的子执行"); + + const listed = await client.listAssets({ + assetLibraryId: library.id, + limit: 20, + }); + expect(listed.items).toHaveLength(1); + const assetId = listed.items[0]!.id; + const initialFacts = await client.listAssetEvidence(assetId, { + kind: "interpreted-facts", + limit: 100, + }); + const initialTitleFact = initialFacts.items.find( + (item) => + item.kind === "interpreted-fact" && + item.property === "asset.title" && + item.value === "恐怖直播", + ); + expect(initialTitleFact).toMatchObject({ + kind: "interpreted-fact", + property: "asset.title", + value: "恐怖直播", + sourceIdentity: "tmdb", + sourceSlot: "tmdb.movie.title", + sourceRecordIds: [expect.any(String)], + }); + if (initialTitleFact?.kind !== "interpreted-fact") + throw new Error("正式扫描没有解释 TMDB 标题事实"); + const initialTitleRecordId = initialTitleFact.sourceRecordIds[0]!; + + const staleDecision = await client.prepareMetadataDecision(assetId, { + kind: "pin", + property: "asset.title", + value: "不应覆盖来源更新的预演值", + }); + expect(staleDecision).toMatchObject({ + status: "awaiting-confirmation", + current: { + status: "resolved", + values: [{ value: "恐怖直播", sourceIdentity: "tmdb" }], + }, + projected: { + status: "resolved", + values: [{ value: "不应覆盖来源更新的预演值", sourceIdentity: "user" }], + }, + }); + + const initialCurrent = await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }); + const initialTitle = initialCurrent.items.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(initialTitle).toMatchObject({ + kind: "current-value", + evidence: { + property: "asset.title", + result: { + status: "resolved", + values: [ + { + property: "asset.title", + value: "恐怖直播", + sourceIdentity: "tmdb", + reasoning: { kind: "source-preference", preferredSource: "tmdb" }, + sourceRecordIds: [initialTitleRecordId], + }, + ], + notSelected: [ + { + value: "The Terror Live", + sourceIdentity: "omdb", + exclusion: { + kind: "source-preference", + selectedSource: "tmdb", + disagreesWithSelected: true, + }, + }, + ], + }, + }, + }); + const initialRecords = await client.listAssetEvidence(assetId, { + kind: "source-records", + limit: 100, + }); + expect(initialRecords.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "source-record", + id: initialTitleRecordId, + status: "available", + sourceIdentity: "tmdb", + nativeAddress: "https://www.themoviedb.org/movie/209764", + coverage: expect.objectContaining({ + completeness: "complete", + authority: "official", + }), + }), + ]), + ); + const priorHistory = await client.listWorkflowHistory({ limit: 100 }); + const initialScanHistory = priorHistory.items.find( + ({ execution }) => execution.id === scanChild.executionId, + ); + expect(initialScanHistory).toMatchObject({ + execution: { id: scanChild.executionId, operationKind: "scan-filesystem" }, + resultReferences: { + acceptedAssetIds: expect.arrayContaining([assetId]), + sourceRecordIds: expect.arrayContaining([initialTitleRecordId]), + }, + }); + + controlled = await loadMovieSourceIo({ + controlledDirectory, + tmdbToken, + omdbKey, + tmdbTitle: "恐怖直播·资料复核", + }); + const refresh = await client.startMetadataRefresh({ subjectId: assetId }); + await expect + .poll(async () => (await client.inspectExecution(refresh.id)).status, { + timeout: 120_000, + interval: 200, + }) + .toBe("completed"); + + await expect( + client.confirmMetadataDecision(staleDecision.previewId), + ).resolves.toMatchObject({ status: "stale" }); + + const updatedCurrent = await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }); + const updatedTitle = updatedCurrent.items.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(updatedTitle).toMatchObject({ + kind: "current-value", + evidence: { + property: "asset.title", + result: { + status: "resolved", + values: [ + { + property: "asset.title", + value: "恐怖直播·资料复核", + sourceIdentity: "tmdb", + reasoning: { kind: "source-preference", preferredSource: "tmdb" }, + }, + ], + notSelected: [expect.objectContaining({ value: "The Terror Live" })], + }, + }, + }); + const currentRecords = await client.listAssetEvidence(assetId, { + kind: "source-records", + limit: 100, + }); + const updatedTitleFact = ( + await client.listAssetEvidence(assetId, { + kind: "interpreted-facts", + limit: 100, + }) + ).items.find( + (item) => + item.kind === "interpreted-fact" && + item.property === "asset.title" && + item.value === "恐怖直播·资料复核", + ); + expect(updatedTitleFact).toMatchObject({ + kind: "interpreted-fact", + sourceIdentity: "tmdb", + sourceRecordIds: [expect.any(String)], + }); + if (updatedTitleFact?.kind !== "interpreted-fact") + throw new Error("正式刷新没有保留更新后的解释事实"); + const updatedTitleRecordId = updatedTitleFact.sourceRecordIds[0]!; + expect(updatedTitleRecordId).not.toBe(initialTitleRecordId); + expect(updatedTitle).toMatchObject({ + kind: "current-value", + evidence: { + property: "asset.title", + result: { + values: [ + { + value: "恐怖直播·资料复核", + sourceIdentity: "tmdb", + reasoning: { kind: "source-preference", preferredSource: "tmdb" }, + sourceRecordIds: [updatedTitleRecordId], + }, + ], + notSelected: [ + { + value: "The Terror Live", + sourceIdentity: "omdb", + exclusion: { + kind: "source-preference", + selectedSource: "tmdb", + disagreesWithSelected: true, + }, + }, + ], + }, + }, + }); + expect(currentRecords.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: updatedTitleRecordId, + sourceIdentity: "tmdb", + }), + ]), + ); + const originalRecordAfterRefresh = currentRecords.items.find( + (item) => item.kind === "source-record" && item.id === initialTitleRecordId, + ); + expect(originalRecordAfterRefresh).toMatchObject({ + kind: "source-record", + status: "available", + sourceIdentity: "tmdb", + }); + const priorSource = await client.readAssetSourceRecord( + assetId, + initialTitleRecordId, + { offset: 0, limit: 2048 }, + ); + expect(priorSource.content).toContain('"title":"恐怖直播"'); + expect(priorSource).not.toHaveProperty("record.body"); + const updatedSource = await client.readAssetSourceRecord( + assetId, + updatedTitleRecordId, + { offset: 0, limit: 2048 }, + ); + expect(updatedSource.content).toContain('"title":"恐怖直播·资料复核"'); + + const invalidAdoption = await fetch( + new URL( + `/api/v1/assets/${encodeURIComponent(assetId)}/metadata-decisions/previews`, + baseUrl, + ), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + kind: "adopt", + property: "asset.title", + candidateReference: "metadata-candidate_not-a-current-conflict", + }), + }, + ); + expect(invalidAdoption.status).toBe(409); + + const emptyValue = await fetch( + new URL( + `/api/v1/assets/${encodeURIComponent(assetId)}/metadata-decisions/previews`, + baseUrl, + ), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + kind: "pin", + property: "asset.title", + value: " ", + }), + }, + ); + expect(emptyValue.status).toBe(400); + await expect( + client.listAssetEvidence(assetId, { + kind: "metadata-decisions", + limit: 20, + }), + ).resolves.toMatchObject({ items: [] }); + + const cancelledDecision = await client.prepareMetadataDecision(assetId, { + kind: "pin", + property: "asset.title", + value: "取消后不能生效", + }); + await expect( + client.cancelMetadataDecision(cancelledDecision.previewId), + ).resolves.toMatchObject({ status: "rejected" }); + await expect( + client.listAssetEvidence(assetId, { + kind: "metadata-decisions", + limit: 20, + }), + ).resolves.toMatchObject({ items: [] }); + + const failedPreparationDatabase = new DatabaseSync( + path.join(productDataDirectory, "catalogue.sqlite"), + ); + failedPreparationDatabase.exec(` + CREATE TRIGGER reject_metadata_preview_insert + BEFORE INSERT ON change_impact_previews + WHEN json_extract(NEW.preview_json, '$.change.kind') = 'metadata-decision' + BEGIN + SELECT RAISE(ABORT, '数据库写入必须保密标记'); + END; + `); + expect(() => + failedPreparationDatabase + .prepare( + "INSERT INTO change_impact_previews (id, execution_id, asset_id, preview_json) VALUES (?, ?, ?, ?)", + ) + .run( + "trigger-check", + "trigger-check", + assetId, + JSON.stringify({ change: { kind: "metadata-decision" } }), + ), + ).toThrow("数据库写入必须保密标记"); + try { + const failedPreparation = await fetch( + new URL( + `/api/v1/assets/${encodeURIComponent(assetId)}/metadata-decisions/previews`, + baseUrl, + ), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + kind: "pin", + property: "asset.title", + value: "数据库故障不能变成输入错误", + }), + }, + ); + const body = await failedPreparation.text(); + expect(failedPreparation.status).toBe(503); + expect(body).toContain("暂时无法形成属性修订预演"); + expect(body).not.toContain("数据库写入必须保密标记"); + expect(body).not.toContain("SQLITE_CONSTRAINT_TRIGGER"); + } finally { + failedPreparationDatabase.exec( + "DROP TRIGGER reject_metadata_preview_insert", + ); + failedPreparationDatabase.close(); + } + + const pinDecision = await client.prepareMetadataDecision(assetId, { + kind: "pin", + property: "asset.title", + value: "固定人工标题", + }); + expect(pinDecision).toMatchObject({ + current: { + status: "resolved", + values: [{ value: "恐怖直播·资料复核", sourceIdentity: "tmdb" }], + }, + projected: { + status: "resolved", + values: [{ value: "固定人工标题", sourceIdentity: "user" }], + }, + }); + const failedConfirmationDatabase = new DatabaseSync( + path.join(productDataDirectory, "catalogue.sqlite"), + ); + failedConfirmationDatabase.exec(` + CREATE TRIGGER reject_metadata_decision_insert + BEFORE INSERT ON metadata_decisions + BEGIN + SELECT RAISE(ABORT, '数据库决定写入必须保密标记'); + END; + `); + expect(() => + failedConfirmationDatabase + .prepare( + "INSERT INTO metadata_decisions (id, target_kind, target_entity_id, property, metadata_usage_context, kind, value, initiated_by) VALUES (?, 'entity', ?, 'asset.title', 'asset.ordinary', 'pin', '标题', 'tester')", + ) + .run("trigger-check", assetId), + ).toThrow("数据库决定写入必须保密标记"); + try { + const failedConfirmation = await fetch( + new URL( + `/api/v1/metadata-decision-previews/${encodeURIComponent(pinDecision.previewId)}/confirm`, + baseUrl, + ), + { method: "POST" }, + ); + const body = await failedConfirmation.text(); + expect(failedConfirmation.status).toBe(503); + expect(body).toContain("暂时无法确认属性修订"); + expect(body).not.toContain("数据库决定写入必须保密标记"); + expect(body).not.toContain("SQLITE_CONSTRAINT_TRIGGER"); + } finally { + failedConfirmationDatabase.exec( + "DROP TRIGGER reject_metadata_decision_insert", + ); + failedConfirmationDatabase.close(); + } + await expect( + client.confirmMetadataDecision(pinDecision.previewId), + ).resolves.toMatchObject({ status: "applied" }); + const pinnedCurrent = await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }); + expect(pinnedCurrent.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "current-value", + evidence: { + property: "asset.title", + result: { + status: "resolved", + values: [ + expect.objectContaining({ + value: "固定人工标题", + sourceIdentity: "user", + }), + ], + notSelected: [], + }, + }, + }), + ]), + ); + await expect( + client.listAssetEvidence(assetId, { + kind: "metadata-decisions", + limit: 20, + }), + ).resolves.toMatchObject({ + items: [ + expect.objectContaining({ + kind: "metadata-decision", + property: "asset.title", + decision: "pin", + value: "固定人工标题", + current: true, + }), + ], + }); + + controlled = await loadMovieSourceIo({ + controlledDirectory, + tmdbToken, + omdbKey, + tmdbTitle: "恐怖直播·再次更新", + }); + const refreshWhilePinned = await client.startMetadataRefresh({ + subjectId: assetId, + }); + await expect + .poll( + async () => (await client.inspectExecution(refreshWhilePinned.id)).status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + const refreshedWhilePinned = await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }); + const refreshedPinnedTitle = refreshedWhilePinned.items.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(refreshedPinnedTitle?.kind).toBe("current-value"); + if (refreshedPinnedTitle?.kind !== "current-value") + throw new Error("来源刷新后没有读取到标题裁决"); + expect(refreshedPinnedTitle.evidence.result.status).toBe("resolved"); + if (refreshedPinnedTitle.evidence.result.status !== "resolved") + throw new Error("固定人工标题没有维持为有效值"); + expect( + refreshedPinnedTitle.evidence.result.values.map( + ({ value, sourceIdentity }) => ({ + value, + sourceIdentity, + }), + ), + ).toEqual([{ value: "固定人工标题", sourceIdentity: "user" }]); + + const clearDecision = await client.prepareMetadataDecision(assetId, { + kind: "clear", + property: "asset.title", + }); + expect(clearDecision).toMatchObject({ + kind: "clear", + current: { + status: "resolved", + values: [{ value: "固定人工标题", sourceIdentity: "user" }], + }, + projected: { + status: "resolved", + values: [{ value: "恐怖直播·再次更新", sourceIdentity: "tmdb" }], + }, + }); + await expect( + client.confirmMetadataDecision(clearDecision.previewId), + ).resolves.toMatchObject({ status: "applied" }); + const afterClear = await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }); + const clearedTitle = afterClear.items.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(clearedTitle?.kind).toBe("current-value"); + if (clearedTitle?.kind !== "current-value") + throw new Error("撤回修订后没有读取到标题裁决"); + expect(clearedTitle.evidence.result.status).toBe("resolved"); + if (clearedTitle.evidence.result.status !== "resolved") + throw new Error("撤回修订后来源标题没有恢复"); + expect( + clearedTitle.evidence.result.values.map(({ value, sourceIdentity }) => ({ + value, + sourceIdentity, + })), + ).toEqual([{ value: "恐怖直播·再次更新", sourceIdentity: "tmdb" }]); + await expect( + client.listAssetEvidence(assetId, { + kind: "metadata-decisions", + limit: 20, + }), + ).resolves.toMatchObject({ + items: [ + expect.objectContaining({ decision: "pin", current: false }), + expect.objectContaining({ decision: "clear", current: true }), + ], + }); + const sourceRecordAfterDecisions = await client.readAssetSourceRecord( + assetId, + initialTitleRecordId, + { offset: 0, limit: 2048 }, + ); + expect(sourceRecordAfterDecisions.content).toContain('"title":"恐怖直播"'); + + const historyAfterRefresh = await client.listWorkflowHistory({ limit: 100 }); + const initialHistoryAfterRefresh = historyAfterRefresh.items.find( + ({ execution }) => execution.id === scanChild.executionId, + ); + expect(initialHistoryAfterRefresh).toMatchObject({ + execution: { id: scanChild.executionId, operationKind: "scan-filesystem" }, + resultReferences: { + acceptedAssetIds: expect.arrayContaining([assetId]), + sourceRecordIds: expect.arrayContaining([initialTitleRecordId]), + }, + }); + const refreshHistory = historyAfterRefresh.items.find( + ({ execution }) => execution.id === refresh.id, + ); + expect(refreshHistory).toMatchObject({ + execution: { id: refresh.id, operationKind: "refresh-metadata" }, + resultReferences: { + acceptedAssetIds: expect.arrayContaining([assetId]), + sourceRecordIds: expect.arrayContaining([updatedTitleRecordId]), + }, + }); + + let markSourceEntered!: () => void; + let releaseSource!: () => void; + const entered = new Promise((resolve) => { + markSourceEntered = resolve; + }); + const released = new Promise((resolve) => { + releaseSource = resolve; + }); + nextSourceGate = { + markEntered: markSourceEntered, + released, + release: releaseSource, + }; + const cancelledRefresh = await client.startMetadataRefresh({ + subjectId: assetId, + }); + try { + await entered; + const cancellation = await client.cancelExecution(cancelledRefresh.id, { + reason: "验证历史保留实际取消结局", + }); + expect(cancellation.requestStatus).toBe("requested"); + } finally { + releaseSource(); + } + await expect + .poll( + async () => (await client.inspectExecution(cancelledRefresh.id)).outcome, + ) + .toBe("cancelled"); + const cancelledHistory = await client.listWorkflowHistory({ limit: 100 }); + const cancelledHistoryIndex = cancelledHistory.items.findIndex( + ({ execution }) => execution.id === cancelledRefresh.id, + ); + expect(cancelledHistoryIndex).toBeGreaterThanOrEqual(0); + expect( + cancelledHistory.items[cancelledHistoryIndex]?.execution, + ).toMatchObject({ + status: "failed", + stage: "failed", + outcome: "cancelled", + }); +}, 180_000); + +it("资产来源记录页保留已提交 FANBOX unchanged 摘要及其历史正文", async () => { + const root = await mkdtemp( + path.join(tmpdir(), "ewc08-unchanged-fanbox-summary-"), + ); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialRoot = path.join(root, "library"); + const fanboxPostMaterial = path.join( + materialFixture, + "customudon", + "23.12.31 コスプレ4", + ); + await cp( + fanboxPostMaterial, + path.join(materialRoot, "customudon", "23.12.31 コスプレ4"), + { recursive: true }, + ); + + const controlled = await loadControlledSourceIo({ + manifestPath: sourceManifest, + }); + if (controlled.sessionTransport === undefined) + throw new Error("受控 FANBOX 样例缺少正式会话端点传输"); + const sourceIo: ProductConfiguration["sourceIo"] = { + mode: "controlled", + request: controlled.request, + sessionTransport: controlled.sessionTransport, + }; + const baseUrl = await startInspectionService(productDataDirectory, sourceIo); + const client = createPublicApiClient({ baseUrl }); + const pairing = await client.createFanboxConnectionRequest({ + creatorId: "customudon", + }); + const browserCredential = JSON.parse( + await readFile(fanboxConnectionFixture, "utf8"), + ) as unknown; + await expect( + client.completeFanboxConnectionRequest({ + requestId: pairing.request.id, + pairingCode: pairing.pairingCode, + browserCredential, + }), + ).resolves.toMatchObject({ status: "succeeded" }); + + const library = await client.saveAssetLibrary({ + displayName: "受控 FANBOX 摘要证据库", + directories: [materialRoot], + recognitionProfileIds: ["fanbox"], + sourceIdentities: ["fanbox"], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + const assets = await client.listAssets({ + assetLibraryId: library.id, + limit: 10, + }); + expect(assets.items).toHaveLength(1); + const assetId = assets.items[0]!.id; + const initialRecords = await client.listAssetEvidence(assetId, { + kind: "source-records", + limit: 100, + }); + expect(initialRecords.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "source-record", + sourceIdentity: "fanbox", + coverage: expect.objectContaining({ + id: "fanbox.creator-post-summaries", + }), + status: "available", + }), + ]), + ); + + const refreshUnchangedSummary = async () => { + const refresh = await client.startMetadataRefresh({ subjectId: assetId }); + await expect + .poll(async () => (await client.inspectExecution(refresh.id)).status, { + timeout: 120_000, + interval: 200, + }) + .toBe("completed"); + const inspection = await client.inspectExecution(refresh.id); + if (inspection.kind !== "execution") + throw new Error("正式 FANBOX 刷新没有返回执行检查记录"); + const run = inspection.sourceRefreshRuns.find( + (candidate) => + candidate.requestedSelection.subjectId === assetId && + candidate.requestedSelection.sourceIdentity === "fanbox", + ); + if (run === undefined) throw new Error("正式 FANBOX 刷新没有保留来源运行"); + const summaryScope = run.scopes.find( + (scope) => scope.coverage.coverageId === "fanbox.creator-post-summaries", + ); + expect(summaryScope?.outcome).toBe("unchanged"); + if (summaryScope?.candidateSourceRecordId === undefined) + throw new Error("unchanged 摘要范围没有正式候选记录"); + return summaryScope.candidateSourceRecordId; + }; + const currentUnchangedSummaryId = await refreshUnchangedSummary(); + const historicalUnchangedSummaryId = await refreshUnchangedSummary(); + expect(historicalUnchangedSummaryId).not.toBe(currentUnchangedSummaryId); + + const sourceRecordPages: Array< + Awaited>["items"][number] + > = []; + let cursor: string | undefined; + do { + const page = await client.listAssetEvidence(assetId, { + kind: "source-records", + limit: 1, + ...(cursor === undefined ? {} : { cursor }), + }); + sourceRecordPages.push(...page.items); + cursor = page.nextCursor; + } while (cursor !== undefined); + expect(sourceRecordPages).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "source-record", + id: historicalUnchangedSummaryId, + status: "available", + sourceIdentity: "fanbox", + coverage: expect.objectContaining({ + id: "fanbox.creator-post-summaries", + }), + }), + expect.objectContaining({ + kind: "source-record", + id: currentUnchangedSummaryId, + status: "available", + sourceIdentity: "fanbox", + coverage: expect.objectContaining({ + id: "fanbox.creator-post-summaries", + }), + }), + ]), + ); + + const readCompleteBody = async (sourceRecordId: string) => { + let offset = 0; + let complete = false; + let body = ""; + while (!complete) { + const chunk = await client.readAssetSourceRecord( + assetId, + sourceRecordId, + { + offset, + limit: 256, + }, + ); + expect(chunk.record.id).toBe(sourceRecordId); + expect(chunk.offset).toBe(offset); + body += chunk.content; + offset = chunk.nextOffset; + complete = chunk.complete; + } + return JSON.parse(body) as { + readonly body: { + readonly posts: readonly { + readonly id: string; + readonly title: string; + }[]; + }; + }; + }; + for (const sourceRecordId of [ + historicalUnchangedSummaryId, + currentUnchangedSummaryId, + ]) { + const body = await readCompleteBody(sourceRecordId); + expect(body.body.posts).toContainEqual( + expect.objectContaining({ id: "7225781", title: "Movie | コスプレ4" }), + ); + } + + const bookRoot = path.join(root, "unrelated-books"); + await mkdir(bookRoot, { recursive: true }); + await writeFile( + path.join(bookRoot, "无关书籍.pdf"), + syntheticPdf({ author: "无关资产作者", pageCount: 1 }), + ); + const bookLibrary = await client.saveAssetLibrary({ + displayName: "无关资产书库", + directories: [bookRoot], + recognitionProfileIds: ["books"], + sourceIdentities: [], + }); + const bookScan = await client.startAssetLibraryScan(bookLibrary.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(bookScan.parentExecutionId)) + .status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + const unrelatedAssets = await client.listAssets({ + assetLibraryId: bookLibrary.id, + limit: 10, + }); + expect(unrelatedAssets.items).toHaveLength(1); + const unrelatedRead = await fetch( + new URL( + `/api/v1/assets/${encodeURIComponent(unrelatedAssets.items[0]!.id)}/inspection/source-records/${encodeURIComponent(currentUnchangedSummaryId)}`, + baseUrl, + ), + ); + expect(unrelatedRead.status).toBe(404); +}, 180_000); + +it("真实 HTTP 通过当前 nbinet 多值冲突明确采纳候选并拒绝过期候选", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc09-metadata-conflict-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const controlledDirectory = path.join(root, "controlled-source"); + const responseDirectory = path.join(controlledDirectory, "responses"); + await mkdir(controlledDirectory, { recursive: true }); + await cp(movieSourceResponses, responseDirectory, { recursive: true }); + const controlledManifestPath = path.join( + controlledDirectory, + "manifest.json", + ); + await cp(movieSourceManifest, controlledManifestPath); + const nbinetResponsePath = path.join(responseDirectory, "epub-5-nbinet.json"); + const setNbinetTitles = async (titles: readonly string[]) => { + const body = JSON.parse(await readFile(nbinetResponsePath, "utf8")) as { + docs: Array<{ pnx: { display: { title: readonly string[] } } }>; + }; + const first = body.docs[0]; + if (first === undefined) throw new Error("受控书目资料缺少记录"); + first.pnx.display.title = [...titles]; + await writeFile(nbinetResponsePath, JSON.stringify(body)); + }; + await setNbinetTitles(["幼女战记 第一册", "幼女战记·第一册"]); + let controlled = await loadControlledSourceIo({ + manifestPath: controlledManifestPath, + }); + const sourceIo: ProductConfiguration["sourceIo"] = { + mode: "controlled", + request: (input, init) => controlled.request(input, init), + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + + await prepareProductionCatalogue({ productDataDirectory }); + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + running.push({ server, abort: () => bootstrap.abort() }); + await server.ready(); + bootstrap.publish(); + const baseUrl = new URL(server.url!); + const client = createPublicApiClient({ baseUrl }); + + const materialDirectory = path.join(root, "普通出版物", "幼女战记(1)"); + await cp( + path.join(movieMaterialFixture, "books", "幼女战记(1)"), + materialDirectory, + { recursive: true }, + ); + const library = await client.saveAssetLibrary({ + displayName: "书目冲突受控变体", + directories: [path.dirname(materialDirectory)], + recognitionProfileIds: ["books"], + sourceIdentities: ["nbinet"], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + const assets = await client.listAssets({ + assetLibraryId: library.id, + limit: 10, + }); + expect(assets.items).toHaveLength(1); + const assetId = assets.items[0]!.id; + const initialValues = await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }); + const initialTitle = initialValues.items.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(initialTitle?.kind).toBe("current-value"); + if ( + initialTitle?.kind !== "current-value" || + initialTitle.evidence.result.status !== "conflict" + ) + throw new Error("受控书目变体没有形成同一来源的真实标题冲突"); + expect(initialTitle.evidence.result).toMatchObject({ + kind: "metadata-value-conflict", + candidates: [ + expect.objectContaining({ + value: "幼女战记 第一册", + sourceIdentity: "nbinet", + }), + expect.objectContaining({ + value: "幼女战记·第一册", + sourceIdentity: "nbinet", + }), + ], + }); + const initialCandidate = initialTitle.evidence.result.candidates.find( + (candidate) => candidate.value === "幼女战记·第一册", + ); + if (initialCandidate?.reference === undefined) + throw new Error("当前标题冲突候选缺少选择引用"); + const cancelledAdoption = await client.prepareMetadataDecision(assetId, { + kind: "adopt", + property: "asset.title", + candidateReference: initialCandidate.reference, + }); + await expect( + client.cancelMetadataDecision(cancelledAdoption.previewId), + ).resolves.toMatchObject({ status: "rejected" }); + const unchangedAfterCancellation = ( + await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }) + ).items.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(unchangedAfterCancellation).toMatchObject({ + kind: "current-value", + evidence: { property: "asset.title", result: { status: "conflict" } }, + }); + const staleAdoption = await client.prepareMetadataDecision(assetId, { + kind: "adopt", + property: "asset.title", + candidateReference: initialCandidate.reference, + }); + expect(staleAdoption).toMatchObject({ + status: "awaiting-confirmation", + current: { status: "conflict" }, + projected: { + status: "resolved", + values: [{ value: "幼女战记·第一册", sourceIdentity: "user" }], + }, + adoptedFromSourceIdentity: "nbinet", + }); + const adoptedFirstCandidate = await client.prepareMetadataDecision(assetId, { + kind: "adopt", + property: "asset.title", + candidateReference: initialCandidate.reference, + }); + await expect( + client.confirmMetadataDecision(adoptedFirstCandidate.previewId), + ).resolves.toMatchObject({ status: "applied" }); + + await setNbinetTitles(["幼女战记 第一册新版", "幼女战记·第一册新版"]); + controlled = await loadControlledSourceIo({ + manifestPath: controlledManifestPath, + }); + const refresh = await client.startMetadataRefresh({ subjectId: assetId }); + await expect + .poll(async () => (await client.inspectExecution(refresh.id)).status, { + timeout: 120_000, + interval: 200, + }) + .toBe("completed"); + await expect( + client.confirmMetadataDecision(staleAdoption.previewId), + ).resolves.toMatchObject({ status: "stale" }); + + const clearFirstCandidate = await client.prepareMetadataDecision(assetId, { + kind: "clear", + property: "asset.title", + }); + expect(clearFirstCandidate).toMatchObject({ + current: { status: "resolved" }, + projected: { status: "conflict" }, + }); + await expect( + client.confirmMetadataDecision(clearFirstCandidate.previewId), + ).resolves.toMatchObject({ status: "applied" }); + + const refreshedValues = await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }); + const refreshedTitle = refreshedValues.items.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(refreshedTitle?.kind).toBe("current-value"); + if ( + refreshedTitle?.kind !== "current-value" || + refreshedTitle.evidence.result.status !== "conflict" + ) + throw new Error("来源刷新后没有形成新的标题冲突候选"); + const currentCandidate = refreshedTitle.evidence.result.candidates.find( + (candidate) => candidate.value === "幼女战记·第一册新版", + ); + if ( + currentCandidate?.reference === undefined || + currentCandidate.sourceRecordIds.length === 0 + ) + throw new Error("刷新后的标题候选缺少选择引用"); + const adopted = await client.prepareMetadataDecision(assetId, { + kind: "adopt", + property: "asset.title", + candidateReference: currentCandidate.reference, + }); + expect(adopted.projected).toMatchObject({ + status: "resolved", + values: [{ value: "幼女战记·第一册新版", sourceIdentity: "user" }], + }); + await expect( + client.confirmMetadataDecision(adopted.previewId), + ).resolves.toMatchObject({ status: "applied" }); + const adoptedValues = await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }); + const adoptedTitle = adoptedValues.items.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(adoptedTitle?.kind).toBe("current-value"); + if ( + adoptedTitle?.kind !== "current-value" || + adoptedTitle.evidence.result.status !== "resolved" + ) + throw new Error("确认后没有读到已固定的冲突候选"); + expect( + adoptedTitle.evidence.result.values.map(({ value, sourceIdentity }) => ({ + value, + sourceIdentity, + })), + ).toEqual([{ value: "幼女战记·第一册新版", sourceIdentity: "user" }]); + const decisionHistory = await client.listAssetEvidence(assetId, { + kind: "metadata-decisions", + limit: 20, + }); + expect(decisionHistory.items).toEqual([ + expect.objectContaining({ + decisionId: expect.any(String), + initiatedBy: "local-user", + decision: "pin", + value: "幼女战记·第一册", + current: false, + adoptedFrom: { + sourceIdentity: "nbinet", + sourceSlot: initialCandidate.sourceSlot, + sourceRecordIds: initialCandidate.sourceRecordIds, + }, + }), + expect.objectContaining({ + decision: "clear", + predecessorDecisionId: expect.any(String), + current: false, + }), + expect.objectContaining({ + decisionId: expect.any(String), + initiatedBy: "local-user", + decision: "pin", + value: "幼女战记·第一册新版", + predecessorDecisionId: expect.any(String), + adoptedFrom: { + sourceIdentity: "nbinet", + sourceSlot: currentCandidate.sourceSlot, + sourceRecordIds: currentCandidate.sourceRecordIds, + }, + current: true, + }), + ]); + const [firstDecision, clearDecisionHistory, latestDecision] = + decisionHistory.items; + expect(firstDecision?.kind).toBe("metadata-decision"); + expect(clearDecisionHistory?.kind).toBe("metadata-decision"); + expect(latestDecision?.kind).toBe("metadata-decision"); + if ( + firstDecision?.kind !== "metadata-decision" || + clearDecisionHistory?.kind !== "metadata-decision" || + latestDecision?.kind !== "metadata-decision" + ) + throw new Error("元数据决定历史缺少一次修订动作"); + expect(clearDecisionHistory.predecessorDecisionId).toBe( + firstDecision.decisionId, + ); + expect(latestDecision.predecessorDecisionId).toBe( + clearDecisionHistory.decisionId, + ); + for (const [decision, candidate] of [ + [firstDecision, initialCandidate], + [latestDecision, currentCandidate], + ] as const) { + if (decision.adoptedFrom === undefined) + throw new Error("已采纳修订缺少候选来源依据"); + expect(decision.adoptedFrom.sourceRecordIds).toEqual( + candidate.sourceRecordIds, + ); + for (const recordId of decision.adoptedFrom.sourceRecordIds) { + const source = await client.readAssetSourceRecord(assetId, recordId, { + offset: 0, + limit: 2048, + }); + expect(source.content).toContain(candidate.value); + } + } + const evidenceRecords = await client.listAssetEvidence(assetId, { + kind: "source-records", + limit: 100, + }); + const latestNbinetRecord = evidenceRecords.items.find( + (item) => + item.kind === "source-record" && + currentCandidate.sourceRecordIds.includes(item.id), + ); + if (latestNbinetRecord?.kind !== "source-record") + throw new Error("冲突候选没有指向可检查的正式来源记录"); + const unchangedSource = await client.readAssetSourceRecord( + assetId, + latestNbinetRecord.id, + { offset: 0, limit: 2048 }, + ); + expect(unchangedSource.content).toContain("幼女战记·第一册新版"); + expect(unchangedSource.content).toContain("幼女战记 第一册新版"); +}, 180_000); + +it("真实 HTTP 取消扫描后,历史详情优先显示父子取消结局", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc08-cancelled-scan-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const controlledDirectory = path.join(root, "controlled-source"); + await mkdir(controlledDirectory, { recursive: true }); + await cp(movieSourceResponses, path.join(controlledDirectory, "responses"), { + recursive: true, + }); + + const tmdbToken = "controlled-tmdb-cancelled-scan-token"; + const omdbKey = "controlled-omdb-cancelled-scan-key"; + const controlled = await loadMovieSourceIo({ + controlledDirectory, + tmdbToken, + omdbKey, + tmdbTitle: "恐怖直播", + }); + let nextSourceGate: + | { + readonly markEntered: () => void; + readonly released: Promise; + readonly release: () => void; + } + | undefined; + const sourceIo: ProductConfiguration["sourceIo"] = { + mode: "controlled", + request: async (input, init) => { + const gate = nextSourceGate; + if (gate !== undefined) { + nextSourceGate = undefined; + gate.markEntered(); + await gate.released; + } + return await controlled.request(input, init); + }, + sessionTransport: refusingSessionTransport(), + }; + + await prepareProductionCatalogue({ productDataDirectory }); + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + running.push({ server, abort: () => bootstrap.abort() }); + await server.ready(); + bootstrap.publish(); + const baseUrl = new URL(server.url!); + const client = createPublicApiClient({ baseUrl }); + await expect( + client.configureSourceConnection({ + sourceId: "tmdb", + expectedVersion: 0, + secret: tmdbToken, + }), + ).resolves.toMatchObject({ verification: "verified" }); + + const materialDirectory = path.join(root, "普通影视目录", "恐怖直播 (2013)"); + await cp( + path.join(movieMaterialFixture, "films", "恐怖直播 (2013)"), + materialDirectory, + { recursive: true }, + ); + const library = await client.saveAssetLibrary({ + displayName: "取消验证影视库", + directories: [path.dirname(materialDirectory)], + recognitionProfileIds: ["movies"], + sourceIdentities: ["tmdb"], + }); + + let markSourceEntered!: () => void; + let releaseSource!: () => void; + const entered = new Promise((resolve) => { + markSourceEntered = resolve; + }); + const released = new Promise((resolve) => { + releaseSource = resolve; + }); + nextSourceGate = { + markEntered: markSourceEntered, + released, + release: releaseSource, + }; + const scan = await client.startAssetLibraryScan(library.id); + try { + const sourceRequestStarted = await new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), 10_000); + void entered.then(() => { + clearTimeout(timeout); + resolve(true); + }); + }); + expect(sourceRequestStarted).toBe(true); + const cancellation = await client.cancelExecution(scan.parentExecutionId, { + reason: "验证历史父子执行状态", + }); + expect(cancellation.requestStatus).toBe("requested"); + } finally { + releaseSource(); + } + + await expect + .poll( + async () => + (await client.inspectExecution(scan.parentExecutionId)).outcome, + { timeout: 120_000, interval: 200 }, + ) + .toBe("cancelled"); + const inspection = await client.inspectExecution(scan.parentExecutionId); + expect(inspection).toMatchObject({ + kind: "asset-library-scan", + status: "failed", + outcome: "cancelled", + children: [ + { + status: "available", + execution: { + status: "failed", + stage: "failed", + outcome: "cancelled", + }, + }, + ], + }); + const history = await client.listWorkflowHistory({ limit: 100 }); + const historyIndex = history.items.findIndex( + ({ execution }) => execution.id === scan.parentExecutionId, + ); + expect(historyIndex).toBeGreaterThanOrEqual(0); + expect(history.items[historyIndex]?.execution).toMatchObject({ + status: "failed", + stage: "failed", + outcome: "cancelled", + }); +}, 180_000); + +it("公共资产读取保留 PDF 与 EPUB 封面所依据的精确父修订版", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc08-cover-provenance-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialRoot = path.join(root, "实际出版物"); + const pdfName = "终将成为你 佐伯沙弥香的追忆.pdf"; + const epubName = "幼女战记(1).epub"; + const pdfSource = path.join( + movieMaterialFixture, + "books", + "やがて君になる 佐伯沙弥香について", + "01. ", + "终将成为你 佐伯沙弥香的追忆", + pdfName, + ); + const epubSource = path.join( + movieMaterialFixture, + "books", + "幼女战记(1)", + "publication.epub", + ); + const pdfPath = path.join(materialRoot, "漫画", "佐伯沙弥香", pdfName); + const epubPath = path.join(materialRoot, "漫画", "幼女战记(1)", epubName); + await mkdir(path.dirname(pdfPath), { recursive: true }); + await mkdir(path.dirname(epubPath), { recursive: true }); + await cp(pdfSource, pdfPath); + await cp(epubSource, epubPath); + + const controlled = await loadControlledSourceIo({ + manifestPath: movieSourceManifest, + }); + const sourceIo: ProductConfiguration["sourceIo"] = { + mode: "controlled", + request: controlled.request, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + const baseUrl = await startInspectionService(productDataDirectory, sourceIo); + const client = createPublicApiClient({ baseUrl }); + const library = await client.saveAssetLibrary({ + displayName: "出版物封面依据", + directories: [materialRoot], + recognitionProfileIds: ["books"], + sourceIdentities: [], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + + const assets = await client.listAssets({ + assetLibraryId: library.id, + limit: 20, + }); + const readCoverPart = (fileName: string, locator: string) => { + const asset = assets.items.find(({ locations }) => + locations.some(({ address }) => address.endsWith(fileName)), + ); + expect(asset).toBeDefined(); + if (asset === undefined) throw new Error(`没有读取到 ${fileName} 对应资产`); + const cover = asset.descriptions + .flatMap(({ artifactRoles }) => artifactRoles) + .find(({ role }) => role === "asset.primary-cover"); + expect(cover).toBeDefined(); + if (cover === undefined) throw new Error(`${fileName} 没有当前封面角色`); + + expect(cover.observedArtifactParts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + associationId: expect.any(String), + artifactId: expect.any(String), + revisionId: expect.any(String), + locator, + }), + ]), + ); + const parts = cover.observedArtifactParts ?? []; + expect(parts.length).toBeGreaterThan(0); + expect( + parts.every(({ associationId }) => + cover.associationIds.includes(associationId), + ), + ).toBe(true); + const part = parts.find((candidate) => candidate.locator === locator); + expect(part).toBeDefined(); + if (part === undefined) throw new Error(`${fileName} 封面没有精确材料依据`); + expect(cover.associationIds).toContain(part.associationId); + const parentLocation = asset.locations.find( + ({ artifactId }) => artifactId === part.artifactId, + ); + expect(parentLocation).toMatchObject({ + content: { + status: "confirmed", + target: { + kind: "artifact-revision", + artifactId: part.artifactId, + revisionId: part.revisionId, + }, + }, + }); + return asset; + }; + + readCoverPart(pdfName, "pdf:page[1]"); + const epubAsset = readCoverPart( + epubName, + "epub:item/standard.opf#/manifest/item[@id='cover'][@properties~='cover-image']->entry:item/image/i-001.jpg", + ); + const representativeBody = epubAsset.descriptions + .flatMap(({ artifactRoles }) => artifactRoles) + .find(({ role }) => role === "asset.representative-body"); + expect(representativeBody).toBeDefined(); + expect(representativeBody).not.toHaveProperty("observedArtifactParts"); +}, 180_000); + +it("已安装 TUI 翻查资料历史并完成标题修订【安装客户端】", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc08-inspection-tui-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialRoot = path.join(root, "我的收藏"); + const workDirectory = path.join(materialRoot, "声音", "2025", "RJ01029383"); + await mkdir(path.dirname(workDirectory), { recursive: true }); + await cp(path.join(materialFixture, "RJ01029383"), workDirectory, { + recursive: true, + }); + await writeFile( + path.join(materialRoot, "普通说明.txt"), + "普通目录中的说明材料。\n", + ); + + const controlled = await loadControlledSourceIo({ + manifestPath: sourceManifest, + }); + const sourceIo: ProductConfiguration["sourceIo"] = { + mode: "controlled", + request: controlled.request, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + const tuiRequests: string[] = []; + const baseUrl = await startInspectionService( + productDataDirectory, + sourceIo, + tuiRequests, + ); + const client = createPublicApiClient({ baseUrl }); + const library = await client.saveAssetLibrary({ + displayName: "资料检查普通目录", + directories: [materialRoot], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + const assets = await client.listAssets({ + assetLibraryId: library.id, + limit: 100, + }); + const asset = assets.items.find(({ locations }) => + locations.some(({ address }) => address.includes("RJ01029383")), + ); + expect(asset).toBeDefined(); + const currentValues = await client.listAssetEvidence(asset!.id, { + kind: "current-values", + limit: 100, + }); + const titleProperties = currentValues.items.map((item) => + item.kind === "current-value" ? item.evidence.property : "", + ); + const titleIndex = currentValues.items.findIndex( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(titleIndex).toBeGreaterThanOrEqual(0); + const titleBeforeTui = currentValues.items[titleIndex]; + if (titleBeforeTui?.kind !== "current-value") + throw new Error("普通目录样例没有标题裁决"); + + await verifyInstalledTui(baseUrl, asset!, library.displayName, tuiRequests); + await verifyMetadataDecisionTui( + baseUrl, + library.displayName, + asset!, + titleIndex, + titleProperties, + ); + const titleAfterTui = ( + await client.listAssetEvidence(asset!.id, { + kind: "current-values", + limit: 100, + }) + ).items.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(titleAfterTui).toEqual(titleBeforeTui); +}, 180_000); + +it("已安装 TUI 取消冲突采纳且保留当前来源候选【安装客户端】", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc09-conflict-tui-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const controlledDirectory = path.join(root, "controlled-source"); + const responseDirectory = path.join(controlledDirectory, "responses"); + await mkdir(controlledDirectory, { recursive: true }); + await cp(movieSourceResponses, responseDirectory, { recursive: true }); + const controlledManifestPath = path.join( + controlledDirectory, + "manifest.json", + ); + await cp(movieSourceManifest, controlledManifestPath); + const nbinetResponsePath = path.join(responseDirectory, "epub-5-nbinet.json"); + const body = JSON.parse(await readFile(nbinetResponsePath, "utf8")) as { + docs: Array<{ pnx: { display: { title: readonly string[] } } }>; + }; + const firstRecord = body.docs[0]; + if (firstRecord === undefined) throw new Error("受控书目资料缺少记录"); + firstRecord.pnx.display.title = ["幼女战记 第一册", "幼女战记·第一册"]; + await writeFile(nbinetResponsePath, JSON.stringify(body)); + + const controlled = await loadControlledSourceIo({ + manifestPath: controlledManifestPath, + }); + const sourceIo: ProductConfiguration["sourceIo"] = { + mode: "controlled", + request: controlled.request, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + const baseUrl = await startInspectionService(productDataDirectory, sourceIo); + const client = createPublicApiClient({ baseUrl }); + const materialDirectory = path.join(root, "普通出版物", "幼女战记(1)"); + await cp( + path.join(movieMaterialFixture, "books", "幼女战记(1)"), + materialDirectory, + { recursive: true }, + ); + const library = await client.saveAssetLibrary({ + displayName: "书目冲突受控变体", + directories: [path.dirname(materialDirectory)], + recognitionProfileIds: ["books"], + sourceIdentities: ["nbinet"], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + const assets = await client.listAssets({ + assetLibraryId: library.id, + limit: 10, + }); + expect(assets.items).toHaveLength(1); + const assetId = assets.items[0]!.id; + const values = await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }); + const title = values.items.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(title?.kind).toBe("current-value"); + if (title?.kind !== "current-value") + throw new Error("受控书目变体没有返回标题资料"); + expect(title.evidence.result.status).toBe("conflict"); + const titleProperties = values.items.map((item) => + item.kind === "current-value" ? item.evidence.property : "", + ); + await verifyConflictCancellationTui( + baseUrl, + library.displayName, + assets.items[0]!, + values.items.findIndex((item) => item === title), + titleProperties, + ); + const titleAfterTui = ( + await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }) + ).items.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(titleAfterTui).toMatchObject({ + kind: "current-value", + evidence: { property: "asset.title", result: { status: "conflict" } }, + }); +}, 120_000); + +it("已安装 TUI 查看正式取消的来源刷新历史【安装客户端】", async () => { + const scenario = await prepareMovieHistoryTuiScenario(); + const { baseUrl, client, libraryId, libraryName } = scenario; + const initialScan = await client.startAssetLibraryScan(libraryId); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(initialScan.parentExecutionId)) + .status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + const assets = await client.listAssets({ + assetLibraryId: libraryId, + limit: 10, + }); + expect(assets.items).toHaveLength(1); + const assetId = assets.items[0]!.id; + const refreshId = await cancelAtSourceRequest( + client, + scenario.gateNextSourceRequest, + async () => (await client.startMetadataRefresh({ subjectId: assetId })).id, + "验证 TUI 刷新取消历史", + ); + const history = await client.listWorkflowHistory({ limit: 100 }); + const historyIndex = history.items.findIndex( + ({ execution }) => execution.id === refreshId, + ); + expect(historyIndex).toBeGreaterThanOrEqual(0); + + await verifyCancelledHistoryTui( + baseUrl, + libraryName, + historyIndex, + "资料更新 · 已取消", + undefined, + ); +}, 180_000); + +it("已安装 TUI 查看正式取消的资产库扫描历史【安装客户端】", async () => { + const scenario = await prepareMovieHistoryTuiScenario(); + const { baseUrl, client, libraryId, libraryName } = scenario; + const cancelledScanId = await cancelAtSourceRequest( + client, + scenario.gateNextSourceRequest, + async () => + (await client.startAssetLibraryScan(libraryId)).parentExecutionId, + "验证 TUI 扫描取消历史", + ); + const history = await client.listWorkflowHistory({ limit: 100 }); + const historyIndex = history.items.findIndex( + ({ execution }) => execution.id === cancelledScanId, + ); + expect(historyIndex).toBeGreaterThanOrEqual(0); + + await verifyCancelledHistoryTui( + baseUrl, + libraryName, + historyIndex, + "资料库扫描 · 已取消", + "目录状态:已取消 1", + ); +}, 180_000); + +async function prepareMovieHistoryTuiScenario(): Promise<{ + readonly baseUrl: URL; + readonly client: ReturnType; + readonly libraryId: string; + readonly libraryName: string; + readonly gateNextSourceRequest: () => { + readonly hasEntered: () => boolean; + readonly release: () => void; + }; +}> { + const root = await mkdtemp(path.join(tmpdir(), "ewc08-history-tui-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const controlledDirectory = path.join(root, "controlled-source"); + await mkdir(controlledDirectory, { recursive: true }); + await cp(movieSourceResponses, path.join(controlledDirectory, "responses"), { + recursive: true, + }); + + const tmdbToken = "controlled-tmdb-history-tui-token"; + const controlled = await loadMovieSourceIo({ + controlledDirectory, + tmdbToken, + omdbKey: "controlled-omdb-history-tui-key", + tmdbTitle: "恐怖直播", + }); + let nextSourceGate: + | { + readonly markEntered: () => void; + readonly released: Promise; + readonly release: () => void; + } + | undefined; + const sourceIo: ProductConfiguration["sourceIo"] = { + mode: "controlled", + request: async (input, init) => { + const gate = nextSourceGate; + if (gate !== undefined) { + nextSourceGate = undefined; + gate.markEntered(); + await gate.released; + } + return await controlled.request(input, init); + }, + sessionTransport: refusingSessionTransport(), + }; + const baseUrl = await startInspectionService(productDataDirectory, sourceIo); + const client = createPublicApiClient({ baseUrl }); + await expect( + client.configureSourceConnection({ + sourceId: "tmdb", + expectedVersion: 0, + secret: tmdbToken, + }), + ).resolves.toMatchObject({ verification: "verified" }); + + const materialDirectory = path.join(root, "普通影视目录", "恐怖直播 (2013)"); + await cp( + path.join(movieMaterialFixture, "films", "恐怖直播 (2013)"), + materialDirectory, + { recursive: true }, + ); + const library = await client.saveAssetLibrary({ + displayName: "取消验证影视库", + directories: [path.dirname(materialDirectory)], + recognitionProfileIds: ["movies"], + sourceIdentities: ["tmdb"], + }); + + return { + baseUrl, + client, + libraryId: library.id, + libraryName: library.displayName, + gateNextSourceRequest: () => { + let requestEntered = false; + let releaseRequest!: () => void; + const released = new Promise((resolve) => { + releaseRequest = resolve; + }); + nextSourceGate = { + markEntered: () => { + requestEntered = true; + }, + released, + release: releaseRequest, + }; + return { + hasEntered: () => requestEntered, + release: () => { + nextSourceGate = undefined; + releaseRequest(); + }, + }; + }, + }; +} + +async function cancelAtSourceRequest( + client: ReturnType, + gateNextSourceRequest: () => { + readonly hasEntered: () => boolean; + readonly release: () => void; + }, + start: () => Promise, + reason: string, +): Promise { + const gate = gateNextSourceRequest(); + let executionId: string | undefined; + try { + executionId = await start(); + await expect + .poll(async () => gate.hasEntered(), { timeout: 10_000 }) + .toBe(true); + await expect( + client.cancelExecution(executionId, { reason }), + ).resolves.toMatchObject({ requestStatus: "requested" }); + } finally { + gate.release(); + } + if (executionId === undefined) throw new Error("取消验收没有创建执行"); + await expect + .poll(async () => (await client.inspectExecution(executionId!)).outcome, { + timeout: 120_000, + interval: 200, + }) + .toBe("cancelled"); + return executionId; +} + +async function startInspectionService( + productDataDirectory: string, + sourceIo: ProductConfiguration["sourceIo"], + requestLog?: string[], +): Promise { + await prepareProductionCatalogue({ productDataDirectory }); + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }); + const server = serve({ + fetch: + requestLog === undefined + ? bootstrap.app.fetch + : async (request) => { + requestLog.push(new URL(request.url).pathname); + return await bootstrap.app.fetch(request); + }, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + running.push({ server, abort: () => bootstrap.abort() }); + await server.ready(); + bootstrap.publish(); + return new URL(server.url!); +} + +async function verifyCancelledHistoryTui( + baseUrl: URL, + libraryName: string, + historyIndex: number, + historyLabel: string, + childSummary?: string, +): Promise { + const terminal = startAssetInspectionTerminal(baseUrl, 58, 16, 15_000); + await terminal.waitForOutput(libraryName); + if (historyIndex === 0) { + await terminal.sendAndWait("6", ["工作流历史", `› ${historyLabel}`]); + } else { + await terminal.sendAndWait("6", ["工作流历史", "› "]); + for (let index = 1; index <= historyIndex; index++) + await terminal.sendAndWait( + "j", + index === historyIndex ? `› ${historyLabel}` : "› ", + ); + } + const detailMarkers = ["执行详情", "已取消", historyLabel]; + if (childSummary !== undefined) detailMarkers.push(childSummary); + await terminal.sendAndWait("v", detailMarkers); + const terminalOutput = await finishInstalledTui(terminal); + expect(terminalOutput).toContain("工作流历史"); + expect(terminalOutput).toContain("执行详情"); + expect(terminalOutput).toContain("已取消"); + expect(terminalOutput).toContain(historyLabel); + if (childSummary !== undefined) + expect(terminalOutput).toContain(childSummary); + expect(terminalOutput).not.toContain("资料更新 · 遇到问题"); + expect(terminalOutput).not.toContain("遇到问题 · 失败"); + expect(terminalOutput).not.toContain("失败 · 已停止"); + expect(terminalOutput).not.toContain("已停止"); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + expect(terminalOutput).not.toContain("TERMINAL_NOT_RESTORED"); +} + +async function verifyInstalledTui( + baseUrl: URL, + asset: PublicCurrentAsset, + libraryName: string, + requestLog: string[], +): Promise { + const assetId = asset.id; + const client = createPublicApiClient({ baseUrl }); + const sourcePage = await client.listAssetEvidence(assetId, { + kind: "source-records", + limit: 24, + }); + const sourceRecords = sourcePage.items.filter( + (item) => item.kind === "source-record", + ); + if (sourceRecords.length === 0) + throw new Error("安装 TUI 检查样例没有来源记录"); + const sourceIndex = Math.min(2, sourceRecords.length - 1); + const sourceRecord = sourceRecords[sourceIndex]; + if (sourceRecord === undefined) + throw new Error("安装 TUI 检查样例缺少目标来源记录"); + const sourceAddress = sourceRecord.nativeAddress ?? "来源资料"; + const sourceAddressMarker = sourceAddress.match(/RJ\d+/u)?.[0] ?? "来源资料"; + const sourceChunkLimit = 40; + const firstChunk = await client.readAssetSourceRecord( + assetId, + sourceRecord.id, + { offset: 0, limit: sourceChunkLimit }, + ); + const nextChunk = firstChunk.complete + ? undefined + : await client.readAssetSourceRecord(assetId, sourceRecord.id, { + offset: firstChunk.nextOffset, + limit: sourceChunkLimit, + }); + + const terminal = startAssetInspectionTerminal(baseUrl, 34, 10, 30_000); + await enterAssetLibrary(terminal, libraryName, asset); + await terminal.sendAndWait("i", ["资产资料检查", "› 本地观察:"]); + await terminal.sendAndWait("j", "› 格式读取结果:"); + await terminal.sendAndWait("j", "› 来源记录:"); + await terminal.sendAndWait("\r", ["来源记录 ·", "› "]); + for (let index = 0; index < sourceIndex; index++) + await terminal.sendAndWait("j", "› "); + const sourceChunkMarker = (chunk: { + readonly offset: number; + readonly nextOffset: number; + }): string => `来源资料 ${chunk.offset + 1}–${chunk.nextOffset} 字符`; + await terminal.sendAndWait("\r", [ + `来源:`, + sourceAddressMarker, + sourceChunkMarker(firstChunk), + ]); + if (nextChunk !== undefined) { + await terminal.sendAndWait("n", sourceChunkMarker(nextChunk)); + await terminal.sendAndWait("p", sourceChunkMarker(firstChunk)); + } + await terminal.sendAndWait("\x1b", ["来源记录 ·", "› "]); + await terminal.sendAndWait("\x1b", "› 来源记录:"); + await terminal.sendAndWait("\x1b", "i/Enter检查"); + + const attention = await client.listCurrentAttention({ limit: 24 }); + const attentionMarker = attention.items[0]?.summary ?? "目前没有待处理事项"; + await terminal.sendAndWait("5", ["待处理事项", attentionMarker]); + if (attention.nextCursor !== undefined) { + const nextAttention = await client.listCurrentAttention({ + limit: 24, + cursor: attention.nextCursor, + }); + const nextAttentionMarker = + nextAttention.items[0]?.summary ?? "目前没有待处理事项"; + await terminal.sendAndWait("n", ["待处理事项", nextAttentionMarker]); + } + await terminal.sendAndWait("\x1b", "i/Enter检查"); + + const history = await client.listWorkflowHistory({ limit: 24 }); + const selectedHistoryIndex = Math.min(1, history.items.length - 1); + const selectedExecutionId = history.items[selectedHistoryIndex]?.execution.id; + if (selectedExecutionId === undefined) + throw new Error("安装 TUI 检查样例没有工作流历史"); + const requestStart = requestLog.length; + await terminal.sendAndWait("6", ["工作流历史", "› "]); + if (history.items.length > 1) await terminal.sendAndWait("j", "› "); + await terminal.sendAndWait("v", "执行详情"); + expect(requestLog.slice(requestStart)).toContain( + `/api/v1/executions/${selectedExecutionId}`, + ); + await terminal.sendAndWait("\x1b", ["工作流历史", "› "]); + await terminal.sendAndWait("\x1b", "i/Enter检查"); + const terminalOutput = await finishInstalledTui(terminal); + const visibleOutput = terminalOutput + .replaceAll(String.fromCharCode(27), "") + .replace(/\[[0-?]*[ -/]*[@-~]/gu, "") + .replace(/[\r\n]/gu, ""); + expect(terminalOutput).toContain("资产资料检查"); + expect(terminalOutput).toContain("来源记录"); + expect(terminalOutput).toContain("工作流历史"); + expect(terminalOutput).toContain("执行详情"); + expect(visibleOutput).not.toContain(assetId); + expect(visibleOutput).not.toMatch(/\b(?:entity|execution)_[a-f\d-]{36}\b/u); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + expect(terminalOutput).not.toContain("TERMINAL_NOT_RESTORED"); +} + +async function verifyMetadataDecisionTui( + baseUrl: URL, + libraryName: string, + asset: PublicCurrentAsset, + titleIndex: number, + titleProperties: readonly string[], +): Promise { + const terminal = startAssetInspectionTerminal(baseUrl, 48, 16, 30_000); + await enterAssetLibrary(terminal, libraryName, asset); + await openCurrentValuesTitle(terminal, titleIndex, titleProperties); + await terminal.sendAndWait("m", "asset.title 新值:"); + await terminal.sendAndWait("终端取消标题", "终端取消标题"); + await terminal.sendAndWait("\r", "y 确认 · n 取消"); + await terminal.sendAndWait("n", "已取消,没有修改资料"); + await terminal.sendAndWait("\r", "› asset.title"); + + await terminal.sendAndWait("m", "asset.title 新值:"); + await terminal.sendAndWait("终端修订标题", "终端修订标题"); + await terminal.sendAndWait("\r", "y 确认 · n 取消"); + await terminal.sendAndWait("y", "已确认"); + await terminal.sendAndWait("\r", `› ${titleProperties[0]}`); + await moveCurrentValueSelection(terminal, titleIndex, titleProperties); + + await terminal.sendAndWait("x", "撤回修订"); + await terminal.sendAndWait("y", ["已确认", "撤回修订"]); + await terminal.sendAndWait("\r", `› ${titleProperties[0]}`); + const output = await finishInstalledTui(terminal); + expect(output).toContain("资产资料检查"); + expect(output).toContain("终端取消标题"); + expect(output).toContain("已取消,没有修改资料"); + expect(output).toContain("终端修订标题"); + expect(output).toContain("已确认"); + expect(output).toContain("确认后固定人工输入值"); + expect(output).toContain("撤回修订"); + expect(output).toContain("TERMINAL_RESTORED"); + expect(output).not.toContain("TERMINAL_NOT_RESTORED"); +} + +async function verifyConflictCancellationTui( + baseUrl: URL, + libraryName: string, + asset: PublicCurrentAsset, + titleIndex: number, + titleProperties: readonly string[], +): Promise { + const terminal = startAssetInspectionTerminal(baseUrl, 48, 16, 30_000); + await enterAssetLibrary(terminal, libraryName, asset); + await openCurrentValuesTitle(terminal, titleIndex, titleProperties); + await terminal.sendAndWait("a", [ + "选择要固定的冲突值", + "幼女战记 第一册", + "幼女战记·第一册", + ]); + await terminal.sendAndWait("\r", "y 确认 · n 取消"); + await terminal.sendAndWait("n", "已取消,没有修改资料"); + await terminal.sendAndWait("\r", `› ${titleProperties[titleIndex]}`); + await terminal.sendAndWait("\x1b", "› 当前采用值及依据:"); + await terminal.sendAndWait("\x1b", "› "); + const output = await finishInstalledTui(terminal); + expect(output).toContain("存在冲突"); + expect(output).toContain("幼女战记 第一册"); + expect(output).toContain("幼女战记·第一册"); + expect(output).toContain("已取消,没有修改资料"); + expect(output).toContain("TERMINAL_RESTORED"); + expect(output).not.toContain("TERMINAL_NOT_RESTORED"); +} + +function startAssetInspectionTerminal( + baseUrl: URL, + columns: number, + rows: number, + timeoutMs: number, +): InteractiveTerminalSession { + const terminalCommand = `stty cols ${columns} rows ${rows} -icanon -echo; terminalModeBefore=$(stty -g); pnpm --filter @digital/service exec digital-tui --url ${shellQuote(baseUrl.origin)}; terminalModeAfter=$(stty -g); if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi`; + const terminal = startInteractiveTerminal(terminalCommand, { + cwd: serviceRoot, + columns, + rows, + timeoutMs, + }); + children.push(terminal.child); + return terminal; +} + +async function enterAssetLibrary( + terminal: InteractiveTerminalSession, + libraryName: string, + asset: PublicCurrentAsset, +): Promise { + await terminal.waitForOutput(libraryName); + await terminal.sendAndWait("/", "筛选本页:"); + await terminal.sendAndWait(libraryName, [libraryName, "筛选本页:"]); + await terminal.sendAndWait("\r", "(1/1)"); + await terminal.sendAndWait("\r", ["›2 资产", selectedAssetRowMarker(asset)]); +} + +function selectedAssetRowMarker(asset: PublicCurrentAsset): string { + const title = asset.descriptions + .filter( + (description) => + description.target.kind === "entity" && + description.target.entityId === asset.id, + ) + .flatMap((description) => description.metadata) + .find( + (metadata) => + metadata.property === "asset.title" && + metadata.result.status === "resolved", + )?.result; + const name = + (title?.status === "resolved" ? title.values[0]?.value : undefined) ?? + asset.relatedEntities.find((entity) => entity.id === asset.id)?.label ?? + "未命名资产"; + return `› ${Array.from(name).slice(0, 6).join("")}`; +} + +async function openCurrentValuesTitle( + terminal: InteractiveTerminalSession, + titleIndex: number, + titleProperties: readonly string[], +): Promise { + await terminal.sendAndWait("i", ["资产资料检查", "› 本地观察:"]); + await terminal.sendAndWait("j", "› 格式读取结果:"); + await terminal.sendAndWait("j", "› 来源记录:"); + await terminal.sendAndWait("j", "› 解释事实:"); + await terminal.sendAndWait("j", "› 当前采用值及依据:"); + const firstProperty = titleProperties[0]; + if (firstProperty === undefined || firstProperty.length === 0) + throw new Error("当前值资料没有可用于选择的属性"); + await terminal.sendAndWait("\r", [ + "当前采用值及依据 ·", + `› ${firstProperty}`, + ]); + await moveCurrentValueSelection(terminal, titleIndex, titleProperties); +} + +async function moveCurrentValueSelection( + terminal: InteractiveTerminalSession, + titleIndex: number, + titleProperties: readonly string[], +): Promise { + const targetProperty = titleProperties[titleIndex]; + if (targetProperty === undefined || targetProperty.length === 0) + throw new Error("当前值资料中没有目标属性"); + for (let index = 1; index <= titleIndex; index++) { + const property = titleProperties[index]; + if (property === undefined || property.length === 0) + throw new Error(`当前值资料缺少第 ${index + 1} 个属性`); + await terminal.sendAndWait("j", `› ${property}`); + } +} + +async function finishInstalledTui( + terminal: InteractiveTerminalSession, +): Promise { + await terminal.sendAndWait("q", "TERMINAL_RESTORED"); + const exitCode = await terminal.closeAndWait(); + expect(exitCode, `${terminal.errors}\n${terminal.output}`).toBe(0); + expect(terminal.output).toContain("TERMINAL_RESTORED"); + expect(terminal.output).not.toContain("TERMINAL_NOT_RESTORED"); + return terminal.output; +} + +async function loadMovieSourceIo(input: { + readonly controlledDirectory: string; + readonly tmdbToken: string; + readonly omdbKey: string; + readonly tmdbTitle: string; +}) { + const responseDirectory = path.join(input.controlledDirectory, "responses"); + const movieResponsePath = path.join( + responseDirectory, + "tmdb-movie-209764.json", + ); + const movieResponse = JSON.parse( + await readFile(movieResponsePath, "utf8"), + ) as Record; + await writeFile( + movieResponsePath, + JSON.stringify({ ...movieResponse, title: input.tmdbTitle }), + ); + + const manifest = JSON.parse(await readFile(movieSourceManifest, "utf8")) as { + readonly formatVersion: number; + readonly ordinary: readonly Record[]; + }; + const manifestPath = path.join(input.controlledDirectory, "manifest.json"); + await writeFile( + manifestPath, + JSON.stringify({ + formatVersion: manifest.formatVersion, + ordinary: manifest.ordinary.map((entry) => ({ + ...entry, + ...(entry.id === "tmdb-auth" + ? { + require: { + headers: { + Authorization: `Bearer ${input.tmdbToken}`, + }, + }, + } + : {}), + ...(typeof entry.url === "string" && + entry.url.includes("synthetic-omdb-key") + ? { url: entry.url.replaceAll("synthetic-omdb-key", input.omdbKey) } + : {}), + })), + }), + ); + return await loadControlledSourceIo({ manifestPath }); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function syntheticPdf(input: { + readonly author: string; + readonly pageCount: number; +}): Uint8Array { + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + `<< /Type /Pages /Kids [${Array.from({ length: input.pageCount }, (_, index) => `${index + 3} 0 R`).join(" ")}] /Count ${input.pageCount} >>`, + ...Array.from( + { length: input.pageCount }, + () => "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>", + ), + `<< /Author (${input.author}) >>`, + ]; + let source = "%PDF-1.7\n"; + const offsets = [0]; + for (const [index, object] of objects.entries()) { + offsets.push(new TextEncoder().encode(source).byteLength); + source += `${index + 1} 0 obj\n${object}\nendobj\n`; + } + const xref = new TextEncoder().encode(source).byteLength; + source += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + source += offsets + .slice(1) + .map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`) + .join(""); + source += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R /Info ${objects.length + 1} 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return new TextEncoder().encode(source); +} diff --git a/apps/service/test/automation-http.test.ts b/apps/service/test/automation-http.test.ts new file mode 100644 index 0000000..c282182 --- /dev/null +++ b/apps/service/test/automation-http.test.ts @@ -0,0 +1,920 @@ +import { spawn } from "node:child_process"; +import { cp, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { runProduct } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { createPublicHttpApi } from "@digital/http-delivery"; +import type { + ProductApplication, + ProductAutomationHostOptions, +} from "@digital/product-application"; +import { createPublicApiClient } from "@digital/public-api"; +import { + loadControlledSourceIo, + refusingSessionTransport, + resolveSourceIoFromEnvironment, +} from "@digital/test-support/source-io"; +import { Hono } from "hono"; +import { serve } from "srvx"; +import { afterEach, expect, it } from "vitest"; + +import { createProductService } from "../src/service.ts"; +import { interactiveTerminalEnvironment } from "./interactive-terminal.ts"; + +const servers: Array>> = []; +const bootstraps: Array>> = []; +type TestProduct = ReturnType>; +const products: TestProduct[] = []; +const temporaryRoots: string[] = []; +const terminals: Array> = []; +const sourceGateReleases: Array<() => void> = []; +const servicePackageRoot = fileURLToPath(new URL("../", import.meta.url)); +const audioMaterialRoot = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/material"), +); +const audioManifestPath = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/manifest.json"), +); + +afterEach(async () => { + for (const release of sourceGateReleases.splice(0).reverse()) release(); + for (const terminal of terminals.splice(0).reverse()) { + if (terminal.pid !== undefined && terminal.exitCode === null) { + try { + process.kill(-terminal.pid, "SIGTERM"); + } catch { + terminal.kill("SIGTERM"); + } + } + } + for (const server of servers.splice(0).reverse()) await server.close(true); + for (const bootstrap of bootstraps.splice(0).reverse()) + await bootstrap.abort(); + for (const product of products.splice(0).reverse()) await product.close(); + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); +}); + +it("通过公共 HTTP 查询持久自动刷新配置", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc18-automation-http-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + await prepareProductionCatalogue({ productDataDirectory }); + const sourceIo = await resolveSourceIoFromEnvironment({ + DIGITAL_SOURCE_MODE: "offline", + }); + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }); + bootstraps.push(bootstrap); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + servers.push(server); + await server.ready(); + bootstrap.publish(); + const baseUrl = new URL(server.url!); + + await expect + .poll(async () => (await fetch(new URL("/api/ready", baseUrl))).status) + .toBe(200); + const client = createPublicApiClient({ baseUrl }); + await expect(client.listAutomationIntents()).resolves.toEqual({ items: [] }); +}); + +it("将未知自动刷新查询故障转成不泄漏内部错误的 503", async () => { + const api = createPublicHttpApi({ + workflow: { + listAutomationIntents: async () => { + throw new Error("sensitive sqlite detail"); + }, + }, + } as never); + const response = await api.request("/automation-intents"); + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: { + code: "unavailable", + message: "暂时无法读取自动刷新设置", + }, + }); +}); + +it("客户端退出并重启服务后保留自动刷新设置,按到期策略执行且停用后不再自动运行", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc18-due-automation-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const observedDirectory = path.join(root, "普通目录"); + await mkdir(observedDirectory); + await prepareProductionCatalogue({ productDataDirectory }); + const sourceIo = await resolveSourceIoFromEnvironment({ + DIGITAL_SOURCE_MODE: "offline", + }); + const now = { value: new Date() }; + const firstClock = controlledAutomationClock(now); + const firstProduct = runProduct( + { + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }, + { start: (application) => application }, + ); + products.push(firstProduct); + await firstProduct.result.startAutomation({ + clock: firstClock.clock, + pollIntervalMilliseconds: 1_000, + }); + const firstServer = await startProductApi(firstProduct); + const firstClient = createPublicApiClient({ baseUrl: firstServer.baseUrl }); + const options = await firstClient.listAssetLibraryOptions(); + const library = await firstClient.saveAssetLibrary({ + displayName: "自动刷新验收库", + directories: [observedDirectory], + recognitionProfileIds: options.recognitionProfiles.map(({ id }) => id), + sourceIdentities: [], + }); + const directory = library.directories.find( + ({ status }) => status === "active", + ); + if (directory === undefined) throw new Error("资产库没有返回活动目录"); + const automationUrl = new URL( + "api/v1/automation-intents", + firstServer.baseUrl, + ); + const invalidRequest = await fetch(automationUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + assetLibraryId: library.id, + directoryId: directory.id, + intervalMilliseconds: 0, + acceptTrusted: true, + }), + }); + expect(invalidRequest.status).toBe(400); + await expect(invalidRequest.json()).resolves.toMatchObject({ + error: { code: "invalid-request" }, + }); + const staleDirectory = await fetch(automationUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + assetLibraryId: library.id, + directoryId: "已撤销的目录授权", + intervalMilliseconds: 60_000, + acceptTrusted: true, + }), + }); + expect(staleDirectory.status).toBe(409); + await expect(staleDirectory.json()).resolves.toMatchObject({ + error: { code: "conflict" }, + }); + for (const relativeScope of ["../outside", "/tmp"]) { + const invalidScope = await fetch(automationUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + assetLibraryId: library.id, + directoryId: directory.id, + relativeScope, + intervalMilliseconds: 60_000, + acceptTrusted: true, + }), + }); + expect(invalidScope.status).toBe(400); + await expect(invalidScope.json()).resolves.toEqual({ + error: { + code: "invalid-request", + message: "自动刷新设置无效", + }, + }); + } + await expect(firstClient.listAutomationIntents()).resolves.toEqual({ + items: [], + }); + const enabled = await firstClient.enableAutomationIntent({ + assetLibraryId: library.id, + directoryId: directory.id, + intervalMilliseconds: 60_000, + acceptTrusted: true, + }); + expect(enabled.intent).toMatchObject({ + scope: { + assetLibraryId: library.id, + directoryId: directory.id, + relativeScope: ".", + }, + enabled: true, + intervalMilliseconds: 60_000, + acceptTrusted: true, + }); + expect(enabled.initial?.status).toBe("started"); + const initial = enabled.initial; + if (initial?.status !== "started") + throw new Error("首次自动刷新没有返回执行身份"); + const initialExecutionId = initial.executionId; + await expect + .poll(async () => await firstClient.inspectExecution(initialExecutionId)) + .toMatchObject({ + kind: "asset-library-scan", + id: initialExecutionId, + status: "completed", + }); + const initialHistory = await firstClient.listWorkflowHistory({ limit: 100 }); + const initialIds = initialHistory.items.map(({ execution }) => execution.id); + expect(initialIds).toContain(initialExecutionId); + expect( + initialHistory.items.some( + ({ execution }) => + execution.id === initialExecutionId && + execution.automationIntentId === enabled.intent.id, + ), + ).toBe(true); + + await closeProductApi(firstServer); + await closeProduct(firstProduct); + + const secondClock = controlledAutomationClock(now); + const secondProduct = runProduct( + { + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }, + { start: (application) => application }, + ); + products.push(secondProduct); + await secondProduct.result.startAutomation({ + clock: secondClock.clock, + pollIntervalMilliseconds: 1_000, + }); + const secondServer = await startProductApi(secondProduct); + const secondClient = createPublicApiClient({ baseUrl: secondServer.baseUrl }); + await expect(secondClient.listAutomationIntents()).resolves.toMatchObject({ + items: [ + expect.objectContaining({ + id: enabled.intent.id, + enabled: true, + intervalMilliseconds: 60_000, + }), + ], + }); + const historyAfterRestart = await secondClient.listWorkflowHistory({ + limit: 100, + }); + expect( + historyAfterRestart.items.map(({ execution }) => execution.id).toSorted(), + ).toEqual(initialIds.toSorted()); + + now.value = new Date(now.value.getTime() + 61_000); + secondClock.tick(); + await secondProduct.result.automation.settled(); + const dueHistory = await secondClient.listWorkflowHistory({ limit: 100 }); + expect(dueHistory.items).toHaveLength(initialHistory.items.length + 2); + const dueExecutionId = dueHistory.items + .map(({ execution }) => execution) + .find( + (execution) => + execution.operationKind === "scan-asset-library" && + !initialIds.includes(execution.id) && + execution.automationIntentId === enabled.intent.id, + )?.id; + if (dueExecutionId === undefined) + throw new Error("到期自动刷新没有新增执行记录"); + await expect + .poll(async () => await secondClient.inspectExecution(dueExecutionId)) + .toMatchObject({ + kind: "asset-library-scan", + id: dueExecutionId, + status: "completed", + }); + + await expect( + secondClient.disableAutomationIntent(enabled.intent.id), + ).resolves.toEqual({ status: "disabled" }); + await expect(secondClient.listAutomationIntents()).resolves.toMatchObject({ + items: [expect.objectContaining({ id: enabled.intent.id, enabled: false })], + }); + const historyAfterDisable = await secondClient.listWorkflowHistory({ + limit: 100, + }); + now.value = new Date(now.value.getTime() + 61_000); + secondClock.tick(); + await secondProduct.result.automation.settled(); + const afterDisabledTick = await secondClient.listWorkflowHistory({ + limit: 100, + }); + expect( + afterDisabledTick.items.map(({ execution }) => execution.id).toSorted(), + ).toEqual( + historyAfterDisable.items.map(({ execution }) => execution.id).toSorted(), + ); + const manual = await secondClient.triggerAutomationIntent(enabled.intent.id); + expect(manual.status).toBe("started"); + if (manual.status !== "started") throw new Error("立即刷新没有开始"); + await expect + .poll(async () => await secondClient.inspectExecution(manual.executionId)) + .toMatchObject({ + kind: "asset-library-scan", + id: manual.executionId, + status: "completed", + }); +}, 60_000); + +it("公共 HTTP 自动刷新沿用冻结的产品信息需求,并在客户端断开后完成受控来源刷新", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc18-automation-product-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const observedDirectory = path.join(root, "RJ01668691"); + await cp(path.join(audioMaterialRoot, "RJ01668691"), observedDirectory, { + recursive: true, + }); + await prepareProductionCatalogue({ productDataDirectory }); + const controlled = await loadControlledSourceIo({ + manifestPath: audioManifestPath, + }); + const requested: string[] = []; + const productInfoUrl = + "https://www.dlsite.com/maniax/product/info/ajax?product_id=RJ01668691"; + let pendingManualRequest: + | { + readonly entered: () => void; + readonly release: Promise; + } + | undefined; + let releaseCover!: () => void; + const coverGate = new Promise((resolve) => { + releaseCover = resolve; + }); + sourceGateReleases.push(releaseCover); + let markCoverRequested!: () => void; + const coverRequested = new Promise((resolve) => { + markCoverRequested = resolve; + }); + const coverUrl = + "https://img.dlsite.jp/modpub/images2/work/doujin/RJ01669000/RJ01668691_img_main.jpg"; + const sourceIo = { + mode: "controlled" as const, + async request( + url: string, + init?: Parameters[1], + ) { + requested.push(url); + if (url === coverUrl) { + markCoverRequested(); + await coverGate; + } + if (url === productInfoUrl && pendingManualRequest !== undefined) { + const gate = pendingManualRequest; + pendingManualRequest = undefined; + gate.entered(); + await gate.release; + } + return await controlled.request(url, init); + }, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + const product = runProduct( + { + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }, + { start: (application) => application }, + ); + products.push(product); + await product.result.startAutomation({ pollIntervalMilliseconds: 1_000 }); + const firstServer = await startProductApi(product); + const firstClient = createPublicApiClient({ baseUrl: firstServer.baseUrl }); + const options = await firstClient.listAssetLibraryOptions(); + expect(options.recognitionProfiles.map(({ id }) => id)).toContain( + "dlsite-voice", + ); + expect(options.sources.map(({ id }) => id)).toContain("dlsite"); + const library = await firstClient.saveAssetLibrary({ + displayName: "产品需求自动刷新验收库", + directories: [observedDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + const directory = library.directories[0]; + if (directory === undefined) throw new Error("自动刷新验收库没有目录"); + const enabled = await firstClient.enableAutomationIntent({ + assetLibraryId: library.id, + directoryId: directory.id, + intervalMilliseconds: 60_000, + acceptTrusted: true, + }); + expect(enabled.initial?.status).toBe("started"); + if (enabled.initial?.status !== "started") + throw new Error("自动刷新没有启动正式产品扫描"); + const executionId = enabled.initial.executionId; + + await coverRequested; + await closeProductApi(firstServer); + releaseCover(); + await expect + .poll(async () => await product.result.workflow.inspect(executionId), { + timeout: 30_000, + }) + .toMatchObject({ execution: { status: "completed", outcome: "accepted" } }); + + const secondServer = await startProductApi(product); + const secondClient = createPublicApiClient({ baseUrl: secondServer.baseUrl }); + await expect + .poll(async () => await secondClient.inspectExecution(executionId)) + .toMatchObject({ status: "completed", outcome: "accepted" }); + const page = await secondClient.listAssets({ + assetLibraryId: library.id, + limit: 100, + }); + const asset = page.items.find(({ sourceAssociations }) => + sourceAssociations.some( + ({ sourceIdentity, nativeAddress }) => + sourceIdentity === "dlsite" && nativeAddress.includes("RJ01668691"), + ), + ); + expect(asset).toMatchObject({ + sourceArtifacts: expect.arrayContaining([ + expect.objectContaining({ role: "asset.primary-cover" }), + ]), + descriptions: expect.arrayContaining([ + expect.objectContaining({ + metadata: expect.arrayContaining([ + expect.objectContaining({ + property: "asset.title", + result: expect.objectContaining({ status: "resolved" }), + }), + ]), + }), + ]), + }); + expect(requested).toContain(coverUrl); + expect(requested).toContain(productInfoUrl); + + let releaseManualRequest!: () => void; + const manualRequestGate = new Promise((resolve) => { + releaseManualRequest = resolve; + }); + sourceGateReleases.push(releaseManualRequest); + let markManualRequest!: () => void; + let manualRequestWasEntered = false; + const manualRequestEntered = new Promise((resolve) => { + markManualRequest = () => { + manualRequestWasEntered = true; + resolve(); + }; + }); + pendingManualRequest = { + entered: markManualRequest, + release: manualRequestGate, + }; + const manualTrigger = secondClient.triggerAutomationIntent(enabled.intent.id); + let manualExecutionId: string | undefined; + try { + const triggerRace = await Promise.race([ + manualTrigger.then((result) => ({ result })), + manualRequestEntered.then(() => ({ blocked: true as const })), + ]); + if ("blocked" in triggerRace) { + releaseManualRequest(); + await manualTrigger; + throw new Error("手动自动刷新等待产品执行完成后才返回执行身份"); + } + expect(triggerRace.result.status).toBe("started"); + if (triggerRace.result.status !== "started") + throw new Error("手动自动刷新没有返回执行身份"); + manualExecutionId = triggerRace.result.executionId; + await expect.poll(() => manualRequestWasEntered).toBe(true); + await expect + .poll(async () => await secondClient.inspectExecution(manualExecutionId!)) + .toMatchObject({ + id: manualExecutionId, + kind: "asset-library-scan", + }); + } finally { + releaseManualRequest(); + } + if (manualExecutionId === undefined) + throw new Error("手动自动刷新没有返回执行身份"); + await expect + .poll(async () => await secondClient.inspectExecution(manualExecutionId!)) + .toMatchObject({ status: "completed" }); +}, 60_000); + +it("已安装终端可配置、查询和触发自动刷新,退出不停止产品服务【安装客户端】", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc18-automation-tui-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const observedDirectory = path.join(root, "普通目录"); + await mkdir(path.join(observedDirectory, "n"), { recursive: true }); + await mkdir(path.join(observedDirectory, "m"), { recursive: true }); + await prepareProductionCatalogue({ productDataDirectory }); + const sourceIo = await resolveSourceIoFromEnvironment({ + DIGITAL_SOURCE_MODE: "offline", + }); + const product = runProduct( + { + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }, + { start: (application) => application }, + ); + products.push(product); + await product.result.startAutomation({ pollIntervalMilliseconds: 1_000 }); + const service = await startProductApi(product); + const client = createPublicApiClient({ baseUrl: service.baseUrl }); + const options = await client.listAssetLibraryOptions(); + const library = await client.saveAssetLibrary({ + displayName: "终端验收库", + directories: [observedDirectory], + recognitionProfileIds: options.recognitionProfiles.map(({ id }) => id), + sourceIdentities: [], + }); + + const terminalCommand = `stty cols 80 rows 24 -icanon -echo; pnpm --filter @digital/tui exec digital-tui --url ${service.baseUrl.origin}; printf '\\nTUI_EXITED\\n'`; + const terminal = spawn("script", ["-qfec", terminalCommand, "/dev/null"], { + cwd: servicePackageRoot, + env: interactiveTerminalEnvironment({ + TERM: "xterm-256color", + COLUMNS: "80", + LINES: "24", + }), + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + }); + terminals.push(terminal); + const output: Buffer[] = []; + terminal.stdout.on("data", (chunk: Buffer) => output.push(chunk)); + terminal.stderr.on("data", (chunk: Buffer) => output.push(chunk)); + const terminalText = () => Buffer.concat(output).toString("utf8"); + + await waitForTerminalText( + terminal, + terminalText, + () => terminalText().includes(library.displayName), + "TUI 未显示正式资产库", + ); + terminal.stdin.write("a"); + await waitForTerminalText( + terminal, + terminalText, + () => terminalText().includes("自动刷新:"), + "TUI 未打开自动刷新设置", + ); + terminal.stdin.write("i"); + await waitForTerminalText( + terminal, + terminalText, + () => terminalText().includes("新设置:6 小时"), + "TUI 未切换自动刷新间隔", + ); + terminal.stdin.write("t"); + await waitForTerminalText( + terminal, + terminalText, + () => terminalText().includes("信任结果:需审阅"), + "TUI 未切换自动刷新信任策略", + ); + terminal.stdin.write("f"); + await waitForTerminalText( + terminal, + terminalText, + () => terminalText().includes("编辑中:."), + "TUI 未打开扫描范围编辑", + ); + const scopeEditOutputStart = terminalText().length; + terminal.stdin.write("\u0015"); + await waitForTerminalText( + terminal, + terminalText, + () => terminalText().slice(scopeEditOutputStart).includes("编辑中:"), + "TUI 未清空相对扫描范围输入", + ); + terminal.stdin.write("n"); + await waitForTerminalText( + terminal, + terminalText, + () => terminalText().includes("编辑中:n"), + "TUI 未读取相对扫描范围输入", + ); + terminal.stdin.write("\r"); + await waitForTerminalText( + terminal, + terminalText, + () => terminalText().includes("扫描范围(相对目录):n"), + "TUI 未保存相对扫描范围", + ); + terminal.stdin.write("e"); + await expect + .poll(async () => await client.listAutomationIntents(), { + timeout: 15_000, + }) + .toMatchObject({ + items: [ + expect.objectContaining({ + scope: expect.objectContaining({ + assetLibraryId: library.id, + relativeScope: "n", + }), + enabled: true, + intervalMilliseconds: 21_600_000, + acceptTrusted: false, + }), + ], + }); + const configured = (await client.listAutomationIntents()).items[0]; + if (configured === undefined) throw new Error("终端没有创建自动刷新设置"); + const initialHistory = await client.listWorkflowHistory({ limit: 100 }); + const initialExecutionIds = initialHistory.items.map( + ({ execution }) => execution.id, + ); + + const outputBeforeTrigger = terminalText().length; + terminal.stdin.write("s"); + let triggered: string | undefined; + await expect + .poll( + async () => { + const history = await client.listWorkflowHistory({ limit: 100 }); + triggered = history.items.find( + ({ execution }) => + execution.operationKind === "scan-asset-library" && + execution.automationIntentId === configured.id && + !initialExecutionIds.includes(execution.id), + )?.execution.id; + return triggered; + }, + { timeout: 15_000 }, + ) + .toBeDefined(); + if (triggered === undefined) throw new Error("终端没有触发立即刷新"); + await expect + .poll(async () => await client.inspectExecution(triggered!), { + timeout: 15_000, + }) + .toMatchObject({ kind: "asset-library-scan", status: "completed" }); + await waitForTerminalText( + terminal, + terminalText, + () => + terminalText().slice(outputBeforeTrigger).includes("当前执行:无变更"), + "TUI 未显示自动刷新实际结局", + ); + terminal.stdin.write("d"); + await expect + .poll(async () => await client.listAutomationIntents(), { + timeout: 15_000, + }) + .toMatchObject({ + items: [expect.objectContaining({ id: configured.id, enabled: false })], + }); + + const secondScopeEditOutputStart = terminalText().length; + terminal.stdin.write("f"); + await waitForTerminalText( + terminal, + terminalText, + () => + terminalText().slice(secondScopeEditOutputStart).includes("编辑中:n"), + "TUI 未为停用的目录打开扫描范围编辑", + ); + const clearSecondScopeOutputStart = terminalText().length; + terminal.stdin.write("\u0015"); + await waitForTerminalText( + terminal, + terminalText, + () => + terminalText().slice(clearSecondScopeOutputStart).includes("编辑中:"), + "TUI 未清空第二次扫描范围输入", + ); + terminal.stdin.write("m"); + await waitForTerminalText( + terminal, + terminalText, + () => terminalText().includes("编辑中:m"), + "TUI 未读取第二次相对扫描范围", + ); + terminal.stdin.write("\r"); + await waitForTerminalText( + terminal, + terminalText, + () => terminalText().includes("扫描范围(相对目录):m"), + "TUI 未保存第二次相对扫描范围", + ); + terminal.stdin.write("e"); + let latestConfigured: typeof configured | undefined; + await expect + .poll(async () => { + const intents = await client.listAutomationIntents(); + latestConfigured = intents.items.at(-1); + return intents.items.map(({ id, scope, enabled }) => ({ + id, + relativeScope: scope.relativeScope, + enabled, + })); + }) + .toEqual([ + { id: configured.id, relativeScope: "n", enabled: false }, + expect.objectContaining({ relativeScope: "m", enabled: true }), + ]); + if (latestConfigured === undefined) + throw new Error("第二次启用没有创建新的自动刷新设置"); + expect(latestConfigured.id).not.toBe(configured.id); + await expect + .poll(async () => { + const history = await client.listWorkflowHistory({ limit: 100 }); + return history.items.find( + ({ execution }) => + execution.automationIntentId === latestConfigured?.id && + execution.operationKind === "scan-asset-library", + )?.execution.status; + }) + .toBe("completed"); + const disableLatestOutputStart = terminalText().length; + terminal.stdin.write("d"); + await expect + .poll(async () => await client.listAutomationIntents(), { + timeout: 15_000, + }) + .toMatchObject({ + items: [ + expect.objectContaining({ id: configured.id, enabled: false }), + expect.objectContaining({ id: latestConfigured.id, enabled: false }), + ], + }); + await waitForTerminalText( + terminal, + terminalText, + () => { + const currentOutput = terminalText().slice(disableLatestOutputStart); + return ( + currentOutput.includes("扫描范围(相对目录):m") && + currentOutput.includes("已停用") + ); + }, + "TUI 未显示最新的已停用扫描范围", + ); + const outputBeforeLatestTrigger = terminalText().length; + const historyBeforeLatestTrigger = await client.listWorkflowHistory({ + limit: 100, + }); + const previousExecutionIds = new Set( + historyBeforeLatestTrigger.items.map(({ execution }) => execution.id), + ); + terminal.stdin.write("s"); + let latestTriggeredIntentId: string | undefined; + let latestTriggeredExecutionId: string | undefined; + await expect + .poll( + async () => { + const history = await client.listWorkflowHistory({ limit: 100 }); + const execution = history.items.find( + ({ execution }) => + execution.operationKind === "scan-asset-library" && + !previousExecutionIds.has(execution.id), + )?.execution; + latestTriggeredIntentId = execution?.automationIntentId; + latestTriggeredExecutionId = execution?.id; + return latestTriggeredIntentId; + }, + { timeout: 15_000 }, + ) + .toBe(latestConfigured.id); + if (latestTriggeredExecutionId === undefined) + throw new Error("终端没有触发最新自动刷新设置"); + await expect + .poll( + async () => await client.inspectExecution(latestTriggeredExecutionId!), + ) + .toMatchObject({ kind: "asset-library-scan", status: "completed" }); + await waitForTerminalText( + terminal, + terminalText, + () => + terminalText() + .slice(outputBeforeLatestTrigger) + .includes("当前执行:无变更"), + "TUI 未显示最新设置所触发的执行结局", + ); + const outputBeforeReturn = terminalText().length; + terminal.stdin.write("q"); + await waitForTerminalText( + terminal, + terminalText, + () => terminalText().slice(outputBeforeReturn).includes("a 自动刷新"), + "TUI 未返回资产库菜单", + ); + terminal.stdin.write("q"); + await waitForTerminalText( + terminal, + terminalText, + () => terminalText().includes("TUI_EXITED"), + "TUI 未能正常退出", + ); + const history = await client.listWorkflowHistory({ limit: 100 }); + const publicIds = [ + configured.id, + latestConfigured.id, + ...history.items.map(({ execution }) => execution.id), + ]; + expect(publicIds.every((id) => !terminalText().includes(id))).toBe(true); + expect(terminalText()).not.toContain(observedDirectory); + expect(JSON.stringify(await client.listAutomationIntents())).not.toContain( + observedDirectory, + ); + await expect(client.listAutomationIntents()).resolves.toMatchObject({ + items: [ + expect.objectContaining({ id: configured.id, enabled: false }), + expect.objectContaining({ id: latestConfigured.id, enabled: false }), + ], + }); +}, 60_000); + +function controlledAutomationClock(now: { value: Date }) { + type AutomationClock = NonNullable; + let poll: (() => void) | undefined; + const clock: AutomationClock = { + now: () => new Date(now.value), + setInterval(callback, milliseconds) { + if (milliseconds !== 1_000) + throw new Error("自动刷新轮询间隔与验收配置不一致"); + poll = callback; + return "ewc18-automation-clock"; + }, + clearInterval() { + poll = undefined; + }, + }; + return { + clock, + tick() { + if (poll === undefined) throw new Error("自动刷新宿主尚未启动"); + poll(); + }, + }; +} + +async function startProductApi(product: TestProduct) { + const api = new Hono().route("/api/v1", createPublicHttpApi(product.result)); + const server = serve({ + fetch: api.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + servers.push(server); + await server.ready(); + return { server, baseUrl: new URL(server.url!) }; +} + +async function closeProductApi(service: { + readonly server: Awaited>; +}) { + const index = servers.indexOf(service.server); + if (index >= 0) servers.splice(index, 1); + await service.server.close(true); +} + +async function closeProduct(product: TestProduct) { + const index = products.indexOf(product); + if (index >= 0) products.splice(index, 1); + await product.close(); +} + +async function waitForTerminalText( + terminal: ReturnType, + output: () => string, + condition: () => boolean, + description: string, +) { + await expect + .poll( + () => { + const satisfied = condition(); + if (satisfied) return true; + if (terminal.exitCode !== null || terminal.signalCode !== null) + throw new Error( + `${description};TUI 已退出:${output().slice(-1500)}`, + ); + return false; + }, + { + timeout: 15_000, + message: `${description};终端输出 ${output().slice(-1500)}`, + }, + ) + .toBe(true); +} diff --git a/apps/service/test/development-session.test.ts b/apps/service/test/development-session.test.ts new file mode 100644 index 0000000..5d3d4ec --- /dev/null +++ b/apps/service/test/development-session.test.ts @@ -0,0 +1,834 @@ +import { spawn } from "node:child_process"; +import { execFile as execFileCallback } from "node:child_process"; +import { + cp, + mkdtemp, + mkdir, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { createServer, type AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +import { inspectEnvironment, stopEnvironment } from "@digital/environment"; +import { createPublicApiClient } from "@digital/public-api"; +import { expect, it } from "vitest"; + +import { startInteractiveTerminal } from "./interactive-terminal.ts"; + +const execFile = promisify(execFileCallback); +const workspaceRoot = fileURLToPath(new URL("../../..", import.meta.url)); +const fixtureMaterial = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/material"), +); +const environment = "development-session"; +const environmentPortExclusions = new Set([4_321, 34_650]); +const ignoredWorkspaceSegments = new Set([ + ".agents", + ".codex", + ".digital", + ".git", + ".scratch", + ".turbo", + "coverage", + "dist", + "node_modules", + "playwright-report", + "test-results", +]); + +it( + "领域、应用和来源更新进入 HTTP 服务,隔离测试与 TUI 共存【安装客户端】", + async () => { + const temporaryRoot = await mkdtemp( + path.join(tmpdir(), "digital-development-session-"), + ); + const copiedWorkspace = path.join(temporaryRoot, "workspace"); + await cp(workspaceRoot, copiedWorkspace, { + recursive: true, + filter: (source) => + !path + .relative(workspaceRoot, source) + .split(path.sep) + .some((segment) => ignoredWorkspaceSegments.has(segment)), + }); + + const watcherProcesses: ReturnType[] = []; + const terminalChildren: ReturnType[] = []; + let passed = false; + let watcherOutput = ""; + let firstEntryMilliseconds = 0; + let savedConfigurationReentryMilliseconds = 0; + let developmentEntryMilliseconds = 0; + let explicitRefreshMilliseconds = 0; + let applicationUpdateMilliseconds = 0; + let domainUpdateMilliseconds = 0; + let sourceUpdateMilliseconds = 0; + let failedBuildRecoveryMilliseconds = 0; + let isolatedTestMilliseconds = 0; + + const rememberOutput = (chunk: Buffer): void => { + watcherOutput = `${watcherOutput}${chunk.toString("utf8")}`.slice( + -4_000_000, + ); + }; + + try { + await execFile("pnpm", ["install", "--frozen-lockfile", "--offline"], { + cwd: copiedWorkspace, + timeout: 120_000, + maxBuffer: 4 * 1024 * 1024, + }); + await execFile( + "pnpm", + [ + "exec", + "turbo", + "run", + "build", + "--filter=@digital/service", + "--filter=@digital/db-migrations", + "--filter=@digital/tui", + "--env-mode=loose", + ], + { + cwd: copiedWorkspace, + timeout: 180_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + + const port = await availablePort(); + const inputDirectory = path.join(temporaryRoot, "observed-material"); + await mkdir(inputDirectory, { recursive: true }); + await cp( + path.join(fixtureMaterial, "RJ01029383"), + path.join(inputDirectory, "RJ01029383"), + { recursive: true }, + ); + + const firstEntryStarted = performance.now(); + const firstEntry = await runPnpm(copiedWorkspace, [ + "environment", + "enter", + "--workspace-path", + copiedWorkspace, + "--environment", + environment, + "--source-mode", + "offline", + "--input", + inputDirectory, + "--config-json", + JSON.stringify({ webPort: port }), + ]); + firstEntryMilliseconds = performance.now() - firstEntryStarted; + const firstEntryResult = lastJsonLine(firstEntry.stdout); + expect(firstEntryResult).toMatchObject({ + variant: "entered", + data: { run: { state: "ready" }, preparation: { status: "prepared" } }, + }); + + let client = createPublicApiClient({ + baseUrl: `http://127.0.0.1:${String(port)}`, + }); + const retainedLibrary = await client.saveAssetLibrary({ + displayName: "开发会话保留资料", + directories: [inputDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + + await runPnpm(copiedWorkspace, [ + "environment", + "stop", + "--workspace-path", + copiedWorkspace, + "--environment", + environment, + ]); + const reentryStarted = performance.now(); + const reentry = await runPnpm(copiedWorkspace, [ + "environment", + "enter", + "--workspace-path", + copiedWorkspace, + "--environment", + environment, + ]); + savedConfigurationReentryMilliseconds = + performance.now() - reentryStarted; + expect(lastJsonLine(reentry.stdout)).toMatchObject({ + variant: "entered", + data: { preparation: { status: "reused" }, run: { state: "ready" } }, + }); + client = createPublicApiClient({ + baseUrl: `http://127.0.0.1:${String(port)}`, + }); + await expect + .poll(async () => (await client.listAssetLibraries()).items) + .toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: retainedLibrary.id, + displayName: "开发会话保留资料", + }), + ]), + ); + await runPnpm(copiedWorkspace, [ + "environment", + "stop", + "--workspace-path", + copiedWorkspace, + "--environment", + environment, + ]); + + const watcherStarted = performance.now(); + const watcher = spawn("pnpm", ["preview:dev", environment], { + cwd: copiedWorkspace, + detached: process.platform !== "win32", + env: { ...process.env, TERM: "xterm-256color" }, + stdio: ["ignore", "pipe", "pipe"], + }); + watcherProcesses.push(watcher); + watcher.stdout.on("data", (chunk: Buffer) => { + rememberOutput(chunk); + }); + watcher.stderr.on("data", (chunk: Buffer) => { + rememberOutput(chunk); + }); + const baseUrl = `http://127.0.0.1:${String(port)}`; + await waitForReady(baseUrl, watcher, () => watcherOutput); + developmentEntryMilliseconds = performance.now() - watcherStarted; + client = createPublicApiClient({ baseUrl }); + expect( + (await client.listAssetLibraries()).items.map(({ id }) => id), + ).toContain(retainedLibrary.id); + expect( + ( + await client.listAssets({ + assetLibraryId: retainedLibrary.id, + limit: 100, + }) + ).items, + ).toEqual([]); + + const developmentDomainArtifact = path.join( + copiedWorkspace, + "packages/domain/.turbo/development/dist/effective-metadata.js", + ); + const developmentDomainArtifactBefore = await readFile( + developmentDomainArtifact, + ); + const librariesBeforeIsolatedTest = await client.listAssetLibraries(); + const isolatedTestStarted = performance.now(); + await execFile( + "pnpm", + [ + "exec", + "turbo", + "run", + "test", + "--filter=@digital/domain", + "--env-mode=loose", + ], + { + cwd: copiedWorkspace, + timeout: 120_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + isolatedTestMilliseconds = performance.now() - isolatedTestStarted; + expect(await readFile(developmentDomainArtifact)).toEqual( + developmentDomainArtifactBefore, + ); + expect((await fetch(new URL("/api/ready", baseUrl))).status).toBe(200); + expect(await client.listAssetLibraries()).toEqual( + librariesBeforeIsolatedTest, + ); + + const scanStarted = performance.now(); + const initialScan = await client.startAssetLibraryScan( + retainedLibrary.id, + ); + await waitForScan(client, initialScan.parentExecutionId); + const firstAssets = await client.listAssets({ + assetLibraryId: retainedLibrary.id, + limit: 100, + }); + expect(firstAssets.items.length).toBeGreaterThan(0); + const assetId = firstAssets.items[0]!.id; + const oldMediaPath = path.join( + inputDirectory, + "RJ01029383", + "おまけ音声", + "12++_おまけ。30秒の寝息を30分ループ.wav", + ); + const updatedMediaPath = path.join( + path.dirname(oldMediaPath), + "显式刷新后可见的音声.wav", + ); + await rename(oldMediaPath, updatedMediaPath); + const unchangedEvidence = await client.listAssetEvidence(assetId, { + kind: "local-observations", + limit: 100, + }); + expect( + unchangedEvidence.items.some( + (item) => + item.kind === "local-observation" && + item.relativePath.includes("12++_おまけ"), + ), + ).toBe(true); + + const refreshed = await client.startAssetLibraryScan(retainedLibrary.id); + const refreshedScan = await waitForScan( + client, + refreshed.parentExecutionId, + ); + explicitRefreshMilliseconds = performance.now() - scanStarted; + expect( + refreshedScan.directories.some((directory) => + directory.files.some((file) => + file.relativePaths.some((relativePath) => + relativePath.includes("显式刷新后可见的音声.wav"), + ), + ), + ), + ).toBe(true); + + const sourceFile = path.join( + copiedWorkspace, + "packages/application/src/asset-libraries.ts", + ); + const originalApplication = await readFile(sourceFile, "utf8"); + const displayNameExpression = + "const displayName = input.displayName.trim();"; + expect(originalApplication).toContain(displayNameExpression); + const updatedApplication = originalApplication.replace( + displayNameExpression, + 'const displayName = input.displayName.trim().replace("开发更新旧标记", "开发更新新标记");', + ); + const inFlightScanStart = await client.startAssetLibraryScan( + retainedLibrary.id, + ); + const inFlightScanBeforeUpdate = await client.inspectAssetLibraryScan( + inFlightScanStart.parentExecutionId, + ); + expect(inFlightScanBeforeUpdate.status).toBe("running"); + const previousApplicationGeneration = await inspectEnvironment({ + workspacePath: copiedWorkspace, + environment, + }); + expect(previousApplicationGeneration.run.pid).not.toBeNull(); + const applicationUpdateStarted = performance.now(); + await writeFile(sourceFile, updatedApplication); + await waitForNewReady( + copiedWorkspace, + watcher, + previousApplicationGeneration.run.runId ?? undefined, + () => watcherOutput, + ); + const updatedApplicationGeneration = await inspectEnvironment({ + workspacePath: copiedWorkspace, + environment, + }); + expect(updatedApplicationGeneration.run.runId).not.toBe( + previousApplicationGeneration.run.runId, + ); + expect(updatedApplicationGeneration.run.pid).not.toBeNull(); + expect(updatedApplicationGeneration.serviceReadiness).toBe("ready"); + await waitForProcessExit(previousApplicationGeneration.run.pid!); + applicationUpdateMilliseconds = + performance.now() - applicationUpdateStarted; + client = createPublicApiClient({ baseUrl }); + const inFlightScanAfterUpdate = await client.inspectAssetLibraryScan( + inFlightScanStart.parentExecutionId, + ); + const completedInFlightScanAfterUpdate = await waitForScan( + client, + inFlightScanStart.parentExecutionId, + ); + expect(inFlightScanAfterUpdate).toMatchObject({ + parentExecutionId: inFlightScanStart.parentExecutionId, + assetLibraryId: retainedLibrary.id, + }); + expect(inFlightScanAfterUpdate.directories.map(({ id }) => id)).toEqual( + inFlightScanBeforeUpdate.directories.map(({ id }) => id), + ); + expect(completedInFlightScanAfterUpdate).toMatchObject({ + parentExecutionId: inFlightScanStart.parentExecutionId, + assetLibraryId: retainedLibrary.id, + status: "completed", + }); + const codeUpdatedLibrary = await client.saveAssetLibrary({ + displayName: "开发更新旧标记", + directories: [inputDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + expect(codeUpdatedLibrary.displayName).toBe("开发更新新标记"); + expect( + (await client.listAssetLibraries()).items.map(({ id }) => id), + ).toContain(retainedLibrary.id); + + const pinnedTitle = await client.prepareMetadataDecision(assetId, { + kind: "pin", + property: "asset.title", + value: "领域更新后仍保留的人工标题", + }); + await expect( + client.confirmMetadataDecision(pinnedTitle.previewId), + ).resolves.toMatchObject({ status: "applied" }); + const domainFile = path.join( + copiedWorkspace, + "packages/domain/src/effective-metadata.ts", + ); + const originalDomain = await readFile(domainFile, "utf8"); + const selectedPreferenceBranch = " if (selectedPreferenceIndex === 0) {"; + expect(originalDomain).toContain(selectedPreferenceBranch); + const updatedDomain = originalDomain.replace( + selectedPreferenceBranch, + " if (selected.manualDecision === true)\n" + + " return unranked(selected, alternatives, contributors);\n" + + selectedPreferenceBranch, + ); + const previousDomainGeneration = await inspectEnvironment({ + workspacePath: copiedWorkspace, + environment, + }); + expect(previousDomainGeneration.run.pid).not.toBeNull(); + const domainUpdateStarted = performance.now(); + await writeFile(domainFile, updatedDomain); + await waitForNewReady( + copiedWorkspace, + watcher, + previousDomainGeneration.run.runId ?? undefined, + () => watcherOutput, + ); + await waitForProcessExit(previousDomainGeneration.run.pid!); + domainUpdateMilliseconds = performance.now() - domainUpdateStarted; + client = createPublicApiClient({ baseUrl }); + const currentAfterDomainUpdate = await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }); + expect(currentAfterDomainUpdate.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "current-value", + evidence: expect.objectContaining({ + property: "asset.title", + result: expect.objectContaining({ + status: "resolved", + values: expect.arrayContaining([ + expect.objectContaining({ + value: "领域更新后仍保留的人工标题", + reasoning: { kind: "unranked-source-assertion" }, + }), + ]), + }), + }), + }), + ]), + ); + + const providerFile = path.join( + copiedWorkspace, + "sources/dlsite/src/index.ts", + ); + const originalProvider = await readFile(providerFile, "utf8"); + const providerDisplayName = 'displayName: "DLsite",'; + expect(originalProvider).toContain(providerDisplayName); + const updatedProvider = originalProvider.replace( + providerDisplayName, + 'displayName: "DLsite 开发更新",', + ); + const previousSourceGeneration = await inspectEnvironment({ + workspacePath: copiedWorkspace, + environment, + }); + expect(previousSourceGeneration.run.pid).not.toBeNull(); + const sourceUpdateStarted = performance.now(); + await writeFile(providerFile, updatedProvider); + await waitForNewReady( + copiedWorkspace, + watcher, + previousSourceGeneration.run.runId ?? undefined, + () => watcherOutput, + ); + await waitForProcessExit(previousSourceGeneration.run.pid!); + sourceUpdateMilliseconds = performance.now() - sourceUpdateStarted; + client = createPublicApiClient({ baseUrl }); + expect(await client.listSourceInputRequirements()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sourceIdentity: "dlsite", + displayName: "DLsite 开发更新", + }), + ]), + ); + + const failedSource = updatedApplication.replace( + 'const displayName = input.displayName.trim().replace("开发更新旧标记", "开发更新新标记");', + "const displayName = input.displayName.trim().replace(;", + ); + const currentRunId = ( + await inspectEnvironment({ + workspacePath: copiedWorkspace, + environment, + }) + ).run?.runId; + await writeFile(sourceFile, failedSource); + await waitForFailedUpdate(copiedWorkspace, watcher, () => watcherOutput); + expect(watcher.exitCode).toBeNull(); + const failedInspection = await inspectEnvironment({ + workspacePath: copiedWorkspace, + environment, + }); + expect(failedInspection.development?.failure).toEqual(expect.any(String)); + expect(failedInspection.development?.failure?.length).toBeGreaterThan(0); + const repairStarted = performance.now(); + await writeFile(sourceFile, updatedApplication); + await waitForNewReady( + copiedWorkspace, + watcher, + currentRunId ?? undefined, + () => watcherOutput, + ); + failedBuildRecoveryMilliseconds = performance.now() - repairStarted; + client = createPublicApiClient({ baseUrl }); + expect((await client.listAssetLibraries()).items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: retainedLibrary.id, + displayName: "开发会话保留资料", + }), + expect.objectContaining({ + id: codeUpdatedLibrary.id, + displayName: "开发更新新标记", + }), + ]), + ); + + const terminal = + "stty cols 80 rows 24 -icanon -echo; terminalModeBefore=$(stty -g); " + + `pnpm --filter @digital/service exec digital-tui --url ${shellQuote(baseUrl)}; ` + + 'terminalModeAfter=$(stty -g); if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; fi; echo TUI_EXITED'; + const terminalSession = startInteractiveTerminal(terminal, { + cwd: copiedWorkspace, + columns: 80, + rows: 24, + timeoutMs: 15_000, + }); + terminalChildren.push(terminalSession.child); + await terminalSession.waitForOutput("开发更新新标记"); + await terminalSession.sendAndWait("q", [ + "TERMINAL_RESTORED", + "TUI_EXITED", + ]); + const terminalExitCode = await terminalSession.closeAndWait(); + const terminalResult = { + stdout: terminalSession.output, + stderr: terminalSession.errors, + }; + expect( + terminalExitCode, + `${terminalResult.stderr}\n${terminalResult.stdout}`, + ).toBe(0); + expect(terminalResult.stdout).toContain("开发更新新标记"); + expect(terminalResult.stdout).toContain("TERMINAL_RESTORED"); + expect(terminalResult.stdout).toContain("TUI_EXITED"); + expect(terminalResult.stdout).not.toContain("turbo watch dev:serve"); + expect((await fetch(new URL("/api/ready", baseUrl))).status).toBe(200); + + const runningEnvironment = await inspectEnvironment({ + workspacePath: copiedWorkspace, + environment, + }); + expect(runningEnvironment.development?.update).toBe("ready"); + const turboLog = runningEnvironment.development?.logFile; + const productLog = runningEnvironment.run?.productLogFile; + expect(typeof turboLog).toBe("string"); + expect(typeof productLog).toBe("string"); + expect(turboLog).not.toBe(productLog); + expect((await readFile(turboLog!, "utf8")).length).toBeGreaterThan(0); + expect((await readFile(productLog!, "utf8")).length).toBeGreaterThan(0); + + const stopStarted = performance.now(); + await stopProcessGroup(watcher); + const signalCleanupMilliseconds = performance.now() - stopStarted; + const stoppedEnvironment = await inspectEnvironment({ + workspacePath: copiedWorkspace, + environment, + }); + expect(stoppedEnvironment.development?.state).toBe("ended"); + expect(stoppedEnvironment.run?.state).toBe("stopped"); + console.info( + JSON.stringify({ + event: "development-session-timings", + firstEntryMilliseconds, + savedConfigurationReentryMilliseconds, + developmentEntryMilliseconds, + explicitRefreshMilliseconds, + applicationUpdateMilliseconds, + domainUpdateMilliseconds, + sourceUpdateMilliseconds, + failedBuildRecoveryMilliseconds, + isolatedTestMilliseconds, + inFlightScanBeforeUpdate: inFlightScanBeforeUpdate.status, + inFlightScanAfterUpdate: inFlightScanAfterUpdate.status, + inFlightScanCompletedAfterUpdate: + completedInFlightScanAfterUpdate.status, + previousApplicationRunId: previousApplicationGeneration.run.runId, + previousApplicationPid: previousApplicationGeneration.run.pid, + updatedApplicationRunId: updatedApplicationGeneration.run.runId, + updatedApplicationPid: updatedApplicationGeneration.run.pid, + previousApplicationPidExited: true, + signalCleanupMilliseconds, + turboLog, + productLog, + watcherStopped: + watcher.exitCode !== null || watcher.signalCode !== null, + }), + ); + passed = true; + } finally { + for (const terminalChild of terminalChildren) + await stopProcessGroup(terminalChild).catch(() => undefined); + for (const watcher of watcherProcesses) { + if (watcher.exitCode !== null || watcher.signalCode !== null) continue; + try { + if (watcher.pid !== undefined && process.platform !== "win32") + process.kill(-watcher.pid, "SIGTERM"); + else watcher.kill("SIGTERM"); + } catch { + watcher.kill("SIGTERM"); + } + } + if (watcherProcesses.length > 0) { + await Promise.all( + watcherProcesses.map((watcher) => + waitForClose(watcher, 30_000).catch(() => undefined), + ), + ); + } + const inspection = await inspectEnvironment({ + workspacePath: copiedWorkspace, + environment, + }).catch(() => undefined); + if ( + inspection?.run !== null && + inspection?.run?.state !== "stopped" && + inspection?.run?.state !== undefined + ) + await stopEnvironment({ + workspacePath: copiedWorkspace, + environment, + ...(inspection?.development?.sessionId === undefined + ? {} + : { developmentSession: inspection.development.sessionId }), + }); + if (passed) await rm(temporaryRoot, { recursive: true, force: true }); + else + console.error( + `开发会话验收失败,保留隔离工作区供诊断:${temporaryRoot}`, + ); + } + }, + 15 * 60_000, +); + +async function runPnpm( + cwd: string, + arguments_: readonly string[], +): Promise<{ readonly stdout: string; readonly stderr: string }> { + const result = await execFile("pnpm", arguments_, { + cwd, + timeout: 180_000, + maxBuffer: 8 * 1024 * 1024, + env: { ...process.env, TERM: "xterm-256color" }, + }); + return { stdout: result.stdout, stderr: result.stderr }; +} + +async function availablePort(): Promise { + for (;;) { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + await new Promise((resolve, reject) => + server.close((error) => + error === undefined ? resolve() : reject(error), + ), + ); + if (!environmentPortExclusions.has(address.port)) return address.port; + } +} + +async function waitForReady( + baseUrl: string, + watcher: ReturnType, + readOutput: () => string, +): Promise { + await eventually(async () => { + if (watcher.exitCode !== null || watcher.signalCode !== null) + throw new Error(`preview:dev 提前退出:${readOutput().slice(-12_000)}`); + const response = await fetch(new URL("/api/ready", baseUrl), { + signal: AbortSignal.timeout(1_000), + }).catch(() => undefined); + return response?.status === 200; + }, readOutput); +} + +async function waitForProcessExit(pid: number): Promise { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === "ESRCH" + ) + return; + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`旧开发服务进程 ${String(pid)} 在期限内仍存活`); +} + +async function waitForNewReady( + workspacePath: string, + watcher: ReturnType, + previousRunId: string | undefined, + readOutput: () => string, +): Promise { + await eventually(async () => { + if (watcher.exitCode !== null || watcher.signalCode !== null) + throw new Error(`preview:dev 提前退出:${readOutput().slice(-12_000)}`); + const inspection = await inspectEnvironment({ + workspacePath, + environment, + }); + const run = inspection.run; + if ( + inspection.development?.update !== "ready" || + run === undefined || + run === null || + run.runtime !== "development" || + run.runId === previousRunId || + run.address === null + ) + return false; + const response = await fetch(new URL("/api/ready", run.address)).catch( + () => undefined, + ); + return response?.status === 200; + }, readOutput); +} + +async function waitForFailedUpdate( + workspacePath: string, + watcher: ReturnType, + readOutput: () => string, +): Promise { + await eventually(async () => { + if (watcher.exitCode !== null || watcher.signalCode !== null) + throw new Error(`preview:dev 提前退出:${readOutput().slice(-12_000)}`); + const inspection = await inspectEnvironment({ + workspacePath, + environment, + }); + return inspection.development?.update === "failed"; + }, readOutput); +} + +async function eventually( + condition: () => Promise, + readOutput: () => string, +): Promise { + const deadline = Date.now() + 180_000; + while (Date.now() < deadline) { + if (await condition()) return; + if (readOutput().includes('"state":"failed"')) + throw new Error(`开发编译被报告失败:${readOutput().slice(-12_000)}`); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error(`等待开发行为超时:${readOutput().slice(-12_000)}`); +} + +async function waitForScan( + client: ReturnType, + executionId: string, +): Promise>> { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const scan = await client.inspectAssetLibraryScan(executionId); + if (scan.status === "completed") return scan; + if (scan.status === "failed") + throw new Error(`显式目录扫描${scan.status}:${executionId}`); + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error(`显式目录扫描等待超时:${executionId}`); +} + +function lastJsonLine(output: string): Record { + for (const line of output.split("\n").toReversed()) { + try { + const parsed: unknown = JSON.parse(line); + if (typeof parsed === "object" && parsed !== null) + return parsed as Record; + } catch { + // environment CLI 前面的 pnpm 命令行不是结果对象。 + } + } + throw new Error(`environment CLI 没有交出结构化结果:${output}`); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +async function waitForClose( + child: ReturnType, + timeoutMilliseconds: number, +): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("开发会话测试进程未在期限内结束")), + timeoutMilliseconds, + ); + child.once("close", () => { + clearTimeout(timeout); + resolve(); + }); + }); +} + +async function stopProcessGroup( + child: ReturnType, +): Promise { + if (child.exitCode !== null || child.signalCode !== null) return; + if (child.pid !== undefined && process.platform !== "win32") { + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + } else child.kill("SIGTERM"); + await waitForClose(child, 30_000); +} diff --git a/apps/service/test/execution-recovery-application.test.ts b/apps/service/test/execution-recovery-application.test.ts new file mode 100644 index 0000000..df422c1 --- /dev/null +++ b/apps/service/test/execution-recovery-application.test.ts @@ -0,0 +1,319 @@ +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { runProduct } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { productGeneralAutomationInformationRequirement } from "@digital/product-application"; +import { + loadControlledSourceIo, + refusingSessionTransport, +} from "@digital/test-support/source-io"; +import { afterEach, expect, it } from "vitest"; + +const temporaryRoots: string[] = []; +const fixtureMaterialDirectory = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/material"), +); +const fixtureManifestPath = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/manifest.json"), +); +const directedMaterialDirectory = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/publication-screen/material"), +); +const directedManifestPath = fileURLToPath( + import.meta + .resolve("@digital/preview-corpus/publication-screen/manifest.json"), +); + +afterEach(async () => { + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); +}); + +it("正式产品刷新用例沿同一 SQLite 工作流完成扫描、重新刮削和元数据刷新", async () => { + const root = await mkdtemp( + path.join(tmpdir(), "digital-refresh-operations-product-"), + ); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialDirectory = path.join(root, "material"); + await mkdir(materialDirectory); + await cp( + path.join(fixtureMaterialDirectory, "RJ01668691"), + path.join(materialDirectory, "RJ01668691"), + { recursive: true }, + ); + await prepareProductionCatalogue({ productDataDirectory }); + const controlled = await loadControlledSourceIo({ + manifestPath: fixtureManifestPath, + }); + let externalRequestBlocked = false; + let markExternalRequestStarted!: () => void; + let releaseExternalRequest!: () => void; + const externalRequestStarted = new Promise((resolve) => { + markExternalRequestStarted = resolve; + }); + const externalRequestRelease = new Promise((resolve) => { + releaseExternalRequest = resolve; + }); + const product = runProduct( + { + productDataDirectory, + environment: {}, + sourceIo: { + mode: "controlled", + async request(input, init) { + if (externalRequestBlocked) { + externalRequestBlocked = false; + markExternalRequestStarted(); + await externalRequestRelease; + } + return await controlled.request(input, init); + }, + sessionTransport: + controlled.sessionTransport ?? refusingSessionTransport(), + }, + }, + { start: (application) => application }, + ); + + try { + const application = product.result; + const waitForRefresh = async (executionId: string) => { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const inspection = await application.refresh.inspect(executionId); + if (inspection === undefined) + throw new Error(`刷新执行不可读取:${executionId}`); + const status = + inspection.kind === "asset-library-scan" + ? inspection.scan.status + : inspection.execution.execution.status; + if (status !== "running") return inspection; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`刷新执行未在期限内结束:${executionId}`); + }; + const libraries = application.workflow.assetLibraries; + const library = await libraries.save({ + displayName: "进度恢复产品用例", + directories: [materialDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + + const scan = await application.refresh.startLibraryScan({ + libraryId: library.id, + }); + expect(scan.id.length).toBeGreaterThan(0); + const scanInspection = await waitForRefresh(scan.id); + expect(scanInspection.kind).toBe("asset-library-scan"); + if (scanInspection.kind !== "asset-library-scan") + throw new Error("库扫描未返回父账本检查"); + expect(scanInspection.execution.execution.id).toBe(scan.id); + expect(scanInspection.scan.parentExecutionId).toBe(scan.id); + expect(scanInspection.scan.status).toBe("completed"); + expect(scanInspection.children).toHaveLength(1); + + const current = await application.workflow.listCurrentCataloguePage({ + assetLibraryId: library.id, + limit: 20, + }); + const accepted = current.items.find( + (subject) => subject.sourceAssociations.length > 0, + ); + if (accepted === undefined) + throw new Error("受控正式扫描没有建立带来源的当前主体"); + + await expect( + application.refresh.startCurrentScopeRescrape({ + subjectId: accepted.id, + }), + ).rejects.toThrow("当前内容没有可重新刮削的识别来源"); + + const completedMetadata = await application.refresh.startMetadataRefresh({ + subjectId: accepted.id, + }); + const completedMetadataInspection = await waitForRefresh( + completedMetadata.id, + ); + expect(completedMetadataInspection.kind).toBe("execution"); + if (completedMetadataInspection.kind !== "execution") + throw new Error("元数据刷新返回了非预期父检查"); + expect(completedMetadataInspection.execution.execution.id).toBe( + completedMetadata.id, + ); + expect( + completedMetadataInspection.execution.sourceRefreshRuns?.length, + ).toBeGreaterThan(0); + expect( + completedMetadataInspection.execution.sourceRefreshRuns?.every( + (run) => run.changeSets.length > 0, + ), + ).toBe(true); + + externalRequestBlocked = true; + const metadata = await application.refresh.startMetadataRefresh({ + subjectId: accepted.id, + }); + let requestStartTimeout: ReturnType | undefined; + const requestStarted = await Promise.race([ + externalRequestStarted.then(() => true), + new Promise((resolve) => { + requestStartTimeout = setTimeout(() => resolve(false), 15_000); + }), + ]); + if (requestStartTimeout !== undefined) clearTimeout(requestStartTimeout); + expect(requestStarted).toBe(true); + const cancellation = await application.refresh.cancel({ + executionId: metadata.id, + requestedBy: "service-test", + requestedAt: new Date().toISOString(), + reason: "验证取消状态与后台结局", + }); + expect(cancellation.status).toBe("requested"); + expect(cancellation.inspection.kind).toBe("execution"); + if (cancellation.inspection.kind !== "execution") + throw new Error("元数据刷新取消返回了非预期父检查"); + expect(cancellation.inspection.execution.execution).toMatchObject({ + id: metadata.id, + status: "failed", + outcome: "cancelled", + cancellation: { requestedBy: "service-test" }, + }); + expect(cancellation.inspection.execution.sourceRefreshRuns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ phase: "terminal", outcome: "cancelled" }), + ]), + ); + releaseExternalRequest(); + + const metadataInspection = await waitForRefresh(metadata.id); + expect(metadataInspection.kind).toBe("execution"); + if (metadataInspection.kind !== "execution") + throw new Error("元数据刷新返回了非预期父检查"); + expect(metadataInspection.execution.execution.id).toBe(metadata.id); + expect(metadataInspection.execution.execution.operationKind).toBe( + "refresh-metadata", + ); + expect(metadataInspection.execution.execution).toMatchObject({ + status: "failed", + outcome: "cancelled", + }); + expect( + metadataInspection.execution.sourceRefreshRuns?.length, + ).toBeGreaterThan(0); + } finally { + releaseExternalRequest(); + await product.close(); + } +}, 180_000); + +it("重新刮削从当前主体的识别来源继续,并保留相同主体身份", async () => { + const root = await mkdtemp( + path.join(tmpdir(), "digital-refresh-rescrape-product-"), + ); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialDirectory = path.join(root, "material"); + const sourceMaterialDirectory = path.join( + directedMaterialDirectory, + "films", + "[怒火救援].Man.on.Fire.2004.Blu-ray.1080p.AVC.DTS-HD.MA5.1-CMCT", + ); + await cp(sourceMaterialDirectory, materialDirectory, { recursive: true }); + await writeFile( + path.join(materialDirectory, "BDMV", "MovieObject.bdmv"), + "movie", + ); + await prepareProductionCatalogue({ productDataDirectory }); + const controlled = await loadControlledSourceIo({ + manifestPath: directedManifestPath, + }); + const product = runProduct( + { + productDataDirectory, + environment: {}, + sourceIo: { + mode: "controlled", + request: controlled.request, + sessionTransport: + controlled.sessionTransport ?? refusingSessionTransport(), + }, + }, + { start: (application) => application }, + ); + + try { + const application = product.result; + const workflow = application.workflow; + workflow.sourceConnections.tmdb.configureAccessToken( + "controlled-test-tmdb-token", + ); + await workflow.sourceConnections.tmdb.verify(); + const library = await workflow.assetLibraries.save({ + displayName: "重新刮削受控影视库", + directories: [materialDirectory], + recognitionProfileIds: ["movies"], + sourceIdentities: ["tmdb"], + }); + const [rootRegistration] = await workflow.assetLibraries.directories( + library.id, + ); + if (rootRegistration === undefined) + throw new Error("正式资产库未登记影视资料目录"); + const initial = await workflow.directedRecognitionSourceInputObservedScope({ + registrationId: rootRegistration.id, + relativeScope: ".", + sourceIdentity: "tmdb", + sourceInput: { movie: "9509" }, + informationRequirement: productGeneralAutomationInformationRequirement, + }); + expect(initial.execution.status).toBe("completed"); + expect(["accepted", "partially-succeeded"]).toContain( + initial.execution.outcome, + ); + const subjectId = initial.itemResults?.[0]?.accepted?.entity.id; + if (subjectId === undefined) + throw new Error("定向影视识别没有建立当前主体"); + const initialTarget = await workflow.inspectRefreshTarget(subjectId); + expect(initialTarget?.recognitionSourceAssociation).toMatchObject({ + sourceIdentity: "tmdb", + }); + + const rescrape = await application.refresh.startCurrentScopeRescrape({ + subjectId, + }); + expect(rescrape.id).toBeTruthy(); + const deadline = Date.now() + 120_000; + let inspection = await application.refresh.inspect(rescrape.id); + while ( + inspection?.kind === "execution" && + inspection.execution.execution.status === "running" && + Date.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, 50)); + inspection = await application.refresh.inspect(rescrape.id); + } + expect(inspection?.kind).toBe("execution"); + if (inspection?.kind !== "execution") + throw new Error("重新刮削执行不可读取"); + expect(inspection.execution.execution).toMatchObject({ + id: rescrape.id, + operationKind: "rescrape", + status: "completed", + }); + expect(["accepted", "no-change", "partially-succeeded"]).toContain( + inspection.execution.execution.outcome, + ); + const currentAfter = await workflow.inspectRefreshTarget(subjectId); + expect(currentAfter?.subjectId).toBe(subjectId); + expect(currentAfter?.recognitionSourceAssociation?.sourceIdentity).toBe( + "tmdb", + ); + } finally { + await product.close(); + } +}, 180_000); diff --git a/apps/service/test/execution-recovery-diagnostic.test.ts b/apps/service/test/execution-recovery-diagnostic.test.ts new file mode 100644 index 0000000..ee277fc --- /dev/null +++ b/apps/service/test/execution-recovery-diagnostic.test.ts @@ -0,0 +1,201 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer as createHttpServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { + createPublicApiClient, + PublicExecutionClientError, +} from "@digital/public-api"; +import { resolveSourceIoFromEnvironment } from "@digital/test-support/source-io"; +import { serve } from "srvx"; +import { afterEach, expect, it } from "vitest"; + +import { createProductService } from "../src/service.ts"; + +const serviceServers: Array>> = []; +const proxies: Server[] = []; +const bootstraps: Array>> = []; +const temporaryRoots: string[] = []; + +afterEach(async () => { + for (const proxy of proxies.splice(0).reverse()) + await new Promise((resolve, reject) => + proxy.close((error) => (error === undefined ? resolve() : reject(error))), + ); + for (const server of serviceServers.splice(0).reverse()) + await server.close(true); + for (const bootstrap of bootstraps.splice(0).reverse()) + await bootstrap.abort(); + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); +}); + +it("查询连接被重置时保留同一正式执行,随后仍可读到后台结局", async () => { + const root = await mkdtemp( + path.join(tmpdir(), "digital-execution-recovery-"), + ); + temporaryRoots.push(root); + const materialDirectory = path.join(root, "ordinary-material"); + await mkdir(materialDirectory); + await writeFile(path.join(materialDirectory, "ordinary.txt"), "普通资料\n"); + + const productDataDirectory = path.join(root, "product-data"); + await prepareProductionCatalogue({ productDataDirectory }); + const sourceIo = await resolveSourceIoFromEnvironment({ + DIGITAL_SOURCE_MODE: "offline", + }); + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + ...(sourceIo === undefined ? {} : { sourceIo }), + }); + bootstraps.push(bootstrap); + const service = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + serviceServers.push(service); + await service.ready(); + bootstrap.publish(); + const serviceUrl = new URL(service.url!); + const proxy = await startOneShotExecutionResetProxy(serviceUrl); + const client = createPublicApiClient({ baseUrl: proxy.url }); + + const library = await client.saveAssetLibrary({ + displayName: "执行恢复诊断资料库", + directories: [materialDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: [], + }); + const { parentExecutionId: executionId } = await client.startAssetLibraryScan( + library.id, + ); + + let queryFailure: unknown; + try { + await client.inspectExecution(executionId); + } catch (error) { + queryFailure = error; + } + + await expect + .poll(async () => (await client.inspectExecution(executionId)).status, { + timeout: 60_000, + }) + .toBe("completed"); + const recovered = await client.inspectExecution(executionId); + const readyAfterDisconnect = await fetch(new URL("/api/ready", serviceUrl)); + expect(proxy.state).toMatchObject({ + resetExecutionId: executionId, + responseStatus: 200, + responseExecutionStatus: "running", + }); + expect(readyAfterDisconnect.status).toBe(200); + expect(proxy.state.scanPostCount).toBe(1); + expect(recovered).toMatchObject({ id: executionId, status: "completed" }); + expect(queryFailure).toBeInstanceOf(PublicExecutionClientError); + expect(queryFailure).toMatchObject({ + executionId, + kind: "transport", + cause: { cause: { code: "ECONNRESET" } }, + }); +}); + +async function startOneShotExecutionResetProxy(serviceUrl: URL): Promise<{ + readonly url: URL; + readonly state: { + readonly resetExecutionId: string | undefined; + readonly responseStatus: number | undefined; + readonly responseExecutionStatus: string | undefined; + scanPostCount: number; + }; +}> { + const state: { + resetExecutionId: string | undefined; + responseStatus: number | undefined; + responseExecutionStatus: string | undefined; + scanPostCount: number; + } = { + resetExecutionId: undefined, + responseStatus: undefined, + responseExecutionStatus: undefined, + scanPostCount: 0, + }; + const proxy = createHttpServer((incoming, outgoing) => { + void (async () => { + const chunks: Buffer[] = []; + for await (const chunk of incoming) chunks.push(Buffer.from(chunk)); + const requestBody = Buffer.concat(chunks); + const requestPath = incoming.url ?? "/"; + if ( + incoming.method === "POST" && + /^\/api\/v1\/asset-libraries\/[^/]+\/scans$/u.test(requestPath) + ) + state.scanPostCount += 1; + + const headers = new Headers(); + for (const [name, value] of Object.entries(incoming.headers)) + if ( + typeof value === "string" && + name !== "host" && + name !== "connection" && + name !== "content-length" && + name !== "transfer-encoding" + ) + headers.set(name, value); + const response = await fetch(new URL(requestPath, serviceUrl), { + method: incoming.method, + headers, + ...(requestBody.length === 0 + ? {} + : { body: new Uint8Array(requestBody) }), + }); + const responseBody = new Uint8Array(await response.arrayBuffer()); + const executionPath = requestPath.match( + /^\/api\/v1\/executions\/([^/?]+)/u, + ); + if ( + state.resetExecutionId === undefined && + incoming.method === "GET" && + executionPath !== null + ) { + state.resetExecutionId = executionPath[1]; + state.responseStatus = response.status; + const executionReply = JSON.parse( + Buffer.from(responseBody).toString("utf8"), + ) as { readonly status?: unknown }; + state.responseExecutionStatus = + typeof executionReply.status === "string" + ? executionReply.status + : undefined; + outgoing.socket?.resetAndDestroy(); + return; + } + + const responseHeaders: Record = {}; + for (const [name, value] of response.headers) + if (name !== "connection" && name !== "transfer-encoding") + responseHeaders[name] = value; + outgoing.writeHead(response.status, responseHeaders); + outgoing.end(responseBody); + })().catch((error: unknown) => { + if (!outgoing.destroyed) outgoing.destroy(error as Error); + }); + }); + await new Promise((resolve, reject) => { + proxy.once("error", reject); + proxy.listen(0, "127.0.0.1", resolve); + }); + proxies.push(proxy); + const address = proxy.address(); + if (address === null || typeof address === "string") + throw new Error("诊断代理没有取得临时端口"); + const proxyUrl = new URL(`http://127.0.0.1:${String(address.port)}`); + const proxyState = state; + return { url: proxyUrl, state: proxyState }; +} diff --git a/apps/service/test/execution-recovery-http.test.ts b/apps/service/test/execution-recovery-http.test.ts new file mode 100644 index 0000000..bc8eb47 --- /dev/null +++ b/apps/service/test/execution-recovery-http.test.ts @@ -0,0 +1,1117 @@ +import { spawn } from "node:child_process"; +import { cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer as createHttpServer, type Server } from "node:http"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; + +import { runProduct } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { createPublicHttpApi } from "@digital/http-delivery"; +import { + createPublicApiClient, + PublicExecutionClientError, +} from "@digital/public-api"; +import { + loadControlledSourceIo, + refusingSessionTransport, +} from "@digital/test-support/source-io"; +import { Hono } from "hono"; +import { serve } from "srvx"; +import { afterEach, expect, it } from "vitest"; + +import { interactiveTerminalEnvironment } from "./interactive-terminal.ts"; + +const products: Array> = []; +const serviceServers: Array>> = []; +const proxies: Server[] = []; +const terminals: Array> = []; +const sourceGates: Array> = []; +const temporaryRoots: string[] = []; +const servicePackageRoot = fileURLToPath(new URL("../", import.meta.url)); +const materialRoot = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/publication-screen/material"), +); +const manifestPath = fileURLToPath( + import.meta + .resolve("@digital/preview-corpus/publication-screen/manifest.json"), +); + +afterEach(async () => { + for (const gate of sourceGates.splice(0).reverse()) gate.releaseRequest(); + for (const terminal of terminals.splice(0).reverse()) { + if (terminal.exitCode !== null || terminal.signalCode !== null) continue; + if (terminal.pid !== undefined && process.platform !== "win32") { + try { + process.kill(-terminal.pid, "SIGTERM"); + } catch { + terminal.kill("SIGTERM"); + } + } else terminal.kill("SIGTERM"); + await new Promise((resolve) => { + const timer = setTimeout(() => { + if (terminal.pid !== undefined && process.platform !== "win32") { + try { + process.kill(-terminal.pid, "SIGKILL"); + } catch { + terminal.kill("SIGKILL"); + } + } else terminal.kill("SIGKILL"); + resolve(); + }, 2_000); + terminal.once("close", () => { + clearTimeout(timer); + resolve(); + }); + }); + } + for (const proxy of proxies.splice(0).reverse()) { + proxy.closeAllConnections(); + await new Promise((resolve, reject) => + proxy.close((error) => (error === undefined ? resolve() : reject(error))), + ); + } + for (const server of serviceServers.splice(0).reverse()) + await server.close(true); + for (const product of products.splice(0).reverse()) await product.close(); + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); +}); + +it("真实 HTTP 执行在查询连接重置后以原 ID 恢复,取消与来源变更结局分开", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc07-public-http-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialDirectory = path.join(root, "影视收藏"); + const sourceMaterial = path.join( + materialRoot, + "films", + "[怒火救援].Man.on.Fire.2004.Blu-ray.1080p.AVC.DTS-HD.MA5.1-CMCT", + ); + await cp(sourceMaterial, materialDirectory, { recursive: true }); + await writeFile( + path.join(materialDirectory, "BDMV", "MovieObject.bdmv"), + "受控目录结构标记", + ); + await prepareProductionCatalogue({ productDataDirectory }); + + const controlled = await loadControlledSourceIo({ manifestPath }); + let nextGate: + | { + readonly entered: Promise; + readonly markEntered: () => void; + readonly release: Promise; + readonly releaseRequest: () => void; + } + | undefined; + const sourceIo = { + mode: "controlled" as const, + async request( + input: string, + init?: Parameters[1], + ) { + const gate = nextGate; + if (gate !== undefined) { + nextGate = undefined; + gate.markEntered(); + await gate.release; + } + return await controlled.request(input, init); + }, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + const product = runProduct( + { + productDataDirectory, + environment: {}, + sourceIo, + }, + { start: (application) => application }, + ); + products.push(product); + const application = product.result; + application.workflow.sourceConnections.tmdb.configureAccessToken( + "controlled-test-tmdb-token", + ); + await application.workflow.sourceConnections.tmdb.verify(); + + let refreshPostCount = 0; + const api = new Hono(); + api.use("/api/v1/refreshes", async (context, next) => { + if (context.req.method === "POST") refreshPostCount += 1; + await next(); + }); + api.route("/api/v1", createPublicHttpApi(application)); + const server = serve({ + fetch: api.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + serviceServers.push(server); + await server.ready(); + const baseUrl = new URL(server.url!); + const client = createPublicApiClient({ baseUrl }); + + const library = await application.workflow.assetLibraries.save({ + displayName: "07 受控影视库", + directories: [materialDirectory], + recognitionProfileIds: ["movies"], + sourceIdentities: ["tmdb"], + }); + const [registration] = await application.workflow.assetLibraries.directories( + library.id, + ); + if (registration === undefined) throw new Error("正式资产库没有登记影视目录"); + await expect( + client.startDirectedRecognition({ + registrationId: registration.id, + relativeScope: ".", + sourceIdentity: "tmdb", + sourceInput: { movie: "invalid" }, + }), + ).rejects.toMatchObject({ kind: "invalid-request", status: 400 }); + const failedStartDatabase = new DatabaseSync( + path.join(productDataDirectory, "catalogue.sqlite"), + ); + failedStartDatabase.exec(` + CREATE TRIGGER reject_directed_recognition_start + BEFORE INSERT ON workflow_executions + BEGIN + SELECT RAISE(ABORT, '10 confidential recognition start failure'); + END; + `); + try { + const failedStart = await fetch( + new URL("/api/v1/directed-recognitions", baseUrl), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + registrationId: registration.id, + relativeScope: ".", + sourceIdentity: "tmdb", + sourceInput: { movie: "9509" }, + }), + }, + ); + const body = await failedStart.text(); + expect(failedStart.status).toBe(503); + expect(body).toContain("暂时无法发起指定来源识别"); + expect(body).not.toContain("10 confidential recognition start failure"); + expect(body).not.toContain("SQLITE_CONSTRAINT_TRIGGER"); + } finally { + failedStartDatabase.exec("DROP TRIGGER reject_directed_recognition_start"); + failedStartDatabase.close(); + } + const directed = await client.startDirectedRecognition({ + registrationId: registration.id, + relativeScope: ".", + sourceIdentity: "tmdb", + sourceInput: { movie: "9509" }, + }); + const established = await waitForExecution(client, directed.id); + expect(established).toMatchObject({ + kind: "execution", + id: directed.id, + operationKind: "scan-filesystem", + status: "completed", + }); + expect(["accepted", "partially-succeeded"]).toContain(established.outcome); + const subjectId = + established.kind === "execution" + ? established.progress.workItems[0]?.acceptedAssetId + : undefined; + if (subjectId === undefined) + throw new Error("正式产品用例没有建立受控影视主体"); + const covers = await client.inspectArtifactRoleCovers(subjectId); + const selectedCover = covers.candidates[0]; + if (selectedCover === undefined) + throw new Error("受控来源识别没有留下封面候选"); + const coverPreview = await client.prepareArtifactRoleDecision(subjectId, { + initiatedBy: "operator", + candidateReference: selectedCover.reference, + }); + expect(coverPreview.status).toBe("awaiting-confirmation"); + const failedConfirmationDatabase = new DatabaseSync( + path.join(productDataDirectory, "catalogue.sqlite"), + ); + failedConfirmationDatabase.exec(` + CREATE TRIGGER reject_artifact_role_decision_insert + BEFORE INSERT ON artifact_role_decisions + BEGIN + SELECT RAISE(ABORT, '10 confidential cover write failure'); + END; + `); + try { + const failedConfirmation = await fetch( + new URL( + `/api/v1/change-impact-previews/${encodeURIComponent(coverPreview.previewId)}/confirm`, + baseUrl, + ), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirmedBy: "operator" }), + }, + ); + const body = await failedConfirmation.text(); + expect(failedConfirmation.status).toBe(503); + expect(body).toContain("暂时无法确认变更影响"); + expect(body).not.toContain("10 confidential cover write failure"); + expect(body).not.toContain("SQLITE_CONSTRAINT_TRIGGER"); + } finally { + failedConfirmationDatabase.exec( + "DROP TRIGGER reject_artifact_role_decision_insert", + ); + failedConfirmationDatabase.close(); + } + await expect( + client.confirmChangeImpactPreview(coverPreview.previewId, "operator"), + ).resolves.toMatchObject({ status: "applied" }); + await expect( + client.inspectArtifactRoleCovers(subjectId), + ).resolves.toMatchObject({ + effectiveReference: selectedCover.reference, + decision: { kind: "select", reference: selectedCover.reference }, + }); + const unavailableCoverDatabase = new DatabaseSync( + path.join(productDataDirectory, "catalogue.sqlite"), + ); + unavailableCoverDatabase.exec( + "ALTER TABLE artifact_role_decisions RENAME TO unavailable_artifact_role_decisions", + ); + try { + const unavailableCover = await fetch( + new URL( + `/api/v1/assets/${encodeURIComponent(subjectId)}/artifact-role-covers`, + baseUrl, + ), + ); + const body = await unavailableCover.text(); + expect(unavailableCover.status).toBe(503); + expect(body).toContain("暂时无法检查封面候选"); + expect(body).not.toContain("no such table"); + } finally { + unavailableCoverDatabase.exec( + "ALTER TABLE unavailable_artifact_role_decisions RENAME TO artifact_role_decisions", + ); + unavailableCoverDatabase.close(); + } + + const scanDirectory = path.join(root, "扫描用普通目录"); + await mkdir(scanDirectory); + await writeFile(path.join(scanDirectory, "普通说明.txt"), "扫描目录材料\n"); + const scanLibrary = await client.saveAssetLibrary({ + displayName: "07 普通目录扫描", + directories: [scanDirectory], + recognitionProfileIds: ["movies"], + sourceIdentities: [], + }); + const scanStart = await client.startAssetLibraryScan(scanLibrary.id); + const scan = await waitForScan(client, scanStart.parentExecutionId); + expect(scan).toMatchObject({ + parentExecutionId: scanStart.parentExecutionId, + status: "completed", + }); + const scanExecution = await client.inspectExecution( + scanStart.parentExecutionId, + ); + expect(scanExecution).toMatchObject({ + kind: "asset-library-scan", + id: scanStart.parentExecutionId, + status: "completed", + children: [ + { + executionId: expect.stringContaining("execution_"), + status: "available", + }, + ], + }); + + const rescrape = await client.startCurrentScopeRescrape({ subjectId }); + const rescraped = await waitForExecution(client, rescrape.id); + expect(rescraped).toMatchObject({ + kind: "execution", + id: rescrape.id, + operationKind: "rescrape", + status: "completed", + }); + const listedAssets = await client.listAssets({ limit: 100 }); + expect(listedAssets.items.some((asset) => asset.id === subjectId)).toBe(true); + + const metadata = await client.startMetadataRefresh({ subjectId }); + const metadataResult = await waitForExecution(client, metadata.id); + expect(metadataResult).toMatchObject({ + kind: "execution", + id: metadata.id, + operationKind: "refresh-metadata", + status: "completed", + sourceRefreshRuns: expect.arrayContaining([ + expect.objectContaining({ + phase: "terminal", + changeSets: expect.arrayContaining([ + expect.objectContaining({ id: expect.any(String) }), + ]), + }), + ]), + }); + if (metadataResult.kind !== "execution") + throw new Error("元数据刷新返回了库扫描检查"); + expect(["succeeded", "partially-succeeded"]).toContain( + metadataResult.sourceRefreshRuns[0]?.outcome, + ); + const changeSetId = metadataResult.sourceRefreshRuns[0]?.changeSets[0]?.id; + expect(changeSetId).toBeTruthy(); + + const reviewExecution = await client.startMetadataRefresh({ + subjectId, + reviewPolicy: "review", + }); + await expect + .poll( + async () => { + const inspection = await client.inspectExecution(reviewExecution.id); + return inspection.kind === "execution" + ? inspection.sourceRefreshRuns.flatMap((run) => + run.changeSets.filter( + (changeSet) => changeSet.disposition === "pending", + ), + ).length + : 0; + }, + { timeout: 30_000 }, + ) + .toBeGreaterThan(0); + const reviewInspection = await client.inspectExecution(reviewExecution.id); + if (reviewInspection.kind !== "execution") + throw new Error("来源更新审核返回了资料库扫描"); + const reviewRun = reviewInspection.sourceRefreshRuns[0]; + const pendingChangeSet = reviewRun?.changeSets.find( + (changeSet) => changeSet.disposition === "pending", + ); + if (reviewRun === undefined || pendingChangeSet === undefined) + throw new Error("来源更新没有待审核变更集"); + await expect( + client.reviewSourceRefreshChangeSet(reviewRun.id, { + changeSetId: "missing-change-set", + disposition: "approved", + }), + ).rejects.toMatchObject({ kind: "conflict", status: 409 }); + const reviewedRun = await client.reviewSourceRefreshChangeSet(reviewRun.id, { + changeSetId: pendingChangeSet.id, + disposition: "approved", + }); + expect( + reviewedRun.changeSets.find(({ id }) => id === pendingChangeSet.id), + ).toMatchObject({ disposition: "approved" }); + await expect( + client.inspectSourceRefreshRun(reviewRun.id), + ).resolves.toMatchObject({ + changeSets: expect.arrayContaining([ + expect.objectContaining({ + id: pendingChangeSet.id, + disposition: "approved", + }), + ]), + }); + + const resetGate = blockNextSourceRequest(); + nextGate = resetGate; + const beforeRecoveryPostCount = refreshPostCount; + const pending = await client.startMetadataRefresh({ subjectId }); + await resetGate.entered; + const proxy = await startExecutionResetProxy(baseUrl, pending.id); + const interruptedClient = createPublicApiClient({ baseUrl: proxy.url }); + let queryError: unknown; + try { + await interruptedClient.inspectExecution(pending.id); + } catch (error) { + queryError = error; + } + expect(proxy.state).toMatchObject({ + resetExecutionId: pending.id, + responseStatus: 200, + responseExecutionStatus: "running", + }); + expect(queryError).toBeInstanceOf(PublicExecutionClientError); + expect(queryError).toMatchObject({ + kind: "transport", + executionId: pending.id, + cause: { cause: { code: "ECONNRESET" } }, + }); + expect( + (await fetch(new URL("/api/v1/asset-libraries", baseUrl))).status, + ).toBe(200); + resetGate.releaseRequest(); + const recovered = await waitForExecution(client, pending.id); + expect(recovered).toMatchObject({ + kind: "execution", + id: pending.id, + status: "completed", + }); + expect(refreshPostCount).toBe(beforeRecoveryPostCount + 1); + + const cancelGate = blockNextSourceRequest(); + nextGate = cancelGate; + const cancellable = await client.startMetadataRefresh({ subjectId }); + await cancelGate.entered; + const cancellation = await client.cancelExecution(cancellable.id, { + reason: "验证取消请求与实际结局分离", + }); + expect(cancellation.requestStatus).toBe("requested"); + expect(cancellation.inspection).toMatchObject({ + kind: "execution", + id: cancellable.id, + status: "failed", + outcome: "cancelled", + cancellation: { requestedBy: "public-api" }, + sourceRefreshRuns: [ + expect.objectContaining({ phase: "terminal", outcome: "cancelled" }), + ], + }); + cancelGate.releaseRequest(); + const cancelled = await waitForExecution(client, cancellable.id); + expect(cancelled).toMatchObject({ + kind: "execution", + id: cancellable.id, + status: "failed", + outcome: "cancelled", + }); + await expect( + client.inspectExecution("execution-missing"), + ).rejects.toMatchObject({ + kind: "not-found", + status: 404, + }); + const sourceHistory = await client.inspectSourceAssociationHistory(subjectId); + const currentAssociation = sourceHistory.current[0]; + if (currentAssociation === undefined) + throw new Error("正式来源识别没有建立可修复的来源关联"); + const decisionStart = await client.startSourceAssociationDecision({ + intent: "retract", + assetId: subjectId, + predecessorAssociationId: currentAssociation.id, + initiatedBy: "operator", + reason: "验证公开来源关联撤回流程", + }); + const decision = await client.inspectSourceAssociationDecision( + decisionStart.executionId, + ); + expect(decision).toMatchObject({ + executionId: decisionStart.executionId, + intent: "retract", + predecessor: { id: currentAssociation.id }, + preview: { status: "awaiting-confirmation" }, + }); + const unavailableDecisionDatabase = new DatabaseSync( + path.join(productDataDirectory, "catalogue.sqlite"), + ); + unavailableDecisionDatabase.exec( + "ALTER TABLE workflow_executions RENAME TO unavailable_workflow_executions", + ); + try { + const unavailableDecision = await fetch( + new URL( + `/api/v1/source-association-decisions/${encodeURIComponent(decisionStart.executionId)}`, + baseUrl, + ), + ); + const body = await unavailableDecision.text(); + expect(unavailableDecision.status).toBe(503); + expect(body).toContain("暂时无法读取来源关联决定"); + expect(body).not.toContain("no such table"); + } finally { + unavailableDecisionDatabase.exec( + "ALTER TABLE unavailable_workflow_executions RENAME TO workflow_executions", + ); + unavailableDecisionDatabase.close(); + } + await expect( + client.selectSourceAssociationCandidate( + decisionStart.executionId, + "candidate-from-another-decision", + ), + ).rejects.toMatchObject({ kind: "conflict", status: 409 }); + const associationRetraction = await client.confirmChangeImpactPreview( + decision.preview!.previewId, + "operator", + ); + expect(associationRetraction.status).toBe("applied"); + const retractedHistory = + await client.inspectSourceAssociationHistory(subjectId); + expect(retractedHistory.current).toEqual([]); + expect(retractedHistory.historical).toContainEqual( + expect.objectContaining({ + association: expect.objectContaining({ id: currentAssociation.id }), + status: "retracted", + }), + ); + expect(changeSetId).toBeTruthy(); +}, 180_000); + +it("已安装 TUI 在查询连接重置后按原 ID 恢复并取消真实执行【安装客户端】", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc07-execution-tui-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialDirectory = path.join(root, "影视收藏"); + const sourceMaterial = path.join( + materialRoot, + "films", + "[怒火救援].Man.on.Fire.2004.Blu-ray.1080p.AVC.DTS-HD.MA5.1-CMCT", + ); + await cp(sourceMaterial, materialDirectory, { recursive: true }); + await writeFile( + path.join(materialDirectory, "BDMV", "MovieObject.bdmv"), + "受控目录结构标记", + ); + await prepareProductionCatalogue({ productDataDirectory }); + + const controlled = await loadControlledSourceIo({ manifestPath }); + let nextGate: ReturnType | undefined; + const sourceIo = { + mode: "controlled" as const, + async request( + input: string, + init?: Parameters[1], + ) { + const gate = nextGate; + if (gate !== undefined) { + nextGate = undefined; + gate.markEntered(); + await gate.release; + } + return await controlled.request(input, init); + }, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + const product = runProduct( + { + productDataDirectory, + environment: {}, + sourceIo, + }, + { start: (application) => application }, + ); + products.push(product); + const application = product.result; + application.workflow.sourceConnections.tmdb.configureAccessToken( + "controlled-test-tmdb-token", + ); + await application.workflow.sourceConnections.tmdb.verify(); + const api = new Hono(); + api.route("/api/v1", createPublicHttpApi(application)); + const server = serve({ + fetch: api.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + serviceServers.push(server); + await server.ready(); + const baseUrl = new URL(server.url!); + const client = createPublicApiClient({ baseUrl }); + const library = await application.workflow.assetLibraries.save({ + displayName: "07 受控影视库", + directories: [materialDirectory], + recognitionProfileIds: ["movies"], + sourceIdentities: ["tmdb"], + }); + const [registration] = await application.workflow.assetLibraries.directories( + library.id, + ); + if (registration === undefined) throw new Error("正式资产库没有登记影视目录"); + const directed = await client.startDirectedRecognition({ + registrationId: registration.id, + relativeScope: ".", + sourceIdentity: "tmdb", + sourceInput: { movie: "9509" }, + }); + const established = await waitForExecution(client, directed.id); + expect(established).toMatchObject({ + kind: "execution", + id: directed.id, + operationKind: "scan-filesystem", + status: "completed", + }); + expect(["accepted", "partially-succeeded"]).toContain( + established.kind === "execution" ? established.outcome : undefined, + ); + const subjectId = + established.kind === "execution" + ? established.progress.workItems[0]?.acceptedAssetId + : undefined; + if (subjectId === undefined) + throw new Error("正式产品用例没有建立受控影视主体"); + + const libraries = await client.listAssetLibraries({ limit: 100 }); + const terminalLibraryIndex = libraries.items.findIndex( + ({ id }) => id === library.id, + ); + expect(terminalLibraryIndex).toBeGreaterThanOrEqual(0); + const terminalAssets = await client.listAssets({ + assetLibraryId: library.id, + limit: 20, + }); + const terminalAssetIndex = terminalAssets.items.findIndex( + ({ id }) => id === subjectId, + ); + expect(terminalAssetIndex).toBeGreaterThanOrEqual(0); + const terminalAsset = terminalAssets.items[terminalAssetIndex]; + if (terminalAsset === undefined) + throw new Error("正式资产主体不在终端资产页中"); + const terminalAssetTitle = terminalAsset.descriptions + .filter( + (description) => + description.target.kind === "entity" && + description.target.entityId === terminalAsset.id, + ) + .flatMap((description) => description.metadata) + .find( + (metadata) => + metadata.property === "asset.title" && + metadata.result.status === "resolved", + )?.result; + const terminalAssetLabel = + (terminalAssetTitle?.status === "resolved" + ? terminalAssetTitle.values[0]?.value + : undefined) ?? + terminalAsset.relatedEntities.find( + (entity) => entity.id === terminalAsset.id, + )?.label; + if (terminalAssetLabel === undefined) + throw new Error("正式资产主体没有可显示的名称"); + + const terminalGate = blockNextSourceRequest(); + nextGate = terminalGate; + const terminalProxy = await startExecutionResetProxy(baseUrl); + const terminalCommand = `stty cols 58 rows 18 -icanon -echo; terminalModeBefore=$(stty -g); pnpm --filter @digital/service exec digital-tui --url ${terminalProxy.url.origin}; terminalModeAfter=$(stty -g); if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi`; + const terminal = spawn("script", ["-qfec", terminalCommand, "/dev/null"], { + cwd: servicePackageRoot, + env: interactiveTerminalEnvironment({ + TERM: "xterm-256color", + COLUMNS: "58", + LINES: "18", + }), + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + }); + terminals.push(terminal); + const terminalOutputChunks: Buffer[] = []; + const terminalErrorChunks: Buffer[] = []; + const terminalText = () => + Buffer.concat(terminalOutputChunks).toString("utf8"); + terminal.stdout.on("data", (chunk: Buffer) => + terminalOutputChunks.push(chunk), + ); + terminal.stderr.on("data", (chunk: Buffer) => + terminalErrorChunks.push(chunk), + ); + + await waitForProxy( + terminalProxy.state, + () => + terminalProxy.state.completedPaths.includes( + "/api/v1/asset-libraries?limit=20", + ), + "TUI 未读取真实资产库列表", + ); + await waitForProxy( + terminalProxy.state, + () => terminalText().includes("07 受控影视库"), + "终端未显示正式资产库", + ); + const firstLibrary = libraries.items[0]; + if (firstLibrary === undefined) throw new Error("正式资产库列表为空"); + await waitForTerminal( + () => terminalText().includes(`› ${firstLibrary.displayName}`), + terminal, + terminalText, + "TUI 未显示初始选中的资产库", + ); + for (let index = 0; index < terminalLibraryIndex; index += 1) { + const nextLibrary = libraries.items[index + 1]; + if (nextLibrary === undefined) + throw new Error("资产库列表索引超出当前结果"); + terminal.stdin.write("j"); + await waitForTerminal( + () => terminalText().includes(`› ${nextLibrary.displayName}`), + terminal, + terminalText, + "TUI 未移动到目标资产库", + ); + } + await waitForTerminal( + () => terminalText().includes(`› ${library.displayName}`), + terminal, + terminalText, + "终端未选择目标资产库", + ); + terminal.stdin.write("\r"); + const assetListPath = `/api/v1/assets?assetLibraryId=${encodeURIComponent(library.id)}&limit=20`; + await waitForProxy( + terminalProxy.state, + () => terminalProxy.state.completedPaths.includes(assetListPath), + () => + `TUI 未读取正式资产主体;请求 ${JSON.stringify(terminalProxy.state.requests)};终端错误 ${Buffer.concat(terminalErrorChunks).toString("utf8")};终端输出 ${terminalText()}`, + 5_000, + ); + await waitForTerminal( + () => + terminalText().includes("m 元数据刷新") && + terminalText().includes(`› ${terminalAssetLabel.slice(0, 18)}`), + terminal, + terminalText, + "TUI 未显示目标资产及元数据刷新操作", + 5_000, + ); + for (let index = 0; index < terminalAssetIndex; index += 1) { + const nextAsset = terminalAssets.items[index + 1]; + if (nextAsset === undefined) throw new Error("正式资产页索引超出当前结果"); + const nextTitle = nextAsset.descriptions + .filter( + (description) => + description.target.kind === "entity" && + description.target.entityId === nextAsset.id, + ) + .flatMap((description) => description.metadata) + .find( + (metadata) => + metadata.property === "asset.title" && + metadata.result.status === "resolved", + )?.result; + const nextLabel = + (nextTitle?.status === "resolved" + ? nextTitle.values[0]?.value + : undefined) ?? + nextAsset.relatedEntities.find((entity) => entity.id === nextAsset.id) + ?.label; + if (nextLabel === undefined) throw new Error("资产页包含无法显示的主体"); + terminal.stdin.write("j"); + await waitForTerminal( + () => terminalText().includes(`› ${nextLabel.slice(0, 18)}`), + terminal, + terminalText, + "TUI 未移动到下一资产", + ); + } + terminal.stdin.write("m"); + await waitForProxy( + terminalProxy.state, + () => terminalGate.hasEntered(), + () => + `TUI 元数据刷新未触发来源请求;gateEntered=${terminalGate.hasEntered()};请求 ${JSON.stringify(terminalProxy.state.requests)};终端错误 ${Buffer.concat(terminalErrorChunks).toString("utf8")};终端输出 ${terminalText()}`, + ); + await waitForProxy( + terminalProxy.state, + () => terminalProxy.state.startedExecutionId !== undefined, + "TUI 未通过真实 HTTP 发起元数据刷新", + ); + const terminalExecutionId = terminalProxy.state.startedExecutionId; + if (terminalExecutionId === undefined) + throw new Error("TUI 刷新没有取得正式执行身份"); + await waitForProxy( + terminalProxy.state, + () => terminalProxy.state.resetExecutionId === terminalExecutionId, + "真实 TUI 执行查询没有遇到受控连接中断", + ); + await waitForProxy( + terminalProxy.state, + () => + terminalProxy.state.executionResponses.filter( + ({ id }) => id === terminalExecutionId, + ).length >= 2, + "TUI 未以原执行 ID 恢复查询", + ); + terminal.stdin.write("c"); + await waitForProxy( + terminalProxy.state, + () => terminalProxy.state.cancelRequestExecutionId === terminalExecutionId, + "TUI 未通过真实 HTTP 提交原执行取消请求", + ); + terminalGate.releaseRequest(); + await waitForProxy( + terminalProxy.state, + () => + terminalProxy.state.executionResponses.some( + ({ id, outcome }) => + id === terminalExecutionId && outcome === "cancelled", + ), + "TUI 未读取原执行的持久取消结局", + ); + await waitForProxy( + terminalProxy.state, + () => + terminalText().includes("刷新已取消") && + terminalText().includes("状态:已取消"), + "终端未显示真实取消结局", + ); + terminal.stdin.write("q"); + const terminalExitCode = await new Promise( + (resolve, reject) => { + terminal.once("error", reject); + terminal.once("close", (code) => resolve(code)); + setTimeout(() => { + if (terminal.pid === undefined) return; + try { + process.kill(-terminal.pid, "SIGTERM"); + } catch { + terminal.kill("SIGTERM"); + } + }, 15_000).unref(); + }, + ); + const terminalOutput = terminalText(); + const terminalError = Buffer.concat(terminalErrorChunks).toString("utf8"); + expect(terminalExitCode, `${terminalError}\n${terminalOutput}`).toBe(0); + expect(terminalOutput).toContain(terminalExecutionId); + expect(terminalOutput).toContain("执行结局仍未知"); + expect(terminalOutput).toContain("刷新已取消"); + expect(terminalOutput).toContain("状态:已取消"); + expect(terminalOutput).toContain("来源刷新"); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + expect( + terminalProxy.state.requests.filter( + ({ method, path }) => method === "POST" && path === "/api/v1/refreshes", + ), + ).toHaveLength(1); + expect( + terminalProxy.state.requests.filter( + ({ method, path }) => + method === "POST" && + path === `/api/v1/executions/${terminalExecutionId}/cancel`, + ), + ).toHaveLength(1); +}, 180_000); + +function blockNextSourceRequest(): { + readonly entered: Promise; + readonly markEntered: () => void; + readonly release: Promise; + readonly releaseRequest: () => void; + readonly hasEntered: () => boolean; +} { + let markEntered!: () => void; + let releaseRequest!: () => void; + let enteredSourceRequest = false; + const entered = new Promise((resolve) => { + markEntered = () => { + enteredSourceRequest = true; + resolve(); + }; + }); + const release = new Promise((resolve) => { + releaseRequest = resolve; + }); + const gate = { + entered, + markEntered, + release, + releaseRequest: () => releaseRequest(), + hasEntered: () => enteredSourceRequest, + }; + sourceGates.push(gate); + return gate; +} + +async function waitForExecution( + client: ReturnType, + executionId: string, +): Promise>> { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const inspection = await client.inspectExecution(executionId); + if (inspection.status !== "running") return inspection; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`执行未在期限内结束:${executionId}`); +} + +async function waitForScan( + client: ReturnType, + executionId: string, +): Promise>> { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + const scan = await client.inspectAssetLibraryScan(executionId); + if (scan.status !== "running") return scan; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`扫描未在期限内结束:${executionId}`); +} + +type ExecutionResetProxyState = { + resetExecutionId: string | undefined; + responseStatus: number | undefined; + responseExecutionStatus: string | undefined; + startedExecutionId: string | undefined; + cancelRequestExecutionId: string | undefined; + readonly requests: Array<{ readonly method: string; readonly path: string }>; + readonly completedPaths: string[]; + readonly executionResponses: Array<{ + readonly id: string; + readonly status: string; + readonly outcome: string | undefined; + }>; +}; + +async function waitForProxy( + state: ExecutionResetProxyState, + condition: () => boolean, + failure: string | (() => string), + timeoutMs = 20_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error( + `${typeof failure === "function" ? failure() : failure};请求数 ${state.requests.length}`, + ); +} + +async function waitForTerminal( + condition: () => boolean, + terminal: ReturnType, + output: () => string, + description: string, + timeoutMs = 20_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + if (terminal.exitCode !== null || terminal.signalCode !== null) + throw new Error( + `${description};TUI 已退出,状态 ${terminal.exitCode ?? terminal.signalCode};终端输出 ${output()}`, + ); + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`${description};终端输出 ${output()}`); +} + +async function startExecutionResetProxy( + serviceUrl: URL, + executionId?: string, +): Promise<{ + readonly url: URL; + readonly state: ExecutionResetProxyState; +}> { + const state: ExecutionResetProxyState = { + resetExecutionId: undefined, + responseStatus: undefined, + responseExecutionStatus: undefined, + startedExecutionId: undefined, + cancelRequestExecutionId: undefined, + requests: [], + completedPaths: [], + executionResponses: [], + }; + const proxy = createHttpServer((incoming, outgoing) => { + void (async () => { + const chunks: Buffer[] = []; + for await (const chunk of incoming) chunks.push(Buffer.from(chunk)); + const requestBody = Buffer.concat(chunks); + const requestPath = incoming.url ?? "/"; + const method = incoming.method ?? "GET"; + state.requests.push({ method, path: requestPath }); + const headers = new Headers(); + for (const [name, value] of Object.entries(incoming.headers)) + if ( + typeof value === "string" && + name !== "host" && + name !== "connection" && + name !== "content-length" && + name !== "transfer-encoding" + ) + headers.set(name, value); + const response = await fetch(new URL(requestPath, serviceUrl), { + method, + headers, + ...(requestBody.length === 0 + ? {} + : { body: new Uint8Array(requestBody) }), + }); + const responseBody = new Uint8Array(await response.arrayBuffer()); + if (method === "POST" && requestPath === "/api/v1/refreshes") { + const started = JSON.parse( + Buffer.from(responseBody).toString("utf8"), + ) as { readonly id?: unknown }; + state.startedExecutionId = + typeof started.id === "string" ? started.id : undefined; + } + if (method === "POST") { + const cancellationPath = requestPath.match( + /^\/api\/v1\/executions\/([^/]+)\/cancel$/, + ); + if (cancellationPath?.[1] !== undefined) + state.cancelRequestExecutionId = decodeURIComponent( + cancellationPath[1], + ); + } + const executionPath = requestPath.match( + /^\/api\/v1\/executions\/([^/]+)$/, + ); + if (method === "GET" && executionPath?.[1] !== undefined) { + const executionReply = JSON.parse( + Buffer.from(responseBody).toString("utf8"), + ) as { + readonly id?: unknown; + readonly status?: unknown; + readonly outcome?: unknown; + }; + if ( + typeof executionReply.id === "string" && + typeof executionReply.status === "string" + ) + state.executionResponses.push({ + id: executionReply.id, + status: executionReply.status, + outcome: + typeof executionReply.outcome === "string" + ? executionReply.outcome + : undefined, + }); + } + const resetTarget = executionId ?? state.startedExecutionId; + if ( + state.resetExecutionId === undefined && + method === "GET" && + resetTarget !== undefined && + requestPath === `/api/v1/executions/${resetTarget}` + ) { + state.resetExecutionId = resetTarget; + state.responseStatus = response.status; + const executionReply = state.executionResponses.at(-1); + state.responseExecutionStatus = executionReply?.status; + outgoing.socket?.resetAndDestroy(); + return; + } + const responseHeaders: Record = {}; + for (const [name, value] of response.headers) + if (name !== "connection" && name !== "transfer-encoding") + responseHeaders[name] = value; + outgoing.writeHead(response.status, responseHeaders); + outgoing.end(responseBody, () => { + state.completedPaths.push(requestPath); + }); + })().catch((error: unknown) => { + if (!outgoing.destroyed) outgoing.destroy(error as Error); + }); + }); + await new Promise((resolve, reject) => { + proxy.once("error", reject); + proxy.listen(0, "127.0.0.1", resolve); + }); + proxies.push(proxy); + const address = proxy.address(); + if (address === null || typeof address === "string") + throw new Error("执行恢复代理没有取得端口"); + return { + url: new URL(`http://127.0.0.1:${String(address.port)}`), + state, + }; +} diff --git a/apps/service/test/fanbox-connection-http.test.ts b/apps/service/test/fanbox-connection-http.test.ts new file mode 100644 index 0000000..e42949d --- /dev/null +++ b/apps/service/test/fanbox-connection-http.test.ts @@ -0,0 +1,391 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import type { ProductSourceIo } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import type { FanboxEndpointSessionTransport } from "@digital/fanbox"; +import { createPublicApiClient } from "@digital/public-api"; +import { createProcessLifecycleRegistry } from "@digital/service-host"; +import { refusingSessionTransport } from "@digital/test-support/source-io"; +import { serve } from "srvx"; +import { afterEach, expect, it, vi } from "vitest"; + +import { createProductService } from "../src/service.ts"; + +const servers: Array>> = []; +const bootstraps: Array>> = []; +const temporaryRoots: string[] = []; + +afterEach(async () => { + vi.restoreAllMocks(); + for (const server of servers.splice(0).reverse()) await server.close(true); + for (const bootstrap of bootstraps.splice(0).reverse()) + await bootstrap.abort(); + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); +}); + +it("创建限范围的限时配对请求,并拒绝其他服务实例", async () => { + const firstBaseUrl = await startService(); + const secondBaseUrl = await startService(); + const firstClient = createPublicApiClient({ baseUrl: firstBaseUrl }); + const secondClient = createPublicApiClient({ baseUrl: secondBaseUrl }); + await firstClient.listSourceConnections(); + await secondClient.listSourceConnections(); + + const created = await firstClient.createFanboxConnectionRequest({ + creatorId: "customudon", + }); + expect(created.request).toMatchObject({ + creatorId: "customudon", + instanceId: expect.any(String), + status: "waiting", + }); + expect(created.pairingCode).toMatch(/^[A-Za-z0-9_-]{43}$/u); + const lifetime = + Date.parse(created.request.expiresAt) - + Date.parse(created.request.createdAt); + expect(lifetime).toBeGreaterThan(0); + expect(lifetime).toBeLessThanOrEqual(5 * 60 * 1000); + + await expect( + secondClient.getFanboxConnectionRequestForPairing({ + requestId: created.request.id, + pairingCode: created.pairingCode, + }), + ).rejects.toMatchObject({ kind: "not-found", status: 404 }); + + const target = await firstClient.getFanboxConnectionRequestForPairing({ + requestId: created.request.id, + pairingCode: created.pairingCode, + }); + expect(target).toMatchObject({ + creatorId: "customudon", + instanceId: created.request.instanceId, + status: "waiting", + }); + expect(JSON.stringify(target)).not.toContain(created.pairingCode); +}); + +it("请求取消后不保存之后回传的浏览器上下文", async () => { + const baseUrl = await startService(); + const client = createPublicApiClient({ baseUrl }); + const created = await client.createFanboxConnectionRequest({ + creatorId: "customudon", + }); + + const cancelled = await client.cancelFanboxConnectionRequest({ + requestId: created.request.id, + pairingCode: created.pairingCode, + }); + expect(cancelled.status).toBe("cancelled"); + + const lateCompletion = await client.completeFanboxConnectionRequest({ + requestId: created.request.id, + pairingCode: created.pairingCode, + browserCredential: { + userAgent: "controlled-browser/1", + cookies: [ + { + name: "FANBOXSESSID", + value: "synthetic-late-session-value", + domain: ".fanbox.cc", + path: "/", + }, + ], + }, + }); + expect(lateCompletion.status).toBe("cancelled"); + expect((await client.listSourceConnections()).items).not.toContainEqual( + expect.objectContaining({ id: "fanbox/customudon" }), + ); +}); + +it("请求过期后不接受之后回传的浏览器上下文", async () => { + const baseUrl = await startService(); + const client = createPublicApiClient({ baseUrl }); + const created = await client.createFanboxConnectionRequest({ + creatorId: "customudon", + }); + const now = vi + .spyOn(Date, "now") + .mockReturnValue(Date.parse(created.request.expiresAt) + 1); + + try { + const expired = await client.getFanboxConnectionRequest(created.request.id); + expect(expired.status).toBe("expired"); + const lateCompletion = await client.completeFanboxConnectionRequest({ + requestId: created.request.id, + pairingCode: created.pairingCode, + browserCredential: syntheticBrowserCredential("synthetic-expired-value"), + }); + expect(lateCompletion.status).toBe("expired"); + } finally { + now.mockRestore(); + } + + expect((await client.listSourceConnections()).items).not.toContainEqual( + expect.objectContaining({ id: "fanbox/customudon" }), + ); +}); + +it("FANBOX 验证失败时若请求已过期则报告过期", async () => { + const verification = gatedSessionTransport( + "synthetic-expired-verification-value", + ); + const baseUrl = await startService(sourceIoWith(verification.transport)); + const client = createPublicApiClient({ baseUrl }); + const created = await client.createFanboxConnectionRequest({ + creatorId: "customudon", + }); + const now = vi + .spyOn(Date, "now") + .mockReturnValue(Date.parse(created.request.createdAt) + 1); + + try { + const completion = client.completeFanboxConnectionRequest({ + requestId: created.request.id, + pairingCode: created.pairingCode, + browserCredential: syntheticBrowserCredential( + "synthetic-expired-verification-value", + ), + }); + await verification.started; + expect( + (await client.getFanboxConnectionRequest(created.request.id)).status, + ).toBe("verifying"); + + now.mockReturnValue(Date.parse(created.request.expiresAt) + 1); + verification.release(); + expect((await completion).status).toBe("expired"); + } finally { + verification.release(); + now.mockRestore(); + } + + expect((await client.listSourceConnections()).items).not.toContainEqual( + expect.objectContaining({ id: "fanbox/customudon" }), + ); +}); + +it.each(["cancel", "revoke", "replacement"] as const)( + "does not persist validation that finishes after pairing %s", + async (interruption) => { + const verification = gatedSessionTransport(); + const baseUrl = await startService(sourceIoWith(verification.transport)); + const client = createPublicApiClient({ baseUrl }); + const created = await client.createFanboxConnectionRequest({ + creatorId: "customudon", + }); + + const completion = client.completeFanboxConnectionRequest({ + requestId: created.request.id, + pairingCode: created.pairingCode, + browserCredential: syntheticBrowserCredential("synthetic-racing-value"), + }); + await verification.started; + expect( + (await client.getFanboxConnectionRequest(created.request.id)).status, + ).toBe("verifying"); + + if (interruption === "cancel") { + const cancelled = await client.cancelFanboxConnectionRequest({ + requestId: created.request.id, + pairingCode: created.pairingCode, + }); + expect(cancelled.status).toBe("cancelled"); + } else if (interruption === "revoke") { + const revoked = await client.revokeFanboxSourceConnection({ + creatorId: "customudon", + expectedVersion: 0, + }); + expect(revoked.connection.credentialStatus).toBe("revoked"); + expect( + (await client.getFanboxConnectionRequest(created.request.id)).status, + ).toBe("superseded"); + } else { + const replacement = await client.createFanboxConnectionRequest({ + creatorId: "customudon", + }); + expect( + (await client.getFanboxConnectionRequest(created.request.id)).status, + ).toBe("superseded"); + verification.release(); + expect((await completion).status).toBe("superseded"); + expect( + ( + await client.completeFanboxConnectionRequest({ + requestId: replacement.request.id, + pairingCode: replacement.pairingCode, + browserCredential: syntheticBrowserCredential( + "synthetic-replacement-value", + ), + }) + ).status, + ).toBe("succeeded"); + } + + if (interruption !== "replacement") { + verification.release(); + expect((await completion).status).toBe( + interruption === "cancel" ? "cancelled" : "superseded", + ); + } + const connection = (await client.listSourceConnections()).items.find( + ({ id }) => id === "fanbox/customudon", + ); + if (interruption === "cancel") expect(connection).toBeUndefined(); + else if (interruption === "replacement") + expect(connection).toMatchObject({ + id: "fanbox/customudon", + version: 1, + credentialStatus: "configured", + availability: "available", + }); + else + expect(connection).toMatchObject({ + id: "fanbox/customudon", + version: 1, + credentialStatus: "revoked", + }); + }, +); + +function gatedSessionTransport(rejectedCookieValue?: string) { + let releaseVerification!: () => void; + let markVerificationStarted!: () => void; + const gate = new Promise((resolve) => { + releaseVerification = resolve; + }); + const started = new Promise((resolve) => { + markVerificationStarted = resolve; + }); + const transport: FanboxEndpointSessionTransport = { + async transact(input) { + markVerificationStarted(); + await gate; + const cookieValue = input.privateContext.cookies.find( + ({ name }) => name === "FANBOXSESSID", + )?.value; + return await input.use(async (endpoint) => ({ + kind: "response", + status: 200, + mediaType: "application/json", + body: new TextEncoder().encode( + JSON.stringify( + cookieValue === rejectedCookieValue + ? { error: "controlled rejection" } + : responseFor(endpoint.kind), + ), + ), + })); + }, + async close() {}, + }; + return { transport, started, release: releaseVerification }; +} + +async function startService( + sourceIo: ProductSourceIo = refusingSourceIo(), +): Promise { + const root = await mkdtemp(path.join(tmpdir(), "digital-fanbox-pairing-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + await prepareProductionCatalogue({ productDataDirectory }); + const bootstrap = await createProductService( + { + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }, + { lifecycle: createIsolatedLifecycle() }, + ); + bootstraps.push(bootstrap); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + servers.push(server); + await server.ready(); + bootstrap.publish(); + const baseUrl = new URL(server.url!); + await waitUntilReady(baseUrl); + return baseUrl; +} + +function sourceIoWith( + sessionTransport: FanboxEndpointSessionTransport, +): ProductSourceIo { + return { + mode: "controlled", + request: async () => { + throw new Error("受控连接验收不允许未声明的来源请求"); + }, + sessionTransport, + }; +} + +function syntheticBrowserCredential(cookieValue: string) { + return { + userAgent: "controlled-browser/1", + cookies: [ + { + name: "FANBOXSESSID", + value: cookieValue, + domain: ".fanbox.cc", + path: "/", + }, + ], + }; +} + +function responseFor(endpointKind: string): unknown { + const post = { + id: "9135862", + creatorId: "customudon", + title: "受控验收帖子", + publishedDatetime: "2025-01-01T00:00:00+09:00", + }; + switch (endpointKind) { + case "login-status": + return { body: 0 }; + case "creator-summaries": + return { body: { posts: [post] } }; + case "post-detail": + return { body: { post } }; + default: + throw new Error("FANBOX 适配器请求了未定义的验收端点"); + } +} + +function createIsolatedLifecycle() { + return createProcessLifecycleRegistry({ + addSignalListener() {}, + removeSignalListener() {}, + forceExit() {}, + }); +} + +async function waitUntilReady(baseUrl: URL): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if ((await fetch(new URL("/api/v1/source-connections", baseUrl))).ok) + return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("受控 FANBOX 服务未就绪"); +} + +function refusingSourceIo(): ProductSourceIo { + return { + mode: "controlled", + request: async () => { + throw new Error("受控连接验收不允许未声明的来源请求"); + }, + sessionTransport: refusingSessionTransport(), + }; +} diff --git a/apps/service/test/fanbox-extension-browser.test.ts b/apps/service/test/fanbox-extension-browser.test.ts new file mode 100644 index 0000000..a93897e --- /dev/null +++ b/apps/service/test/fanbox-extension-browser.test.ts @@ -0,0 +1,906 @@ +import { spawn } from "node:child_process"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import type { FanboxEndpointSessionTransport } from "@digital/fanbox"; +import { createPublicApiClient } from "@digital/public-api"; +import { createProcessLifecycleRegistry } from "@digital/service-host"; +import { refusingSessionTransport } from "@digital/test-support/source-io"; +import { + chromium, + expect, + type BrowserContext, + type Page, + type Worker, +} from "@playwright/test"; +import { serve } from "srvx"; +import { afterEach, it } from "vitest"; + +import { createProductService } from "../src/service.ts"; +import { interactiveTerminalEnvironment } from "./interactive-terminal.ts"; + +const servers: Array>> = []; +const bootstraps: Array>> = []; +const browserContexts: BrowserContext[] = []; +const terminalChildren: Array> = []; +const temporaryRoots: string[] = []; +const capturedContexts: Array<{ + readonly creatorId: string; + readonly cookieValue: string; + readonly userAgent: string; +}> = []; + +async function openOptionsPage( + context: BrowserContext, + worker: Worker, + extensionId: string, +): Promise { + const optionsUrl = `chrome-extension://${extensionId}/options.html`; + await worker.evaluate(async () => { + const extensionChrome = ( + globalThis as typeof globalThis & { + chrome: { runtime: { openOptionsPage: () => Promise } }; + } + ).chrome; + await extensionChrome.runtime.openOptionsPage(); + }); + await expect + .poll(() => context.pages().some((page) => page.url() === optionsUrl)) + .toBe(true); + const optionsPage = context.pages().find((page) => page.url() === optionsUrl); + if (optionsPage === undefined) throw new Error("Chrome 没有打开扩展配置页"); + return optionsPage; +} + +async function readExtensionSessionStorage( + worker: Worker, +): Promise> { + return worker.evaluate(async () => { + const extensionChrome = ( + globalThis as typeof globalThis & { + chrome: { + storage: { + session: { + get: (key: null) => Promise>; + }; + }; + }; + } + ).chrome; + return extensionChrome.storage.session.get(null); + }); +} + +afterEach(async () => { + for (const child of terminalChildren.splice(0).reverse()) { + if (child.exitCode === null && child.signalCode === null) { + if (child.pid !== undefined && process.platform !== "win32") { + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + } else child.kill("SIGTERM"); + } + } + for (const context of browserContexts.splice(0).reverse()) + await context.close(); + for (const server of servers.splice(0).reverse()) await server.close(true); + for (const bootstrap of bootstraps.splice(0).reverse()) + await bootstrap.abort(); + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); + capturedContexts.length = 0; +}); + +it("通过扩展公开入口打开独立连接页,重复打开复用原页面【安装客户端】", async () => { + const extensionDirectory = fileURLToPath( + import.meta.resolve("@digital/fanbox-extension/extension"), + ); + const profile = await mkdtemp( + path.join(tmpdir(), "digital-fanbox-production-chromium-"), + ); + temporaryRoots.push(profile); + const context = await chromium.launchPersistentContext(profile, { + channel: "chromium", + headless: true, + args: [ + `--disable-extensions-except=${extensionDirectory}`, + `--load-extension=${extensionDirectory}`, + "--no-sandbox", + ], + }); + browserContexts.push(context); + const [existingWorker] = context.serviceWorkers(); + const worker = + existingWorker ?? (await context.waitForEvent("serviceworker")); + const extensionId = new URL(worker.url()).host; + const optionsPage = await openOptionsPage(context, worker, extensionId); + await expect(optionsPage.locator("h1")).toHaveText("连接 FANBOX"); + await expect( + optionsPage.getByRole("button", { name: "检查目标与创作者范围" }), + ).toBeVisible(); + const draft = { + serviceAddress: "http://127.0.0.1:31337", + instanceId: "11111111-1111-4111-8111-111111111111", + requestId: "22222222-2222-4222-8222-222222222222", + pairingCode: "A".repeat(43), + }; + await fillPairing(optionsPage, draft); + await expect + .poll(async () => readExtensionSessionStorage(worker)) + .toEqual({ fanboxConnectionDraft: draft }); + const otherPage = await context.newPage(); + await otherPage.bringToFront(); + await optionsPage.bringToFront(); + expect(optionsPage.isClosed()).toBe(false); + await expect(optionsPage.locator("#pairing-code")).toHaveValue( + draft.pairingCode, + ); + await otherPage.close(); + await expect( + optionsPage.getByRole("button", { name: "使用此浏览器登录状态连接" }), + ).toBeDisabled(); + await optionsPage.close(); + const reopenedPage = await openOptionsPage(context, worker, extensionId); + await expect(reopenedPage.locator("#service-url")).toHaveValue( + draft.serviceAddress, + ); + await expect(reopenedPage.locator("#instance-id")).toHaveValue( + draft.instanceId, + ); + await expect(reopenedPage.locator("#request-id")).toHaveValue( + draft.requestId, + ); + await expect(reopenedPage.locator("#pairing-code")).toHaveValue( + draft.pairingCode, + ); + await expect(reopenedPage.locator("#status")).toHaveText(""); + await expect( + reopenedPage.getByRole("button", { name: "使用此浏览器登录状态连接" }), + ).toBeDisabled(); + expect(await openOptionsPage(context, worker, extensionId)).toBe( + reopenedPage, + ); + expect( + context.pages().filter((page) => page.url() === reopenedPage.url()), + ).toHaveLength(1); +}, 60_000); + +it("编辑已核对的服务和请求后必须重新检查才会捕获浏览器会话【安装客户端】", async () => { + const firstBaseUrl = await startService(controlledSourceIo()); + const secondBaseUrl = await startService(controlledSourceIo()); + const firstClient = createPublicApiClient({ baseUrl: firstBaseUrl }); + const secondClient = createPublicApiClient({ baseUrl: secondBaseUrl }); + const first = await firstClient.createFanboxConnectionRequest({ + creatorId: "customudon", + }); + const second = await secondClient.createFanboxConnectionRequest({ + creatorId: "customudon", + }); + const builtExtensionDirectory = fileURLToPath( + import.meta.resolve("@digital/fanbox-extension/extension"), + ); + const profile = await mkdtemp( + path.join(tmpdir(), "digital-fanbox-review-binding-"), + ); + temporaryRoots.push(profile); + const extensionDirectory = path.join(profile, "extension"); + await cp(builtExtensionDirectory, extensionDirectory, { recursive: true }); + const manifestPath = path.join(extensionDirectory, "manifest.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { + optional_host_permissions?: string[]; + host_permissions?: string[]; + }; + delete manifest.optional_host_permissions; + manifest.host_permissions = [ + `${firstBaseUrl.origin}/*`, + `${secondBaseUrl.origin}/*`, + "https://fanbox.cc/*", + "https://*.fanbox.cc/*", + ]; + await writeFile(manifestPath, JSON.stringify(manifest)); + const context = await chromium.launchPersistentContext(profile, { + channel: "chromium", + headless: true, + args: [ + `--disable-extensions-except=${extensionDirectory}`, + `--load-extension=${extensionDirectory}`, + "--no-sandbox", + ], + }); + browserContexts.push(context); + const [existingWorker] = context.serviceWorkers(); + const worker = + existingWorker ?? (await context.waitForEvent("serviceworker")); + const extensionId = new URL(worker.url()).host; + const optionsPage = await openOptionsPage(context, worker, extensionId); + + await fillPairing(optionsPage, { + serviceAddress: firstBaseUrl.origin, + instanceId: first.request.instanceId, + requestId: first.request.id, + pairingCode: first.pairingCode, + }); + await optionsPage + .getByRole("button", { name: "检查目标与创作者范围" }) + .click(); + await expect(optionsPage.locator("#scope")).toContainText( + `目标实例:${first.request.instanceId}`, + ); + await expect( + optionsPage.getByRole("button", { name: "使用此浏览器登录状态连接" }), + ).toBeEnabled(); + + await context.addCookies([ + { + name: "FANBOXSESSID", + value: "synthetic-review-binding-session-value", + domain: ".fanbox.cc", + path: "/", + secure: true, + httpOnly: true, + sameSite: "None", + }, + ]); + await fillPairing(optionsPage, { + serviceAddress: secondBaseUrl.origin, + instanceId: second.request.instanceId, + requestId: second.request.id, + pairingCode: second.pairingCode, + }); + await optionsPage + .getByRole("button", { name: "使用此浏览器登录状态连接" }) + .click(); + + await expect(optionsPage.locator("#status")).toContainText( + "目标信息已更改,请重新检查", + ); + expect(capturedContexts).toHaveLength(0); + expect( + (await firstClient.getFanboxConnectionRequest(first.request.id)).status, + ).toBe("waiting"); + expect( + (await secondClient.getFanboxConnectionRequest(second.request.id)).status, + ).toBe("waiting"); + expect( + (await firstClient.listSourceConnections()).items.some( + ({ id }) => id === "fanbox/customudon", + ), + ).toBe(false); + expect( + (await secondClient.listSourceConnections()).items.some( + ({ id }) => id === "fanbox/customudon", + ), + ).toBe(false); + + await optionsPage + .getByRole("button", { name: "检查目标与创作者范围" }) + .click(); + await expect(optionsPage.locator("#scope")).toContainText( + `目标实例:${second.request.instanceId}`, + ); + await optionsPage + .getByRole("button", { name: "使用此浏览器登录状态连接" }) + .click(); + await expect(optionsPage.locator("#status")).toContainText("FANBOX 连接成功"); + await expect + .poll( + async () => + (await secondClient.getFanboxConnectionRequest(second.request.id)) + .status, + ) + .toBe("succeeded"); + expect(capturedContexts).toMatchObject([ + { + creatorId: "customudon", + cookieValue: "synthetic-review-binding-session-value", + }, + ]); + expect(JSON.stringify(await fanboxConnection(secondClient))).not.toContain( + "synthetic-review-binding-session-value", + ); + expect( + (await firstClient.getFanboxConnectionRequest(first.request.id)).status, + ).toBe("waiting"); +}, 60_000); + +it("通过实际 Chromium 扩展与正式 FANBOX 验证器完成 TUI 连接【安装客户端】", async () => { + const verificationGate = createVerificationGate(); + const baseUrl = await startService(controlledSourceIo(verificationGate)); + const wrongTargetUrl = await startService(refusingSourceIo()); + const client = createPublicApiClient({ baseUrl }); + const terminal = await startTui(baseUrl.origin); + + await terminal.waitFor("还没有资产库"); + await terminal.send("7"); + await terminal.waitFor("当前没有来源连接"); + await terminal.send("f"); + await terminal.waitFor("连接 FANBOX 创作者"); + await terminal.send("customudon"); + await terminal.waitFor("customudon"); + await terminal.send("\r"); + await terminal.waitFor("限时配对码:"); + + const prompt = terminal.output(); + const instanceId = matchAfter(prompt, /目标实例:\s*([0-9a-f-]{36})/iu); + const requestId = matchAfter(prompt, /请求编号:\s*([0-9a-f-]{36})/iu); + const pairingCode = matchAfter(prompt, /限时配对码:\s*([A-Za-z0-9_-]{43})/u); + const builtExtensionDirectory = fileURLToPath( + import.meta.resolve("@digital/fanbox-extension/extension"), + ); + const profile = await mkdtemp( + path.join(tmpdir(), "digital-fanbox-chromium-"), + ); + temporaryRoots.push(profile); + const extensionDirectory = path.join(profile, "extension"); + await cp(builtExtensionDirectory, extensionDirectory, { recursive: true }); + const manifestPath = path.join(extensionDirectory, "manifest.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { + optional_host_permissions?: string[]; + host_permissions?: string[]; + }; + delete manifest.optional_host_permissions; + manifest.host_permissions = [ + `${baseUrl.origin}/*`, + `${wrongTargetUrl.origin}/*`, + "https://fanbox.cc/*", + "https://*.fanbox.cc/*", + ]; + await writeFile(manifestPath, JSON.stringify(manifest)); + const context = await chromium.launchPersistentContext(profile, { + channel: "chromium", + headless: true, + args: [ + `--disable-extensions-except=${extensionDirectory}`, + `--load-extension=${extensionDirectory}`, + "--no-sandbox", + ], + }); + browserContexts.push(context); + const [existingWorker] = context.serviceWorkers(); + const worker = + existingWorker ?? (await context.waitForEvent("serviceworker")); + const extensionId = new URL(worker.url()).host; + const optionsPage = await openOptionsPage(context, worker, extensionId); + + await fillPairing(optionsPage, { + serviceAddress: wrongTargetUrl.origin, + instanceId, + requestId, + pairingCode, + }); + await optionsPage + .getByRole("button", { name: "检查目标与创作者范围" }) + .click(); + await expect(optionsPage.locator("#status")).toContainText("配对信息无效"); + expect(capturedContexts).toHaveLength(0); + + await optionsPage.locator("#service-url").fill(baseUrl.origin); + await optionsPage + .getByRole("button", { name: "检查目标与创作者范围" }) + .click(); + await expect(optionsPage.locator("#scope")).toContainText( + "创作者范围:customudon", + ); + await expect(optionsPage.locator("#scope")).toContainText( + `目标实例:${instanceId}`, + ); + await expect( + optionsPage.getByRole("button", { name: "使用此浏览器登录状态连接" }), + ).toBeEnabled(); + + await optionsPage + .getByRole("button", { name: "使用此浏览器登录状态连接" }) + .click(); + await expect(optionsPage.locator("#status")).toContainText( + "没有 FANBOX 登录会话", + ); + expect(capturedContexts).toHaveLength(0); + expect((await client.getFanboxConnectionRequest(requestId)).status).toBe( + "waiting", + ); + + await context.addCookies([ + { + name: "FANBOXSESSID", + value: "synthetic-controlled-session-value", + domain: ".fanbox.cc", + path: "/", + secure: true, + httpOnly: true, + sameSite: "None", + }, + ]); + await optionsPage + .getByRole("button", { name: "使用此浏览器登录状态连接" }) + .click(); + await expect(optionsPage.locator("#status")).toContainText( + "正在由目标服务验证", + ); + await verificationGate.started; + await terminal.waitFor("当前状态:正在验证 FANBOX 登录状态"); + verificationGate.release(); + await expect(optionsPage.locator("#status")).toContainText( + "FANBOX 连接成功", + { + timeout: 20_000, + }, + ); + await expect + .poll( + async () => (await client.getFanboxConnectionRequest(requestId)).status, + ) + .toBe("succeeded"); + await expect(optionsPage.locator("#pairing-code")).toHaveValue(""); + await expect + .poll(async () => readExtensionSessionStorage(worker)) + .toEqual({ + fanboxConnectionDraft: { + serviceAddress: baseUrl.origin, + instanceId, + requestId, + pairingCode: "", + }, + }); + expect(capturedContexts).toHaveLength(1); + expect(capturedContexts[0]).toMatchObject({ + creatorId: "customudon", + cookieValue: "synthetic-controlled-session-value", + }); + expect(capturedContexts[0]?.userAgent).toBeTruthy(); + expect(JSON.stringify(await fanboxConnection(client))).not.toContain( + "synthetic-controlled-session-value", + ); + expect(terminal.output()).not.toContain("synthetic-controlled-session-value"); + + await terminal.send("q"); + await terminal.waitFor("FANBOX customudon:可用"); + const secondCreatorPromptOffset = terminal.output().length; + await terminal.send("f"); + await terminal.waitForAfter("连接 FANBOX 创作者", secondCreatorPromptOffset); + const secondCreatorValueOffset = terminal.output().length; + await terminal.send("customudon"); + await terminal.waitForAfter("customudon", secondCreatorValueOffset); + const secondPairingPromptOffset = terminal.output().length; + await terminal.send("\r"); + await terminal.waitForAfter("限时配对码:", secondPairingPromptOffset); + const failedPrompt = terminal.output(); + const failedInstanceId = matchAfter( + failedPrompt, + /目标实例:\s*([0-9a-f-]{36})/iu, + ); + const failedRequestId = matchAfter( + failedPrompt, + /请求编号:\s*([0-9a-f-]{36})/iu, + ); + const failedPairingCode = matchAfter( + failedPrompt, + /限时配对码:\s*([A-Za-z0-9_-]{43})/u, + ); + await fillPairing(optionsPage, { + serviceAddress: baseUrl.origin, + instanceId: failedInstanceId, + requestId: failedRequestId, + pairingCode: failedPairingCode, + }); + await optionsPage + .getByRole("button", { name: "检查目标与创作者范围" }) + .click(); + await expect(optionsPage.locator("#status")).toContainText( + "请核对上方服务实例", + ); + await expect(optionsPage.locator("#scope")).toContainText( + "创作者范围:customudon", + ); + await expect( + optionsPage.getByRole("button", { name: "使用此浏览器登录状态连接" }), + ).toBeEnabled(); + await context.clearCookies(); + await context.addCookies([ + { + name: "FANBOXSESSID", + value: "synthetic-rejected-session-value", + domain: ".fanbox.cc", + path: "/", + secure: true, + httpOnly: true, + sameSite: "None", + }, + ]); + await optionsPage + .getByRole("button", { name: "使用此浏览器登录状态连接" }) + .click(); + await expect(optionsPage.locator("#status")).toContainText("验证失败", { + timeout: 20_000, + }); + await expect + .poll( + async () => + (await client.getFanboxConnectionRequest(failedRequestId)).status, + ) + .toBe("failed"); + await expect(optionsPage.locator("#pairing-code")).toHaveValue(""); + await expect + .poll(async () => readExtensionSessionStorage(worker)) + .toMatchObject({ fanboxConnectionDraft: { pairingCode: "" } }); + await terminal.waitFor("当前状态:验证失败"); + expect(capturedContexts.map(({ cookieValue }) => cookieValue)).toEqual([ + "synthetic-controlled-session-value", + "synthetic-rejected-session-value", + ]); + expect(JSON.stringify(await fanboxConnection(client))).not.toContain( + "synthetic-rejected-session-value", + ); + + expect((await fanboxConnection(client)).version).toBe(1); + const replacementListOffset = terminal.output().length; + await terminal.send("q"); + await terminal.waitForAfter("FANBOX customudon:可用", replacementListOffset); + const replacementCreatorOffset = terminal.output().length; + await terminal.send("f"); + await terminal.waitForAfter("连接 FANBOX 创作者", replacementCreatorOffset); + const replacementCreatorValueOffset = terminal.output().length; + await terminal.send("customudon"); + await terminal.waitForAfter("customudon", replacementCreatorValueOffset); + const replacementPairingOffset = terminal.output().length; + await terminal.send("\r"); + await terminal.waitForAfter("限时配对码:", replacementPairingOffset); + const replacementPrompt = terminal.output(); + const replacementInstanceId = matchAfter( + replacementPrompt, + /目标实例:\s*([0-9a-f-]{36})/iu, + ); + const replacementRequestId = matchAfter( + replacementPrompt, + /请求编号:\s*([0-9a-f-]{36})/iu, + ); + const replacementPairingCode = matchAfter( + replacementPrompt, + /限时配对码:\s*([A-Za-z0-9_-]{43})/u, + ); + await fillPairing(optionsPage, { + serviceAddress: baseUrl.origin, + instanceId: replacementInstanceId, + requestId: replacementRequestId, + pairingCode: replacementPairingCode, + }); + await optionsPage + .getByRole("button", { name: "检查目标与创作者范围" }) + .click(); + await expect(optionsPage.locator("#status")).toContainText( + "请核对上方服务实例", + ); + await expect( + optionsPage.getByRole("button", { name: "使用此浏览器登录状态连接" }), + ).toBeEnabled(); + await context.clearCookies(); + await context.addCookies([ + { + name: "FANBOXSESSID", + value: "synthetic-replacement-session-value", + domain: ".fanbox.cc", + path: "/", + secure: true, + httpOnly: true, + sameSite: "None", + }, + ]); + await optionsPage + .getByRole("button", { name: "使用此浏览器登录状态连接" }) + .click(); + await expect(optionsPage.locator("#status")).toContainText( + "FANBOX 连接成功", + { + timeout: 20_000, + }, + ); + await expect + .poll( + async () => + (await client.getFanboxConnectionRequest(replacementRequestId)).status, + ) + .toBe("succeeded"); + await expect(optionsPage.locator("#pairing-code")).toHaveValue(""); + await expect + .poll(async () => readExtensionSessionStorage(worker)) + .toMatchObject({ fanboxConnectionDraft: { pairingCode: "" } }); + await terminal.waitFor("当前状态:连接成功"); + expect((await fanboxConnection(client)).version).toBe(2); + expect(capturedContexts.map(({ cookieValue }) => cookieValue)).toEqual([ + "synthetic-controlled-session-value", + "synthetic-rejected-session-value", + "synthetic-replacement-session-value", + ]); + expect(JSON.stringify(await fanboxConnection(client))).not.toContain( + "synthetic-replacement-session-value", + ); + expect(terminal.output()).not.toContain( + "synthetic-replacement-session-value", + ); + + const finalListOffset = terminal.output().length; + await terminal.send("q"); + await terminal.waitForAfter("FANBOX customudon:可用", finalListOffset); + const mainMenuOffset = terminal.output().length; + await terminal.send("q"); + await terminal.waitForAfter("3 未识别材料", mainMenuOffset); + await terminal.send("q"); + expect(await terminal.close()).toBe(0); + expect(terminal.output()).toContain("TERMINAL_RESTORED"); +}, 60_000); + +async function startService( + sessionTransport: FanboxEndpointSessionTransport, +): Promise { + const root = await mkdtemp(path.join(tmpdir(), "digital-fanbox-extension-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + await prepareProductionCatalogue({ productDataDirectory }); + const bootstrap = await createProductService( + { + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo: { + mode: "controlled", + request: async () => { + throw new Error("受控 FANBOX 验收不允许未声明的普通来源请求"); + }, + sessionTransport, + }, + }, + { lifecycle: createIsolatedLifecycle() }, + ); + bootstraps.push(bootstrap); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + servers.push(server); + await server.ready(); + bootstrap.publish(); + const baseUrl = new URL(server.url!); + await waitUntilReady(baseUrl); + return baseUrl; +} + +function controlledSourceIo( + verificationGate?: ReturnType, +): FanboxEndpointSessionTransport { + let gateUsed = false; + return { + async transact(input) { + const cookie = input.privateContext.cookies.find( + ({ name }) => name === "FANBOXSESSID", + ); + if (cookie === undefined) + throw new Error("受控会话缺少 FANBOX 登录 Cookie"); + capturedContexts.push({ + creatorId: input.creatorId, + cookieValue: cookie.value, + userAgent: input.privateContext.userAgent, + }); + if ( + cookie.value === "synthetic-controlled-session-value" && + verificationGate !== undefined && + !gateUsed + ) { + gateUsed = true; + verificationGate.markStarted(); + await verificationGate.wait; + } + return await input.use(async (endpoint) => { + const body = + cookie.value === "synthetic-rejected-session-value" + ? { error: "controlled rejection" } + : responseFor(endpoint); + return { + kind: "response", + status: 200, + mediaType: "application/json", + body: new TextEncoder().encode(JSON.stringify(body)), + }; + }); + }, + async close() {}, + }; +} + +function createVerificationGate() { + let release!: () => void; + let markStarted!: () => void; + const wait = new Promise((resolve) => { + release = resolve; + }); + const started = new Promise((resolve) => { + markStarted = resolve; + }); + return { started, wait, release, markStarted }; +} + +function responseFor(endpoint: { readonly kind: string }): unknown { + switch (endpoint.kind) { + case "login-status": + return { body: 0 }; + case "creator-summaries": + return { + body: { + posts: [ + { + id: "9135862", + creatorId: "customudon", + title: "受控验收帖子", + publishedDatetime: "2025-01-01T00:00:00+09:00", + }, + ], + }, + }; + case "post-detail": + return { + body: { + post: { + id: "9135862", + creatorId: "customudon", + title: "受控验收帖子", + publishedDatetime: "2025-01-01T00:00:00+09:00", + }, + }, + }; + default: + throw new Error("FANBOX 适配器请求了未定义的验收端点"); + } +} + +function refusingSourceIo(): FanboxEndpointSessionTransport { + return refusingSessionTransport(); +} + +async function fanboxConnection( + client: ReturnType, +) { + const connections = await client.listSourceConnections(); + const connection = connections.items.find( + ({ id }) => id === "fanbox/customudon", + ); + if (connection === undefined) throw new Error("FANBOX 私密连接没有写入"); + return connection; +} + +function createIsolatedLifecycle() { + return createProcessLifecycleRegistry({ + addSignalListener() {}, + removeSignalListener() {}, + forceExit() {}, + }); +} + +async function waitUntilReady(baseUrl: URL): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if ((await fetch(new URL("/api/v1/source-connections", baseUrl))).ok) + return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error("受控 FANBOX 服务未就绪"); +} + +async function startTui(serviceAddress: string) { + const terminalCommand = [ + "stty cols 100 rows 24 -icanon -echo", + "terminalModeBefore=$(stty -g)", + `pnpm --filter @digital/service exec digital-tui --url ${shellQuote(serviceAddress)}`, + "terminalModeAfter=$(stty -g)", + 'if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi', + ].join("; "); + const child = spawn("script", ["-qfec", terminalCommand, "/dev/null"], { + cwd: process.cwd(), + env: interactiveTerminalEnvironment({ + TERM: "xterm-256color", + COLUMNS: "100", + LINES: "24", + }), + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + }); + terminalChildren.push(child); + const closed = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + let output = ""; + let errors = ""; + child.stdout.on("data", (chunk: Buffer) => { + output += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + errors += chunk.toString("utf8"); + }); + return { + output: () => stripAnsi(output), + async waitFor(text: string, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (stripAnsi(output).includes(text)) return; + if (child.exitCode !== null || child.signalCode !== null) + throw new Error( + `TUI 未显示“${text}”:${errors}\n${stripAnsi(output).slice(-3000)}`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error( + `TUI 未显示“${text}”:${errors}\n${stripAnsi(output).slice(-3000)}`, + ); + }, + async waitForAfter(text: string, offset: number, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (stripAnsi(output).slice(offset).includes(text)) return; + if (child.exitCode !== null || child.signalCode !== null) + throw new Error( + `TUI 未显示新的“${text}”:${errors}\n${stripAnsi(output).slice(-3000)}`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error( + `TUI 未显示新的“${text}”:${errors}\n${stripAnsi(output).slice(-3000)}`, + ); + }, + async send(text: string) { + child.stdin.write(text); + await new Promise((resolve) => setTimeout(resolve, 120)); + }, + async close() { + child.stdin.end(); + const exitCode = await closed; + if (child.pid !== undefined && process.platform !== "win32") + terminalChildren.splice(terminalChildren.indexOf(child), 1); + return exitCode; + }, + }; +} + +async function fillPairing( + optionsPage: Page, + input: { + readonly serviceAddress: string; + readonly instanceId: string; + readonly requestId: string; + readonly pairingCode: string; + }, +): Promise { + await optionsPage.locator("#service-url").fill(input.serviceAddress); + await optionsPage.locator("#instance-id").fill(input.instanceId); + await optionsPage.locator("#request-id").fill(input.requestId); + await optionsPage.locator("#pairing-code").fill(input.pairingCode); +} + +function matchAfter(value: string, pattern: RegExp): string { + const matches = [...value.matchAll(new RegExp(pattern.source, "gu"))]; + const match = matches.at(-1); + if (match?.[1] === undefined) + throw new Error("TUI 没有显示完整 FANBOX 配对信息"); + return match[1]; +} + +function stripAnsi(value: string): string { + let stripped = ""; + let inEscapeSequence = false; + for (const character of value) { + if (inEscapeSequence) { + if (character >= "@" && character <= "~") inEscapeSequence = false; + } else if (character === String.fromCharCode(27)) { + inEscapeSequence = true; + } else stripped += character; + } + return stripped; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/apps/service/test/helpers/scan-responsiveness-service.ts b/apps/service/test/helpers/scan-responsiveness-service.ts new file mode 100644 index 0000000..14ae6b6 --- /dev/null +++ b/apps/service/test/helpers/scan-responsiveness-service.ts @@ -0,0 +1,90 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import type { ProductConfiguration } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { + loadControlledSourceIo, + refusingSessionTransport, +} from "@digital/test-support/source-io"; +import { serve } from "srvx"; + +import { createProductService } from "../../src/service.ts"; + +const productDataDirectory = process.env["EW07_SCAN_PRODUCT_DATA"]; +const fixtureDirectory = process.env["EW07_SCAN_FIXTURE_DIRECTORY"]; +if (productDataDirectory === undefined || fixtureDirectory === undefined) + throw new Error("扫描 readiness 受控服务配置不完整"); + +await prepareProductionCatalogue({ productDataDirectory }); +const fixture = await loadControlledSourceIo({ + manifestPath: path.join(fixtureDirectory, "manifest.json"), +}); +const episodeDetails = new Map(); +for (const subjectId of [243981, 400602, 515759]) { + const page = JSON.parse( + await readFile( + path.join( + fixtureDirectory, + "responses", + `bangumi-episodes-${String(subjectId)}.json`, + ), + "utf8", + ), + ) as { readonly data: readonly Record[] }; + for (const episode of page.data) + episodeDetails.set(String(episode["id"]), episode); +} +const sourceIo: NonNullable = { + mode: "controlled", + async request(input, init) { + const url = new URL(input); + const match = /^\/v0\/episodes\/(\d+)$/u.exec(url.pathname); + if (url.origin === "https://api.bgm.tv" && match !== null) { + const episode = episodeDetails.get(match[1]!); + if (episode === undefined) + throw new Error(`公开受控 episode 列表中没有 ${match[1]}`); + return new Response(JSON.stringify(episode), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return fixture.request(input, init); + }, + sessionTransport: fixture.sessionTransport ?? refusingSessionTransport(), +}; +const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, +}); +const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, +}); +await server.ready(); +bootstrap.publish(); +process.stdout.write( + `${JSON.stringify({ kind: "ready", address: server.url })}\n`, +); + +let closing = false; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk: string) => { + if (!chunk.split(/\s+/u).includes("stop") || closing) return; + closing = true; + process.stdin.destroy(); + void (async () => { + await server.close(true); + await bootstrap.abort(); + process.stdout.write(`${JSON.stringify({ kind: "stopped" })}\n`); + })().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? error.stack : String(error)}\n`, + ); + process.exitCode = 1; + }); +}); diff --git a/apps/service/test/initial-configuration-http.test.ts b/apps/service/test/initial-configuration-http.test.ts new file mode 100644 index 0000000..8cf9cf4 --- /dev/null +++ b/apps/service/test/initial-configuration-http.test.ts @@ -0,0 +1,254 @@ +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import type { ProductSourceIo } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { createPublicApiClient } from "@digital/public-api"; +import { createProcessLifecycleRegistry } from "@digital/service-host"; +import { refusingSessionTransport } from "@digital/test-support/source-io"; +import { serve } from "srvx"; +import { afterEach, expect, it } from "vitest"; + +import { createProductService } from "../src/service.ts"; + +type RunningService = { + readonly client: ReturnType; + readonly server: Awaited>; + readonly bootstrap: Awaited>; +}; +const services: RunningService[] = []; +const temporaryRoots: string[] = []; + +afterEach(async () => { + for (const service of services.splice(0).reverse()) { + await service.server.close(true); + await service.bootstrap.abort(); + } + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); +}); + +it("显式保存的资产库配置会在新数据世代通过产品业务恢复", async () => { + const root = await mkdtemp( + path.join(tmpdir(), "ewc15-initial-configuration-"), + ); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const initialConfigurationDirectory = path.join( + root, + "retained-configuration", + ); + const directories = [ + path.join(root, "资料", "声音", "2025"), + path.join(root, "资料", "声音", "2026"), + path.join(root, "资料", "书籍"), + ]; + await Promise.all( + directories.map((directory) => mkdir(directory, { recursive: true })), + ); + await prepareProductionCatalogue({ productDataDirectory }); + + const first = await startService({ + productDataDirectory, + initialConfigurationDirectory, + dataGeneration: "g1", + }); + const options = await first.client.listAssetLibraryOptions(); + const voice = await first.client.saveAssetLibrary({ + displayName: "初始声音库", + directories: directories.slice(0, 2), + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + const books = await first.client.saveAssetLibrary({ + displayName: "初始书库", + directories: [directories[2]!], + recognitionProfileIds: ["books"], + sourceIdentities: [], + }); + const saved = await first.client.saveInitialAssetLibraryConfiguration(); + expect(saved).toEqual({ libraryCount: 2 }); + + await first.client.saveAssetLibrary({ + id: voice.id, + expectedVersion: voice.configVersion, + displayName: "临时声音库", + directories: [directories[0]!], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: [], + }); + await first.client.removeAssetLibrary({ + id: books.id, + expectedVersion: books.configVersion, + }); + expect(options.recognitionProfiles.map(({ id }) => id)).toContain("books"); + await stopService(first); + + const restarted = await startService({ + productDataDirectory, + initialConfigurationDirectory, + dataGeneration: "g1", + }); + const unchangedGeneration = await restarted.client.listAssetLibraries(); + expect(unchangedGeneration.items).toHaveLength(1); + expect(unchangedGeneration.items[0]?.displayName).toBe("临时声音库"); + await stopService(restarted); + + await rm(productDataDirectory, { recursive: true, force: true }); + await prepareProductionCatalogue({ productDataDirectory }); + const second = await startService({ + productDataDirectory, + initialConfigurationDirectory, + dataGeneration: "g2", + }); + const restored = await second.client.listAssetLibraries(); + expect(restored.items).toHaveLength(2); + expect(restored.items).toContainEqual( + expect.objectContaining({ + displayName: "初始声音库", + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + directories: expect.arrayContaining([ + expect.objectContaining({ path: directories[0], status: "active" }), + expect.objectContaining({ path: directories[1], status: "active" }), + ]), + }), + ); + expect(restored.items).toContainEqual( + expect.objectContaining({ + displayName: "初始书库", + recognitionProfileIds: ["books"], + sourceIdentities: [], + directories: expect.arrayContaining([ + expect.objectContaining({ path: directories[2], status: "active" }), + ]), + }), + ); + + const restoredVoice = restored.items.find( + ({ displayName }) => displayName === "初始声音库", + ); + const restoredBooks = restored.items.find( + ({ displayName }) => displayName === "初始书库", + ); + if (restoredVoice === undefined || restoredBooks === undefined) + throw new Error("未恢复初始资产库"); + await second.client.saveAssetLibrary({ + id: restoredVoice.id, + expectedVersion: restoredVoice.configVersion, + displayName: "更新后的初始声音库", + directories: directories.slice(0, 2), + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + expect(await second.client.saveInitialAssetLibraryConfiguration()).toEqual({ + libraryCount: 2, + }); + const changedAfterUpdate = await second.client.listAssetLibraries(); + const changedVoice = changedAfterUpdate.items.find( + ({ id }) => id === restoredVoice.id, + ); + const changedBooks = changedAfterUpdate.items.find( + ({ id }) => id === restoredBooks.id, + ); + if (changedVoice === undefined || changedBooks === undefined) + throw new Error("更新初始配置后资产库缺失"); + await second.client.saveAssetLibrary({ + id: changedVoice.id, + expectedVersion: changedVoice.configVersion, + displayName: "重建前的任意声音库", + directories: [directories[0]!], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: [], + }); + await second.client.removeAssetLibrary({ + id: changedBooks.id, + expectedVersion: changedBooks.configVersion, + }); + await stopService(second); + + await rm(productDataDirectory, { recursive: true, force: true }); + await prepareProductionCatalogue({ productDataDirectory }); + const third = await startService({ + productDataDirectory, + initialConfigurationDirectory, + dataGeneration: "g3", + }); + const updatedRestoration = await third.client.listAssetLibraries(); + expect(updatedRestoration.items).toHaveLength(2); + expect(updatedRestoration.items).toContainEqual( + expect.objectContaining({ + displayName: "更新后的初始声音库", + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + directories: expect.arrayContaining([ + expect.objectContaining({ path: directories[0], status: "active" }), + expect.objectContaining({ path: directories[1], status: "active" }), + ]), + }), + ); + expect( + updatedRestoration.items.map(({ displayName }) => displayName), + ).not.toContain("重建前的任意声音库"); +}); + +async function startService(input: { + readonly productDataDirectory: string; + readonly initialConfigurationDirectory: string; + readonly dataGeneration: string; +}): Promise<{ + readonly client: ReturnType; + readonly server: Awaited>; + readonly bootstrap: Awaited>; +}> { + const sourceIo: ProductSourceIo = { + mode: "controlled", + async request() { + throw new Error("本验收不允许访问外部来源"); + }, + sessionTransport: refusingSessionTransport(), + }; + const bootstrap = await createProductService( + { + productDataDirectory: input.productDataDirectory, + initialAssetLibraryConfigurationDirectory: + input.initialConfigurationDirectory, + dataGeneration: input.dataGeneration, + environment: { DIGITAL_PRODUCT_DATA: input.productDataDirectory }, + sourceIo, + }, + { lifecycle: isolatedLifecycle() }, + ); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + await server.ready(); + bootstrap.publish(); + const service: RunningService = { + client: createPublicApiClient({ baseUrl: server.url! }), + server, + bootstrap, + }; + services.push(service); + return service; +} + +async function stopService(service: RunningService): Promise { + const index = services.indexOf(service); + if (index >= 0) services.splice(index, 1); + await service.server.close(true); + await service.bootstrap.abort(); +} + +function isolatedLifecycle() { + return createProcessLifecycleRegistry({ + addSignalListener() {}, + removeSignalListener() {}, + forceExit() {}, + }); +} diff --git a/apps/service/test/initial-configuration-rebuild.test.ts b/apps/service/test/initial-configuration-rebuild.test.ts new file mode 100644 index 0000000..ffed2ab --- /dev/null +++ b/apps/service/test/initial-configuration-rebuild.test.ts @@ -0,0 +1,602 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + cp, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, +} from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + deleteEnvironment, + inspectEnvironment, + prepareEnvironment, + rebuildEnvironment, + startEnvironment, + stopEnvironment, +} from "@digital/environment"; +import { createPublicApiClient } from "@digital/public-api"; +import { loadControlledSourceIo } from "@digital/test-support/source-io"; +import { afterEach, expect, it } from "vitest"; + +const workspaceRoot = path.resolve( + fileURLToPath(new URL(".", import.meta.url)), + "..", + "..", + "..", +); +const fanboxFixture = path.join( + workspaceRoot, + "fixtures", + "controlled-fanbox-session", +); +const manifestPath = path.join(fanboxFixture, "manifest.json"); +const credentialPath = path.join( + fanboxFixture, + "connections", + "synthetic-customudon.json", +); +const environments: string[] = []; +const temporaryRoots: string[] = []; + +afterEach(async () => { + const deletedContainerDirectories: string[] = []; + for (const environment of environments.splice(0).reverse()) { + const target = { workspacePath: workspaceRoot, environment }; + const containerDirectory = path.join( + workspaceRoot, + ".digital", + "environments", + environment, + ); + const stopped = await stopEnvironment(target); + if (stopped.status !== "stopped" && stopped.status !== "already-stopped") + throw new Error( + `第15票验收环境 ${environment} 未能经公开入口停止:${JSON.stringify(stopped.failure ?? {})}`, + ); + const deleted = await deleteEnvironment(target); + if (deleted.status !== "deleted") + throw new Error( + `第15票验收环境 ${environment} 未能经公开入口删除:${JSON.stringify(deleted.failure ?? {})}`, + ); + deletedContainerDirectories.push(containerDirectory); + expect((await inspectEnvironment(target)).state).toBe("unprepared"); + } + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); + + const remainingContainerDirectories: string[] = []; + for (const directory of deletedContainerDirectories) { + try { + await lstat(directory); + remainingContainerDirectories.push(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + expect(remainingContainerDirectories).toEqual([]); +}); + +it("重建时一个目录暂时离线仍可启动服务并使用其他已恢复目录", async () => { + const materialsRoot = await mkdtemp( + path.join(tmpdir(), "ewc15-offline-directory-materials-"), + ); + temporaryRoots.push(materialsRoot); + const firstDirectory = path.join(materialsRoot, "A"); + const secondDirectory = path.join(materialsRoot, "B"); + const parkedDirectory = path.join(materialsRoot, "B-temporarily-offline"); + await Promise.all( + [firstDirectory, secondDirectory].map((directory) => + mkdir(directory, { recursive: true }), + ), + ); + + const environment = `ewc15-offline-${randomUUID().slice(0, 8)}`; + environments.push(environment); + const port = await freePort(); + const controlled = await loadControlledSourceIo({ manifestPath }); + await controlled.sessionTransport?.close(); + const prepared = await prepareEnvironment({ + workspacePath: workspaceRoot, + environment, + inputs: [firstDirectory, secondDirectory], + sourceMode: "controlled", + config: { + profile: environment, + webPort: port, + sourceIo: { + manifestPath, + contentIdentity: controlled.contentIdentity, + }, + }, + }); + expect(prepared.status, JSON.stringify(prepared.failure ?? {})).toBe( + "prepared", + ); + + const initial = await startEnvironment({ + workspacePath: workspaceRoot, + environment, + }); + expect(initial.status, JSON.stringify(initial.failure ?? {})).toBe("ready"); + if (initial.status !== "ready") throw new Error("初次启动没有就绪"); + if (initial.run.address === null) throw new Error("初次启动结果缺少地址"); + const initialClient = createPublicApiClient({ baseUrl: initial.run.address }); + await initialClient.saveAssetLibrary({ + displayName: "可恢复资产库", + directories: [firstDirectory, secondDirectory], + recognitionProfileIds: ["books"], + sourceIdentities: [], + }); + await initialClient.saveInitialAssetLibraryConfiguration(); + await stopEnvironment({ workspacePath: workspaceRoot, environment }); + const rebuilt = await rebuildEnvironment({ + workspacePath: workspaceRoot, + environment, + }); + expect(rebuilt.status, JSON.stringify(rebuilt.failure ?? {})).toBe("rebuilt"); + await rename(secondDirectory, parkedDirectory); + + const degraded = await startEnvironment({ + workspacePath: workspaceRoot, + environment, + }); + expect(degraded.status, JSON.stringify(degraded.failure ?? {})).toBe("ready"); + if (degraded.status !== "ready") throw new Error("目录离线时服务没有就绪"); + if (degraded.run.address === null) + throw new Error("目录离线时服务启动结果缺少地址"); + const degradedClient = createPublicApiClient({ + baseUrl: degraded.run.address, + }); + await expect(degradedClient.listAssetLibraries()).resolves.toMatchObject({ + items: [ + expect.objectContaining({ + displayName: "可恢复资产库", + directories: [ + expect.objectContaining({ + path: firstDirectory, + status: "active", + reachable: true, + }), + ], + }), + ], + }); + + await expect( + degradedClient.inspectInitialAssetLibraryConfiguration(), + ).resolves.toMatchObject({ + status: "pending", + pendingDirectories: [ + { libraryName: "可恢复资产库", path: secondDirectory }, + ], + }); + + await rename(parkedDirectory, secondDirectory); + await expect( + degradedClient.restoreInitialAssetLibraryConfiguration(), + ).resolves.toMatchObject({ status: "applied", pendingDirectories: [] }); + await expect(degradedClient.listAssetLibraries()).resolves.toMatchObject({ + items: [ + expect.objectContaining({ + displayName: "可恢复资产库", + directories: expect.arrayContaining([ + expect.objectContaining({ path: firstDirectory }), + expect.objectContaining({ path: secondDirectory }), + ]), + }), + ], + }); +}, 600_000); + +it("公开环境重建恢复显式更新的资产库基线并清空执行、人工决定与 FANBOX 授权", async () => { + const materialsRoot = await mkdtemp( + path.join(tmpdir(), "ewc15-environment-materials-"), + ); + temporaryRoots.push(materialsRoot); + const firstDirectory = path.join(materialsRoot, "收藏", "FANBOX-甲"); + const secondDirectory = path.join(materialsRoot, "收藏", "FANBOX-乙"); + const booksDirectory = path.join(materialsRoot, "阅读", "书籍"); + await Promise.all( + [firstDirectory, secondDirectory, booksDirectory].map((directory) => + mkdir(directory, { recursive: true }), + ), + ); + await cp( + path.join(fanboxFixture, "material", "customudon"), + path.join(firstDirectory, "customudon"), + { recursive: true }, + ); + const materialDigest = await digestDirectories([ + firstDirectory, + secondDirectory, + booksDirectory, + ]); + + const environment = `ewc15-${randomUUID().slice(0, 8)}`; + environments.push(environment); + const containerDirectory = path.join( + workspaceRoot, + ".digital", + "environments", + environment, + ); + const port = await freePort(); + const controlled = await loadControlledSourceIo({ manifestPath }); + await controlled.sessionTransport?.close(); + const prepared = await prepareEnvironment({ + workspacePath: workspaceRoot, + environment, + inputs: [firstDirectory, secondDirectory, booksDirectory], + sourceMode: "controlled", + config: { + profile: environment, + webPort: port, + sourceIo: { + manifestPath, + contentIdentity: controlled.contentIdentity, + }, + }, + }); + expect(prepared.status, JSON.stringify(prepared.failure ?? {})).toBe( + "prepared", + ); + + const start = async () => { + const result = await startEnvironment({ + workspacePath: workspaceRoot, + environment, + }); + expect(result.status, JSON.stringify(result.failure ?? {})).toBe("ready"); + if (result.run.address === null) throw new Error("服务启动结果缺少地址"); + return createPublicApiClient({ baseUrl: result.run.address }); + }; + const client = await start(); + const firstLibrary = await client.saveAssetLibrary({ + displayName: "初始 FANBOX 库", + directories: [firstDirectory, secondDirectory], + recognitionProfileIds: ["fanbox"], + sourceIdentities: ["fanbox"], + }); + await client.saveAssetLibrary({ + displayName: "初始书籍库", + directories: [booksDirectory], + recognitionProfileIds: ["books"], + sourceIdentities: [], + }); + expect(await client.saveInitialAssetLibraryConfiguration()).toEqual({ + libraryCount: 2, + }); + + const browserCredential = JSON.parse( + await readFile(credentialPath, "utf8"), + ) as unknown; + const connectedRequest = await client.createFanboxConnectionRequest({ + creatorId: "customudon", + }); + await expect( + client.completeFanboxConnectionRequest({ + requestId: connectedRequest.request.id, + pairingCode: connectedRequest.pairingCode, + browserCredential, + }), + ).resolves.toMatchObject({ status: "succeeded" }); + expect( + (await client.listSourceConnections()).items.filter(({ id }) => + id.startsWith("fanbox/"), + ), + ).toEqual([ + expect.objectContaining({ + id: "fanbox/customudon", + credentialStatus: "configured", + availability: "available", + }), + ]); + const retainedConfiguration = await readFile( + path.join( + containerDirectory, + "initial-configuration", + "asset-library-initial-configuration.json", + ), + "utf8", + ); + expect(retainedConfiguration).not.toContain("FANBOXSESSID"); + expect(retainedConfiguration).not.toContain( + "synthetic-controlled-session-not-a-real-credential", + ); + + const scan = await client.startAssetLibraryScan(firstLibrary.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + const assets = await client.listAssets({ + assetLibraryId: firstLibrary.id, + limit: 20, + }); + expect(assets.items).toHaveLength(1); + const assetId = assets.items[0]!.id; + const manualDecision = await client.prepareMetadataDecision(assetId, { + kind: "pin", + property: "asset.title", + value: "用户修正标题", + }); + await expect( + client.confirmMetadataDecision(manualDecision.previewId), + ).resolves.toMatchObject({ status: "applied" }); + await expectPinnedTitle(client, assetId, "用户修正标题"); + const changed = await client.saveAssetLibrary({ + id: firstLibrary.id, + expectedVersion: firstLibrary.configVersion, + displayName: "停止前当前配置", + directories: [firstDirectory, secondDirectory], + recognitionProfileIds: ["books"], + sourceIdentities: [], + }); + expect(changed.displayName).toBe("停止前当前配置"); + const stopped = await stopEnvironment({ + workspacePath: workspaceRoot, + environment, + }); + expect(stopped.status, JSON.stringify(stopped.failure ?? {})).toBe("stopped"); + + const restartedClient = await start(); + await expect(restartedClient.listAssetLibraries()).resolves.toMatchObject({ + items: expect.arrayContaining([ + expect.objectContaining({ + id: firstLibrary.id, + displayName: "停止前当前配置", + recognitionProfileIds: ["books"], + sourceIdentities: [], + }), + ]), + }); + await expectPinnedTitle(restartedClient, assetId, "用户修正标题"); + await expect( + restartedClient.inspectExecution(scan.parentExecutionId), + ).resolves.toMatchObject({ + status: "completed", + }); + expect( + (await restartedClient.listSourceConnections()).items.filter(({ id }) => + id.startsWith("fanbox/"), + ), + ).toEqual([ + expect.objectContaining({ + id: "fanbox/customudon", + credentialStatus: "configured", + availability: "available", + }), + ]); + const staleRequest = await restartedClient.createFanboxConnectionRequest({ + creatorId: "customudon", + }); + expect(staleRequest.request.status).toBe("waiting"); + await expect( + restartedClient.getFanboxConnectionRequest(staleRequest.request.id), + ).resolves.toMatchObject({ status: "waiting" }); + + await stopEnvironment({ workspacePath: workspaceRoot, environment }); + const firstRebuild = await rebuildEnvironment({ + workspacePath: workspaceRoot, + environment, + }); + expect(firstRebuild.status, JSON.stringify(firstRebuild.failure ?? {})).toBe( + "rebuilt", + ); + expect( + await digestDirectories([firstDirectory, secondDirectory, booksDirectory]), + ).toBe(materialDigest); + + const firstRebuiltClient = await start(); + const restored = await firstRebuiltClient.listAssetLibraries(); + expect(restored.items).toHaveLength(2); + expect(restored.items).toContainEqual( + expect.objectContaining({ + displayName: "初始 FANBOX 库", + recognitionProfileIds: ["fanbox"], + sourceIdentities: ["fanbox"], + directories: expect.arrayContaining([ + expect.objectContaining({ path: firstDirectory, status: "active" }), + expect.objectContaining({ path: secondDirectory, status: "active" }), + ]), + }), + ); + expect(restored.items).toContainEqual( + expect.objectContaining({ + displayName: "初始书籍库", + recognitionProfileIds: ["books"], + sourceIdentities: [], + directories: expect.arrayContaining([ + expect.objectContaining({ path: booksDirectory, status: "active" }), + ]), + }), + ); + await expect( + firstRebuiltClient.listAssets({ limit: 20 }), + ).resolves.toMatchObject({ + items: [], + }); + await expect( + firstRebuiltClient.listWorkflowHistory({ limit: 20 }), + ).resolves.toMatchObject({ + items: [], + }); + expect( + (await firstRebuiltClient.listSourceConnections()).items.filter(({ id }) => + id.startsWith("fanbox/"), + ), + ).toEqual([]); + await expect( + firstRebuiltClient.getFanboxConnectionRequest(connectedRequest.request.id), + ).rejects.toMatchObject({ kind: "not-found", status: 404 }); + await expect( + firstRebuiltClient.getFanboxConnectionRequest(staleRequest.request.id), + ).rejects.toMatchObject({ kind: "not-found", status: 404 }); + await expect( + firstRebuiltClient.completeFanboxConnectionRequest({ + requestId: staleRequest.request.id, + pairingCode: staleRequest.pairingCode, + browserCredential, + }), + ).rejects.toMatchObject({ kind: "not-found", status: 404 }); + expect( + await digestDirectories([firstDirectory, secondDirectory, booksDirectory]), + ).toBe(materialDigest); + + await stopEnvironment({ workspacePath: workspaceRoot, environment }); + + const restoredVoice = restored.items.find( + ({ displayName }) => displayName === "初始 FANBOX 库", + ); + const restoredBooks = restored.items.find( + ({ displayName }) => displayName === "初始书籍库", + ); + if (restoredVoice === undefined || restoredBooks === undefined) + throw new Error("首次重建后初始资产库缺失"); + const updateClient = await start(); + await updateClient.saveAssetLibrary({ + id: restoredVoice.id, + expectedVersion: restoredVoice.configVersion, + displayName: "更新后的初始 FANBOX 库", + directories: [firstDirectory, secondDirectory], + recognitionProfileIds: ["fanbox"], + sourceIdentities: ["fanbox"], + }); + expect(await updateClient.saveInitialAssetLibraryConfiguration()).toEqual({ + libraryCount: 2, + }); + const currentVoice = (await updateClient.listAssetLibraries()).items.find( + ({ id }) => id === restoredVoice.id, + ); + const currentBooks = (await updateClient.listAssetLibraries()).items.find( + ({ id }) => id === restoredBooks.id, + ); + if (currentVoice === undefined || currentBooks === undefined) + throw new Error("显式更新初始配置后资产库缺失"); + await updateClient.saveAssetLibrary({ + id: currentVoice.id, + expectedVersion: currentVoice.configVersion, + displayName: "重建前任意改动", + directories: [secondDirectory], + recognitionProfileIds: ["books"], + sourceIdentities: [], + }); + await updateClient.removeAssetLibrary({ + id: currentBooks.id, + expectedVersion: currentBooks.configVersion, + }); + await stopEnvironment({ workspacePath: workspaceRoot, environment }); + + const secondRebuild = await rebuildEnvironment({ + workspacePath: workspaceRoot, + environment, + }); + expect( + secondRebuild.status, + JSON.stringify(secondRebuild.failure ?? {}), + ).toBe("rebuilt"); + const secondRebuiltClient = await start(); + const updatedRestoration = await secondRebuiltClient.listAssetLibraries(); + expect(updatedRestoration.items).toHaveLength(2); + expect(updatedRestoration.items).toContainEqual( + expect.objectContaining({ + displayName: "更新后的初始 FANBOX 库", + recognitionProfileIds: ["fanbox"], + sourceIdentities: ["fanbox"], + directories: expect.arrayContaining([ + expect.objectContaining({ path: firstDirectory, status: "active" }), + expect.objectContaining({ path: secondDirectory, status: "active" }), + ]), + }), + ); + expect( + updatedRestoration.items.map(({ displayName }) => displayName), + ).not.toContain("重建前任意改动"); + await expect( + secondRebuiltClient.listAssets({ limit: 20 }), + ).resolves.toMatchObject({ + items: [], + }); + expect( + await digestDirectories([firstDirectory, secondDirectory, booksDirectory]), + ).toBe(materialDigest); +}, 600_000); + +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen({ host: "127.0.0.1", port: 0 }, resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("未能分配本机验收端口"); + await new Promise((resolve, reject) => { + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + return address.port; +} + +async function expectPinnedTitle( + client: ReturnType, + assetId: string, + expected: string, +): Promise { + const page = await client.listAssetEvidence(assetId, { + kind: "current-values", + limit: 100, + }); + const title = page.items.find( + (item) => + item.kind === "current-value" && item.evidence.property === "asset.title", + ); + expect(title?.kind).toBe("current-value"); + if (title?.kind !== "current-value") throw new Error("当前值中没有资产标题"); + expect(title.evidence.result.status).toBe("resolved"); + if (title.evidence.result.status !== "resolved") + throw new Error("人工标题没有维持为采用值"); + expect( + title.evidence.result.values.map(({ value, sourceIdentity }) => ({ + value, + sourceIdentity, + })), + ).toEqual([{ value: expected, sourceIdentity: "user" }]); +} + +async function digestDirectories( + directories: readonly string[], +): Promise { + const files: string[] = []; + for (const directory of directories) + await collectFiles(directory, directory, files); + return files.toSorted().join("\n"); +} + +async function collectFiles( + directory: string, + root: string, + files: string[], +): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) { + await collectFiles(absolute, root, files); + continue; + } + const digest = createHash("sha256") + .update(await readFile(absolute)) + .digest("hex"); + files.push(`${path.relative(root, absolute)} ${digest}`); + } +} diff --git a/apps/service/test/interactive-terminal.ts b/apps/service/test/interactive-terminal.ts new file mode 100644 index 0000000..ed47709 --- /dev/null +++ b/apps/service/test/interactive-terminal.ts @@ -0,0 +1,162 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; + +export function interactiveTerminalEnvironment( + overrides: NodeJS.ProcessEnv = {}, +): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = { ...process.env, ...overrides }; + delete environment.CI; + delete environment.CONTINUOUS_INTEGRATION; + return environment; +} + +export type InteractiveTerminalMarkers = string | readonly string[]; + +export interface InteractiveTerminalSession { + readonly child: ChildProcessWithoutNullStreams; + readonly output: string; + readonly errors: string; + waitForOutput(markers: InteractiveTerminalMarkers): Promise; + sendAndWait( + input: string, + markers: InteractiveTerminalMarkers, + ): Promise; + closeAndWait(): Promise; +} + +export function startInteractiveTerminal( + terminalCommand: string, + options: { + readonly cwd: string; + readonly columns: number; + readonly rows: number; + readonly timeoutMs: number; + }, +): InteractiveTerminalSession { + const child = spawn("script", ["-qfec", terminalCommand, "/dev/null"], { + cwd: options.cwd, + env: interactiveTerminalEnvironment({ + TERM: "xterm-256color", + COLUMNS: String(options.columns), + LINES: String(options.rows), + }), + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + }); + const outputChunks: Buffer[] = []; + const errorChunks: Buffer[] = []; + let closed = false; + let exitCode: number | null = null; + let spawnError: Error | undefined; + + child.stdout.on("data", (chunk: Buffer) => outputChunks.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => errorChunks.push(chunk)); + child.once("error", (error) => { + spawnError = error; + }); + child.once("close", (code) => { + closed = true; + exitCode = code; + }); + + const output = (): string => Buffer.concat(outputChunks).toString("utf8"); + const errors = (): string => Buffer.concat(errorChunks).toString("utf8"); + + const waitForOutputSince = ( + offset: number, + expected: InteractiveTerminalMarkers, + ): Promise => { + const markers = typeof expected === "string" ? [expected] : expected; + const hasMarkers = (): boolean => { + const newOutput = output().slice(offset); + return markers.every((marker) => newOutput.includes(marker)); + }; + + if (hasMarkers()) return Promise.resolve(); + if (spawnError !== undefined) return Promise.reject(spawnError); + if (closed) + return Promise.reject( + new Error( + `交互终端在输出 ${markers.join("、")} 前退出(exit=${String(exitCode)})\n${errors()}\n${output()}`, + ), + ); + + return new Promise((resolve, reject) => { + const finish = (error?: Error): void => { + clearTimeout(timeout); + child.stdout.off("data", checkOutput); + child.off("close", checkClosed); + child.off("error", checkError); + if (error === undefined) resolve(); + else reject(error); + }; + const checkOutput = (): void => { + if (hasMarkers()) finish(); + }; + const checkClosed = (code: number | null): void => { + finish( + new Error( + `交互终端在输出 ${markers.join("、")} 前退出(exit=${String(code)})\n${errors()}\n${output()}`, + ), + ); + }; + const checkError = (error: Error): void => finish(error); + const timeout = setTimeout(() => { + finish( + new Error( + `等待交互终端输出 ${markers.join("、")} 超时(${options.timeoutMs}ms)\n${errors()}\n${output()}`, + ), + ); + }, options.timeoutMs); + child.stdout.on("data", checkOutput); + child.once("close", checkClosed); + child.once("error", checkError); + checkOutput(); + }); + }; + + return { + child, + get output() { + return output(); + }, + get errors() { + return errors(); + }, + waitForOutput(markers) { + return waitForOutputSince(0, markers); + }, + sendAndWait(input, markers) { + const wait = waitForOutputSince(output().length, markers); + child.stdin.write(input); + return wait; + }, + closeAndWait() { + if (!child.stdin.destroyed) child.stdin.end(); + if (closed) return Promise.resolve(exitCode); + return new Promise((resolve, reject) => { + const finish = (error?: Error): void => { + clearTimeout(timeout); + child.off("close", checkClosed); + child.off("error", checkError); + if (error === undefined) resolve(exitCode); + else reject(error); + }; + const checkClosed = (code: number | null): void => { + exitCode = code; + closed = true; + finish(); + }; + const checkError = (error: Error): void => finish(error); + const timeout = setTimeout(() => { + finish( + new Error( + `等待交互终端退出超时(${options.timeoutMs}ms)\n${errors()}\n${output()}`, + ), + ); + }, options.timeoutMs); + child.once("close", checkClosed); + child.once("error", checkError); + }); + }, + }; +} diff --git a/apps/service/test/media-interaction-http.test.ts b/apps/service/test/media-interaction-http.test.ts new file mode 100644 index 0000000..89ac185 --- /dev/null +++ b/apps/service/test/media-interaction-http.test.ts @@ -0,0 +1,322 @@ +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { ProductConfiguration } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { createPublicApiClient } from "@digital/public-api"; +import { + loadControlledSourceIo, + refusingSessionTransport, +} from "@digital/test-support/source-io"; +import { serve } from "srvx"; +import { afterEach, expect, it } from "vitest"; + +import { createProductService } from "../src/service.ts"; + +const running: Array<{ + readonly server: Awaited>; + readonly abort: () => Promise; +}> = []; +const temporaryRoots: string[] = []; +const materialFixture = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/material"), +); +const sourceManifest = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/manifest.json"), +); +const sourceResponses = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/responses"), +); + +afterEach(async () => { + for (const item of running.splice(0).reverse()) { + await item.server.close(true); + await item.abort(); + } + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); +}); + +it("通过公共 HTTP 读取正式扫描材料、播放计划、托管封面和实例交互状态", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc19-media-http-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialRoot = path.join(root, "普通收藏"); + await cp( + path.join(materialFixture, "RJ01029383"), + path.join(materialRoot, "RJ01029383"), + { recursive: true }, + ); + const materialPath = path.join( + materialRoot, + "RJ01029383", + "おまけ音声", + "12++_おまけ。30秒の寝息を30分ループ.wav", + ); + const fixtureWav = await readFile(materialPath); + const expandedWav = Buffer.alloc(512 * 1024); + fixtureWav.copy(expandedWav, 0, 0, 44); + expandedWav.writeUInt32LE(expandedWav.byteLength - 8, 4); + expandedWav.writeUInt32LE(expandedWav.byteLength - 44, 40); + await writeFile(materialPath, expandedWav); + await prepareProductionCatalogue({ productDataDirectory }); + const controlled = await loadControlledSourceIo({ + manifestPath: sourceManifest, + }); + const sourceIo: ProductConfiguration["sourceIo"] = { + mode: "controlled", + request: controlled.request, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + running.push({ server, abort: () => bootstrap.abort() }); + await server.ready(); + bootstrap.publish(); + const baseUrl = new URL(server.url!); + const client = createPublicApiClient({ baseUrl }); + const missingManagedArtifact = await client.readManagedArtifact( + "0".repeat(64), + ); + expect(missingManagedArtifact.status).toBe(503); + expect(await missingManagedArtifact.json()).toEqual({ + error: { code: "unavailable", message: "媒体服务暂时不可用" }, + }); + const invalidRevision = await fetch( + new URL("/api/v1/media/artifact-revisions/%5C", baseUrl), + ); + expect(invalidRevision.status).toBe(400); + expect(await invalidRevision.json()).toEqual({ + error: { code: "invalid-request", message: "媒体请求格式无效" }, + }); + const library = await client.saveAssetLibrary({ + displayName: "媒体验收普通目录", + directories: [materialRoot], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => { + return (await client.inspectAssetLibraryScan(scan.parentExecutionId)) + .status; + }, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + const assets = await client.listAssets({ + assetLibraryId: library.id, + limit: 100, + }); + const asset = assets.items.find(({ locations }) => + locations.some(({ address }) => address.includes("RJ01029383")), + ); + expect(asset).toBeDefined(); + const wavLocation = asset!.locations.find( + ({ kind, address }) => + kind === "observed" && + address.endsWith("12++_おまけ。30秒の寝息を30分ループ.wav"), + ); + expect(wavLocation).toBeDefined(); + const originalWav = await readFile(materialPath); + const artworkLocation = asset!.locations.find( + ({ kind, address }) => + kind === "observed" && address.endsWith("裏世界少女2_イラスト.png"), + ); + expect(artworkLocation).toBeDefined(); + const observedArtworkBytes = await readFile( + path.join( + materialRoot, + "RJ01029383", + "イラスト、台本", + "裏世界少女2_イラスト.png", + ), + ); + const observedResponse = await client.readObservedMaterial( + asset!.id, + artworkLocation!.id, + { assetLibraryId: library.id, range: "bytes=0-3" }, + ); + expect(observedResponse.status).toBe(206); + expect(Buffer.from(await observedResponse.arrayBuffer())).toEqual( + observedArtworkBytes.subarray(0, 4), + ); + + const confirmed = await client.confirmObservedMaterial( + asset!.id, + wavLocation!.id, + library.id, + ); + const revisionId = confirmed.artifactRevisionId; + const malformedPlaybackDigest = await fetch( + new URL( + `/api/v1/media/playback-media/${encodeURIComponent(revisionId)}/invalid`, + baseUrl, + ), + ); + expect(malformedPlaybackDigest.status).toBe(400); + expect(await malformedPlaybackDigest.json()).toEqual({ + error: { code: "invalid-request", message: "媒体请求格式无效" }, + }); + + const plan = await client.createPlaybackPlan( + revisionId, + { + consumer: { + contractVersion: "consumer-playback/1", + supportsByteRanges: true, + media: [{ mediaType: "audio/wav", codecs: ["pcm"] }], + }, + preferences: {}, + }, + library.id, + ); + expect(plan).toMatchObject({ + status: "ready", + blobDigest: expect.any(String), + }); + const rangeResponse = await client.readArtifactRevision(revisionId, { + assetLibraryId: library.id, + range: "bytes=0-43", + }); + expect(rangeResponse.status).toBe(206); + expect(Buffer.from(await rangeResponse.arrayBuffer())).toEqual( + originalWav.subarray(0, 44), + ); + + const playbackRange = await client.readPlaybackMedia( + revisionId, + confirmed.blobDigest, + { assetLibraryId: library.id, range: "bytes=100-107" }, + ); + expect(playbackRange.status).toBe(206); + expect(playbackRange.headers.get("content-range")).toBe( + `bytes 100-107/${String(originalWav.byteLength)}`, + ); + expect(Buffer.from(await playbackRange.arrayBuffer())).toEqual( + originalWav.subarray(100, 108), + ); + const etag = `"sha256:${confirmed.blobDigest}"`; + const notModified = await client.readPlaybackMedia( + revisionId, + confirmed.blobDigest, + { assetLibraryId: library.id, ifNoneMatch: etag }, + ); + expect(notModified.status).toBe(304); + const preconditionFailed = await client.readPlaybackMedia( + revisionId, + confirmed.blobDigest, + { assetLibraryId: library.id, ifMatch: '"other"' }, + ); + expect(preconditionFailed.status).toBe(412); + const unsatisfiedRange = await client.readArtifactRevision(revisionId, { + assetLibraryId: library.id, + range: `bytes=${String(originalWav.byteLength)}-`, + }); + expect(unsatisfiedRange.status).toBe(416); + + const cover = asset!.descriptions + .flatMap(({ artifactRoles }) => artifactRoles) + .find(({ role, address }) => role === "asset.primary-cover" && address); + expect(cover).not.toHaveProperty("observedArtifactParts"); + expect(cover?.address).toMatch( + /^\/api\/v1\/media\/managed-artifacts\/[a-f0-9]{64}$/u, + ); + const digest = cover!.address!.split("/").at(-1)!; + const coverResponse = await client.readManagedArtifact(digest); + expect(coverResponse.status).toBe(200); + expect(coverResponse.headers.get("content-type")).toBe("image/jpeg"); + const coverBytes = await coverResponse.arrayBuffer(); + expect(Buffer.from(coverBytes)).toEqual( + await readFile(path.join(sourceResponses, "RJ01029383-cover.jpg")), + ); + + const incompatible = await client.createPlaybackPlan( + revisionId, + { + consumer: { + contractVersion: "consumer-playback/1", + supportsByteRanges: true, + media: [{ mediaType: "audio/wav", codecs: ["unsupported"] }], + }, + preferences: {}, + }, + library.id, + ); + expect(incompatible).toMatchObject({ + status: "incompatible", + }); + const unavailablePreference = await client.createPlaybackPlan( + revisionId, + { + consumer: { + contractVersion: "consumer-playback/1", + supportsByteRanges: true, + media: [{ mediaType: "audio/wav", codecs: ["pcm"] }], + }, + preferences: { audioTrackId: "missing-track" }, + }, + library.id, + ); + expect(unavailablePreference).toEqual({ + status: "incompatible", + reason: "preference-unavailable", + }); + + expect(await client.readPlaybackInteractions([revisionId])).toEqual([]); + await client.savePlaybackInteraction({ + artifactRevisionId: revisionId, + positionMilliseconds: 2_500, + completed: false, + }); + const anotherClient = createPublicApiClient({ baseUrl }); + expect(await anotherClient.readPlaybackInteractions([revisionId])).toEqual([ + { + artifactRevisionId: revisionId, + positionMilliseconds: 2_500, + completed: false, + }, + ]); + + const controller = new AbortController(); + const cancellable = await client.readArtifactRevision(revisionId, { + assetLibraryId: library.id, + range: "bytes=0-524287", + signal: controller.signal, + }); + const reader = cancellable.body!.getReader(); + const firstChunk = await reader.read(); + expect(firstChunk.value!.byteLength).toBeGreaterThan(0); + controller.abort(); + await expect(reader.read()).rejects.toThrow(); + + const changedWav = originalWav.slice(); + changedWav[100] = changedWav[100]! ^ 1; + await writeFile(materialPath, changedWav); + const expiredRevision = await client.readArtifactRevision(revisionId, { + assetLibraryId: library.id, + }); + expect(expiredRevision.status).toBe(409); + await writeFile(materialPath, originalWav); + await client.removeAssetLibrary({ + id: library.id, + expectedVersion: library.configVersion, + }); + const revokedRevision = await client.readArtifactRevision(revisionId, { + assetLibraryId: library.id, + }); + expect(revokedRevision.status).toBe(409); +}); diff --git a/apps/service/test/offline-maintenance.test.ts b/apps/service/test/offline-maintenance.test.ts new file mode 100644 index 0000000..37acc79 --- /dev/null +++ b/apps/service/test/offline-maintenance.test.ts @@ -0,0 +1,526 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmod, + cp, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { ProductSourceIo } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { createPublicApiClient } from "@digital/public-api"; +import { + loadControlledSourceIo, + refusingSessionTransport, + resolveSourceIoFromEnvironment, +} from "@digital/test-support/source-io"; +import { serve } from "srvx"; +import { afterEach, expect, it } from "vitest"; + +import { createProductService } from "../src/service.ts"; + +const roots: string[] = []; +const services: Array<{ + readonly server: Awaited>; + readonly abort: () => Promise; +}> = []; +const packageDirectory = fileURLToPath(new URL("../", import.meta.url)); +const maintenanceEntry = fileURLToPath( + import.meta.resolve("@digital/product-maintenance/cli"), +); +const repositoryRoot = path.resolve(packageDirectory, "../.."); +const controlledManifestPath = path.join( + repositoryRoot, + "fixtures/controlled-source-io/manifest.json", +); +const controlledMaterialDirectory = path.join( + repositoryRoot, + "fixtures/controlled-source-io/material/RJ01668691", +); + +afterEach(async () => { + for (const service of services.splice(0).reverse()) { + await service.server.close(true); + await service.abort(); + } + for (const root of roots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); +}); + +it("运行中的真实 HTTP 产品占用数据根时,正式维护入口拒绝离线备份", async () => { + const workspace = await mkdtemp( + path.join(tmpdir(), "digital-offline-maintenance-"), + ); + roots.push(workspace); + const productDataDirectory = path.join(workspace, "product-data"); + const backupDirectory = path.join(workspace, "backup"); + await prepareProductionCatalogue({ productDataDirectory }); + + const sourceIo = await resolveSourceIoFromEnvironment({ + DIGITAL_SOURCE_MODE: "offline", + }); + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + services.push({ server, abort: () => bootstrap.abort() }); + await server.ready(); + bootstrap.publish(); + const baseUrl = new URL(server.url!); + await expect + .poll( + async () => { + const response = await fetch(new URL("/api/ready", baseUrl), { + signal: AbortSignal.timeout(1_000), + }); + return { + status: response.status, + body: await response.json(), + }; + }, + { timeout: 30_000, interval: 100 }, + ) + .toMatchObject({ status: 200, body: { ok: true } }); + + const maintenance = await runMaintenance([ + "offline-backup", + "--product-data", + productDataDirectory, + "--backup-directory", + backupDirectory, + ]); + expect(maintenance.code).toBe(1); + expect(maintenance.stderr).toContain("产品正在运行"); +}, 45_000); + +it("正式业务操作形成的状态可经停止服务后的维护产物恢复,并由真实 HTTP 服务读回", async () => { + const workspace = await mkdtemp( + path.join(tmpdir(), "digital-offline-maintenance-round-trip-"), + ); + roots.push(workspace); + const sourceProductData = path.join(workspace, "source-product-data"); + const backupDirectory = path.join(workspace, "offline-backup"); + const restoredProductData = path.join(workspace, "restored-product-data"); + const observedRoot = path.join(workspace, "observed-media"); + const observedWork = path.join(observedRoot, "RJ01668691"); + const secret = "offline-maintenance-private-test-token"; + await cp(controlledMaterialDirectory, observedWork, { recursive: true }); + const observedDigest = await digestDirectory(observedRoot); + await prepareProductionCatalogue({ + productDataDirectory: sourceProductData, + }); + + const controlled = await loadControlledSourceIo({ + manifestPath: controlledManifestPath, + }); + const sourceIo: ProductSourceIo = { + mode: "controlled", + async request(input, init) { + const url = new URL(input); + if ( + url.host === "api.themoviedb.org" && + url.pathname === "/3/authentication" + ) { + const headers = new Headers(init?.headers as HeadersInit | undefined); + expect(headers.get("authorization")).toBe(`Bearer ${secret}`); + return new Response(null, { status: 200 }); + } + return await controlled.request(input, init); + }, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + + let service = await startProductService(sourceProductData, sourceIo); + let client = createPublicApiClient({ baseUrl: service.baseUrl }); + const library = await client.saveAssetLibrary({ + displayName: "离线维护恢复演练", + directories: [observedRoot], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + const startedScan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(startedScan.parentExecutionId)) + .status, + { timeout: 120_000 }, + ) + .toBe("completed"); + const acceptedScan = await client.inspectAssetLibraryScan( + startedScan.parentExecutionId, + ); + expect(acceptedScan).toMatchObject({ + assetLibraryId: library.id, + outcome: "accepted", + status: "completed", + }); + const sourceAssets = await client.listAssets({ + assetLibraryId: library.id, + limit: 100, + }); + expect(sourceAssets.items.length).toBeGreaterThan(0); + const readPrimaryCover = async (assets: typeof sourceAssets) => { + const asset = assets.items.find(({ descriptions }) => + descriptions.some(({ artifactRoles }) => + artifactRoles.some(({ role }) => role === "asset.primary-cover"), + ), + ); + const role = asset?.descriptions + .flatMap(({ artifactRoles }) => artifactRoles) + .find(({ role }) => role === "asset.primary-cover"); + expect(role?.address).toMatch( + /^\/api\/v1\/media\/managed-artifacts\/[a-f0-9]{64}$/u, + ); + const digest = role!.address!.split("/").at(-1)!; + const response = await client.readManagedArtifact(digest); + expect(response.status).toBe(200); + const bytes = Buffer.from(await response.arrayBuffer()); + const location = asset!.locations.find(({ id }) => id === role!.locationId); + expect(location?.content.status).toBe("confirmed"); + if (location?.content.status !== "confirmed") + throw new Error("封面没有确认的正式产物修订"); + const revisionResponse = await client.readArtifactRevision( + location.content.target.revisionId, + { assetLibraryId: library.id }, + ); + expect(revisionResponse.status).toBe(200); + return { + address: role!.address!, + bytes, + revisionBytes: Buffer.from(await revisionResponse.arrayBuffer()), + }; + }; + const sourceCover = await readPrimaryCover(sourceAssets); + expect(sourceCover.revisionBytes).toEqual(sourceCover.bytes); + const acceptedExecution = await client.inspectExecution( + startedScan.parentExecutionId, + ); + + const configuredConnection = await client.configureSourceConnection({ + sourceId: "tmdb", + expectedVersion: 0, + secret, + }); + expect(configuredConnection.verification).toBe("verified"); + const sourceConnections = await client.listSourceConnections(); + expect(JSON.stringify(sourceConnections)).not.toContain(secret); + expect(sourceConnections.items).toContainEqual( + expect.objectContaining({ + id: "tmdb", + credentialStatus: "configured", + availability: "available", + }), + ); + + const privateSecretPath = path.join( + sourceProductData, + "private-source-connections", + "tmdb.json", + ); + const privateSecretBytes = await readFile(privateSecretPath); + await chmod(privateSecretPath, 0o600); + await mkdir(path.join(sourceProductData, "cache"), { recursive: true }); + await writeFile( + path.join(sourceProductData, "cache", "rebuildable"), + "cache", + ); + await mkdir(path.join(sourceProductData, "future-durable-area")); + await writeFile( + path.join(sourceProductData, "future-durable-area", "state"), + "future durable state", + ); + const managedArtifactPath = path.join( + sourceProductData, + "managed-artifacts", + "maintenance-rehearsal.bin", + ); + const managedArtifactBytes = new Uint8Array([7, 6, 5, 4]); + await writeFile(managedArtifactPath, managedArtifactBytes); + await chmod(managedArtifactPath, 0o640); + + const busyBackup = await runMaintenance([ + "offline-backup", + "--product-data", + sourceProductData, + "--backup-directory", + backupDirectory, + ]); + expect(busyBackup.code).toBe(1); + expect(busyBackup.stderr).toContain("产品正在运行"); + expect(busyBackup.stdout).not.toContain("完成"); + + await service.close(); + const backup = await runMaintenance([ + "offline-backup", + "--product-data", + sourceProductData, + "--backup-directory", + backupDirectory, + ]); + expect(backup.code).toBe(0); + expect(backup.stdout).toContain("离线备份完成"); + expect(`${backup.stdout}\n${backup.stderr}`).not.toContain(secret); + await expect( + lstat(path.join(backupDirectory, "cache")), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + readFile( + path.join(backupDirectory, "future-durable-area", "state"), + "utf8", + ), + ).resolves.toBe("future durable state"); + await expect( + lstat(path.join(backupDirectory, "observed-media")), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + readFile( + path.join(backupDirectory, "private-source-connections", "tmdb.json"), + ), + ).resolves.toEqual(privateSecretBytes); + expect( + ( + await lstat( + path.join(backupDirectory, "private-source-connections", "tmdb.json"), + ) + ).mode & 0o777, + ).toBe(0o600); + expect( + await readFile( + path.join( + backupDirectory, + "managed-artifacts", + "maintenance-rehearsal.bin", + ), + ), + ).toEqual(Buffer.from(managedArtifactBytes)); + expect( + ( + await lstat( + path.join( + backupDirectory, + "managed-artifacts", + "maintenance-rehearsal.bin", + ), + ) + ).mode & 0o777, + ).toBe(0o640); + + await mkdir(restoredProductData); + await writeFile(path.join(restoredProductData, "keep"), "do not replace"); + const invalidRestore = await runMaintenance([ + "offline-restore", + "--backup-directory", + backupDirectory, + "--product-data", + restoredProductData, + ]); + expect(invalidRestore.code).toBe(1); + expect(invalidRestore.stdout).not.toContain("离线恢复完成"); + await expect( + readFile(path.join(restoredProductData, "keep"), "utf8"), + ).resolves.toBe("do not replace"); + await rm(restoredProductData, { recursive: true }); + + const incompleteBackup = path.join(workspace, "incomplete-backup"); + const failedProductData = path.join(workspace, "failed-product-data"); + await mkdir(incompleteBackup); + await mkdir(failedProductData); + const failedRestore = await runMaintenance([ + "offline-restore", + "--backup-directory", + incompleteBackup, + "--product-data", + failedProductData, + ]); + expect(failedRestore.code).toBe(1); + expect(failedRestore.stderr).toContain("备份源缺少产品 Catalogue"); + expect(failedRestore.stdout).not.toContain("离线恢复完成"); + expect(await readdir(failedProductData)).toEqual([ + "runtime-state", + "staging", + ]); + await rm(failedProductData, { recursive: true }); + + await mkdir(restoredProductData); + + const restored = await runMaintenance([ + "offline-restore", + "--backup-directory", + backupDirectory, + "--product-data", + restoredProductData, + ]); + expect(restored.code).toBe(0); + expect(restored.stdout).toContain("离线恢复完成"); + expect(`${restored.stdout}\n${restored.stderr}`).not.toContain(secret); + expect( + await readFile( + path.join( + restoredProductData, + "managed-artifacts", + "maintenance-rehearsal.bin", + ), + ), + ).toEqual(Buffer.from(managedArtifactBytes)); + expect( + ( + await lstat( + path.join( + restoredProductData, + "managed-artifacts", + "maintenance-rehearsal.bin", + ), + ) + ).mode & 0o777, + ).toBe(0o640); + expect(await digestDirectory(observedRoot)).toBe(observedDigest); + await rm(sourceProductData, { recursive: true }); + + service = await startProductService(restoredProductData, sourceIo); + client = createPublicApiClient({ baseUrl: service.baseUrl }); + await expect(client.listAssetLibraries()).resolves.toMatchObject({ + items: [library], + }); + const restoredAssets = await client.listAssets({ + assetLibraryId: library.id, + limit: 100, + }); + const restoredCover = await readPrimaryCover(restoredAssets); + expect(restoredCover.address).toBe(sourceCover.address); + expect(restoredCover.bytes).toEqual(sourceCover.bytes); + expect(restoredCover.revisionBytes).toEqual(sourceCover.revisionBytes); + expect(restoredAssets).toEqual(sourceAssets); + await expect( + client.inspectAssetLibraryScan(startedScan.parentExecutionId), + ).resolves.toEqual(acceptedScan); + await expect( + client.inspectExecution(startedScan.parentExecutionId), + ).resolves.toEqual(acceptedExecution); + const restoredConnections = await client.listSourceConnections(); + expect(restoredConnections).toEqual(sourceConnections); + expect(JSON.stringify(restoredConnections)).not.toContain(secret); + + const refreshed = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(refreshed.parentExecutionId)) + .status, + { timeout: 120_000 }, + ) + .toBe("completed"); + const refreshedScan = await client.inspectAssetLibraryScan( + refreshed.parentExecutionId, + ); + expect(refreshedScan).toMatchObject({ outcome: "no-change" }); + await expect( + client.listAssets({ assetLibraryId: library.id, limit: 100 }), + ).resolves.toEqual(sourceAssets); + await service.close(); +}, 180_000); + +async function startProductService( + productDataDirectory: string, + sourceIo: ProductSourceIo, +): Promise<{ readonly baseUrl: URL; close(): Promise }> { + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + const service = { server, abort: () => bootstrap.abort() }; + services.push(service); + await server.ready(); + bootstrap.publish(); + const baseUrl = new URL(server.url!); + await expect + .poll( + async () => { + const response = await fetch(new URL("/api/ready", baseUrl), { + signal: AbortSignal.timeout(1_000), + }); + return { + status: response.status, + body: await response.json(), + }; + }, + { timeout: 30_000, interval: 100 }, + ) + .toMatchObject({ status: 200, body: { ok: true } }); + return { + baseUrl, + async close() { + const index = services.indexOf(service); + if (index !== -1) services.splice(index, 1); + await server.close(true); + await bootstrap.abort(); + }, + }; +} + +async function runMaintenance(args: readonly string[]): Promise<{ + readonly code: number; + readonly stdout: string; + readonly stderr: string; +}> { + const child = spawn(process.execPath, [maintenanceEntry, ...args], { + cwd: packageDirectory, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (exitCode) => resolve(exitCode ?? 1)); + }); + return { + code, + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + }; +} + +async function digestDirectory(directory: string): Promise { + const hash = createHash("sha256"); + const files: string[] = []; + const visit = async (current: string) => { + for (const entry of await readdir(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) await visit(entryPath); + else if (entry.isFile()) files.push(entryPath); + } + }; + await visit(directory); + for (const file of files.toSorted()) { + hash.update(path.relative(directory, file)); + hash.update(await readFile(file)); + } + return hash.digest("hex"); +} diff --git a/apps/service/test/public-http.test.ts b/apps/service/test/public-http.test.ts new file mode 100644 index 0000000..0553087 --- /dev/null +++ b/apps/service/test/public-http.test.ts @@ -0,0 +1,1401 @@ +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + chmod, + chown, + cp, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + unlink, + writeFile, +} from "node:fs/promises"; +import { createServer as createNetServer, type AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; + +import type { ProductConfiguration } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { createPublicApiClient } from "@digital/public-api"; +import { + loadControlledSourceIo, + refusingSessionTransport, + resolveSourceIoFromEnvironment, +} from "@digital/test-support/source-io"; +import { serve } from "srvx"; +import { afterEach, expect, it } from "vitest"; + +import { createProductService } from "../src/service.ts"; +import { + interactiveTerminalEnvironment, + startInteractiveTerminal, +} from "./interactive-terminal.ts"; + +const openServers: Array>> = []; +const bootstraps: Array>> = []; +const temporaryRoots: string[] = []; +const terminalChildren: Array> = []; +const servicePackageRoot = fileURLToPath(new URL("../", import.meta.url)); +const fixtureMaterialDirectory = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/material"), +); +const fixtureManifestPath = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/manifest.json"), +); + +afterEach(async () => { + for (const child of terminalChildren.splice(0).reverse()) { + if (child.exitCode === null && child.signalCode === null) { + if (child.pid !== undefined && process.platform !== "win32") { + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + } else child.kill("SIGTERM"); + await new Promise((resolve) => + child.once("close", () => resolve()), + ); + } + } + for (const server of openServers.splice(0).reverse()) + await server.close(true); + for (const bootstrap of bootstraps.splice(0).reverse()) + await bootstrap.abort(); + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); +}); + +it("serves empty libraries, assets and unrecognized pages over the real HTTP/SQLite path", async () => { + const baseUrl = await startEmptyProductService(); + + const ready = await eventuallyReady(baseUrl); + expect(ready).toBe(true); + const client = createPublicApiClient({ baseUrl }); + const [libraries, assets, unrecognized] = await Promise.all([ + client.listAssetLibraries(), + client.listAssets({ limit: 20 }), + client.listUnrecognizedMaterials({ limit: 20 }), + ]); + expect(libraries.items).toEqual([]); + expect(assets.items).toEqual([]); + expect(unrecognized.items).toEqual([]); + + const invalidLibraryCursor = await fetch( + new URL("/api/v1/asset-libraries?cursor=not-a-cursor", baseUrl), + ); + expect(invalidLibraryCursor.status).toBe(400); + await expect(invalidLibraryCursor.json()).resolves.toMatchObject({ + error: { code: "invalid-request" }, + }); + const emptyLibraryCursor = await fetch( + new URL("/api/v1/asset-libraries?cursor=", baseUrl), + ); + expect(emptyLibraryCursor.status).toBe(400); + await expect(emptyLibraryCursor.json()).resolves.toMatchObject({ + error: { code: "invalid-request" }, + }); + const invalidPageCursorResponses = await Promise.all( + ["assets", "unrecognized-materials"].map((endpoint) => + fetch(new URL(`/api/v1/${endpoint}?cursor=not-a-cursor`, baseUrl)), + ), + ); + expect(invalidPageCursorResponses.map(({ status }) => status)).toEqual([ + 400, 400, + ]); + for (const response of invalidPageCursorResponses) + await expect(response.json()).resolves.toMatchObject({ + error: { code: "invalid-request" }, + }); + + const invalid = await fetch(new URL("/api/v1/assets?limit=0", baseUrl)); + expect(invalid.status).toBe(400); + await expect(invalid.json()).resolves.toMatchObject({ + error: { code: "invalid-request" }, + }); + + const missingLibrary = await fetch( + new URL("/api/v1/assets?assetLibraryId=missing", baseUrl), + ); + expect(missingLibrary.status).toBe(404); + await expect(missingLibrary.json()).resolves.toMatchObject({ + error: { code: "not-found" }, + }); + + const invalidUnrecognized = await fetch( + new URL("/api/v1/unrecognized-materials?limit=0", baseUrl), + ); + expect(invalidUnrecognized.status).toBe(400); + await expect(invalidUnrecognized.json()).resolves.toMatchObject({ + error: { code: "invalid-request" }, + }); + + const missingUnrecognizedLibrary = await fetch( + new URL("/api/v1/unrecognized-materials?assetLibraryId=missing", baseUrl), + ); + expect(missingUnrecognizedLibrary.status).toBe(404); + await expect(missingUnrecognizedLibrary.json()).resolves.toMatchObject({ + error: { code: "not-found" }, + }); +}); + +it("通过公开客户端创建、修改、移除用户命名的多目录资产库并读回持久配置", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc06-library-http-")); + temporaryRoots.push(root); + const firstDirectory = path.join(root, "收藏", "声音", "2025"); + const secondDirectory = path.join(root, "收藏", "声音", "2026"); + const thirdDirectory = path.join(root, "另一个书架"); + await Promise.all( + [firstDirectory, secondDirectory, thirdDirectory].map((directory) => + mkdir(directory, { recursive: true }), + ), + ); + const baseUrl = await startEmptyProductService(); + const client = createPublicApiClient({ baseUrl }); + + const options = await client.listAssetLibraryOptions(); + expect(options.recognitionProfiles.map(({ id }) => id)).toContain( + "dlsite-voice", + ); + expect(options.sources.map(({ id }) => id)).toContain("dlsite"); + await expect( + client.saveAssetLibrary({ + displayName: "错误目录配置", + directories: [path.join(root, "尚不存在的目录")], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }), + ).rejects.toMatchObject({ + kind: "invalid-request", + status: 400, + message: "文件位置必须是当前可读的目录", + }); + await expect(client.listAssetLibraries()).resolves.toMatchObject({ + items: [], + }); + const created = await client.saveAssetLibrary({ + displayName: "我的中文声音库", + directories: [firstDirectory, secondDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + expect(created).toMatchObject({ + displayName: "我的中文声音库", + configVersion: 1, + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + expect(created.directories.map(({ path }) => path).toSorted()).toEqual( + [firstDirectory, secondDirectory].toSorted(), + ); + expect(created.directories.every(({ status }) => status === "active")).toBe( + true, + ); + + const updated = await client.saveAssetLibrary({ + id: created.id, + expectedVersion: created.configVersion, + displayName: "我的更新声音库", + directories: [firstDirectory, thirdDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: [], + }); + expect(updated).toMatchObject({ + id: created.id, + displayName: "我的更新声音库", + configVersion: 2, + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: [], + }); + expect( + updated.directories + .filter(({ status }) => status === "active") + .map(({ path }) => path) + .toSorted(), + ).toEqual([firstDirectory, thirdDirectory].toSorted()); + expect(updated.directories).toContainEqual( + expect.objectContaining({ path: secondDirectory, status: "removed" }), + ); + await expect(client.listAssetLibraries()).resolves.toMatchObject({ + items: [updated], + }); + await expect( + client.saveAssetLibrary({ + id: "missing-asset-library", + expectedVersion: 1, + displayName: "缺失库不能修改", + directories: [firstDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }), + ).rejects.toMatchObject({ kind: "not-found", status: 404 }); + await expect( + client.removeAssetLibrary({ + id: "missing-asset-library", + expectedVersion: 1, + }), + ).rejects.toMatchObject({ kind: "not-found", status: 404 }); + await expect( + client.saveAssetLibrary({ + id: created.id, + expectedVersion: created.configVersion, + displayName: "过期配置不能覆盖新版本", + directories: [firstDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }), + ).rejects.toMatchObject({ kind: "conflict", status: 409 }); + + await client.removeAssetLibrary({ + id: updated.id, + expectedVersion: updated.configVersion, + }); + await expect(client.listAssetLibraries()).resolves.toMatchObject({ + items: [], + }); +}, 30_000); + +it("把 SQLite 写入故障作为服务不可用返回,不向用户泄露存储错误", async () => { + const root = await mkdtemp( + path.join(tmpdir(), "ewc06-library-storage-error-"), + ); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialDirectory = path.join(root, "源材料"); + await mkdir(materialDirectory, { recursive: true }); + await prepareProductionCatalogue({ productDataDirectory }); + const service = await startProductServiceWithData( + productDataDirectory, + undefined, + ); + const database = new DatabaseSync( + path.join(productDataDirectory, "catalogue.sqlite"), + ); + try { + database.exec(` + CREATE TRIGGER ewc06_reject_asset_library_insert + BEFORE INSERT ON asset_libraries + BEGIN + SELECT RAISE(ABORT, 'ewc06 storage write failure'); + END; + `); + } finally { + database.close(); + } + + const response = await fetch( + new URL("/api/v1/asset-libraries", service.baseUrl), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + displayName: "存储错误验证库", + directories: [materialDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }), + }, + ); + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: { + code: "unavailable", + message: "资产库操作暂时不可用,请稍后重试", + }, + }); +}); + +it("查询缺失目录与撤销的访问权限,并在扫描结果中保留具体失败原因", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc06-directory-errors-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const missingDirectory = path.join(root, "已不可用"); + const revokedDirectory = path.join(root, "权限已撤销"); + const inaccessibleDirectory = path.join(root, "暂不可读"); + await Promise.all( + [missingDirectory, revokedDirectory, inaccessibleDirectory].map( + (directory) => mkdir(directory, { recursive: true }), + ), + ); + await writeFile(path.join(missingDirectory, "说明.txt"), "之后移除\n"); + await writeFile(path.join(revokedDirectory, "说明.txt"), "之后撤销权限\n"); + await writeFile(path.join(inaccessibleDirectory, "说明.txt"), "无权限读取\n"); + if (process.platform !== "win32") await chmod(inaccessibleDirectory, 0o000); + + const serviceUser = + process.platform !== "win32" && process.getuid?.() === 0 + ? { uid: 65_534, gid: 65_534 } + : undefined; + if (serviceUser !== undefined) await chmod(root, 0o755); + const baseUrl = await startInstalledProductService({ + productDataDirectory, + manifestPath: fixtureManifestPath, + port: await reserveEwc06Port(), + ...(serviceUser === undefined ? {} : { serviceUser }), + }); + const client = createPublicApiClient({ baseUrl }); + if (process.platform !== "win32") { + try { + const inaccessible = await fetch( + new URL("/api/v1/asset-libraries", baseUrl), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + displayName: "不可读目录不能保存", + directories: [inaccessibleDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }), + }, + ); + expect(inaccessible.status).toBe(400); + await expect(inaccessible.json()).resolves.toEqual({ + error: { + code: "invalid-request", + message: "文件位置必须是当前可读的目录", + }, + }); + } finally { + await chmod(inaccessibleDirectory, 0o755); + } + } + const library = await client.saveAssetLibrary({ + displayName: "目录故障观察", + directories: [missingDirectory, revokedDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + await rm(missingDirectory, { recursive: true }); + await chmod(revokedDirectory, 0o000); + try { + const listed = await client.listAssetLibraries(); + const states = new Map( + listed.items[0]!.directories.map((directory) => [ + directory.path, + directory, + ]), + ); + expect(states.get(missingDirectory)).toMatchObject({ + reachable: false, + problem: expect.stringContaining("文件位置当前缺失、不可读"), + }); + expect(states.get(revokedDirectory)).toMatchObject({ + reachable: false, + problem: expect.stringContaining("文件位置当前缺失、不可读"), + }); + + const { parentExecutionId } = await client.startAssetLibraryScan( + library.id, + ); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(parentExecutionId)).status, + { timeout: 30_000 }, + ) + .not.toBe("running"); + const scan = await client.inspectAssetLibraryScan(parentExecutionId); + expect(scan).toMatchObject({ status: "failed", outcome: "failed" }); + expect(scan.directories).toHaveLength(2); + for (const directory of scan.directories) { + expect(directory.outcome).toBe("failed"); + expect(directory.reason).toContain( + "冻结文件位置当前不可用,无法安全继续", + ); + } + } finally { + await chmod(revokedDirectory, 0o755); + } +}, 90_000); + +it("从普通父目录发现多部作品、处理中间目录和未知材料,并以增量扫描保留身份", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc06-library-scan-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialRoot = path.join(root, "我的收藏"); + const workDirectories = [ + ["RJ01029383", path.join(materialRoot, "作者甲", "2024", "RJ01029383")], + [ + "RJ01196709", + path.join(materialRoot, "作者乙", "2025", "已归档", "RJ01196709"), + ], + ["RJ01497788", path.join(materialRoot, "作者丙", "新作", "RJ01497788")], + ] as const; + await Promise.all( + workDirectories.map(([, directory]) => + mkdir(directory, { recursive: true }), + ), + ); + for (const [workName, directory] of workDirectories) + await cp(path.join(fixtureMaterialDirectory, workName), directory, { + recursive: true, + }); + const notesDirectory = path.join(materialRoot, "普通文件夹", "杂项"); + await mkdir(notesDirectory, { recursive: true }); + await writeFile( + path.join(notesDirectory, "材料列表.txt"), + "用户自己的说明\n", + ); + await prepareProductionCatalogue({ productDataDirectory }); + const controlled = await loadControlledSourceIo({ + manifestPath: fixtureManifestPath, + }); + const sourceIo = { + mode: "controlled" as const, + request: controlled.request, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + let service = await startProductServiceWithData( + productDataDirectory, + sourceIo, + ); + let client = createPublicApiClient({ baseUrl: service.baseUrl }); + const created = await client.saveAssetLibrary({ + displayName: "普通目录声音库", + directories: [materialRoot], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + expect(created.directories.map(({ path }) => path)).toContain(materialRoot); + const preparedMaterialDigest = await digestDirectories([materialRoot]); + + const scan = async () => { + const started = await client.startAssetLibraryScan(created.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(started.parentExecutionId)) + .status, + { timeout: 120_000 }, + ) + .toBe("completed"); + return await client.inspectAssetLibraryScan(started.parentExecutionId); + }; + const firstScan = await scan(); + expect(await digestDirectories([materialRoot])).toBe(preparedMaterialDigest); + expect(firstScan).toMatchObject({ + assetLibraryId: created.id, + status: "completed", + }); + expect(firstScan.directories).toHaveLength(1); + const firstUnrecognized = await client.listUnrecognizedMaterials({ + assetLibraryId: created.id, + limit: 100, + }); + expect( + firstUnrecognized.items.some((item) => + item.relativePaths.some((relativePath) => + relativePath.includes("材料列表.txt"), + ), + ), + ).toBe(true); + + const firstAssets: Awaited< + ReturnType + >["items"][number][] = []; + let assetCursor: string | undefined; + do { + const page = await client.listAssets({ + assetLibraryId: created.id, + limit: 100, + ...(assetCursor === undefined ? {} : { cursor: assetCursor }), + }); + firstAssets.push(...page.items); + assetCursor = page.nextCursor; + } while (assetCursor !== undefined); + const firstAssetByWork = new Map(); + for (const [workName] of workDirectories) { + const matching = firstAssets.filter((asset) => + asset.locations.some(({ address }) => address.includes(`/${workName}/`)), + ); + expect( + matching, + `普通父目录中应发现 ${workName} 对应的实际作品`, + ).toHaveLength(1); + firstAssetByWork.set(workName, matching[0]!.id); + } + expect(new Set(firstAssetByWork.values()).size).toBe(3); + for (const [workName, assetId] of firstAssetByWork) { + expect( + firstScan.directories[0]!.files.some( + (file) => + file.assetId === assetId && + file.relativePaths.some((relativePath) => + relativePath.includes(workName), + ), + ), + ).toBe(true); + } + + const addedWorkName = "RJ01115164"; + const addedWorkDirectory = path.join( + materialRoot, + "作者丁", + "2026", + addedWorkName, + ); + const beforeAddingMaterialDigest = await digestDirectories([materialRoot]); + await mkdir(path.dirname(addedWorkDirectory), { recursive: true }); + await cp( + path.join(fixtureMaterialDirectory, addedWorkName), + addedWorkDirectory, + { + recursive: true, + }, + ); + const afterAddingMaterialDigest = await digestDirectories([materialRoot]); + expect(afterAddingMaterialDigest).not.toBe(beforeAddingMaterialDigest); + const secondScan = await scan(); + expect(await digestDirectories([materialRoot])).toBe( + afterAddingMaterialDigest, + ); + expect(secondScan.parentExecutionId).not.toBe(firstScan.parentExecutionId); + const secondAssets: typeof firstAssets = []; + assetCursor = undefined; + do { + const page = await client.listAssets({ + assetLibraryId: created.id, + limit: 100, + ...(assetCursor === undefined ? {} : { cursor: assetCursor }), + }); + secondAssets.push(...page.items); + assetCursor = page.nextCursor; + } while (assetCursor !== undefined); + for (const [workName, id] of firstAssetByWork) + expect( + secondAssets + .filter((asset) => + asset.locations.some(({ address }) => + address.includes(`/${workName}/`), + ), + ) + .map(({ id: currentId }) => currentId), + ).toEqual([id]); + const addedAssets = secondAssets.filter((asset) => + asset.locations.some(({ address }) => + address.includes(`/${addedWorkName}/`), + ), + ); + expect(addedAssets).toHaveLength(1); + expect(addedAssets[0]!.id).not.toBe(firstAssetByWork.get("RJ01115164")); + expect(new Set(secondAssets.map(({ id }) => id)).size).toBe( + secondAssets.length, + ); + + await service.close(); + service = await startProductServiceWithData(productDataDirectory, sourceIo); + client = createPublicApiClient({ baseUrl: service.baseUrl }); + const persistedLibraries = await client.listAssetLibraries(); + expect(persistedLibraries.items).toHaveLength(1); + expect(persistedLibraries.items[0]).toMatchObject({ + id: created.id, + displayName: "普通目录声音库", + configVersion: 1, + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + await expect( + client.inspectAssetLibraryScan(secondScan.parentExecutionId), + ).resolves.toMatchObject({ + parentExecutionId: secondScan.parentExecutionId, + status: "completed", + }); + const persistedAssets = await client.listAssets({ + assetLibraryId: created.id, + limit: 100, + }); + for (const id of secondAssets.map(({ id }) => id)) + expect(persistedAssets.items.map(({ id }) => id)).toContain(id); + const persistedUnrecognized = await client.listUnrecognizedMaterials({ + assetLibraryId: created.id, + limit: 100, + }); + expect( + persistedUnrecognized.items.some((item) => + item.relativePaths.includes("普通文件夹/杂项/材料列表.txt"), + ), + ).toBe(true); +}, 240_000); + +it("正式扫描普通目录后经公共 HTTP 客户端分页读取资产和未识别材料", async () => { + const root = await mkdtemp( + path.join(tmpdir(), "digital-service-normal-http-"), + ); + temporaryRoots.push(root); + const material = path.join(root, "material"); + await cp(fixtureMaterialDirectory, material, { recursive: true }); + await writeFile(path.join(material, "ordinary.txt"), "普通材料\n"); + await writeFile(path.join(material, "ordinary-2.txt"), "另一份普通材料\n"); + const controlled = await loadControlledSourceIo({ + manifestPath: fixtureManifestPath, + }); + const baseUrl = await startProductService({ + mode: "controlled", + request: controlled.request, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }); + const client = createPublicApiClient({ baseUrl }); + const options = await client.listAssetLibraryOptions(); + expect(options.recognitionProfiles.map(({ id }) => id)).toContain( + "dlsite-voice", + ); + expect(options.sources.map(({ id }) => id)).toContain("dlsite"); + const created = await client.saveAssetLibrary({ + displayName: "普通目录资料库", + directories: [material], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + const secondLibrary = await client.saveAssetLibrary({ + displayName: "第二资料库", + directories: [material], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + const scan = await client.startAssetLibraryScan(created.id); + await expect + .poll( + async () => await client.inspectAssetLibraryScan(scan.parentExecutionId), + { timeout: 120_000 }, + ) + .toMatchObject({ status: "completed" }); + + const firstLibraryPage = await client.listAssetLibraries({ limit: 1 }); + expect(firstLibraryPage.items).toHaveLength(1); + expect(firstLibraryPage.nextCursor).toBeTypeOf("string"); + const secondLibraryPage = await client.listAssetLibraries({ + limit: 1, + cursor: firstLibraryPage.nextCursor, + }); + expect(secondLibraryPage.items).toHaveLength(1); + expect(secondLibraryPage.nextCursor).toBeUndefined(); + expect( + [ + ...firstLibraryPage.items.map(({ displayName }) => displayName), + ...secondLibraryPage.items.map(({ displayName }) => displayName), + ].toSorted(), + ).toEqual(["普通目录资料库", "第二资料库"].toSorted()); + const assets = await client.listAssets({ + assetLibraryId: created.id, + limit: 3, + }); + expect(assets.items).toHaveLength(3); + expect(assets.nextCursor).toBeTypeOf("string"); + const nextAssets = await client.listAssets({ + assetLibraryId: created.id, + limit: 3, + cursor: assets.nextCursor, + }); + expect(nextAssets.items).toHaveLength(3); + expect(nextAssets.items.map(({ id }) => id)).not.toEqual( + assets.items.map(({ id }) => id), + ); + + const unrecognized = await client.listUnrecognizedMaterials({ + assetLibraryId: created.id, + limit: 3, + }); + expect(unrecognized.items).toHaveLength(3); + expect(unrecognized.nextCursor).toBeTypeOf("string"); + const firstUnrecognizedCursor = unrecognized.nextCursor!; + const unrecognizedPages = [unrecognized]; + let nextCursor = unrecognized.nextCursor; + while (nextCursor !== undefined && unrecognizedPages.length < 20) { + const nextPage = await client.listUnrecognizedMaterials({ + assetLibraryId: created.id, + limit: 3, + cursor: nextCursor, + }); + unrecognizedPages.push(nextPage); + nextCursor = nextPage.nextCursor; + } + expect(nextCursor).toBeUndefined(); + const observations = unrecognizedPages.flatMap(({ items }) => items); + expect( + observations.every(({ kind }) => kind === "filesystem-observation"), + ).toBe(true); + const paths = observations.flatMap(({ relativePaths }) => relativePaths); + expect(paths).toContain("ordinary.txt"); + expect(paths).toContain("ordinary-2.txt"); + expect( + paths.some((relativePath) => relativePath.startsWith("RJ01029383/")), + ).toBe(false); + expect(new Set(paths).size).toBe(paths.length); + + const wrongScopeAssetCursor = new URL("/api/v1/assets", baseUrl); + wrongScopeAssetCursor.searchParams.set("assetLibraryId", secondLibrary.id); + wrongScopeAssetCursor.searchParams.set("cursor", firstUnrecognizedCursor); + const wrongScopeAssetResponse = await fetch(wrongScopeAssetCursor); + expect(wrongScopeAssetResponse.status).toBe(400); + await expect(wrongScopeAssetResponse.json()).resolves.toMatchObject({ + error: { code: "invalid-request" }, + }); + + const wrongScopeUnrecognizedCursor = new URL( + "/api/v1/unrecognized-materials", + baseUrl, + ); + wrongScopeUnrecognizedCursor.searchParams.set( + "assetLibraryId", + secondLibrary.id, + ); + wrongScopeUnrecognizedCursor.searchParams.set( + "cursor", + firstUnrecognizedCursor, + ); + const wrongScopeUnrecognizedResponse = await fetch( + wrongScopeUnrecognizedCursor, + ); + expect( + wrongScopeUnrecognizedResponse.status, + await wrongScopeUnrecognizedResponse.clone().text(), + ).toBe(400); + await expect(wrongScopeUnrecognizedResponse.json()).resolves.toMatchObject({ + error: { code: "invalid-request" }, + }); +}, 180_000); + +it("默认未识别列表只读当前完成扫描,不重复展示同一路径的旧观察", async () => { + const materialRoot = await mkdtemp( + path.join(tmpdir(), "digital-service-current-observation-"), + ); + temporaryRoots.push(materialRoot); + await writeFile(path.join(materialRoot, "ordinary.txt"), "旧普通材料\n"); + await writeFile(path.join(materialRoot, "stable.txt"), "仍然存在\n"); + const baseUrl = await startEmptyProductService(); + const client = createPublicApiClient({ baseUrl }); + const { id: assetLibraryId } = await client.saveAssetLibrary({ + displayName: "当前观察资料库", + directories: [materialRoot], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + + const scan = async (): Promise => { + const { parentExecutionId } = + await client.startAssetLibraryScan(assetLibraryId); + await expect + .poll( + async () => await client.inspectAssetLibraryScan(parentExecutionId), + { timeout: 30_000 }, + ) + .toMatchObject({ status: "completed" }); + return parentExecutionId; + }; + const historicalExecutionId = await scan(); + const historicalPage = await client.listUnrecognizedMaterials({ + assetLibraryId, + limit: 1, + }); + expect(historicalPage.items).toHaveLength(1); + expect(historicalPage.nextCursor).toBeTypeOf("string"); + const removedPath = historicalPage.items[0]!.relativePaths[0]!; + await unlink(path.join(materialRoot, removedPath)); + await writeFile(path.join(materialRoot, "current.txt"), "当前普通材料\n"); + const currentExecutionId = await scan(); + + const page = await client.listUnrecognizedMaterials({ + assetLibraryId, + limit: 20, + }); + const currentPaths = page.items.flatMap(({ relativePaths }) => relativePaths); + expect(currentPaths).toHaveLength(2); + expect(currentPaths).toContain("current.txt"); + expect(currentPaths).not.toContain(removedPath); + expect( + page.items.every( + ({ parentExecutionId }) => parentExecutionId === currentExecutionId, + ), + ).toBe(true); + await expect( + client.listUnrecognizedMaterials({ + assetLibraryId, + cursor: historicalPage.nextCursor, + limit: 1, + }), + ).resolves.toMatchObject({ + items: [ + { + parentExecutionId: historicalExecutionId, + relativePaths: ["stable.txt"], + }, + ], + }); + await expect( + client.listUnrecognizedMaterials({ + assetLibraryId, + parentExecutionId: historicalExecutionId, + limit: 20, + }), + ).resolves.toMatchObject({ + items: [ + { + parentExecutionId: historicalExecutionId, + relativePaths: expect.arrayContaining(["ordinary.txt"]), + }, + expect.objectContaining({ relativePaths: ["stable.txt"] }), + ], + }); +}, 90_000); + +it("在真实就绪服务前运行正式 TUI 命令并通过独占窄终端退出【安装客户端】", async () => { + const materialRoot = await mkdtemp( + path.join(tmpdir(), "digital-service-tui-unrecognized-"), + ); + temporaryRoots.push(materialRoot); + const productDataDirectory = await mkdtemp( + path.join(tmpdir(), "digital-service-tui-product-data-"), + ); + temporaryRoots.push(productDataDirectory); + await cp(fixtureMaterialDirectory, materialRoot, { recursive: true }); + await writeFile(path.join(materialRoot, "ordinary.txt"), "普通材料\n"); + const baseUrl = await startInstalledProductService({ + productDataDirectory, + manifestPath: fixtureManifestPath, + }); + const publicClient = createPublicApiClient({ baseUrl }); + const library = await publicClient.saveAssetLibrary({ + displayName: "窄终端资料库", + directories: [materialRoot], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + const scan = await publicClient.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + await publicClient.inspectAssetLibraryScan(scan.parentExecutionId), + { timeout: 30_000 }, + ) + .toMatchObject({ status: "completed" }); + const assetPage = await publicClient.listAssets({ + assetLibraryId: library.id, + limit: 20, + }); + const firstAsset = assetPage.items[0]; + expect(firstAsset).toBeDefined(); + const knownTitle = firstAsset!.descriptions + .filter( + (description) => + description.target.kind === "entity" && + description.target.entityId === firstAsset!.id, + ) + .flatMap((description) => description.metadata) + .find( + (metadata) => + metadata.property === "asset.title" && + metadata.result.status === "resolved", + )?.result; + const title = + knownTitle?.status === "resolved" ? knownTitle.values[0]?.value : undefined; + expect(title).toBeTypeOf("string"); + const visibleTitlePrefix = Array.from(title!).slice(0, 12).join(""); + const terminalCommand = `stty cols 34 rows 10 -icanon -echo; terminalModeBefore=$(stty -g); pnpm --filter @digital/service exec digital-tui --url ${baseUrl.origin}; terminalModeAfter=$(stty -g); if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi`; + const terminalSession = startInteractiveTerminal(terminalCommand, { + cwd: servicePackageRoot, + columns: 34, + rows: 10, + timeoutMs: 10_000, + }); + terminalChildren.push(terminalSession.child); + await terminalSession.waitForOutput("窄终端资料库"); + await terminalSession.sendAndWait("\r", visibleTitlePrefix); + await terminalSession.sendAndWait("3", "›3 未识别"); + await terminalSession.sendAndWait("/", "筛选本页:(输入后回车"); + await terminalSession.sendAndWait( + "ordinary.txt", + "筛选本页:ordinary.txt(输入后回车", + ); + await terminalSession.sendAndWait("\r", "筛选本页:ordinary.txt(1/"); + await terminalSession.sendAndWait("q", "TERMINAL_RESTORED"); + const exitCode = await terminalSession.closeAndWait(); + const terminalOutput = terminalSession.output; + const errorOutput = terminalSession.errors; + expect(exitCode, `${errorOutput}\n${terminalOutput}`).toBe(0); + expect(terminalOutput).toContain("数字资料库"); + expect(terminalOutput).toContain("窄终端资料库"); + expect(terminalOutput).toContain(visibleTitlePrefix); + expect(terminalOutput).toContain("ordinary.txt"); + expect(terminalOutput).not.toContain("无法连接当前产品服务"); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + await expect + .poll(async () => { + const response = await fetch(new URL("/api/ready", baseUrl)); + return response.status; + }) + .toBe(200); + await expect(publicClient.listAssetLibraries()).resolves.toMatchObject({ + items: [expect.objectContaining({ id: library.id })], + }); +}, 15_000); + +it("通过安装的 TUI 修正路径错误、配置多目录库、扫描并查看识别与未识别结果【安装客户端】", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc06-tui-library-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const materialRoot = path.join(root, "普通收藏"); + const supplementalRoot = path.join(root, "另一目录"); + const invalidRoot = path.join(root, "ewc06-missing-directory"); + const works = [ + ["RJ01029383", path.join(materialRoot, "作者甲", "2024", "RJ01029383")], + [ + "RJ01196709", + path.join(materialRoot, "作者乙", "2025", "已归档", "RJ01196709"), + ], + ] as const; + for (const [workName, destination] of works) { + await mkdir(destination, { recursive: true }); + await cp(path.join(fixtureMaterialDirectory, workName), destination, { + recursive: true, + }); + } + await mkdir(path.join(materialRoot, "普通文件夹", "杂项"), { + recursive: true, + }); + await writeFile( + path.join(materialRoot, "普通文件夹", "杂项", "材料列表.txt"), + "验收准备:普通未知材料\n", + ); + await mkdir(supplementalRoot, { recursive: true }); + await writeFile( + path.join(supplementalRoot, "补充说明.txt"), + "另一目录中的说明\n", + ); + + const baseUrl = await startInstalledProductService({ + productDataDirectory, + manifestPath: fixtureManifestPath, + port: await reserveEwc06Port(), + }); + const materialDigest = await digestDirectories([ + materialRoot, + supplementalRoot, + ]); + const terminalCommand = `stty cols 48 rows 12 -icanon -echo; terminalModeBefore=$(stty -g); pnpm --filter @digital/service exec digital-tui --url ${shellQuote(baseUrl.origin)}; terminalModeAfter=$(stty -g); if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi`; + const child = spawn("script", ["-qfec", terminalCommand, "/dev/null"], { + cwd: servicePackageRoot, + env: interactiveTerminalEnvironment({ + TERM: "xterm-256color", + COLUMNS: "48", + LINES: "12", + }), + stdio: ["pipe", "pipe", "pipe"], + }); + const childClosed = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve(code)); + }); + terminalChildren.push(child); + let terminalOutput = ""; + let errorOutput = ""; + child.stdout.on("data", (chunk: Buffer) => { + terminalOutput += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + errorOutput += chunk.toString("utf8"); + }); + const waitFor = async ( + predicate: () => boolean, + description: string, + timeoutMs = 15_000, + ) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + if (child.exitCode !== null || child.signalCode !== null) + throw new Error( + `TUI 提前退出,等待${description}失败:${errorOutput}\n${terminalOutput.slice(-3000)}`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error( + `TUI 未显示${description}:${errorOutput}\n${terminalOutput.slice(-3000)}`, + ); + }; + const waitForText = async (value: string, after = 0, timeoutMs?: number) => + await waitFor( + () => terminalOutput.slice(after).includes(value), + `“${value}”`, + timeoutMs, + ); + const send = async (value: string) => { + const after = terminalOutput.length; + child.stdin.write(value); + await new Promise((resolve) => setTimeout(resolve, 160)); + return after; + }; + const sendAndWait = async (value: string, nextScreen: string) => { + const after = await send(value); + await waitForText(nextScreen, after); + return after; + }; + + await waitForText("还没有资产库"); + await sendAndWait("c", "新建资产库:资产库名称"); + await send("终端收藏库"); + await sendAndWait("\r", "新建资产库:服务端目录"); + await send(invalidRoot); + await sendAndWait("\r", "ewc06-missing"); + await send(materialRoot); + await sendAndWait("\r", "普通收藏"); + await send(supplementalRoot); + await sendAndWait("\r", "另一目录"); + await sendAndWait("\r", "识别能力"); + await send(" "); + await sendAndWait("\r", "新建资产库:来源"); + await send(" "); + await sendAndWait("\r", "文件位置必须是当前可读的目录"); + + await send("b"); + await sendAndWait("b", "新建资产库:服务端目录"); + await send(`-${invalidRoot}`); + await sendAndWait("\r", "路径输入:"); + await sendAndWait("\r", "识别能力"); + await sendAndWait("\r", "新建资产库:来源"); + await sendAndWait("\r", "已保存「终端收藏库」"); + + const client = createPublicApiClient({ baseUrl }); + const savedLibraries = await client.listAssetLibraries(); + expect(savedLibraries.items).toHaveLength(1); + const savedLibrary = savedLibraries.items[0]!; + expect(savedLibrary).toMatchObject({ + displayName: "终端收藏库", + configVersion: 1, + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + expect( + savedLibrary.directories + .filter(({ status }) => status === "active") + .map(({ path: directoryPath }) => directoryPath) + .toSorted(), + ).toEqual([materialRoot, supplementalRoot].toSorted()); + + await sendAndWait("s", "扫描执行编号"); + const escape = String.fromCharCode(27); + const plainTerminalOutput = terminalOutput + .replace(new RegExp(`${escape}\\[[0-?]*[ -/]*[@-~]`, "gu"), "") + .replace(/[\r\n]/gu, ""); + const executionId = plainTerminalOutput.match( + /execution_[0-9a-f-]{36}/u, + )?.[0]; + expect(executionId, terminalOutput.slice(-3000)).toBeTypeOf("string"); + await expect + .poll( + async () => (await client.inspectAssetLibraryScan(executionId!)).status, + { timeout: 120_000 }, + ) + .toBe("completed"); + const scan = await client.inspectAssetLibraryScan(executionId!); + expect(scan).toMatchObject({ + parentExecutionId: executionId, + assetLibraryId: savedLibrary.id, + configVersion: 1, + status: "completed", + }); + expect(scan.directories).toHaveLength(2); + + const assets = await client.listAssets({ + assetLibraryId: savedLibrary.id, + limit: 100, + }); + const assetIdByWork = new Map(); + for (const [workName] of works) { + const matching = assets.items.filter((asset) => + asset.locations.some(({ address }) => address.includes(`/${workName}/`)), + ); + expect(matching, `扫描结果应将${workName}归属到唯一资产`).toHaveLength(1); + assetIdByWork.set(workName, matching[0]!.id); + expect( + scan.directories.some((directory) => + directory.files.some( + (file) => + file.assetId === matching[0]!.id && + file.relativePaths.some((relativePath) => + relativePath.includes(workName), + ), + ), + ), + ).toBe(true); + } + expect(new Set(assetIdByWork.values()).size).toBe(works.length); + const unrecognized = await client.listUnrecognizedMaterials({ + assetLibraryId: savedLibrary.id, + limit: 100, + }); + const unrecognizedPaths = unrecognized.items.flatMap( + ({ relativePaths }) => relativePaths, + ); + expect(unrecognizedPaths).toContain("普通文件夹/杂项/材料列表.txt"); + expect(unrecognizedPaths).toContain("补充说明.txt"); + + await waitForText("结果:已完成"); + const assetsModeOffset = await sendAndWait("2", "›2 资产"); + const firstAsset = assets.items[0]!; + const firstTitle = firstAsset.descriptions + .filter( + (description) => + description.target.kind === "entity" && + description.target.entityId === firstAsset.id, + ) + .flatMap((description) => description.metadata) + .find( + (metadata) => + metadata.property === "asset.title" && + metadata.result.status === "resolved", + )?.result; + const visibleTitle = + firstTitle?.status === "resolved" + ? firstTitle.values[0]?.value + : (firstAsset.relatedEntities.find( + (entity) => entity.id === firstAsset.id, + )?.label ?? "未命名资产"); + await waitForText( + Array.from(visibleTitle ?? "未命名资产") + .slice(0, 10) + .join(""), + assetsModeOffset, + ); + const unrecognizedModeOffset = await sendAndWait("3", "›3 未识别材料"); + await waitForText("材料列表.txt", unrecognizedModeOffset); + await sendAndWait("1", "›1 资产库"); + await sendAndWait("e", "修改资产库:资产库名称"); + await send("已更新"); + await sendAndWait("\r", "修改资产库:服务端目录"); + await sendAndWait("\r", "修改资产库:识别能力"); + await sendAndWait("\r", "修改资产库:来源"); + await sendAndWait("\r", "已保存「终端收藏库已更新」"); + + const updatedLibraries = await client.listAssetLibraries(); + expect(updatedLibraries.items).toMatchObject([ + { + id: savedLibrary.id, + displayName: "终端收藏库已更新", + configVersion: 2, + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }, + ]); + await sendAndWait("d", "确认移除「终端收藏库已更新」"); + await sendAndWait("y", "已移除「终端收藏库已更新」"); + await expect(client.listAssetLibraries()).resolves.toMatchObject({ + items: [], + }); + expect(await digestDirectories([materialRoot, supplementalRoot])).toBe( + materialDigest, + ); + + await send("q"); + const exitTimer = setTimeout(() => child.kill("SIGTERM"), 10_000); + const exitCode = await childClosed; + clearTimeout(exitTimer); + expect(exitCode, `${errorOutput}\n${terminalOutput}`).toBe(0); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + expect(terminalOutput).not.toContain("TERMINAL_NOT_RESTORED"); +}, 120_000); + +async function startEmptyProductService(): Promise { + const sourceIo = await resolveSourceIoFromEnvironment({ + DIGITAL_SOURCE_MODE: "offline", + }); + return await startProductService(sourceIo); +} + +async function startInstalledProductService(input: { + readonly productDataDirectory: string; + readonly manifestPath: string; + readonly port?: number; + readonly serviceUser?: { readonly uid: number; readonly gid: number }; +}): Promise { + await prepareProductionCatalogue({ + productDataDirectory: input.productDataDirectory, + }); + if (input.serviceUser !== undefined) + await chownTree( + path.dirname(input.productDataDirectory), + input.serviceUser, + ); + const controlled = await loadControlledSourceIo({ + manifestPath: input.manifestPath, + }); + const sourceIdentity = controlled.contentIdentity; + const port = input.port ?? (await reservePort()); + const child = spawn(process.execPath, ["./dist/main.js"], { + cwd: servicePackageRoot, + env: { + ...process.env, + DIGITAL_PRODUCT_DATA: input.productDataDirectory, + DIGITAL_SOURCE_IO_CONFIG: input.manifestPath, + DIGITAL_SOURCE_IO_IDENTITY: sourceIdentity, + DIGITAL_SOURCE_IO_MODE: "controlled", + HOST: "127.0.0.1", + PORT: String(port), + }, + stdio: ["ignore", "pipe", "pipe"], + detached: process.platform !== "win32", + ...(input.serviceUser === undefined + ? {} + : { uid: input.serviceUser.uid, gid: input.serviceUser.gid }), + }); + terminalChildren.push(child); + const output: Buffer[] = []; + const errors: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => errors.push(chunk)); + + const baseUrl = new URL(`http://127.0.0.1:${port}`); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (child.exitCode !== null || child.signalCode !== null) + throw new Error( + `正式产品服务提前退出\n${Buffer.concat(output).toString("utf8")}\n${Buffer.concat(errors).toString("utf8")}`, + ); + try { + const response = await fetch(new URL("/api/ready", baseUrl)); + if (response.ok) return baseUrl; + } catch { + // 服务尚未绑定端口,继续等待公开就绪接口。 + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error( + `正式产品服务未就绪\n${Buffer.concat(output).toString("utf8")}\n${Buffer.concat(errors).toString("utf8")}`, + ); +} + +async function chownTree( + directory: string, + user: { readonly uid: number; readonly gid: number }, +): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) await chownTree(entryPath, user); + await chown(entryPath, user.uid, user.gid); + } + await chown(directory, user.uid, user.gid); +} + +async function reservePort(): Promise { + const server = createNetServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("临时 HTTP 端口未分配"); + const port = (address as AddressInfo).port; + await new Promise((resolve, reject) => { + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + return port; +} + +async function reserveEwc06Port(): Promise { + for (let port = 48606; port <= 48699; port += 1) { + const server = createNetServer(); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + await new Promise((resolve, reject) => { + server.close((error) => + error === undefined ? resolve() : reject(error), + ); + }); + return port; + } catch { + server.close(); + } + } + throw new Error("ewc06 专用端口段没有可用端口"); +} + +async function digestDirectories( + directories: readonly string[], +): Promise { + const hash = createHash("sha256"); + const files: string[] = []; + const visit = async (directory: string) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(entryPath); + else if (entry.isFile()) files.push(entryPath); + } + }; + for (const directory of directories) await visit(directory); + for (const file of files.toSorted()) { + hash.update(path.relative(path.dirname(directories[0]!), file)); + hash.update(await readFile(file)); + } + return hash.digest("hex"); +} + +async function startProductService( + sourceIo: ProductConfiguration["sourceIo"], +): Promise { + const root = await mkdtemp(path.join(tmpdir(), "digital-service-http-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + await prepareProductionCatalogue({ productDataDirectory }); + const service = await startProductServiceWithData( + productDataDirectory, + sourceIo, + ); + return service.baseUrl; +} + +async function startProductServiceWithData( + productDataDirectory: string, + sourceIo: ProductConfiguration["sourceIo"], +): Promise<{ readonly baseUrl: URL; readonly close: () => Promise }> { + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + ...(sourceIo === undefined ? {} : { sourceIo }), + }); + bootstraps.push(bootstrap); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + openServers.push(server); + await server.ready(); + bootstrap.publish(); + const baseUrl = new URL(server.url!); + expect(await eventuallyReady(baseUrl)).toBe(true); + return { + baseUrl, + async close() { + const serverIndex = openServers.indexOf(server); + if (serverIndex >= 0) openServers.splice(serverIndex, 1); + const bootstrapIndex = bootstraps.indexOf(bootstrap); + if (bootstrapIndex >= 0) bootstraps.splice(bootstrapIndex, 1); + await server.close(true); + await bootstrap.abort(); + }, + }; +} + +async function eventuallyReady(baseUrl: URL): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const response = await fetch(new URL("/api/ready", baseUrl)); + if (response.ok) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return false; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/apps/service/test/relationship-identity-http.test.ts b/apps/service/test/relationship-identity-http.test.ts new file mode 100644 index 0000000..db2641b --- /dev/null +++ b/apps/service/test/relationship-identity-http.test.ts @@ -0,0 +1,780 @@ +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { ProductConfiguration } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { createPublicHttpApi } from "@digital/http-delivery"; +import type { ProductApplication } from "@digital/product-application"; +import { createPublicApiClient } from "@digital/public-api"; +import { + loadControlledSourceIo, + refusingSessionTransport, +} from "@digital/test-support/source-io"; +import { serve } from "srvx"; +import { afterEach, expect, it } from "vitest"; + +import { createProductService } from "../src/service.ts"; + +const running: Array<{ + readonly server: Awaited>; + readonly abort: () => Promise; +}> = []; +const temporaryRoots: string[] = []; +const materialFixture = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/material"), +); +const sourceManifest = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/manifest.json"), +); +const sourceResponses = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/audio-posts/responses"), +); + +afterEach(async () => { + for (const item of running.splice(0).reverse()) { + await item.server.close(true); + await item.abort(); + } + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); +}); + +// 场景边界:用户从空的正式 SQLite 业务库及两个普通目录开始;准备阶段仅复制 DLsite 材料,并控制第二作响应使用与第一作相同的稳定系列键,未预置实体、指称解析或关系。 +// 实际操作经真实 HTTP 公共客户端完成资产库配置与扫描、关系和指称检查、人工解析、本地身份及关系创建、身份合并、合并后新增关系、公开刷新,再分别撤回指定合并与指称解析。 +// 可观察结果包括来源稳定身份按键复用、同名但身份不同的材料各自保留单次出现身份(occurrence-local)、刷新后的作品名、反向作品查询,以及撤回后仍保留的来源归属、人工署名与刷新结果。 +// 未覆盖 TUI 或旧 Web 的关系编辑界面;本场景仅证明公开 HTTP API 的业务路径。 +it("经真实 HTTP 从普通目录形成作品关系,再独立撤回指称解析和指定身份合并", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc11-relationship-http-")); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + const firstDirectory = path.join(root, "收藏", "声音", "2025"); + const secondDirectory = path.join(root, "继续收藏", "声音", "2026"); + await mkdir(firstDirectory, { recursive: true }); + await mkdir(secondDirectory, { recursive: true }); + await cp( + path.join(materialFixture, "RJ01029383"), + path.join(firstDirectory, "RJ01029383"), + { recursive: true }, + ); + await cp( + path.join(materialFixture, "RJ01049458"), + path.join(secondDirectory, "RJ01049458"), + { recursive: true }, + ); + await mkdir(path.join(firstDirectory, "普通文件夹"), { recursive: true }); + await writeFile( + path.join(firstDirectory, "普通文件夹", "目录说明.txt"), + "普通目录素材\n", + ); + + const controlRoot = path.join(root, "controlled-source-variant"); + const responseRoot = path.join(controlRoot, "responses"); + await mkdir(responseRoot, { recursive: true }); + await cp(sourceResponses, responseRoot, { recursive: true }); + const controlledManifestPath = path.join(controlRoot, "manifest.json"); + await cp(sourceManifest, controlledManifestPath); + + const secondProductPath = path.join(responseRoot, "RJ01049458.json"); + let secondProductResponse = JSON.parse( + await readFile(secondProductPath, "utf8"), + ) as Record>; + const secondProduct = secondProductResponse.RJ01049458; + if (secondProduct === undefined) throw new Error("DLsite 受控作品响应缺失"); + // 测试变体让第二作带上正式稳定系列来源键;默认 corpus 保持原样。 + secondProduct.title_id = "SRI0000036577"; + secondProduct.title_name = "裏世界少女"; + secondProduct.series_id = "SRI0000036577"; + secondProduct.series_name = "裏世界少女"; + await writeFile(secondProductPath, JSON.stringify(secondProductResponse)); + const controlled = await loadControlledSourceIo({ + manifestPath: controlledManifestPath, + }); + const secondProductUrl = + "https://www.dlsite.com/maniax/product/info/ajax?product_id=RJ01049458"; + const sourceIo: ProductConfiguration["sourceIo"] = { + mode: "controlled", + request: async (url, init) => { + if (url === secondProductUrl) { + const bytes = new TextEncoder().encode( + JSON.stringify(secondProductResponse), + ); + return { + ok: true, + status: 200, + url, + headers: { get: () => "application/json" }, + arrayBuffer: async () => + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer, + }; + } + return await controlled.request(url, init); + }, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + await prepareProductionCatalogue({ productDataDirectory }); + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + running.push({ server, abort: () => bootstrap.abort() }); + await server.ready(); + bootstrap.publish(); + const client = createPublicApiClient({ baseUrl: new URL(server.url!) }); + + const definitions = await client.listRelationshipTypeDefinitions(); + expect(definitions.map(({ id }) => id)).toEqual( + expect.arrayContaining(["dlsite.contribution", "dlsite.series-membership"]), + ); + const library = await client.saveAssetLibrary({ + displayName: "关系修复普通目录", + directories: [firstDirectory, secondDirectory], + recognitionProfileIds: ["dlsite-voice"], + sourceIdentities: ["dlsite"], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 120_000, interval: 200 }, + ) + .toBe("completed"); + + const firstAsset = await assetForWork(client, library.id, "RJ01029383"); + const secondAsset = await assetForWork(client, library.id, "RJ01049458"); + const circleMatches = await client.findRelatedEntities("G線上の牡丹雪"); + expect(circleMatches).toHaveLength(1); + const circle = await client.inspectRelatedEntity(circleMatches[0]!.id); + expect(circle.entity).toMatchObject({ + sourceIdentity: "dlsite", + identityNamespace: "dlsite.circle", + sourceKey: "RG51784", + }); + expect(circle.accepted.map(({ id }) => id).toSorted()).toEqual( + [firstAsset.id, secondAsset.id].toSorted(), + ); + + const seriesMatches = await client.findRelatedEntities("裏世界少女"); + expect(seriesMatches).toHaveLength(1); + const seriesId = seriesMatches[0]!.id; + const series = await client.inspectRelatedEntity(seriesId); + expect(series.entity).toMatchObject({ + identityNamespace: "dlsite.series", + sourceKey: "SRI0000036577", + }); + expect(series.accepted.map(({ id }) => id).toSorted()).toEqual( + [firstAsset.id, secondAsset.id].toSorted(), + ); + const seriesMembershipForFirstWork = series.relationships.find( + ({ type, participants }) => + type === "asset.membership" && + participants.some( + ({ entityId, role }) => entityId === firstAsset.id && role === "asset", + ), + ); + expect(seriesMembershipForFirstWork).toBeDefined(); + expect(seriesMembershipForFirstWork!.sources).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sourceIdentity: "dlsite", + nativeAddress: expect.stringContaining("RJ01029383"), + }), + ]), + ); + const firstWorkRelationships = await client.inspectRelatedEntity( + firstAsset.id, + ); + expect(firstWorkRelationships.relationships).toContainEqual( + expect.objectContaining({ + id: seriesMembershipForFirstWork!.id, + sources: seriesMembershipForFirstWork!.sources, + }), + ); + + const occurrenceLocal = [ + ["涼花みなせ", "dlsite.credit.voice.voice.occurrence-local"], + ["牡丹一", "dlsite.credit.scenario.scenario.occurrence-local"], + ["かふか", "dlsite.credit.illustration.illustration.occurrence-local"], + ] as const; + for (const [label, namespace] of occurrenceLocal) { + const matches = await client.findRelatedEntities(label); + expect(matches).toHaveLength(2); + const details = await Promise.all( + matches.map(({ id }) => client.inspectRelatedEntity(id)), + ); + expect(details.map(({ entity }) => entity.identityNamespace)).toEqual([ + namespace, + namespace, + ]); + expect(new Set(details.map(({ entity }) => entity.id)).size).toBe(2); + const sourceKeys = details.map(({ entity }) => entity.sourceKey ?? ""); + expect(new Set(sourceKeys).size).toBe(2); + expect( + sourceKeys.some((sourceKey) => sourceKey.includes("RJ01029383")), + ).toBe(true); + expect( + sourceKeys.some((sourceKey) => sourceKey.includes("RJ01049458")), + ).toBe(true); + } + + const voiceMatches = await client.findRelatedEntities("涼花みなせ"); + const voiceDetails = await Promise.all( + voiceMatches.map(({ id }) => client.inspectRelatedEntity(id)), + ); + const voiceFirst = voiceDetails.find(({ accepted }) => + accepted.some(({ id }) => id === firstAsset.id), + ); + const voiceSecond = voiceDetails.find(({ accepted }) => + accepted.some(({ id }) => id === secondAsset.id), + ); + expect(voiceFirst).toBeDefined(); + expect(voiceSecond).toBeDefined(); + expect(voiceFirst!.entity.id).not.toBe(voiceSecond!.entity.id); + expect(voiceFirst!.accepted.map(({ id }) => id)).toEqual([firstAsset.id]); + expect(voiceSecond!.accepted.map(({ id }) => id)).toEqual([secondAsset.id]); + await expect( + client.previewEntityIdentityMerge({ + retainedEntityId: voiceFirst!.entity.id, + mergedEntityId: voiceFirst!.entity.id, + }), + ).rejects.toMatchObject({ kind: "invalid-request", status: 400 }); + await expect( + client.inspectEntityMentionResolution("missing-entity-mention"), + ).rejects.toMatchObject({ kind: "not-found", status: 404 }); + + const sourceMention = secondAsset.relationships + .flatMap(({ participants }) => participants) + .find( + (participant) => + participant.entityId === voiceSecond!.entity.id && + participant.role === "voice", + )?.mentionIds?.[0]; + expect(sourceMention).toBeDefined(); + const mentionBefore = await client.inspectEntityMentionResolution( + sourceMention!, + ); + expect(mentionBefore).toMatchObject({ + basis: "source", + entityId: voiceSecond!.entity.id, + label: "涼花みなせ", + }); + await expect( + client.resolveEntityMentionManually({ + mentionId: mentionBefore.mentionId, + entityId: voiceFirst!.entity.id, + actorId: "user:关系修复", + baseline: mentionBefore.baseline, + }), + ).resolves.toEqual({ status: "applied" }); + await expect( + client.inspectEntityMentionResolution(mentionBefore.mentionId), + ).resolves.toMatchObject({ + basis: "manual", + entityId: voiceFirst!.entity.id, + }); + await expect( + client.inspectRelatedEntity(voiceFirst!.entity.id), + ).resolves.toMatchObject({ + accepted: expect.arrayContaining([ + expect.objectContaining({ id: firstAsset.id }), + expect.objectContaining({ id: secondAsset.id }), + ]), + }); + const localAuthor = await client.createLocalRelatedEntity({ + commitmentId: "ewc11-local-author", + label: "人工补录署名", + actorId: "user:关系修复", + }); + const manualRelationship = await client.previewRelationshipChange({ + changes: [ + { + kind: "create", + definitionId: "dlsite.contribution", + relationship: { + type: "asset.contribution", + participants: [ + { entityId: secondAsset.id, role: "asset" }, + { entityId: localAuthor.id, role: "author" }, + ], + }, + }, + ], + }); + expect(manualRelationship.relationships).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "asset.contribution", + participants: expect.arrayContaining([ + expect.objectContaining({ entityId: localAuthor.id, role: "author" }), + ]), + }), + ]), + ); + await expect( + client.applyRelationshipChange({ + actorId: "user:关系修复", + preview: manualRelationship, + }), + ).resolves.toEqual({ status: "applied" }); + await expect( + client.inspectRelatedEntity(localAuthor.id), + ).resolves.toMatchObject({ + relationships: [expect.objectContaining({ type: "asset.contribution" })], + accepted: [expect.objectContaining({ id: secondAsset.id })], + }); + const localContribution = manualRelationship.relationships.find( + ({ type, participants }) => + type === "asset.contribution" && + participants.some(({ entityId }) => entityId === localAuthor.id), + ); + expect(localContribution).toBeDefined(); + const excludedContribution = await client.previewRelationshipChange({ + changes: [{ kind: "exclude", targetRelationshipId: localContribution!.id }], + }); + await expect( + client.applyRelationshipChange({ + actorId: "user:关系修复", + preview: excludedContribution, + }), + ).resolves.toEqual({ status: "applied" }); + await expect( + client.inspectRelatedEntity(localAuthor.id), + ).resolves.toMatchObject({ relationships: [] }); + const selectedContribution = await client.previewRelationshipChange({ + changes: [{ kind: "select", targetRelationshipId: localContribution!.id }], + }); + await expect( + client.applyRelationshipChange({ + actorId: "user:关系修复", + preview: selectedContribution, + }), + ).resolves.toEqual({ status: "applied" }); + await expect( + client.inspectRelatedEntity(localAuthor.id), + ).resolves.toMatchObject({ + relationships: [expect.objectContaining({ id: localContribution!.id })], + }); + + const replacementAuthor = await client.createLocalRelatedEntity({ + commitmentId: "ewc11-local-author-replacement", + label: "人工更正署名", + actorId: "user:关系修复", + }); + const replacedContribution = await client.previewRelationshipChange({ + changes: [ + { + kind: "replace", + targetRelationshipId: localContribution!.id, + definitionId: "dlsite.contribution", + relationship: { + type: "asset.contribution", + participants: [ + { entityId: secondAsset.id, role: "asset" }, + { entityId: replacementAuthor.id, role: "author" }, + ], + }, + }, + ], + }); + await expect( + client.applyRelationshipChange({ + actorId: "user:关系修复", + preview: replacedContribution, + }), + ).resolves.toEqual({ status: "applied" }); + await expect( + client.inspectRelatedEntity(localAuthor.id), + ).resolves.toMatchObject({ relationships: [] }); + const replacementFact = replacedContribution.relationships.find( + ({ type, participants }) => + type === "asset.contribution" && + participants.some(({ entityId }) => entityId === replacementAuthor.id), + ); + expect(replacementFact).toBeDefined(); + await expect( + client.inspectRelatedEntity(replacementAuthor.id), + ).resolves.toMatchObject({ + relationships: [expect.objectContaining({ id: replacementFact!.id })], + }); + + const restoredContribution = await client.previewRelationshipChange({ + changes: [ + { + kind: "replace", + targetRelationshipId: replacementFact!.id, + definitionId: "dlsite.contribution", + relationship: { + type: "asset.contribution", + participants: [ + { entityId: secondAsset.id, role: "asset" }, + { entityId: localAuthor.id, role: "author" }, + ], + }, + }, + ], + }); + await expect( + client.applyRelationshipChange({ + actorId: "user:关系修复", + preview: restoredContribution, + }), + ).resolves.toEqual({ status: "applied" }); + const restoredFact = restoredContribution.relationships.find( + ({ type, participants }) => + type === "asset.contribution" && + participants.some(({ entityId }) => entityId === localAuthor.id), + ); + expect(restoredFact).toBeDefined(); + const clearedContribution = await client.previewRelationshipChange({ + changes: [{ kind: "clear", targetRelationshipId: restoredFact!.id }], + }); + await expect( + client.applyRelationshipChange({ + actorId: "user:关系修复", + preview: clearedContribution, + }), + ).resolves.toEqual({ status: "applied" }); + await expect( + client.inspectRelatedEntity(localAuthor.id), + ).resolves.toMatchObject({ relationships: [] }); + const reselectedContribution = await client.previewRelationshipChange({ + changes: [{ kind: "select", targetRelationshipId: restoredFact!.id }], + }); + await expect( + client.applyRelationshipChange({ + actorId: "user:关系修复", + preview: reselectedContribution, + }), + ).resolves.toEqual({ status: "applied" }); + await expect( + client.inspectRelatedEntity(localAuthor.id), + ).resolves.toMatchObject({ + relationships: [expect.objectContaining({ id: restoredFact!.id })], + }); + const staleRelationshipPreview = await client.previewRelationshipChange({ + changes: [{ kind: "exclude", targetRelationshipId: restoredFact!.id }], + }); + + const mergePreview = await client.previewEntityIdentityMerge({ + retainedEntityId: voiceFirst!.entity.id, + mergedEntityId: voiceSecond!.entity.id, + }); + const replayConflictTarget = await client.createLocalRelatedEntity({ + commitmentId: "ewc11-identity-replay-conflict-target", + label: "提交键冲突目标", + actorId: "user:关系修复", + }); + const conflictingMergePreview = await client.previewEntityIdentityMerge({ + retainedEntityId: voiceFirst!.entity.id, + mergedEntityId: replayConflictTarget.id, + }); + await expect( + client.applyEntityIdentityMerge({ + commitmentId: "ewc11-voice-merge", + actorId: "user:关系修复", + preview: mergePreview, + }), + ).resolves.toEqual({ status: "applied" }); + await expect( + client.applyEntityIdentityMerge({ + commitmentId: "ewc11-voice-merge", + actorId: "user:关系修复", + preview: mergePreview, + }), + ).resolves.toEqual({ status: "applied" }); + await expect( + client.applyEntityIdentityMerge({ + commitmentId: "ewc11-voice-merge", + actorId: "user:关系修复", + preview: conflictingMergePreview, + }), + ).rejects.toMatchObject({ kind: "conflict", status: 409 }); + const postMergeAuthor = await client.createLocalRelatedEntity({ + commitmentId: "ewc11-post-merge-author", + label: "合并后新增署名", + actorId: "user:关系修复", + }); + const postMergeRelationshipPreview = await client.previewRelationshipChange({ + changes: [ + { + kind: "create", + definitionId: "dlsite.contribution", + relationship: { + type: "asset.contribution", + participants: [ + { entityId: secondAsset.id, role: "asset" }, + { entityId: voiceFirst!.entity.id, role: "voice" }, + { entityId: postMergeAuthor.id, role: "author" }, + ], + }, + }, + ], + }); + await expect( + client.applyRelationshipChange({ + actorId: "user:关系修复", + preview: postMergeRelationshipPreview, + }), + ).resolves.toEqual({ status: "applied" }); + const postMergeRelationship = postMergeRelationshipPreview.relationships.find( + ({ type, participants }) => + type === "asset.contribution" && + participants.some(({ entityId }) => entityId === postMergeAuthor.id), + ); + expect(postMergeRelationship).toBeDefined(); + await expect( + client.inspectRelatedEntity(postMergeAuthor.id), + ).resolves.toMatchObject({ + relationships: [expect.objectContaining({ id: postMergeRelationship!.id })], + accepted: [expect.objectContaining({ id: secondAsset.id })], + }); + await expect( + client.inspectRelatedEntity(voiceFirst!.entity.id), + ).resolves.toMatchObject({ + accepted: expect.arrayContaining([ + expect.objectContaining({ id: firstAsset.id }), + expect.objectContaining({ id: secondAsset.id }), + ]), + }); + await expect( + client.applyRelationshipChange({ + actorId: "user:关系修复", + preview: staleRelationshipPreview, + }), + ).rejects.toMatchObject({ kind: "conflict", status: 409 }); + await expect( + client.undoEntityIdentityMerge({ + commitmentId: "ewc11-missing-merge-retraction", + actorId: "user:关系修复", + preview: mergePreview, + }), + ).rejects.toMatchObject({ kind: "conflict", status: 409 }); + + const revisedProduct = JSON.parse(JSON.stringify(secondProductResponse)) as { + readonly RJ01049458: Record; + }; + revisedProduct.RJ01049458.title_name = "裏世界少女・受控更新"; + revisedProduct.RJ01049458.series_name = "裏世界少女・受控更新"; + revisedProduct.RJ01049458.work_name = "受控来源更新后的作品名"; + secondProductResponse = revisedProduct; + const refresh = await client.startMetadataRefresh({ + subjectId: secondAsset.id, + reviewPolicy: "automatic", + }); + await expect + .poll(async () => (await client.inspectExecution(refresh.id)).status, { + timeout: 120_000, + interval: 200, + }) + .toBe("completed"); + await expect( + client.applyEntityIdentityMerge({ + commitmentId: "ewc11-stale-voice-merge", + actorId: "user:关系修复", + preview: mergePreview, + }), + ).rejects.toMatchObject({ kind: "conflict", status: 409 }); + + const refreshedAsset = await assetForWork(client, library.id, "RJ01049458"); + const refreshedTitle = refreshedAsset.descriptions + .flatMap(({ metadata }) => metadata) + .find(({ property }) => property === "asset.title")?.result; + expect(refreshedTitle).toMatchObject({ + status: "resolved", + values: [expect.objectContaining({ value: "受控来源更新后的作品名" })], + }); + const refreshedSeries = await client.inspectRelatedEntity(seriesId); + expect(refreshedSeries.entity).toMatchObject({ + identityNamespace: "dlsite.series", + sourceKey: "SRI0000036577", + }); + expect(refreshedSeries.accepted.map(({ id }) => id).toSorted()).toEqual( + [firstAsset.id, secondAsset.id].toSorted(), + ); + expect( + refreshedSeries.relationships.find( + ({ type, participants }) => + type === "asset.membership" && + participants.some( + ({ entityId, role }) => + entityId === secondAsset.id && role === "asset", + ), + )?.sources, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sourceIdentity: "dlsite", + nativeAddress: expect.stringContaining("RJ01049458"), + }), + ]), + ); + await expect( + client.inspectEntityMentionResolution(mentionBefore.mentionId), + ).resolves.toMatchObject({ + basis: "manual", + entityId: voiceFirst!.entity.id, + }); + await expect( + client.inspectRelatedEntity(voiceFirst!.entity.id), + ).resolves.toMatchObject({ + accepted: expect.arrayContaining([ + expect.objectContaining({ id: firstAsset.id }), + expect.objectContaining({ id: secondAsset.id }), + ]), + }); + + const locatedMerge = await client.previewEntityIdentityMerge({ + retainedEntityId: voiceFirst!.entity.id, + mergedEntityId: voiceSecond!.entity.id, + }); + expect(locatedMerge.mergeId).toBeDefined(); + await expect( + client.undoEntityIdentityMerge({ + commitmentId: "ewc11-voice-merge-retraction", + actorId: "user:关系修复", + preview: locatedMerge, + }), + ).resolves.toEqual({ status: "applied" }); + const retainedVoice = await client.inspectRelatedEntity( + voiceFirst!.entity.id, + ); + const unmergedVoice = await client.inspectRelatedEntity( + voiceSecond!.entity.id, + ); + expect(retainedVoice.entity.id).toBe(voiceFirst!.entity.id); + expect(unmergedVoice.entity.id).toBe(voiceSecond!.entity.id); + expect(retainedVoice.entity.id).not.toBe(unmergedVoice.entity.id); + expect(retainedVoice.relationships).toContainEqual( + expect.objectContaining({ + id: postMergeRelationship!.id, + participants: expect.arrayContaining([ + expect.objectContaining({ entityId: secondAsset.id, role: "asset" }), + expect.objectContaining({ + entityId: voiceFirst!.entity.id, + role: "voice", + }), + expect.objectContaining({ + entityId: postMergeAuthor.id, + role: "author", + }), + ]), + }), + ); + expect(unmergedVoice.relationships).not.toContainEqual( + expect.objectContaining({ id: postMergeRelationship!.id }), + ); + await expect( + client.inspectRelatedEntity(postMergeAuthor.id), + ).resolves.toMatchObject({ + relationships: [expect.objectContaining({ id: postMergeRelationship!.id })], + accepted: [expect.objectContaining({ id: secondAsset.id })], + }); + await expect( + client.inspectRelatedEntity(voiceFirst!.entity.id), + ).resolves.toMatchObject({ + accepted: expect.arrayContaining([ + expect.objectContaining({ id: firstAsset.id }), + expect.objectContaining({ id: secondAsset.id }), + ]), + }); + const manualMention = await client.inspectEntityMentionResolution( + mentionBefore.mentionId, + ); + await expect( + client.retractEntityMentionResolution({ + mentionId: manualMention.mentionId, + actorId: "user:关系修复", + baseline: manualMention.baseline, + }), + ).resolves.toEqual({ status: "applied" }); + await expect( + client.inspectEntityMentionResolution(mentionBefore.mentionId), + ).resolves.toMatchObject({ + basis: "source", + entityId: voiceSecond!.entity.id, + }); + await expect( + client.inspectRelatedEntity(voiceFirst!.entity.id), + ).resolves.toMatchObject({ + accepted: expect.arrayContaining([ + expect.objectContaining({ id: firstAsset.id }), + expect.objectContaining({ id: secondAsset.id }), + ]), + }); + await expect( + client.inspectRelatedEntity(voiceSecond!.entity.id), + ).resolves.toMatchObject({ + accepted: [expect.objectContaining({ id: secondAsset.id })], + }); + await expect( + client.inspectRelatedEntity(localAuthor.id), + ).resolves.toMatchObject({ + relationships: [expect.objectContaining({ type: "asset.contribution" })], + accepted: [expect.objectContaining({ id: secondAsset.id })], + }); + const finalAsset = await assetForWork(client, library.id, "RJ01049458"); + expect( + finalAsset.descriptions + .flatMap(({ metadata }) => metadata) + .find(({ property }) => property === "asset.title")?.result, + ).toMatchObject({ + status: "resolved", + values: [expect.objectContaining({ value: "受控来源更新后的作品名" })], + }); + + await client.removeAssetLibrary({ + id: library.id, + expectedVersion: library.configVersion, + }); + const retainedRelationship = await client.inspectRelatedEntity( + localAuthor.id, + ); + expect(retainedRelationship.accepted).toEqual([]); + expect(retainedRelationship.relationships).toContainEqual( + expect.objectContaining({ type: "asset.contribution" }), + ); +}); + +it("将未知关系应用故障映射为稳定 503 文案", async () => { + const api = createPublicHttpApi({ + workflow: { + relationshipTypeDefinitions() { + throw new Error("database token must not be exposed"); + }, + }, + } as unknown as ProductApplication); + const response = await api.request("/relationships/types"); + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: { + code: "unavailable", + message: "关系资料暂时不可用,请稍后重试", + }, + }); +}); + +async function assetForWork( + client: ReturnType, + assetLibraryId: string, + workId: string, +) { + const page = await client.listAssets({ assetLibraryId, limit: 100 }); + const matches = page.items.filter(({ locations }) => + locations.some(({ address }) => address.includes(workId)), + ); + expect(matches).toHaveLength(1); + return matches[0]!; +} diff --git a/apps/service/test/scan-responsiveness-http.test.ts b/apps/service/test/scan-responsiveness-http.test.ts new file mode 100644 index 0000000..2841431 --- /dev/null +++ b/apps/service/test/scan-responsiveness-http.test.ts @@ -0,0 +1,441 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { performance } from "node:perf_hooks"; +import { fileURLToPath } from "node:url"; + +import { createPublicApiClient } from "@digital/public-api"; +import { afterEach, expect, it } from "vitest"; + +const fixtureDirectory = path.dirname( + fileURLToPath( + import.meta + .resolve("@digital/preview-corpus/publication-screen/manifest.json"), + ), +); +const active: Array<{ + readonly root: string; + service?: ReturnType; +}> = []; + +afterEach(async () => { + const waitForExit = async ( + service: ReturnType, + timeoutMs: number, + ): Promise => { + if (service.exitCode !== null || service.signalCode !== null) return true; + return await new Promise((resolve) => { + let settled = false; + const settle = (exited: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + service.off("exit", onExit); + resolve(exited); + }; + const onExit = () => settle(true); + const timeout = setTimeout(() => settle(false), timeoutMs); + service.once("exit", onExit); + if (service.exitCode !== null || service.signalCode !== null) + settle(true); + }); + }; + + for (const item of active.splice(0).reverse()) { + const service = item.service; + let exited = service === undefined; + if (service !== undefined) { + if (service.exitCode !== null || service.signalCode !== null) + exited = true; + else { + service.stdin?.write("stop\n"); + exited = await waitForExit(service, 10_000); + if (!exited) { + service.kill("SIGTERM"); + exited = await waitForExit(service, 5_000); + } + } + } + if (!exited) { + throw new Error( + `受控服务未确认退出;保留其数据目录以避免删除仍在使用的数据:${item.root}`, + ); + } + if (service !== undefined) + process.stdout.write( + `EW07 子服务已退出:pid=${String(service.pid)},exitCode=${String(service.exitCode)},signalCode=${String(service.signalCode)}\n`, + ); + await rm(item.root, { recursive: true, force: true }); + } +}, 20_000); + +it("扫描进度查询可在 51 项首次及增量扫描中完成", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc07-scan-ready-")); + const evidencePath = + process.env["EW07_SCAN_RESULT_PATH"] ?? path.join(root, "scan-result.json"); + const activeService: { root: string; service?: ReturnType } = { + root, + }; + active.push(activeService); + const productDataDirectory = path.join(root, "product-data"); + const materialRoot = path.join(root, "materials"); + const mediaRoot = path.join(fixtureDirectory, "material", "anime"); + const bloomTitle = "终将成为你 (2018)"; + const frierenTitle = "葬送的芙莉莲 (2023)"; + const episodeCounts = [ + { title: bloomTitle, season: 1, count: 13 }, + { title: frierenTitle, season: 1, count: 28 }, + { title: frierenTitle, season: 2, count: 10 }, + ] as const; + const samplePaths = [ + path.join(mediaRoot, bloomTitle, "Season 1", "终将成为你 - S01E01.mp4"), + path.join(mediaRoot, frierenTitle, "Season 1", "葬送的芙莉莲 - S01E01.mp4"), + path.join( + mediaRoot, + frierenTitle, + "Season 2", + "葬送的芙莉莲 - S02E01 - 第 1 集.mp4", + ), + ]; + const copiedMaterials: string[] = []; + for (const [index, specification] of episodeCounts.entries()) { + const seasonRoot = path.join( + materialRoot, + specification.title, + `Season ${String(specification.season)}`, + ); + await mkdir(seasonRoot, { recursive: true }); + for (let episode = 1; episode <= specification.count; episode += 1) { + const season = String(specification.season).padStart(2, "0"); + const number = String(episode).padStart(2, "0"); + const suffix = + specification.season === 2 ? ` - 第 ${String(episode)} 集` : ""; + const filename = `${specification.title.slice(0, specification.title.lastIndexOf(" ("))} - S${season}E${number}${suffix}.mp4`; + const destination = path.join(seasonRoot, filename); + await copyFile(samplePaths[index]!, destination); + copiedMaterials.push(destination); + } + } + expect(copiedMaterials).toHaveLength(51); + + const serviceScript = fileURLToPath( + new URL("./helpers/scan-responsiveness-service.ts", import.meta.url), + ); + const service = spawn( + process.execPath, + ["--conditions=digital-source", serviceScript], + { + cwd: path.resolve("../.."), + env: { + ...process.env, + EW07_SCAN_PRODUCT_DATA: productDataDirectory, + EW07_SCAN_FIXTURE_DIRECTORY: fixtureDirectory, + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + activeService.service = service; + const serviceOutput: string[] = []; + let stdoutBuffer = ""; + let resolveAddress: ((value: URL) => void) | undefined; + let rejectAddress: ((reason: Error) => void) | undefined; + const addressPromise = new Promise((resolve, reject) => { + resolveAddress = resolve; + rejectAddress = reject; + }); + service.stdout.setEncoding("utf8"); + service.stdout.on("data", (chunk: string) => { + stdoutBuffer += chunk; + for (;;) { + const newline = stdoutBuffer.indexOf("\n"); + if (newline < 0) break; + const line = stdoutBuffer.slice(0, newline); + stdoutBuffer = stdoutBuffer.slice(newline + 1); + serviceOutput.push(line); + const message = JSON.parse(line) as { + readonly kind: string; + readonly address?: string; + }; + if (message.kind === "ready" && message.address !== undefined) + resolveAddress?.(new URL(message.address)); + } + }); + service.stderr.setEncoding("utf8"); + service.stderr.on("data", (chunk: string) => + serviceOutput.push(`stderr: ${chunk}`), + ); + service.once("error", (error) => rejectAddress?.(error)); + service.once("exit", (code) => { + if (resolveAddress !== undefined) + rejectAddress?.( + new Error(`受控服务在 readiness 前退出 (${String(code)})`), + ); + }); + const baseUrl = await addressPromise; + const overallDeadline = AbortSignal.timeout(180_000); + const client = createPublicApiClient({ + baseUrl, + fetch: (input, init) => { + const requestSignal = init?.signal; + return fetch(input, { + ...init, + signal: + requestSignal === undefined || requestSignal === null + ? overallDeadline + : AbortSignal.any([requestSignal, overallDeadline]), + }); + }, + }); + const callbackUrl = new URL( + "/api/v1/source-connections/bangumi/callback", + baseUrl, + ); + await client.configureBangumiOAuthClient({ + clientId: "scan-readiness-controlled-client", + clientSecret: "scan-readiness-controlled-secret", + redirectUri: callbackUrl.toString(), + }); + const authorization = await client.beginBangumiAuthorization(); + const state = new URL(authorization.authorizationUrl).searchParams.get( + "state", + ); + assert(state !== null); + callbackUrl.searchParams.set("code", "scan-readiness-code"); + callbackUrl.searchParams.set("state", state); + expect((await fetch(callbackUrl, { signal: overallDeadline })).status).toBe( + 200, + ); + const library = await client.saveAssetLibrary({ + displayName: "扫描 readiness 回归库", + directories: [materialRoot], + recognitionProfileIds: ["anime"], + sourceIdentities: ["bangumi"], + }); + + const readiness: Array<{ + readonly phase: "initial" | "incremental"; + readonly durationMs: number; + readonly status?: number; + readonly error?: string; + }> = []; + const readinessAbort = new AbortController(); + let readinessPhase: "initial" | "incremental" = "initial"; + const scanSummaries: Array<{ + readonly parentExecutionId: string; + readonly status: string; + readonly outcome?: string; + readonly fileCount: number; + readonly acceptedCount: number; + readonly noChangeCount: number; + readonly assetIds: readonly string[]; + }> = []; + let currentAssetIdsAfterIncremental: readonly string[] | undefined; + const inspectionAttempts: Array<{ + readonly phase: "initial" | "incremental"; + readonly parentExecutionId: string; + readonly durationMs: number; + readonly status?: string; + readonly outcome?: string; + readonly error?: string; + }> = []; + const persistEvidence = async () => { + await writeFile( + evidencePath, + `${JSON.stringify( + { + testBaseCommit: "2f6b6b4eee640fc54808e2513e36f60b6be56863", + evidencePath, + scans: scanSummaries, + currentAssetIdsAfterIncremental, + naturalTransportFailures: inspectionAttempts.filter( + (attempt) => attempt.error !== undefined, + ), + runningProgressObserved: inspectionAttempts.some( + (attempt) => attempt.status === "running", + ), + maxReadinessDurationMs: + readiness.length === 0 + ? undefined + : Math.max(...readiness.map((sample) => sample.durationMs)), + readiness, + inspectionAttempts, + }, + null, + 2, + )}\n`, + ); + }; + const inspectUntilTerminal = async ( + phase: "initial" | "incremental", + parentExecutionId: string, + ): Promise>> => { + let latest: + | Awaited> + | undefined; + while (!overallDeadline.aborted) { + const inspectionStarted = performance.now(); + const inspectionResult = await client + .inspectAssetLibraryScan(parentExecutionId) + .then( + (value) => ({ value }), + (error: unknown) => ({ error }), + ); + const attempt = + "value" in inspectionResult + ? { + phase, + parentExecutionId, + durationMs: performance.now() - inspectionStarted, + status: inspectionResult.value.status, + ...(inspectionResult.value.outcome === undefined + ? {} + : { outcome: inspectionResult.value.outcome }), + } + : { + phase, + parentExecutionId, + durationMs: performance.now() - inspectionStarted, + error: + inspectionResult.error instanceof Error + ? `${inspectionResult.error.name}: ${inspectionResult.error.message}${ + inspectionResult.error.cause instanceof Error + ? ` (cause ${inspectionResult.error.cause.name}: ${inspectionResult.error.cause.message})` + : "" + }` + : String(inspectionResult.error), + }; + inspectionAttempts.push(attempt); + if ("value" in inspectionResult) { + latest = inspectionResult.value; + if (latest.status !== "running") { + const files = latest.directories.flatMap( + (directory) => directory.files, + ); + const summary = { + parentExecutionId, + status: latest.status, + ...(latest.outcome === undefined + ? {} + : { outcome: latest.outcome }), + fileCount: files.length, + acceptedCount: files.filter((file) => file.outcome === "accepted") + .length, + noChangeCount: files.filter((file) => file.outcome === "no-change") + .length, + assetIds: files + .flatMap((file) => + file.assetId === undefined ? [] : [file.assetId], + ) + .toSorted(), + }; + const existingIndex = scanSummaries.findIndex( + (item) => item.parentExecutionId === parentExecutionId, + ); + if (existingIndex === -1) scanSummaries.push(summary); + else scanSummaries[existingIndex] = summary; + await persistEvidence(); + return latest; + } + } else await persistEvidence(); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + await persistEvidence(); + if (latest !== undefined) return latest; + throw new Error(`扫描 ${parentExecutionId} 未能在整体截止前读到结果`); + }; + + const readinessMonitor = (async () => { + while (!overallDeadline.aborted && !readinessAbort.signal.aborted) { + const startedAt = performance.now(); + try { + const response = await fetch(new URL("/api/ready", baseUrl), { + signal: AbortSignal.any([overallDeadline, readinessAbort.signal]), + }); + readiness.push({ + phase: readinessPhase, + durationMs: performance.now() - startedAt, + status: response.status, + }); + } catch (error) { + readiness.push({ + phase: readinessPhase, + durationMs: performance.now() - startedAt, + error: + error instanceof Error + ? `${error.name}: ${error.message}` + : String(error), + }); + if (overallDeadline.aborted || readinessAbort.signal.aborted) return; + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + })(); + + process.stdout.write(`EW07 扫描诊断证据:${evidencePath}\n`); + try { + const started = await client.startAssetLibraryScan(library.id); + const initial = await inspectUntilTerminal( + "initial", + started.parentExecutionId, + ); + const initialFiles = initial.directories.flatMap( + (directory) => directory.files, + ); + const initialAssetIds = initialFiles.flatMap((file) => + file.assetId === undefined ? [] : [file.assetId], + ); + expect(initial.parentExecutionId).toBe(started.parentExecutionId); + expect(initial).toMatchObject({ status: "completed", outcome: "accepted" }); + expect(initialFiles).toHaveLength(51); + expect(initialFiles.every((file) => file.outcome === "accepted")).toBe( + true, + ); + expect(new Set(initialAssetIds).size).toBe(51); + + readinessPhase = "incremental"; + const incrementalStarted = await client.startAssetLibraryScan(library.id); + const incremental = await inspectUntilTerminal( + "incremental", + incrementalStarted.parentExecutionId, + ); + const incrementalFiles = incremental.directories.flatMap( + (directory) => directory.files, + ); + expect(incremental.parentExecutionId).toBe( + incrementalStarted.parentExecutionId, + ); + expect(incremental).toMatchObject({ + status: "completed", + outcome: "no-change", + }); + expect(incrementalFiles).toHaveLength(51); + expect(incrementalFiles.every((file) => file.outcome === "no-change")).toBe( + true, + ); + const currentAssets = await client.listAssets({ + assetLibraryId: library.id, + limit: 100, + }); + currentAssetIdsAfterIncremental = currentAssets.items + .map((asset) => asset.id) + .toSorted(); + await persistEvidence(); + expect(currentAssetIdsAfterIncremental).toHaveLength(51); + expect(currentAssetIdsAfterIncremental).toEqual(initialAssetIds.toSorted()); + + expect( + inspectionAttempts.every((attempt) => attempt.error === undefined), + ).toBe(true); + expect( + inspectionAttempts.some((attempt) => attempt.status === "running"), + ).toBe(true); + expect(readiness.length).toBeGreaterThan(0); + expect(serviceOutput.join("\n")).not.toContain("Error:"); + } finally { + readinessAbort.abort(); + await readinessMonitor; + await persistEvidence(); + } +}, 200_000); diff --git a/apps/service/test/source-connections-http.test.ts b/apps/service/test/source-connections-http.test.ts new file mode 100644 index 0000000..01f417f --- /dev/null +++ b/apps/service/test/source-connections-http.test.ts @@ -0,0 +1,1061 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { createServer, type AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import type { ProductSourceIo } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { createPublicApiClient } from "@digital/public-api"; +import { refusingSessionTransport } from "@digital/test-support/source-io"; +import { serve } from "srvx"; +import { afterEach, expect, it } from "vitest"; + +import { createProductService } from "../src/service.ts"; +import { interactiveTerminalEnvironment } from "./interactive-terminal.ts"; + +const servers: Array>> = []; +const bootstraps: Array>> = []; +const temporaryRoots: string[] = []; +const terminalChildren: Array> = []; +let releaseVerification: (() => void) | undefined; +let releaseBangumiExchange: (() => void) | undefined; + +afterEach(async () => { + releaseBangumiExchange?.(); + releaseBangumiExchange = undefined; + releaseVerification?.(); + releaseVerification = undefined; + for (const child of terminalChildren.splice(0).reverse()) { + if (child.exitCode === null && child.signalCode === null) { + if (child.pid !== undefined && process.platform !== "win32") { + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + } else child.kill("SIGTERM"); + } + } + for (const server of servers.splice(0).reverse()) await server.close(true); + for (const bootstrap of bootstraps.splice(0).reverse()) + await bootstrap.abort(); + for (const root of temporaryRoots.splice(0).reverse()) + await rm(root, { recursive: true, force: true }); +}); + +it("在真实服务中授权、替换并撤销 Bangumi 连接", async () => { + const clientSecret = "controlled-bangumi-client-secret"; + const accessTokens = [ + "controlled-bangumi-access-token", + "controlled-bangumi-replacement-access-token", + ]; + const refreshTokens = [ + "controlled-bangumi-refresh-token", + "controlled-bangumi-replacement-refresh-token", + ]; + const sourceIo: ProductSourceIo = { + mode: "controlled", + request: async (input, init) => { + const url = new URL(input); + if ( + url.host !== "bgm.tv" || + url.pathname !== "/oauth/access_token" || + init?.method !== "POST" + ) { + throw new Error("受控验收遇到未声明的 OAuth 请求"); + } + const parameters = new URLSearchParams(String(init.body)); + expect(parameters.get("client_secret")).toBe(clientSecret); + const code = parameters.get("code"); + const grantIndex = + code === "controlled-authorization-code" + ? 0 + : code === "controlled-replacement-code" + ? 1 + : -1; + expect(grantIndex).toBeGreaterThanOrEqual(0); + return verificationResponse( + true, + url.toString(), + JSON.stringify({ + access_token: accessTokens[grantIndex], + refresh_token: refreshTokens[grantIndex], + expires_in: 3600, + }), + ); + }, + sessionTransport: refusingSessionTransport(), + }; + const { baseUrl, callbackUri, productDataDirectory } = + await startBangumiOAuthService(sourceIo, { + clientId: "controlled-bangumi-client", + clientSecret, + }); + const client = createPublicApiClient({ baseUrl }); + + const authorization = await client.beginBangumiAuthorization(); + const authorizationUrl = new URL(authorization.authorizationUrl); + expect(authorizationUrl.origin).toBe("https://bgm.tv"); + expect(authorizationUrl.pathname).toBe("/oauth/authorize"); + expect(authorizationUrl.searchParams.get("client_id")).toBe( + "controlled-bangumi-client", + ); + expect(authorizationUrl.searchParams.get("redirect_uri")).toBe(callbackUri); + expect(authorization.expiresAt).toMatch(/^\d{4}-\d\d-\d\dT/u); + await expect( + client.getBangumiAuthorizationAttempt(authorization.attemptId), + ).resolves.toMatchObject({ status: "waiting" }); + + const callback = new URL(callbackUri); + callback.searchParams.set("code", "untrusted-authorization-code"); + callback.searchParams.set("state", "untrusted-state"); + const rejected = await fetch(callback); + const rejectedText = await rejected.text(); + expect(rejected.status).toBe(400); + expect(rejectedText).not.toContain("untrusted-authorization-code"); + expect(rejectedText).not.toContain("untrusted-state"); + await expect( + client.getBangumiAuthorizationAttempt(authorization.attemptId), + ).resolves.toMatchObject({ status: "waiting" }); + + callback.search = ""; + callback.searchParams.set("code", "controlled-authorization-code"); + callback.searchParams.set( + "state", + authorizationUrl.searchParams.get("state") ?? "missing-state", + ); + const completed = await fetch(callback); + const completedText = await completed.text(); + expect(completed.status).toBe(200); + expect(completed.headers.get("content-type")).toContain("text/html"); + expect(completedText).toContain("授权已完成"); + expect(completedText).not.toContain(accessTokens[0]); + expect(completedText).not.toContain(refreshTokens[0]); + expect(completedText).not.toContain(clientSecret); + expect(completed.headers.get("referrer-policy")).toBe("no-referrer"); + expect(completed.headers.get("cache-control")).toBe("no-store"); + expect(completed.headers.get("x-content-type-options")).toBe("nosniff"); + await expect( + client.getBangumiAuthorizationAttempt(authorization.attemptId), + ).resolves.toMatchObject({ + attemptId: authorization.attemptId, + status: "succeeded", + }); + + const replacement = await client.beginBangumiAuthorization(); + const replacementCallback = new URL(callbackUri); + replacementCallback.searchParams.set("code", "controlled-replacement-code"); + replacementCallback.searchParams.set( + "state", + new URL(replacement.authorizationUrl).searchParams.get("state") ?? "", + ); + expect((await fetch(replacementCallback)).status).toBe(200); + await expect( + client.getBangumiAuthorizationAttempt(replacement.attemptId), + ).resolves.toMatchObject({ status: "succeeded" }); + + const current = await fetch( + new URL("/api/v1/source-connections/bangumi", baseUrl), + ); + expect(current.status).toBe(200); + const currentText = await current.text(); + expect(currentText).toContain('"id":"bangumi"'); + expect(currentText).toContain('"availability":"available"'); + expect(currentText).not.toContain(accessTokens[1]); + expect(currentText).not.toContain(refreshTokens[1]); + expect(currentText).not.toContain(clientSecret); + await expect(Promise.resolve(JSON.parse(currentText))).resolves.toMatchObject( + { + id: "bangumi", + credentialStatus: "configured", + availability: "available", + version: 2, + }, + ); + await expect( + client.revokeBangumiSourceConnection({ expectedVersion: 1 }), + ).rejects.toMatchObject({ kind: "conflict", status: 409 }); + await expect(client.getSourceConnection("bangumi")).resolves.toMatchObject({ + version: 2, + credentialStatus: "configured", + availability: "available", + }); + await expect( + client.revokeBangumiSourceConnection({ expectedVersion: 2 }), + ).resolves.toMatchObject({ + revoked: true, + connection: { + id: "bangumi", + version: 3, + credentialStatus: "revoked", + availability: "unavailable", + }, + }); + await expect(client.getSourceConnection("bangumi")).resolves.toMatchObject({ + version: 3, + credentialStatus: "revoked", + availability: "unavailable", + }); + await writeFile( + path.join( + productDataDirectory, + "private-source-connections", + "bangumi-oauth.json", + ), + "{corrupt private storage", + ); + await expect( + client.revokeBangumiSourceConnection({ expectedVersion: 3 }), + ).rejects.toMatchObject({ kind: "unavailable", status: 503 }); +}); + +it("经公共 API 配置 Bangumi OAuth 应用并沿用正式私密授权流程", async () => { + const clientId = "controlled-public-client"; + const clientSecret = "controlled-public-client-secret"; + const replacementClientId = "controlled-public-client-replacement"; + const replacementClientSecret = "controlled-public-client-replacement-secret"; + const sourceIo: ProductSourceIo = { + mode: "controlled", + request: async (input, init) => { + const url = new URL(input); + if ( + url.host !== "bgm.tv" || + url.pathname !== "/oauth/access_token" || + init?.method !== "POST" + ) { + throw new Error("受控验收遇到未声明的 OAuth 请求"); + } + const parameters = new URLSearchParams(String(init.body)); + const requestSecret = parameters.get("client_secret"); + const requestClientId = parameters.get("client_id"); + expect([ + [clientId, clientSecret], + [replacementClientId, replacementClientSecret], + ]).toContainEqual([requestClientId, requestSecret]); + return verificationResponse( + true, + url.toString(), + JSON.stringify({ + access_token: `controlled-${requestClientId}-access-token`, + refresh_token: `controlled-${requestClientId}-refresh-token`, + expires_in: 3600, + }), + ); + }, + sessionTransport: refusingSessionTransport(), + }; + const baseUrl = await startService(sourceIo); + const client = createPublicApiClient({ baseUrl }); + const callbackUri = new URL( + "/api/v1/source-connections/bangumi/callback", + baseUrl, + ).toString(); + + const configured = await client.configureBangumiOAuthClient({ + clientId, + clientSecret, + redirectUri: callbackUri, + }); + expect(configured).toEqual({ configured: true }); + expect(JSON.stringify(configured)).not.toContain(clientSecret); + + const authorization = await client.beginBangumiAuthorization(); + const authorizationUrl = new URL(authorization.authorizationUrl); + expect(authorizationUrl.searchParams.get("client_id")).toBe(clientId); + expect(authorizationUrl.searchParams.get("redirect_uri")).toBe(callbackUri); + const callback = new URL(callbackUri); + callback.searchParams.set("code", "controlled-public-authorization-code"); + callback.searchParams.set( + "state", + authorizationUrl.searchParams.get("state") ?? "missing-state", + ); + const callbackResponse = await fetch(callback); + const callbackText = await callbackResponse.text(); + expect(callbackResponse.status).toBe(200); + expect(callbackText).not.toContain(clientSecret); + await expect( + client.getBangumiAuthorizationAttempt(authorization.attemptId), + ).resolves.toMatchObject({ status: "succeeded" }); + await expect(client.getSourceConnection("bangumi")).resolves.toMatchObject({ + availability: "available", + credentialStatus: "configured", + }); + + await expect( + client.configureBangumiOAuthClient({ + clientId: replacementClientId, + clientSecret: replacementClientSecret, + redirectUri: callbackUri, + }), + ).resolves.toEqual({ configured: true }); + await expect(client.getSourceConnection("bangumi")).resolves.toMatchObject({ + availability: "unavailable", + credentialStatus: "revoked", + version: 3, + }); + const replacementAuthorization = await client.beginBangumiAuthorization(); + expect( + new URL(replacementAuthorization.authorizationUrl).searchParams.get( + "client_id", + ), + ).toBe(replacementClientId); + const replacementCallback = new URL(callbackUri); + replacementCallback.searchParams.set( + "code", + "controlled-public-replacement-code", + ); + replacementCallback.searchParams.set( + "state", + new URL(replacementAuthorization.authorizationUrl).searchParams.get( + "state", + ) ?? "missing-state", + ); + expect((await fetch(replacementCallback)).status).toBe(200); + await expect( + client.getBangumiAuthorizationAttempt(replacementAuthorization.attemptId), + ).resolves.toMatchObject({ status: "succeeded" }); + + const ordinaryConfiguration = await fetch( + new URL("/api/v1/source-connections", baseUrl), + ); + const ordinaryText = await ordinaryConfiguration.text(); + expect(ordinaryText).not.toContain(clientId); + expect(ordinaryText).not.toContain(clientSecret); + expect(ordinaryText).not.toContain("controlled-public-access-token"); +}, 20_000); + +it("拒绝无效 Bangumi OAuth 配置且不把提交的密钥放进错误响应", async () => { + const baseUrl = await startService({ + mode: "controlled", + request: async () => { + throw new Error("无效配置验收不应发起外部请求"); + }, + sessionTransport: refusingSessionTransport(), + }); + const clientSecret = "private-invalid-client-secret"; + const response = await fetch( + new URL("/api/v1/source-connections/bangumi/client-configuration", baseUrl), + jsonRequest("PUT", { + clientId: "controlled-client", + clientSecret, + redirectUri: "http://example.com/callback", + }), + ); + const body = await response.text(); + expect(response.status).toBe(400); + expect(body).not.toContain(clientSecret); + expect(body).not.toContain("example.com"); +}); + +it("Bangumi 替换授权失败时保留旧连接并向浏览器报告失败", async () => { + const clientSecret = "controlled-bangumi-client-secret"; + const sourceIo: ProductSourceIo = { + mode: "controlled", + request: async (input, init) => { + const url = new URL(input); + if (url.host !== "bgm.tv" || url.pathname !== "/oauth/access_token") + throw new Error("受控验收遇到未声明的 OAuth 请求"); + const code = new URLSearchParams(String(init?.body)).get("code"); + if (code !== "initial-code") + return verificationResponse( + false, + url.toString(), + '{"error":"failed"}', + ); + return verificationResponse( + true, + url.toString(), + JSON.stringify({ + access_token: "controlled-initial-access", + refresh_token: "controlled-initial-refresh", + expires_in: 3600, + }), + ); + }, + sessionTransport: refusingSessionTransport(), + }; + const { baseUrl, callbackUri } = await startBangumiOAuthService(sourceIo, { + clientId: "controlled-bangumi-client", + clientSecret, + }); + const client = createPublicApiClient({ baseUrl }); + const initial = await client.beginBangumiAuthorization(); + const initialCallback = new URL(callbackUri); + initialCallback.searchParams.set("code", "initial-code"); + initialCallback.searchParams.set( + "state", + new URL(initial.authorizationUrl).searchParams.get("state") ?? "", + ); + expect((await fetch(initialCallback)).status).toBe(200); + + const replacement = await client.beginBangumiAuthorization(); + const deniedCallback = new URL(callbackUri); + deniedCallback.searchParams.set("error", "server_error"); + deniedCallback.searchParams.set( + "error_description", + "provider-private-detail", + ); + deniedCallback.searchParams.set( + "state", + new URL(replacement.authorizationUrl).searchParams.get("state") ?? "", + ); + const denied = await fetch(deniedCallback); + const deniedText = await denied.text(); + expect(denied.status).toBe(200); + expect(deniedText).toContain("Bangumi 授权失败"); + expect(deniedText).not.toContain("provider-private-detail"); + await expect( + client.getBangumiAuthorizationAttempt(replacement.attemptId), + ).resolves.toMatchObject({ status: "failed" }); + const current = await client.getSourceConnection("bangumi"); + expect(current).toMatchObject({ + version: 1, + credentialStatus: "configured", + availability: "available", + }); + expect(JSON.stringify(current)).not.toContain("controlled-initial-access"); + + const userCancelled = await client.beginBangumiAuthorization(); + const cancellationCallback = new URL(callbackUri); + cancellationCallback.searchParams.set("error", "access_denied"); + cancellationCallback.searchParams.set( + "state", + new URL(userCancelled.authorizationUrl).searchParams.get("state") ?? "", + ); + const cancellationResponse = await fetch(cancellationCallback); + expect(await cancellationResponse.text()).toContain("Bangumi 授权已取消"); + await expect( + client.getBangumiAuthorizationAttempt(userCancelled.attemptId), + ).resolves.toMatchObject({ status: "cancelled" }); + await expect(client.getSourceConnection("bangumi")).resolves.toMatchObject({ + version: 1, + availability: "available", + credentialStatus: "configured", + }); +}); + +it("取消正在兑换的 Bangumi 授权会报告处理中且不会覆盖晚到的成功结果", async () => { + const clientSecret = "controlled-bangumi-client-secret"; + let markExchangeStarted!: (value: void) => void; + const exchangeStarted = new Promise((resolve) => { + markExchangeStarted = resolve; + }); + let finishExchange!: ( + response: ReturnType, + ) => void; + const exchangeResponse = new Promise>( + (resolve) => { + finishExchange = resolve; + }, + ); + releaseBangumiExchange = () => + finishExchange( + verificationResponse(false, "https://bgm.tv/oauth/access_token"), + ); + const sourceIo: ProductSourceIo = { + mode: "controlled", + request: async (input, init) => { + const url = new URL(input); + if ( + url.host !== "bgm.tv" || + url.pathname !== "/oauth/access_token" || + init?.method !== "POST" + ) { + throw new Error("受控验收遇到未声明的 OAuth 请求"); + } + markExchangeStarted(undefined); + return await exchangeResponse; + }, + sessionTransport: refusingSessionTransport(), + }; + const { baseUrl, callbackUri } = await startBangumiOAuthService(sourceIo, { + clientId: "controlled-bangumi-client", + clientSecret, + }); + const client = createPublicApiClient({ baseUrl }); + const authorization = await client.beginBangumiAuthorization(); + const callback = new URL(callbackUri); + callback.searchParams.set("code", "controlled-authorization-code"); + callback.searchParams.set( + "state", + new URL(authorization.authorizationUrl).searchParams.get("state") ?? "", + ); + + const callbackResponse = fetch(callback).catch(() => undefined); + await exchangeStarted; + await expect( + client.cancelBangumiAuthorization(authorization.attemptId), + ).resolves.toMatchObject({ + attemptId: authorization.attemptId, + status: "processing", + }); + await expect( + client.getBangumiAuthorizationAttempt(authorization.attemptId), + ).resolves.toMatchObject({ status: "processing" }); + await expect(client.beginBangumiAuthorization()).rejects.toMatchObject({ + kind: "conflict", + }); + + finishExchange( + verificationResponse( + true, + "https://bgm.tv/oauth/access_token", + JSON.stringify({ + access_token: "controlled-late-access", + refresh_token: "controlled-late-refresh", + expires_in: 3600, + }), + ), + ); + releaseBangumiExchange = undefined; + expect((await callbackResponse)?.status).toBe(200); + await expect( + client.getBangumiAuthorizationAttempt(authorization.attemptId), + ).resolves.toMatchObject({ status: "succeeded" }); + await expect(client.getSourceConnection("bangumi")).resolves.toMatchObject({ + availability: "available", + credentialStatus: "configured", + version: 1, + }); +}); + +it("在真实服务中先报告待配置,验证期间保持不可用并在成功后读回状态", async () => { + const controlledAccessToken = "controlled-test-access-token"; + const controlledReplacementToken = "controlled-replacement-access-token"; + let beginFirstVerification!: () => void; + const verificationStarted = new Promise((resolve) => { + beginFirstVerification = resolve; + }); + let beginReplacementVerification!: () => void; + const replacementVerificationStarted = new Promise((resolve) => { + beginReplacementVerification = resolve; + }); + let finishVerification!: () => void; + const verificationGate = new Promise((resolve) => { + finishVerification = resolve; + }); + releaseVerification = finishVerification; + const sourceIo: ProductSourceIo = { + mode: "controlled", + request: async (input, init) => { + const url = new URL(input); + if ( + url.host === "api.themoviedb.org" && + url.pathname === "/3/authentication" + ) { + const headers = init?.headers as Record | undefined; + const authorization = headers?.Authorization; + if (authorization === `Bearer ${controlledAccessToken}`) + beginFirstVerification(); + else if (authorization === `Bearer ${controlledReplacementToken}`) + beginReplacementVerification(); + else throw new Error("受控验收收到未知的 TMDB 访问令牌"); + await verificationGate; + return verificationResponse( + authorization === `Bearer ${controlledAccessToken}` || + authorization === `Bearer ${controlledReplacementToken}`, + url.toString(), + ); + } + throw new Error("受控验收遇到未声明的外部请求"); + }, + sessionTransport: refusingSessionTransport(), + }; + const baseUrl = await startService(sourceIo); + const client = createPublicApiClient({ baseUrl }); + await expect + .poll( + async () => { + const response = await fetch(new URL("/api/ready", baseUrl), { + signal: AbortSignal.timeout(1_000), + }); + return { + status: response.status, + body: await response.json(), + }; + }, + { timeout: 60_000, interval: 100 }, + ) + .toMatchObject({ status: 200, body: { ok: true } }); + + const beforeState = await client.listSourceConnections(); + expect(beforeState.items).toContainEqual( + expect.objectContaining({ + id: "tmdb", + credentialStatus: "unconfigured", + availability: "unavailable", + }), + ); + expect(beforeState.items).toContainEqual( + expect.objectContaining({ + id: "omdb", + credentialStatus: "unconfigured", + availability: "unavailable", + }), + ); + + const configureResponse = fetch( + new URL("/api/v1/source-connections/tmdb", baseUrl), + jsonRequest("POST", { + expectedVersion: 0, + secret: controlledAccessToken, + }), + ); + await verificationStarted; + + const during = await fetch( + new URL("/api/v1/source-connections/tmdb", baseUrl), + ); + expect(during.status).toBe(200); + const duringText = await during.text(); + expect(duringText).toContain('"availability":"unavailable"'); + expect(duringText).toContain('"reason":"verification-pending"'); + expect(duringText).not.toContain(controlledAccessToken); + + const replacement = client.replaceSourceConnection({ + sourceId: "tmdb", + expectedVersion: 1, + secret: controlledReplacementToken, + }); + await replacementVerificationStarted; + const replacedDuring = await client.getSourceConnection("tmdb"); + expect(replacedDuring).toMatchObject({ + version: 2, + availability: "unavailable", + reason: "verification-pending", + }); + expect(JSON.stringify(replacedDuring)).not.toContain( + controlledReplacementToken, + ); + + finishVerification(); + const configured = await configureResponse; + expect(configured.status).toBe(200); + const configuredText = await configured.text(); + expect(configuredText).toContain('"verification":"superseded"'); + expect(configuredText).not.toContain(controlledAccessToken); + await expect(replacement).resolves.toMatchObject({ + verification: "verified", + connection: { version: 2, availability: "available" }, + }); + const current = await client.getSourceConnection("tmdb"); + expect(current).toMatchObject({ + version: 2, + credentialStatus: "configured", + availability: "available", + }); + expect(JSON.stringify(current)).not.toContain(controlledReplacementToken); +}, 70_000); + +it("分别验证 OMDb、替换失败、版本冲突和撤销结果", async () => { + const controlledTmdbToken = "controlled-tmdb-token"; + const controlledOmdbKey = "controlled-omdb-key"; + const sourceIo: ProductSourceIo = { + mode: "controlled", + request: async (input, init) => { + const url = new URL(input); + if ( + url.host === "api.themoviedb.org" && + url.pathname === "/3/authentication" + ) { + const headers = init?.headers as Record | undefined; + return verificationResponse( + headers?.Authorization === `Bearer ${controlledTmdbToken}`, + url.toString(), + ); + } + if (url.host === "www.omdbapi.com") { + const valid = url.searchParams.get("apikey") === controlledOmdbKey; + return verificationResponse( + true, + "https://www.omdbapi.com/", + JSON.stringify( + valid + ? { Response: "True", imdbID: "tt3896198" } + : { Response: "False", Error: "Invalid API key!" }, + ), + ); + } + throw new Error("受控验收遇到未声明的外部请求"); + }, + sessionTransport: refusingSessionTransport(), + }; + const baseUrl = await startService(sourceIo); + const client = createPublicApiClient({ baseUrl }); + + const tmdbConfigured = await fetch( + new URL("/api/v1/source-connections/tmdb", baseUrl), + jsonRequest("POST", { + expectedVersion: 0, + secret: controlledTmdbToken, + }), + ); + const tmdbConfiguredText = await tmdbConfigured.text(); + expect(tmdbConfigured.status).toBe(200); + expect(tmdbConfiguredText).toContain('"verification":"verified"'); + expect(tmdbConfiguredText).not.toContain(controlledTmdbToken); + + const tmdbReplacement = await client.replaceSourceConnection({ + sourceId: "tmdb", + expectedVersion: 1, + secret: "controlled-invalid-tmdb-token", + }); + expect(tmdbReplacement.verification).toBe("failed"); + expect(JSON.stringify(tmdbReplacement)).not.toContain( + "controlled-invalid-tmdb-token", + ); + const failedReplacementState = await fetch( + new URL("/api/v1/source-connections/tmdb", baseUrl), + ); + const failedReplacementText = await failedReplacementState.text(); + expect(failedReplacementState.status).toBe(200); + expect(failedReplacementText).toContain('"reason":"verification-failed"'); + expect(failedReplacementText).toContain('"availability":"unavailable"'); + expect(failedReplacementText).not.toContain("controlled-invalid-tmdb-token"); + + await expect( + client.replaceSourceConnection({ + sourceId: "tmdb", + expectedVersion: 1, + secret: "controlled-stale-tmdb-token", + }), + ).rejects.toMatchObject({ kind: "conflict", status: 409 }); + + const omdbConfigured = await client.configureSourceConnection({ + sourceId: "omdb", + expectedVersion: 0, + secret: controlledOmdbKey, + }); + expect(omdbConfigured.verification).toBe("verified"); + expect(JSON.stringify(omdbConfigured)).not.toContain(controlledOmdbKey); + + const revoked = await client.revokeSourceConnection({ + sourceId: "tmdb", + expectedVersion: 2, + }); + expect(revoked).toMatchObject({ + revoked: true, + connection: { credentialStatus: "revoked" }, + }); + expect(JSON.stringify(revoked)).not.toContain(controlledTmdbToken); +}); + +it("安装的 TUI 通过真实服务隐藏密钥输入并立即验证【安装客户端】", async () => { + const controlledAccessToken = "controlled-pty-access-token"; + const controlledConcurrentToken = "controlled-pty-concurrent-token"; + let verifiedAuthorization: string | undefined; + const sourceIo: ProductSourceIo = { + mode: "controlled", + request: async (input, init) => { + const url = new URL(input); + if ( + url.host === "api.themoviedb.org" && + url.pathname === "/3/authentication" + ) { + const headers = init?.headers as Record | undefined; + verifiedAuthorization = headers?.Authorization; + return verificationResponse( + verifiedAuthorization === `Bearer ${controlledAccessToken}` || + verifiedAuthorization === `Bearer ${controlledConcurrentToken}`, + url.toString(), + ); + } + throw new Error("受控验收遇到未声明的外部请求"); + }, + sessionTransport: refusingSessionTransport(), + }; + const baseUrl = await startService(sourceIo); + const concurrentClient = createPublicApiClient({ baseUrl }); + const terminalCommand = [ + "stty cols 58 rows 16 -icanon -echo", + "terminalModeBefore=$(stty -g)", + `pnpm --filter @digital/service exec digital-tui --url ${shellQuote(baseUrl.origin)}`, + "terminalModeAfter=$(stty -g)", + 'if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi', + ].join("; "); + const child = spawn("script", ["-qfec", terminalCommand, "/dev/null"], { + cwd: process.cwd(), + env: interactiveTerminalEnvironment({ + TERM: "xterm-256color", + COLUMNS: "58", + LINES: "16", + }), + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + }); + terminalChildren.push(child); + const childClosed = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + let terminalOutput = ""; + let errorOutput = ""; + child.stdout.on("data", (chunk: Buffer) => { + terminalOutput += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + errorOutput += chunk.toString("utf8"); + }); + const waitForText = async (value: string, after = 0, timeoutMs = 10_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (terminalOutput.slice(after).includes(value)) return; + if (child.exitCode !== null || child.signalCode !== null) + throw new Error( + `TUI 提前退出,未显示“${value}”:${errorOutput}\n${terminalOutput.slice(-3000)}`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error( + `TUI 未显示“${value}”:${errorOutput}\n${terminalOutput.slice(-3000)}`, + ); + }; + const send = (value: string) => { + const after = terminalOutput.length; + child.stdin.write(value); + return after; + }; + + await waitForText("还没有资产库"); + await waitForText("TMDB:待配置", send("7")); + await waitForText("设置TMDB密钥", send("\r")); + await waitForText( + "•".repeat(Math.min(24, controlledAccessToken.length)), + send(controlledAccessToken), + ); + const concurrentConfiguration = + await concurrentClient.configureSourceConnection({ + sourceId: "tmdb", + expectedVersion: 0, + secret: controlledConcurrentToken, + }); + expect(concurrentConfiguration.verification).toBe("verified"); + await waitForText("来源连接已被其他操作更改", send("\r")); + await waitForText("TMDB:可用", send("r")); + await waitForText("设置TMDB密钥", send("\r")); + await waitForText( + "•".repeat(Math.min(24, controlledAccessToken.length)), + send(controlledAccessToken), + ); + await waitForText("验证成功,来源连接已可用", send("\r")); + expect(verifiedAuthorization).toBe(`Bearer ${controlledAccessToken}`); + expect(terminalOutput).not.toContain(controlledAccessToken); + expect(terminalOutput).not.toContain(controlledConcurrentToken); + + await waitForText("还没有资产库", send("q")); + send("q"); + child.stdin.end(); + const exitCode = await childClosed; + expect(exitCode, `${errorOutput}\n${terminalOutput}`).toBe(0); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + expect(terminalOutput).not.toContain(controlledAccessToken); + expect(terminalOutput).not.toContain(controlledConcurrentToken); +}, 20_000); + +it("安装的 TUI 显示 Bangumi 授权目标并报告明确取消结果【安装客户端】", async () => { + const clientSecret = "controlled-tui-bangumi-client-secret"; + const sourceIo: ProductSourceIo = { + mode: "controlled", + request: async () => { + throw new Error("取消验收不应发起外部请求"); + }, + sessionTransport: refusingSessionTransport(), + }; + const { baseUrl } = await startBangumiOAuthService(sourceIo, { + clientId: "controlled-bangumi-client", + clientSecret, + }); + const publicClient = createPublicApiClient({ baseUrl }); + const terminalCommand = [ + "stty cols 100 rows 20 -icanon -echo", + "terminalModeBefore=$(stty -g)", + `pnpm --filter @digital/service exec digital-tui --url ${shellQuote(baseUrl.origin)}`, + "terminalModeAfter=$(stty -g)", + 'if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi', + ].join("; "); + const child = spawn("script", ["-qfec", terminalCommand, "/dev/null"], { + cwd: process.cwd(), + env: interactiveTerminalEnvironment({ + TERM: "xterm-256color", + COLUMNS: "100", + LINES: "20", + }), + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + }); + terminalChildren.push(child); + const childClosed = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + let terminalOutput = ""; + let errorOutput = ""; + child.stdout.on("data", (chunk: Buffer) => { + terminalOutput += chunk.toString("utf8"); + }); + child.stderr.on("data", (chunk: Buffer) => { + errorOutput += chunk.toString("utf8"); + }); + const waitForText = async (value: string, after = 0, timeoutMs = 10_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (terminalOutput.slice(after).includes(value)) return; + if (child.exitCode !== null || child.signalCode !== null) + throw new Error( + `TUI 提前退出,未显示“${value}”:${errorOutput}\n${terminalOutput.slice(-3000)}`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error( + `TUI 未显示“${value}”:${errorOutput}\n${terminalOutput.slice(-3000)}`, + ); + }; + const send = (value: string) => { + const after = terminalOutput.length; + child.stdin.write(value); + return after; + }; + + await waitForText("还没有资产库"); + await waitForText("Bangumi:待配置", send("7")); + await waitForText("› OMDb:待配置", send("\u001b[B")); + await waitForText("› Bangumi:待配置", send("\u001b[B")); + await waitForText("在你自己的浏览器", send("\r")); + await waitForText("目标实例:"); + await waitForText("授权地址:"); + expect(terminalOutput).toContain(baseUrl.host); + expect(terminalOutput).not.toContain(clientSecret); + await waitForText("当前状态:授权已取消", send("x")); + await waitForText("› Bangumi:待配置", send("q")); + const competingAuthorization = await publicClient.beginBangumiAuthorization(); + const failedStartOutput = send("\r"); + await waitForText("Bangumi 授权正在处理", failedStartOutput); + expect(terminalOutput.slice(failedStartOutput)).not.toContain( + "设置Bangumi密钥", + ); + await publicClient.cancelBangumiAuthorization( + competingAuthorization.attemptId, + ); + await waitForText("还没有资产库", send("q")); + send("q"); + child.stdin.end(); + const exitCode = await childClosed; + expect(exitCode, `${errorOutput}\n${terminalOutput}`).toBe(0); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + expect(terminalOutput).not.toContain(clientSecret); +}, 20_000); + +async function startService(sourceIo: ProductSourceIo): Promise { + const root = await mkdtemp( + path.join(tmpdir(), "digital-source-connection-http-"), + ); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + await mkdir(path.join(root, "ordinary-materials")); + await prepareProductionCatalogue({ productDataDirectory }); + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }); + bootstraps.push(bootstrap); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + servers.push(server); + await server.ready(); + bootstrap.publish(); + return new URL(server.url!); +} + +async function startBangumiOAuthService( + sourceIo: ProductSourceIo, + client: { readonly clientId: string; readonly clientSecret: string }, +): Promise<{ + readonly baseUrl: URL; + readonly callbackUri: string; + readonly productDataDirectory: string; +}> { + const root = await mkdtemp( + path.join(tmpdir(), "digital-bangumi-oauth-http-"), + ); + temporaryRoots.push(root); + const productDataDirectory = path.join(root, "product-data"); + await mkdir(path.join(root, "ordinary-materials")); + await prepareProductionCatalogue({ productDataDirectory }); + const port = await unusedPort(); + const callbackUri = `http://127.0.0.1:${String(port)}/api/v1/source-connections/bangumi/callback`; + const bootstrap = await createProductService({ + productDataDirectory, + environment: { + DIGITAL_PRODUCT_DATA: productDataDirectory, + DIGITAL_BANGUMI_CLIENT_ID: client.clientId, + DIGITAL_BANGUMI_CLIENT_SECRET: client.clientSecret, + DIGITAL_BANGUMI_REDIRECT_URI: callbackUri, + }, + sourceIo, + }); + bootstraps.push(bootstrap); + const server = serve({ + fetch: bootstrap.app.fetch, + port, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + servers.push(server); + await server.ready(); + bootstrap.publish(); + return { + baseUrl: new URL(server.url!), + callbackUri, + productDataDirectory, + }; +} + +async function unusedPort(): Promise { + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("未能分配服务测试端口"); + const port = (address as AddressInfo).port; + await new Promise((resolve, reject) => { + server.close((error) => (error === undefined ? resolve() : reject(error))); + }); + return port; +} + +function jsonRequest(method: "POST" | "PUT" | "DELETE", body: unknown) { + return { + method, + headers: { + accept: "application/json", + "content-type": "application/json", + }, + body: JSON.stringify(body), + }; +} + +function verificationResponse(ok: boolean, url: string, body = "") { + const bytes = new TextEncoder().encode(body); + return { + ok, + status: ok ? 200 : 401, + url, + headers: { get: () => null }, + arrayBuffer: async () => bytes.buffer as ArrayBuffer, + }; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/apps/service/test/source-recognition-http.test.ts b/apps/service/test/source-recognition-http.test.ts new file mode 100644 index 0000000..5a996f6 --- /dev/null +++ b/apps/service/test/source-recognition-http.test.ts @@ -0,0 +1,1554 @@ +import { cp, link, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { fileURLToPath } from "node:url"; + +import type { ProductConfiguration } from "@digital/composition"; +import { prepareProductionCatalogue } from "@digital/db-migrations"; +import { createPublicApiClient } from "@digital/public-api"; +import { + loadControlledSourceIo, + refusingSessionTransport, + resolveSourceIoFromEnvironment, +} from "@digital/test-support/source-io"; +import { serve } from "srvx"; +import { afterEach, expect, it } from "vitest"; + +import { createProductService } from "../src/service.ts"; + +const active: Array<{ + readonly closeServer: () => Promise; + readonly closeService: () => Promise; + readonly root: string; +}> = []; +const materialRoot = fileURLToPath( + import.meta.resolve("@digital/preview-corpus/publication-screen/material"), +); +const nbinetManifestPath = fileURLToPath( + import.meta + .resolve("@digital/preview-corpus/publication-screen/manifest.json"), +); + +afterEach(async () => { + for (const service of active.splice(0).reverse()) { + await service.closeServer(); + await service.closeService(); + await rm(service.root, { recursive: true, force: true }); + } +}); + +it("公开来源输入要求,供调用者按来源准备有效的识别或补充请求", async () => { + const baseUrl = await startService(); + const client = createPublicApiClient({ baseUrl }); + + const requirements = await client.listSourceInputRequirements(); + + expect(requirements).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sourceIdentity: "nbinet", + fields: expect.arrayContaining([ + expect.objectContaining({ key: "isbn", required: true }), + ]), + }), + ]), + ); +}); + +it("定向来源识别拒绝缺少来源坐标的请求", async () => { + const baseUrl = await startService(); + + const response = await fetch( + new URL("/api/v1/directed-recognitions", baseUrl), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ registrationId: "root", relativeScope: "." }), + }, + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "invalid-request" }, + }); +}); + +it("通过正式检查、准备和确认流程将普通本地材料纳入资产", async () => { + const baseUrl = await startService(); + const client = createPublicApiClient({ baseUrl }); + const materialDirectory = path.join(active.at(-1)!.root, "我的资料"); + await mkdir(materialDirectory, { recursive: true }); + await writeFile(path.join(materialDirectory, "一本书.txt"), "正文内容"); + const library = await client.saveAssetLibrary({ + displayName: "本地材料库", + directories: [materialDirectory], + recognitionProfileIds: ["books"], + sourceIdentities: [], + }); + + const started = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(started.parentExecutionId)) + .status, + { timeout: 5_000 }, + ) + .toBe("completed"); + const directoryId = library.directories[0]?.id; + expect(directoryId).toBeTypeOf("string"); + if (directoryId === undefined) throw new Error("资产库目录缺失"); + + const intake = await client.inspectLocalIntake({ + assetLibraryId: library.id, + directoryId, + parentExecutionId: started.parentExecutionId, + }); + expect(intake.files).toEqual([ + expect.objectContaining({ relativePath: "一本书.txt", size: 12 }), + ]); + expect(intake.execution?.id).toBeTypeOf("string"); + await expect( + client.prepareLocalContent({ + executionId: intake.execution!.id, + relativePaths: ["missing.txt"], + classification: "book", + }), + ).rejects.toMatchObject({ kind: "invalid-request", status: 400 }); + + const proposed = await client.prepareLocalContent({ + executionId: intake.execution!.id, + relativePaths: ["一本书.txt"], + classification: "book", + name: "人工整理的书", + }); + expect(proposed.status).toBe("proposed"); + expect(proposed.relativePaths).toEqual(["一本书.txt"]); + expect(proposed.content.id).toBeTypeOf("string"); + await expect( + client.inspectLocalContent(proposed.executionId), + ).resolves.toMatchObject({ + status: "proposed", + relativePaths: ["一本书.txt"], + }); + + const accepted = await client.confirmLocalContent(proposed.executionId); + expect(accepted).toMatchObject({ + executionId: proposed.executionId, + status: "accepted", + content: { id: proposed.content.id }, + }); + await expect( + client.listAssets({ assetLibraryId: library.id }), + ).resolves.toMatchObject({ + items: [expect.objectContaining({ id: proposed.content.id })], + }); + + const associationDecisionStart = await client.startSourceAssociationDecision({ + intent: "establish", + assetId: proposed.content.id, + sourceIdentity: "nbinet", + sourceInput: { isbn: "9789864738281" }, + initiatedBy: "operator", + }); + await expect + .poll( + async () => + ( + await client.inspectSourceAssociationDecision( + associationDecisionStart.executionId, + ) + ).status, + { timeout: 5_000 }, + ) + .toBe("failed"); + const failedDecision = await client.inspectSourceAssociationDecision( + associationDecisionStart.executionId, + ); + expect(failedDecision).toMatchObject({ + state: "failed", + availableActions: expect.arrayContaining(["retry"]), + }); + const retriedDecision = await client.retrySourceAssociationDecision( + associationDecisionStart.executionId, + ); + expect(retriedDecision.executionId).not.toBe( + associationDecisionStart.executionId, + ); + await expect + .poll( + async () => + ( + await client.inspectSourceAssociationDecision( + retriedDecision.executionId, + ) + ).status, + { timeout: 5_000 }, + ) + .toBe("failed"); + + const associatedMaterialPath = path.join(materialDirectory, "补充材料.txt"); + await writeFile(associatedMaterialPath, "新增的完整材料"); + const invalidAssociation = await client.prepareObservedMaterialAssociation({ + targetAssetId: proposed.content.id, + assetLibraryId: library.id, + directoryId, + relativeScope: ".", + relativePaths: ["missing.txt"], + initiatedBy: "operator", + }); + await expect + .poll( + async () => + ( + await client.inspectObservedMaterialAssociation( + invalidAssociation.executionId, + ) + ).status, + { timeout: 5_000 }, + ) + .toBe("failed"); + const association = await client.prepareObservedMaterialAssociation({ + targetAssetId: proposed.content.id, + assetLibraryId: library.id, + directoryId, + relativeScope: ".", + relativePaths: ["补充材料.txt"], + initiatedBy: "operator", + }); + await expect + .poll( + async () => + ( + await client.inspectObservedMaterialAssociation( + association.executionId, + ) + ).status, + { timeout: 5_000 }, + ) + .toBe("awaiting-confirmation"); + const associationReview = await client.inspectObservedMaterialAssociation( + association.executionId, + ); + expect(associationReview).toMatchObject({ + target: { id: proposed.content.id }, + observedRoot: { relativeScope: "." }, + files: [ + expect.objectContaining({ + relativePath: "补充材料.txt", + formatValid: true, + }), + ], + proposal: { + materials: [ + { relativePath: "补充材料.txt", role: "asset.material-delivered" }, + ], + }, + }); + const associationResult = await client.confirmObservedMaterialAssociation( + association.executionId, + "operator", + ); + expect(associationResult).toMatchObject({ + status: "accepted", + confirmedBy: "operator", + associationIds: [expect.any(String)], + }); + + const staleMaterialPath = path.join(materialDirectory, "变化中的材料.txt"); + await writeFile(staleMaterialPath, "确认前会变化"); + const staleAssociation = await client.prepareObservedMaterialAssociation({ + targetAssetId: proposed.content.id, + assetLibraryId: library.id, + directoryId, + relativeScope: ".", + relativePaths: ["变化中的材料.txt"], + initiatedBy: "operator", + }); + await expect + .poll( + async () => + ( + await client.inspectObservedMaterialAssociation( + staleAssociation.executionId, + ) + ).status, + { timeout: 5_000 }, + ) + .toBe("awaiting-confirmation"); + await writeFile(staleMaterialPath, "确认前内容已经变化"); + await expect( + client.confirmObservedMaterialAssociation( + staleAssociation.executionId, + "operator", + ), + ).resolves.toMatchObject({ status: "stale", failure: "material-stale" }); + + const beforeRetraction = await client.inspectSourceAssociationHistory( + proposed.content.id, + ); + expect(beforeRetraction.recognitionState).toBe("recognized"); + await expect( + client.prepareRecognitionRetraction({ + assetId: proposed.content.id, + predecessorDecisionId: beforeRetraction.recognitionDecisionId!, + proposedBy: "operator", + reason: " ", + }), + ).rejects.toMatchObject({ kind: "invalid-request", status: 400 }); + await expect( + client.prepareRecognitionRetraction({ + assetId: "missing-asset", + predecessorDecisionId: "missing-decision", + proposedBy: "operator", + reason: "不存在的识别", + }), + ).rejects.toMatchObject({ kind: "not-found", status: 404 }); + await expect( + client.prepareRecognitionScopeAmendment({ + sourceExecutionId: "missing-execution", + workItemKey: "missing-work-item", + proposedBy: "operator", + reason: "不存在的范围", + }), + ).rejects.toMatchObject({ kind: "not-found", status: 404 }); + const failedPreparationDatabase = new DatabaseSync( + path.join(active.at(-1)!.root, "product-data", "catalogue.sqlite"), + ); + failedPreparationDatabase.exec(` + CREATE TRIGGER reject_recognition_retraction_preview + BEFORE INSERT ON change_impact_previews + BEGIN + SELECT RAISE(ABORT, '10 confidential recognition write failure'); + END; + `); + try { + const failedPreparation = await fetch( + new URL("/api/v1/recognition-retractions", baseUrl), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + assetId: proposed.content.id, + predecessorDecisionId: beforeRetraction.recognitionDecisionId, + proposedBy: "operator", + reason: "验证存储故障不会变成业务冲突", + }), + }, + ); + const body = await failedPreparation.text(); + expect(failedPreparation.status).toBe(503); + expect(body).toContain("暂时无法形成身份撤回预演"); + expect(body).not.toContain("10 confidential recognition write failure"); + expect(body).not.toContain("SQLITE_CONSTRAINT_TRIGGER"); + } finally { + failedPreparationDatabase.exec( + "DROP TRIGGER reject_recognition_retraction_preview", + ); + failedPreparationDatabase.close(); + } + const rejectedPreview = await client.prepareRecognitionRetraction({ + assetId: proposed.content.id, + predecessorDecisionId: beforeRetraction.recognitionDecisionId!, + proposedBy: "operator", + reason: "保留当前识别", + }); + expect(rejectedPreview).toMatchObject({ + status: "awaiting-confirmation", + current: { recognitionState: "recognized" }, + projected: { recognitionState: "unresolved" }, + }); + await expect( + client.confirmChangeImpactPreview(rejectedPreview.previewId, ""), + ).rejects.toMatchObject({ kind: "invalid-request" }); + await expect( + client.rejectChangeImpactPreview(rejectedPreview.previewId, "operator"), + ).resolves.toMatchObject({ status: "rejected" }); + await expect( + client.inspectSourceAssociationHistory(proposed.content.id), + ).resolves.toMatchObject({ recognitionState: "recognized" }); + + const confirmedPreview = await client.prepareRecognitionRetraction({ + assetId: proposed.content.id, + predecessorDecisionId: beforeRetraction.recognitionDecisionId!, + proposedBy: "operator", + reason: "材料不足,撤回当前识别", + }); + await expect( + client.confirmChangeImpactPreview(confirmedPreview.previewId, "operator"), + ).resolves.toMatchObject({ + status: "applied", + current: { recognitionState: "recognized" }, + projected: { recognitionState: "unresolved" }, + }); + await expect( + client.inspectSourceAssociationHistory(proposed.content.id), + ).resolves.toMatchObject({ recognitionState: "unresolved" }); + await expect( + client.prepareRecognitionRetraction({ + assetId: proposed.content.id, + predecessorDecisionId: beforeRetraction.recognitionDecisionId!, + proposedBy: "operator", + reason: "点名已撤回的识别", + }), + ).rejects.toMatchObject({ kind: "conflict", status: 409 }); +}, 60_000); + +it("当前未识别材料排除定向识别已认领路径并保留历史与未认领分页", async () => { + const directoryName = + "[怒火救援].Man.on.Fire.2004.Blu-ray.1080p.AVC.DTS-HD.MA5.1-CMCT"; + const relativeFilmPaths = [ + `${directoryName}/BDMV/index.bdmv`, + `${directoryName}/BDMV/PLAYLIST/00001.mpls`, + `${directoryName}/BDMV/STREAM/00001.m2ts`, + `${directoryName}/BDMV/STREAM/00002.m2ts`, + ]; + const controlled = await loadControlledSourceIo({ + manifestPath: nbinetManifestPath, + }); + const sourceIo: NonNullable = { + mode: "controlled", + request: controlled.request, + sessionTransport: controlled.sessionTransport ?? refusingSessionTransport(), + }; + const baseUrl = await startService(sourceIo); + const client = createPublicApiClient({ baseUrl }); + const materialDirectory = path.join(active.at(-1)!.root, "电影资料库目录"); + await mkdir(materialDirectory, { recursive: true }); + await cp( + path.join(materialRoot, "films", directoryName), + path.join(materialDirectory, directoryName), + { recursive: true }, + ); + await writeFile(path.join(materialDirectory, "未认领甲.txt"), "普通说明甲\n"); + await writeFile(path.join(materialDirectory, "未认领乙.txt"), "普通说明乙\n"); + + const library = await client.saveAssetLibrary({ + displayName: "当前未识别过滤库", + directories: [materialDirectory], + recognitionProfileIds: ["movies"], + sourceIdentities: ["tmdb"], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 30_000 }, + ) + .toBe("completed"); + const scanInspection = await client.inspectAssetLibraryScan( + scan.parentExecutionId, + ); + const registrationId = scanInspection.directories[0]?.id; + expect(registrationId).toBeTypeOf("string"); + const initial = await client.listUnrecognizedMaterials({ + assetLibraryId: library.id, + limit: 100, + }); + const initialPaths = initial.items.flatMap( + ({ relativePaths }) => relativePaths, + ); + expect(initialPaths).toEqual( + expect.arrayContaining([ + ...relativeFilmPaths, + "未认领甲.txt", + "未认领乙.txt", + ]), + ); + + await expect( + client.configureSourceConnection({ + sourceId: "tmdb", + expectedVersion: 0, + secret: "controlled-unrecognized-tmdb-token", + }), + ).resolves.toMatchObject({ verification: "verified" }); + const directed = await client.startDirectedRecognition({ + registrationId: registrationId!, + relativeScope: directoryName, + sourceIdentity: "tmdb", + sourceInput: { movie: "9509" }, + }); + await expect + .poll(async () => (await client.inspectExecution(directed.id)).status, { + timeout: 60_000, + }) + .toBe("completed"); + + const assets = await client.listAssets({ + assetLibraryId: library.id, + limit: 20, + }); + const recognizedFilm = assets.items.find((asset) => + asset.locations.some(({ address }) => + address.endsWith(relativeFilmPaths[0]!), + ), + ); + expect(recognizedFilm).toBeDefined(); + expect( + recognizedFilm!.locations + .map(({ address }) => address) + .filter((address) => + relativeFilmPaths.some((relativePath) => + address.endsWith(relativePath), + ), + ), + ).toHaveLength(relativeFilmPaths.length); + + const historical = await client.listUnrecognizedMaterials({ + assetLibraryId: library.id, + parentExecutionId: scan.parentExecutionId, + limit: 100, + }); + expect( + historical.items.flatMap(({ relativePaths }) => relativePaths), + ).toEqual(expect.arrayContaining(relativeFilmPaths)); + + const currentPaths: string[] = []; + let cursor: string | undefined; + do { + const page = await client.listUnrecognizedMaterials({ + assetLibraryId: library.id, + limit: 1, + ...(cursor === undefined ? {} : { cursor }), + }); + expect(page.items).toHaveLength(1); + currentPaths.push( + ...page.items.flatMap(({ relativePaths }) => relativePaths), + ); + cursor = page.nextCursor; + } while (cursor !== undefined); + expect(currentPaths.toSorted()).toEqual(["未认领乙.txt", "未认领甲.txt"]); +}, 90_000); + +it("通过公开 HTTP 选择 KADOKAWA 日本多候选之一并确认其来源依据", async () => { + const controlled = createKadokawaControlledSourceIo(); + const baseUrl = await startService(controlled.sourceIo); + const client = createPublicApiClient({ baseUrl }); + const accepted = await createAcceptedBook(client, "选择来源候选"); + controlled.setMode("multiple"); + + const started = await client.startSourceAssociationDecision({ + intent: "establish", + assetId: accepted.assetId, + sourceIdentity: "kadokawa-jp", + sourceInput: { isbn: kadokawaIsbn }, + initiatedBy: "operator", + }); + await expect + .poll( + async () => + (await client.inspectSourceAssociationDecision(started.executionId)) + .state, + { timeout: 30_000 }, + ) + .toBe("awaiting-selection"); + const decision = await client.inspectSourceAssociationDecision( + started.executionId, + ); + expect(decision.candidates).toHaveLength(2); + expect(decision.candidates.map(({ nativeAddress }) => nativeAddress)).toEqual( + [ + "https://www.kadokawa.co.jp/product/321912000002/", + "https://www.kadokawa.co.jp/product/321912000003/", + ], + ); + const selectedCandidate = decision.candidates.find( + ({ nativeAddress }) => + nativeAddress === "https://www.kadokawa.co.jp/product/321912000003/", + ); + expect(selectedCandidate?.reference).toBeTypeOf("string"); + if (selectedCandidate === undefined) + throw new Error("没有找到第二册来源候选"); + + const selected = await client.selectSourceAssociationCandidate( + started.executionId, + selectedCandidate.reference, + ); + expect(selected).toMatchObject({ + state: "awaiting-confirmation", + preview: { status: "awaiting-confirmation" }, + }); + const previewId = selected.preview?.previewId; + expect(previewId).toBeTypeOf("string"); + if (previewId === undefined) throw new Error("候选选择没有生成变更预演"); + await expect( + client.inspectChangeImpactPreview(previewId), + ).resolves.toMatchObject({ + status: "awaiting-confirmation", + projected: { + recognitionState: "recognized", + sourceAssociations: [ + { + sourceIdentity: "kadokawa-jp", + nativeAddress: "https://www.kadokawa.co.jp/product/321912000003/", + }, + ], + }, + }); + await expect( + client.confirmChangeImpactPreview(previewId, "operator"), + ).resolves.toMatchObject({ status: "applied" }); + + await expect( + client.inspectSourceAssociationHistory(accepted.assetId), + ).resolves.toMatchObject({ + current: [ + expect.objectContaining({ + sourceIdentity: "kadokawa-jp", + nativeAddress: "https://www.kadokawa.co.jp/product/321912000003/", + }), + ], + }); + const currentValues = await client.listAssetEvidence(accepted.assetId, { + kind: "current-values", + limit: 100, + }); + expect(currentValues.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "current-value", + evidence: expect.objectContaining({ + property: "book.isbn-13", + result: expect.objectContaining({ + status: "resolved", + values: expect.arrayContaining([ + expect.objectContaining({ + value: kadokawaIsbn, + sourceIdentity: "kadokawa-jp", + sourceRecordIds: [expect.any(String)], + }), + ]), + }), + }), + }), + expect.objectContaining({ + kind: "current-value", + evidence: expect.objectContaining({ + property: "asset.title", + result: expect.objectContaining({ + status: "resolved", + notSelected: expect.arrayContaining([ + expect.objectContaining({ + value: "第二册", + sourceIdentity: "kadokawa-jp", + }), + ]), + }), + }), + }), + ]), + ); + expect(controlled.requests).toEqual([ + "POST https://www.kadokawa.co.jp/product/search/", + "GET https://www.kadokawa.co.jp/product/321912000002/", + "GET https://www.kadokawa.co.jp/product/321912000003/", + ]); +}); + +it("来源补充成功及失败后重试成功都会更新当前来源结果和依据", async () => { + const controlled = await createNbinetControlledSourceIo(); + const baseUrl = await startService(controlled.sourceIo); + const client = createPublicApiClient({ baseUrl }); + + const successAsset = await createAcceptedBook( + client, + "来源补充成功", + "nbinet", + ); + const successful = await client.startSourceEnrichment({ + subjectId: successAsset.assetId, + assetLibraryId: successAsset.assetLibraryId, + sourceIdentity: "nbinet", + sourceInput: { isbn: nbinetIsbn }, + }); + await expect + .poll( + async () => (await client.inspectSourceEnrichment(successful.id)).status, + { timeout: 30_000 }, + ) + .not.toBe("running"); + const successfulInspection = await client.inspectSourceEnrichment( + successful.id, + ); + expect(successfulInspection).toMatchObject({ + status: "completed", + outcome: "accepted", + items: [ + expect.objectContaining({ + sourceIdentity: "nbinet", + input: { isbn: nbinetIsbn }, + status: "completed", + canRetry: false, + }), + ], + sourceAssociations: [ + expect.objectContaining({ + sourceIdentity: "nbinet", + nativeAddress: expect.stringContaining( + "q=isbn%2Cexact%2C9789864738281", + ), + }), + ], + }); + const successEvidence = await client.listAssetEvidence(successAsset.assetId, { + kind: "current-values", + limit: 100, + }); + expect(successEvidence.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "current-value", + evidence: expect.objectContaining({ + property: "book.isbn-13", + result: expect.objectContaining({ + status: "resolved", + values: expect.arrayContaining([ + expect.objectContaining({ + value: nbinetIsbn, + sourceIdentity: "nbinet", + sourceRecordIds: [expect.any(String)], + }), + ]), + }), + }), + }), + ]), + ); + + const retryAsset = await createAcceptedBook( + client, + "失败后重试成功", + "nbinet", + ); + const retryIsbn = "9789864738298"; + controlled.setMode("unavailable"); + const failed = await client.startSourceEnrichment({ + subjectId: retryAsset.assetId, + assetLibraryId: retryAsset.assetLibraryId, + sourceIdentity: "nbinet", + sourceInput: { isbn: retryIsbn }, + }); + await expect + .poll( + async () => (await client.inspectSourceEnrichment(failed.id)).status, + { timeout: 30_000 }, + ) + .toBe("failed"); + const failedEvidence = await client.listAssetEvidence(retryAsset.assetId, { + kind: "current-values", + limit: 100, + }); + expect(failedEvidence.items).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "current-value", + evidence: expect.objectContaining({ property: "book.isbn-13" }), + }), + ]), + ); + await expect( + client.listRetryableSourceEnrichments(retryAsset.assetId), + ).resolves.toContainEqual( + expect.objectContaining({ + executionId: failed.id, + subjectId: retryAsset.assetId, + sourceIdentity: "nbinet", + canRetry: true, + }), + ); + + controlled.setMode("success"); + const retried = await client.retrySourceEnrichment({ + executionId: failed.id, + subjectId: retryAsset.assetId, + sourceIdentity: "nbinet", + }); + expect(retried.id).not.toBe(failed.id); + await expect + .poll( + async () => (await client.inspectSourceEnrichment(retried.id)).status, + { timeout: 30_000 }, + ) + .not.toBe("running"); + const retriedInspection = await client.inspectSourceEnrichment(retried.id); + expect(retriedInspection).toMatchObject({ + status: "completed", + outcome: "accepted", + executionId: retried.id, + items: [ + expect.objectContaining({ + sourceIdentity: "nbinet", + input: { isbn: retryIsbn }, + status: "completed", + canRetry: false, + }), + ], + sourceAssociations: [ + expect.objectContaining({ + sourceIdentity: "nbinet", + nativeAddress: expect.stringContaining( + "q=isbn%2Cexact%2C9789864738298", + ), + }), + ], + }); + const retryEvidence = await client.listAssetEvidence(retryAsset.assetId, { + kind: "current-values", + limit: 100, + }); + expect(retryEvidence.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: "current-value", + evidence: expect.objectContaining({ + property: "book.isbn-13", + result: expect.objectContaining({ + status: "resolved", + values: expect.arrayContaining([ + expect.objectContaining({ + value: retryIsbn, + sourceIdentity: "nbinet", + sourceRecordIds: [expect.any(String)], + }), + ]), + }), + }), + }), + ]), + ); + await expect( + client.inspectSourceAssociationHistory(retryAsset.assetId), + ).resolves.toMatchObject({ + current: [ + expect.objectContaining({ + sourceIdentity: "nbinet", + nativeAddress: expect.stringContaining( + "q=isbn%2Cexact%2C9789864738298", + ), + }), + ], + }); +}, 60_000); + +it("来源补充在真实 HTTP 宿主重启后结为中断,并以新执行编号成功重试", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc10-enrichment-recovery-")); + const productDataDirectory = path.join(root, "product-data"); + await prepareProductionCatalogue({ productDataDirectory }); + const gated = await createGatedNbinetControlledSourceIo(); + const baseUrl = await startServiceAt(root, gated.sourceIo); + const client = createPublicApiClient({ baseUrl }); + + const accepted = await createAcceptedBook(client, "中断后补充来源", "nbinet"); + const pendingResponse = client.startSourceEnrichment({ + subjectId: accepted.assetId, + assetLibraryId: accepted.assetLibraryId, + sourceIdentity: "nbinet", + sourceInput: { isbn: nbinetIsbn }, + }); + await gated.entered; + const returnedBeforeSource = await settlesWithin(pendingResponse, 100); + if (!returnedBeforeSource) gated.release(); + expect(returnedBeforeSource).toBe(true); + const started = await pendingResponse; + expect(started.id).toBeTypeOf("string"); + await expect( + client.inspectSourceEnrichment(started.id), + ).resolves.toMatchObject({ + executionId: started.id, + status: "running", + items: [ + expect.objectContaining({ + input: { isbn: nbinetIsbn }, + status: "running", + }), + ], + sourceAssociations: [], + }); + + const previousService = active.at(-1); + if (previousService === undefined) + throw new Error("来源补充服务没有登记清理现场"); + const closing = previousService.closeService(); + gated.release(); + await closing; + await previousService.closeServer(); + active.splice(active.indexOf(previousService), 1); + + const restartedUrl = await startServiceAt(root, gated.sourceIo); + const restarted = createPublicApiClient({ baseUrl: restartedUrl }); + await expect + .poll( + async () => (await restarted.inspectSourceEnrichment(started.id)).status, + { timeout: 30_000 }, + ) + .toBe("failed"); + const interrupted = await restarted.inspectSourceEnrichment(started.id); + expect(interrupted).toMatchObject({ + executionId: started.id, + status: "failed", + outcome: "interrupted", + items: [ + expect.objectContaining({ + input: { isbn: nbinetIsbn }, + }), + ], + sourceAssociations: [], + }); + + const retried = await restarted.startSourceEnrichment({ + subjectId: accepted.assetId, + assetLibraryId: accepted.assetLibraryId, + sourceIdentity: "nbinet", + sourceInput: { isbn: nbinetIsbn }, + }); + expect(retried.id).not.toBe(started.id); + await expect + .poll( + async () => (await restarted.inspectSourceEnrichment(retried.id)).status, + { timeout: 30_000 }, + ) + .toBe("completed"); + await expect( + restarted.inspectSourceEnrichment(retried.id), + ).resolves.toMatchObject({ + executionId: retried.id, + outcome: "accepted", + sourceAssociations: [expect.objectContaining({ sourceIdentity: "nbinet" })], + }); + await expect( + restarted.inspectSourceAssociationHistory(accepted.assetId), + ).resolves.toMatchObject({ + current: [expect.objectContaining({ sourceIdentity: "nbinet" })], + }); +}, 90_000); + +it("指定来源识别在真实 HTTP 宿主重启后结为中断,且重试以新编号认领材料", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ewc10-directed-recovery-")); + const productDataDirectory = path.join(root, "product-data"); + await prepareProductionCatalogue({ productDataDirectory }); + const gated = await createGatedNbinetControlledSourceIo(); + const baseUrl = await startServiceAt(root, gated.sourceIo); + const client = createPublicApiClient({ baseUrl }); + const materialDirectory = path.join(root, "待识别材料"); + await mkdir(materialDirectory, { recursive: true }); + await writeFile( + path.join(materialDirectory, "待识别.pdf"), + syntheticPdf({ author: "Local Author", pageCount: 1 }), + ); + const library = await client.saveAssetLibrary({ + displayName: "指定来源识别恢复库", + directories: [materialDirectory], + recognitionProfileIds: ["books"], + sourceIdentities: ["nbinet"], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 30_000 }, + ) + .toBe("completed"); + const scanInspection = await client.inspectAssetLibraryScan( + scan.parentExecutionId, + ); + const registrationId = scanInspection.directories[0]?.id; + expect(registrationId).toBeTypeOf("string"); + if (registrationId === undefined) + throw new Error("正式扫描没有登记指定来源识别目录"); + + const pendingResponse = client.startDirectedRecognition({ + registrationId, + relativeScope: ".", + sourceIdentity: "nbinet", + sourceInput: { isbn: nbinetIsbn }, + }); + await gated.entered; + const returnedBeforeSource = await settlesWithin(pendingResponse, 100); + if (!returnedBeforeSource) gated.release(); + expect(returnedBeforeSource).toBe(true); + const started = await pendingResponse; + expect(started.id).toBeTypeOf("string"); + + const previousService = active.at(-1); + if (previousService === undefined) + throw new Error("指定来源识别服务没有登记清理现场"); + const closing = previousService.closeService(); + gated.release(); + await closing; + await previousService.closeServer(); + active.splice(active.indexOf(previousService), 1); + + const restartedUrl = await startServiceAt(root, gated.sourceIo); + const restarted = createPublicApiClient({ baseUrl: restartedUrl }); + await expect + .poll(async () => (await restarted.inspectExecution(started.id)).status, { + timeout: 30_000, + }) + .toBe("failed"); + const interrupted = await restarted.inspectExecution(started.id); + expect(interrupted).toMatchObject({ + id: started.id, + kind: "execution", + status: "failed", + outcome: "interrupted", + progress: { + workItems: [ + expect.objectContaining({ + relativePaths: ["待识别.pdf"], + }), + ], + }, + }); + if (interrupted.kind !== "execution") + throw new Error("中断的指定来源识别返回了资料库扫描"); + expect(interrupted.progress.workItems[0]?.acceptedAssetId).toBeUndefined(); + const assetsBeforeRetry = await restarted.listAssets({ + assetLibraryId: library.id, + limit: 20, + }); + expect( + assetsBeforeRetry.items.flatMap( + ({ sourceAssociations }) => sourceAssociations, + ), + ).toEqual([]); + + const retried = await restarted.startDirectedRecognition({ + registrationId, + relativeScope: ".", + sourceIdentity: "nbinet", + sourceInput: { isbn: nbinetIsbn }, + }); + expect(retried.id).not.toBe(started.id); + await expect + .poll(async () => (await restarted.inspectExecution(retried.id)).status, { + timeout: 30_000, + }) + .toBe("completed"); + const retriedInspection = await restarted.inspectExecution(retried.id); + expect(retriedInspection).toMatchObject({ + kind: "execution", + id: retried.id, + status: "completed", + outcome: "proposal", + }); + const history = await restarted.listWorkflowHistory({ limit: 100 }); + const retriedRecord = history.items.find( + ({ execution }) => execution.id === retried.id, + ); + expect(retriedRecord?.resultReferences.changeImpactPreviewIds).toHaveLength( + 1, + ); + const previewId = retriedRecord?.resultReferences.changeImpactPreviewIds[0]; + expect(previewId).toBeTypeOf("string"); + if (previewId === undefined) throw new Error("指定来源识别没有生成确认预演"); + await expect( + restarted.inspectChangeImpactPreview(previewId), + ).resolves.toMatchObject({ + status: "awaiting-confirmation", + projected: { + recognitionState: "recognized", + sourceAssociations: [ + expect.objectContaining({ sourceIdentity: "nbinet" }), + ], + }, + }); + await expect( + restarted.confirmChangeImpactPreview(previewId, "operator"), + ).resolves.toMatchObject({ status: "applied" }); + const assets = await restarted.listAssets({ + assetLibraryId: library.id, + limit: 20, + }); + expect(assets.items).toEqual([ + expect.objectContaining({ + locations: [ + expect.objectContaining({ + address: expect.stringContaining("待识别.pdf"), + }), + ], + sourceAssociations: [ + expect.objectContaining({ sourceIdentity: "nbinet" }), + ], + }), + ]); +}, 90_000); + +it("来源补充失败后公开可重试候选,并以新执行编号保留再次获取结果", async () => { + const baseUrl = await startService(); + const client = createPublicApiClient({ baseUrl }); + const materialDirectory = path.join(active.at(-1)!.root, "待补充来源"); + await mkdir(materialDirectory, { recursive: true }); + await writeFile(path.join(materialDirectory, "一本书.txt"), "正文内容"); + const library = await client.saveAssetLibrary({ + displayName: "来源补充库", + directories: [materialDirectory], + recognitionProfileIds: ["books"], + sourceIdentities: ["nbinet"], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 30_000 }, + ) + .toBe("completed"); + const intake = await client.inspectLocalIntake({ + assetLibraryId: library.id, + directoryId: library.directories[0]!.id, + parentExecutionId: scan.parentExecutionId, + }); + const accepted = await client.confirmLocalContent( + ( + await client.prepareLocalContent({ + executionId: intake.execution!.id, + relativePaths: ["一本书.txt"], + classification: "book", + name: "待补充来源的书", + }) + ).executionId, + ); + + await expect( + client.startSourceEnrichment({ + subjectId: accepted.content.id, + sourceIdentity: "unknown-source", + sourceInput: { isbn: "9789864738281" }, + }), + ).rejects.toMatchObject({ kind: "invalid-request", status: 400 }); + const started = await client.startSourceEnrichment({ + subjectId: accepted.content.id, + assetLibraryId: library.id, + sourceIdentity: "nbinet", + sourceInput: { isbn: "9789864738281" }, + }); + await expect + .poll( + async () => (await client.inspectSourceEnrichment(started.id)).status, + { timeout: 30_000 }, + ) + .toBe("failed"); + const failed = await client.inspectSourceEnrichment(started.id); + expect(failed).toMatchObject({ + executionId: started.id, + status: "failed", + items: [ + expect.objectContaining({ + sourceIdentity: "nbinet", + input: { isbn: "9789864738281" }, + canRetry: true, + }), + ], + sourceAssociations: [], + }); + const retries = await client.listRetryableSourceEnrichments( + accepted.content.id, + ); + expect(retries).toContainEqual( + expect.objectContaining({ + executionId: started.id, + subjectId: accepted.content.id, + sourceIdentity: "nbinet", + canRetry: true, + }), + ); + + const retried = await client.retrySourceEnrichment({ + executionId: started.id, + subjectId: accepted.content.id, + sourceIdentity: "nbinet", + }); + expect(retried.id).not.toBe(started.id); + await expect + .poll( + async () => (await client.inspectSourceEnrichment(retried.id)).status, + { timeout: 30_000 }, + ) + .toBe("failed"); + await expect( + client.inspectSourceEnrichment("missing-execution"), + ).rejects.toMatchObject({ kind: "not-found", status: 404 }); +}); + +it("通过公开 HTTP 预演并确认完整识别范围调整", async () => { + const baseUrl = await startService(); + const client = createPublicApiClient({ baseUrl }); + const directory = path.join(active.at(-1)!.root, "范围调整资料"); + await mkdir(directory, { recursive: true }); + const primary = path.join(directory, "publication.epub"); + await cp( + path.join(materialRoot, "books", "架构整洁之道", "publication.epub"), + primary, + ); + const library = await client.saveAssetLibrary({ + displayName: "范围调整资料库", + directories: [directory], + recognitionProfileIds: ["books"], + sourceIdentities: [], + }); + const initialScan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(initialScan.parentExecutionId)) + .status, + { timeout: 30_000 }, + ) + .toBe("completed"); + const initialInspection = await client.inspectAssetLibraryScan( + initialScan.parentExecutionId, + ); + const assetId = initialInspection.directories + .flatMap((directory) => directory.files) + .find((file) => file.assetId !== undefined)?.assetId; + if (assetId === undefined) throw new Error("首次范围观察没有接受 EPUB 内容"); + + await link(primary, path.join(directory, "publication-alias.epub")); + const expandedScan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(expandedScan.parentExecutionId)) + .status, + { timeout: 30_000 }, + ) + .toBe("completed"); + const rangeMaterial = await client.listUnrecognizedMaterials({ + assetLibraryId: library.id, + parentExecutionId: expandedScan.parentExecutionId, + limit: 100, + }); + const rangeItem = rangeMaterial.items.find( + (item) => + item.kind === "work-item" && + item.relativePaths.includes("publication-alias.epub"), + ); + if (rangeItem === undefined || rangeItem.kind !== "work-item") + throw new Error("新增别名没有留下待明确的完整范围工作项"); + + await expect( + client.prepareRecognitionScopeAmendment({ + sourceExecutionId: rangeItem.childExecutionId, + workItemKey: "missing-work-item", + proposedBy: "operator", + reason: "点名已变化的工作项", + }), + ).rejects.toMatchObject({ kind: "conflict", status: 409 }); + await expect( + client.prepareRecognitionScopeAmendment({ + sourceExecutionId: rangeItem.childExecutionId, + workItemKey: rangeItem.workItemKey, + proposedBy: " ", + reason: "无效提议者", + }), + ).rejects.toMatchObject({ kind: "invalid-request", status: 400 }); + const preview = await client.prepareRecognitionScopeAmendment({ + sourceExecutionId: rangeItem.childExecutionId, + workItemKey: rangeItem.workItemKey, + proposedBy: "operator", + reason: "明确新增别名与原出版物属于同一材料范围", + }); + expect(preview).toMatchObject({ + assetId, + status: "awaiting-confirmation", + current: { recognitionDecisionId: expect.any(String) }, + }); + await expect( + client.confirmChangeImpactPreview(preview.previewId, "operator"), + ).resolves.toMatchObject({ status: "applied" }); + await expect( + client.inspectChangeImpactPreview(preview.previewId), + ).resolves.toMatchObject({ + status: "applied", + projected: { + recognitionDecisionId: expect.any(String), + }, + }); +}, 60_000); + +async function startService( + configuredSourceIo?: ProductConfiguration["sourceIo"], +): Promise { + const root = await mkdtemp(path.join(tmpdir(), "ewc10-http-")); + const productDataDirectory = path.join(root, "product-data"); + await prepareProductionCatalogue({ productDataDirectory }); + return await startServiceAt(root, configuredSourceIo); +} + +async function startServiceAt( + root: string, + configuredSourceIo?: ProductConfiguration["sourceIo"], +): Promise { + const productDataDirectory = path.join(root, "product-data"); + const sourceIo = + configuredSourceIo ?? + (await resolveSourceIoFromEnvironment({ + DIGITAL_SOURCE_IO_MODE: "offline", + })); + const bootstrap = await createProductService({ + productDataDirectory, + environment: { DIGITAL_PRODUCT_DATA: productDataDirectory }, + sourceIo, + }); + const server = serve({ + fetch: bootstrap.app.fetch, + port: 0, + hostname: "127.0.0.1", + silent: true, + gracefulShutdown: false, + }); + await server.ready(); + bootstrap.publish(); + const baseUrl = new URL(server.url!); + const service = { + root, + closeServer: async () => await server.close(true), + closeService: async () => await bootstrap.abort(), + }; + active.push(service); + await waitForServiceReady(baseUrl); + return baseUrl; +} + +async function waitForServiceReady(address: URL): Promise { + const deadline = Date.now() + 60_000; + let lastResponse = "尚未收到就绪响应"; + while (Date.now() < deadline) { + try { + const response = await fetch(new URL("/api/ready", address)); + const body: unknown = await response.json().catch(() => undefined); + if ( + response.ok && + typeof body === "object" && + body !== null && + "ok" in body && + body.ok === true + ) + return; + lastResponse = `HTTP ${String(response.status)} ${JSON.stringify(body)}`; + } catch (error) { + lastResponse = String(error); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`正式服务在 60000 ms 内未就绪:${lastResponse}`); +} + +const nbinetIsbn = "9789864738281"; +const kadokawaIsbn = "9784049131291"; +const kadokawaItemCodes = ["321912000002", "321912000003"] as const; + +function createKadokawaControlledSourceIo() { + let mode: "multiple" | "single" | "unavailable" = "single"; + const requests: string[] = []; + const sourceIo: NonNullable = { + mode: "controlled", + request: async (input, init) => { + const method = init?.method ?? "GET"; + requests.push(`${method} ${input}`); + if (input === "https://www.kadokawa.co.jp/product/search/") { + if (method !== "POST") throw new Error("日角搜索请求必须使用 POST"); + if (init?.body !== `isbn=${kadokawaIsbn}`) + throw new Error("日角搜索请求没有提交指定 ISBN"); + if (mode === "unavailable") + return kadokawaResponse("{}", input, "application/json", 503); + const itemCodes = + mode === "multiple" ? kadokawaItemCodes : [kadokawaItemCodes[0]]; + return kadokawaResponse( + JSON.stringify({ + count_raw: { book: itemCodes.length }, + result: { + book: itemCodes.map((itemCode) => ({ + isbn: kadokawaIsbn, + itemCode, + })), + }, + }), + input, + "application/json", + ); + } + const itemCode = kadokawaItemCodes.find( + (candidate) => + input === `https://www.kadokawa.co.jp/product/${candidate}/`, + ); + if (method !== "GET" || itemCode === undefined) + throw new Error(`没有受控的日角请求:${method} ${input}`); + return kadokawaResponse( + ``, + input, + "text/html", + ); + }, + sessionTransport: refusingSessionTransport(), + }; + return { + sourceIo, + requests, + setMode(next: "multiple" | "single" | "unavailable") { + mode = next; + }, + }; +} + +function kadokawaResponse( + body: string, + url: string, + mediaType: string, + status = 200, +) { + const bytes = new TextEncoder().encode(body); + return { + ok: status >= 200 && status < 300, + status, + url, + headers: { + get(name: string) { + return name.toLowerCase() === "content-type" ? mediaType : null; + }, + }, + arrayBuffer: async () => Uint8Array.from(bytes).buffer, + }; +} + +async function createAcceptedBook( + client: ReturnType, + name: string, + sourceIdentity = "kadokawa-jp", +) { + const directory = path.join(active.at(-1)!.root, name); + await mkdir(directory, { recursive: true }); + const fileName = `${name}.txt`; + await writeFile(path.join(directory, fileName), "本地材料正文"); + const library = await client.saveAssetLibrary({ + displayName: `${name}资料库`, + directories: [directory], + recognitionProfileIds: ["books"], + sourceIdentities: [sourceIdentity], + }); + const scan = await client.startAssetLibraryScan(library.id); + await expect + .poll( + async () => + (await client.inspectAssetLibraryScan(scan.parentExecutionId)).status, + { timeout: 30_000 }, + ) + .toBe("completed"); + const intake = await client.inspectLocalIntake({ + assetLibraryId: library.id, + directoryId: library.directories[0]!.id, + parentExecutionId: scan.parentExecutionId, + }); + const proposal = await client.prepareLocalContent({ + executionId: intake.execution!.id, + relativePaths: [fileName], + classification: "book", + name, + }); + const accepted = await client.confirmLocalContent(proposal.executionId); + return { assetId: accepted.content.id, assetLibraryId: library.id }; +} + +async function createNbinetControlledSourceIo() { + const fixture = await loadControlledSourceIo({ + manifestPath: nbinetManifestPath, + }); + let mode: "success" | "unavailable" = "success"; + const sourceIo: NonNullable = { + mode: "controlled", + request: async (input, init) => + mode === "success" + ? await fixture.request(input, init) + : kadokawaResponse("{}", input, "application/json", 503), + sessionTransport: fixture.sessionTransport ?? refusingSessionTransport(), + }; + return { + sourceIo, + setMode(next: "success" | "unavailable") { + mode = next; + }, + }; +} + +async function createGatedNbinetControlledSourceIo() { + const fixture = await loadControlledSourceIo({ + manifestPath: nbinetManifestPath, + }); + let markEntered!: () => void; + let release!: () => void; + const entered = new Promise((resolve) => { + markEntered = resolve; + }); + const released = new Promise((resolve) => { + release = resolve; + }); + let shouldBlock = true; + const sourceIo: NonNullable = { + mode: "controlled", + async request(input, init) { + if (shouldBlock) { + shouldBlock = false; + markEntered(); + await released; + } + return await fixture.request(input, init); + }, + sessionTransport: fixture.sessionTransport ?? refusingSessionTransport(), + }; + return { + sourceIo, + entered, + release, + }; +} + +async function settlesWithin( + promise: Promise, + milliseconds: number, +): Promise { + return await Promise.race([ + promise.then( + () => true, + () => true, + ), + new Promise((resolve) => setTimeout(resolve, milliseconds, false)), + ]); +} + +function syntheticPdf(input: { + readonly author: string; + readonly pageCount: number; +}): Uint8Array { + const pageIds = Array.from( + { length: input.pageCount }, + (_, index) => index + 3, + ); + const infoId = input.pageCount + 3; + const objects = [ + "<< /Type /Catalog /Pages 2 0 R >>", + `<< /Type /Pages /Kids [${pageIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${input.pageCount} >>`, + ...Array.from( + { length: input.pageCount }, + () => "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] >>", + ), + `<< /Author (${input.author}) >>`, + ]; + let source = "%PDF-1.7\n"; + const offsets = [0]; + for (const [index, object] of objects.entries()) { + offsets.push(new TextEncoder().encode(source).byteLength); + source += `${index + 1} 0 obj\n${object}\nendobj\n`; + } + const xref = new TextEncoder().encode(source).byteLength; + source += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + source += offsets + .slice(1) + .map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`) + .join(""); + source += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R /Info ${infoId} 0 R >>\nstartxref\n${xref}\n%%EOF\n`; + return new TextEncoder().encode(source); +} diff --git a/apps/cli/tsconfig.build.json b/apps/service/tsconfig.build.json similarity index 100% rename from apps/cli/tsconfig.build.json rename to apps/service/tsconfig.build.json diff --git a/apps/service/tsconfig.json b/apps/service/tsconfig.json new file mode 100644 index 0000000..14da01d --- /dev/null +++ b/apps/service/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@digital/typescript-config/base.json", + "compilerOptions": { + "customConditions": ["digital-source"], + "erasableSyntaxOnly": true, + "exactOptionalPropertyTypes": false, + "module": "esnext", + "moduleResolution": "bundler", + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "target": "es2023", + "types": ["node"] + }, + "include": ["scripts/**/*.ts", "src/**/*.ts", "test/**/*.ts"] +} diff --git a/apps/service/turbo.json b/apps/service/turbo.json new file mode 100644 index 0000000..135c96e --- /dev/null +++ b/apps/service/turbo.json @@ -0,0 +1,113 @@ +{ + "extends": ["//"], + "tags": ["composition-root"], + "tasks": { + "build": { + "dependsOn": [ + "@digital/composition#build", + "@digital/http-delivery#build", + "@digital/product-maintenance#build", + "@digital/service-host#build", + "@digital/test-support#build" + ], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "deployment": { + "cache": false, + "passThroughEnv": [ + "DIGITAL_DEPLOYMENT_*", + "DIGITAL_COMPOSE_CHECK_*", + "TMPDIR", + "TMP", + "TEMP", + "DIGITAL_CI_EVIDENCE", + "DOCKER_HOST", + "DOCKER_CONTEXT" + ], + "tags": ["quality:heavy"], + "dependsOn": ["deployment:prepare"] + }, + "deployment:prepare": { + "cache": false, + "passThroughEnv": [ + "DIGITAL_DEPLOYMENT_*", + "DIGITAL_COMPOSE_CHECK_*", + "TMPDIR", + "TMP", + "TEMP", + "DIGITAL_CI_EVIDENCE", + "DOCKER_HOST", + "DOCKER_CONTEXT" + ], + "tags": ["work:preparation"] + }, + "dev:serve": { + "dependsOn": [ + "@digital/composition#dev:build", + "@digital/http-delivery#dev:build", + "@digital/service-host#dev:build", + "@digital/test-support#dev:build", + "@digital/tui#build" + ], + "cache": false, + "inputs": [ + "package.json", + "src/**", + "$TURBO_ROOT$/packages/workspace-automation/scripts/development-task.ts" + ], + "passThroughEnv": [ + "DIGITAL_DEVELOPMENT_SESSION", + "DIGITAL_DEVELOPMENT_WORKSPACE", + "DIGITAL_DEVELOPMENT_ENVIRONMENT" + ], + "tags": ["work:development"] + }, + "format:check": { "outputs": [], "tags": ["quality:light"] }, + "prepare:verify": { + "dependsOn": ["build"], + "cache": false, + "passThroughEnv": ["DIGITAL_PRODUCT_*", "DIGITAL_SOURCE_IO_*"], + "tags": ["work:on-demand"] + }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { "outputs": [], "tags": ["quality:light"] }, + "lint:fix": { "cache": false, "tags": ["work:repair"] }, + "start": { "cache": false, "tags": ["work:development"] }, + "test": { + "dependsOn": [ + "build", + "@digital/db-migrations#build", + "@digital/environment#build", + "@digital/product-maintenance#build" + ], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP"], + "outputs": [], + "tags": ["quality:heavy"], + "cache": false + }, + "test:installed-clients": { + "dependsOn": [ + "build", + "@digital/fanbox-extension#build", + "@digital/db-migrations#build", + "@digital/environment#build", + "@digital/product-maintenance#build", + "@digital/tui#build" + ], + "passThroughEnv": ["TMPDIR", "TMP", "TEMP", "PLAYWRIGHT_BROWSERS_PATH"], + "outputs": [], + "tags": ["quality:heavy"], + "cache": false + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + } + } +} diff --git a/apps/tui/README.md b/apps/tui/README.md new file mode 100644 index 0000000..acf99ac --- /dev/null +++ b/apps/tui/README.md @@ -0,0 +1,70 @@ +# 数字资料库终端 + +`digital-tui` 是连接独立产品服务的终端客户端。服务要先启动;TUI 不启动或管理服务。`--url` 接受服务地址根路径(不带 `/api/v1`),也可用 `DIGITAL_PRODUCT_URL` 设置,默认是 `http://127.0.0.1:3000`。`--execution <编号>` 可直接打开已有执行记录。 + +`@digital/tui` 的正式命令入口是包中的 `digital-tui` bin,指向 `dist/cli.js`;安装包包含构建后的 `dist`,仓库里的 `src` 不是可直接运行的命令。服务已经运行时,只需沿正式 Turbo 依赖图构建 TUI 及其依赖,再运行构建产物连接服务: + +```bash +pnpm exec turbo run build --filter=@digital/tui... +SERVICE_URL=http://127.0.0.1:3000 # 改为现有服务地址;托管环境可查看 inspect 输出 +node apps/tui/dist/cli.js --url "$SERVICE_URL" +``` + +本机已安装 `digital-tui` 时,也可直接执行 `digital-tui --url "$SERVICE_URL"`。只有准备新的完整受控开发环境时才使用 `pnpm development:controlled`;服务生命周期与 TUI 客户端构建、运行相互独立。 + +## 导航与资料库操作 + +在列表页按 `1`、`2`、`3`、`4`、`5`、`6`、`7` 切换资产库、资产、未识别材料、执行、待处理、历史和来源连接。用方向键或 `j`、`k` 移动,`Enter` 选择资产库,`n`、`p` 翻页,`/` 筛选本页,`r` 重读,`q` 退出。 + +资产库页按 `c` 创建、`e` 修改、`d` 移除、`s` 扫描、`a` 配置自动刷新。创建或修改时输入库名和目录,再选择识别能力及来源;用方向键移动、空格切换、回车保存。目录由产品服务所在机器访问,TUI 不上传客户端文件。输入 `-路径` 可从当前目录列表移除目录;移除资产库需再按 `y` 确认。 + +扫描页显示本次执行编号、各目录结果、已识别材料归属及失败原因;资产和未识别材料分别在 `2`、`3` 页查看。 + +执行页显示执行编号和结果。执行仍在进行时可按 `c` 请求取消,按 `r` 重新查询。连接在执行期间断开时,结局暂时未知;恢复连接后用原执行编号查询,不要重复发起扫描或刷新。退出或重启终端后,可用 `digital-tui --url "$DIGITAL_PRODUCT_URL" --execution <编号>` 回到同一执行查看状态;服务管理日志位置见 [`workspace-automation` 使用说明](../../packages/workspace-automation/README.md)。 + +TUI 用于管理资产库、扫描和查看资料状态、已观察资料及来源记录;它不播放音视频,也不提供打开原文件的阅读器。 + +## 本机与远程连接 + +远程工作区的素材目录仍由服务器访问。若从服务所在机器的终端运行 TUI,将 `--url` 设为 `pnpm environment inspect` 输出的服务地址。浏览器在本机时,可从本机通过 SSH 转发服务器回环端口。例如 OAuth 应用登记为 `http://localhost:4321/api/v1/source-connections/bangumi/callback`,本机 4321 转发到服务器服务监听端口 4321: + +```bash +ssh -N -L 4321:127.0.0.1:4321 account@server +``` + +本例中本机转发端口、服务器 `webPort` 和回调 URI 端口都设为 4321,便于与已登记回调地址配合;一般并不要求三者相等。Bangumi 应用登记的完整回调 URI 须与服务收到的回调目标一致;SSH TCP 转发保留浏览器请求中的 HTTP Host。连接服务器的终端中运行 TUI 时,`--url` 使用服务机器上 `inspect` 显示的地址: + +```bash +SERVICE_PORT=4321 # 改为 inspect 输出的服务器端口 +SERVICE_URL="http://127.0.0.1:${SERVICE_PORT}" +pnpm --filter @digital/service exec digital-tui --url "$SERVICE_URL" +``` + +本机已安装 `digital-tui` 时,可在开启上述 SSH 转发后,从本机终端连接同一个远程服务: + +```bash +digital-tui --url http://127.0.0.1:4321 +``` + +Bangumi 回调路由为 `/api/v1/source-connections/bangumi/callback`。授权时 TUI 会显示实际目标实例和回调目标;浏览器应打开该授权地址,并能通过配置的回调 URI 到达正在使用的服务。 + +## 来源连接 + +按 `7` 打开来源连接,使用方向键选择来源,`Enter` 或 `c` 配置,`r` 重读,`x` 撤销并确认。 + +- TMDB:输入 TMDB API Read Access Token(访问令牌)。OMDb:输入 OMDb API key。TUI 隐藏输入,立即验证后保存;更换已配置连接也会先验证新值。 +- Bangumi:OAuth 应用信息通过现有公共客户端 `PublicApiClient.configureBangumiOAuthClient()` 配置,当前 TUI 不提供填写应用密钥的操作。公共 HTTP API 接受 `PUT /api/v1/source-connections/bangumi/client-configuration`,字段为 `clientId`、`clientSecret` 和 `redirectUri`。将 JSON 请求体保存在仓库外、仅当前用户可读的文件中,再从该文件发送,避免将密钥写入命令历史或进程参数: + + ```bash + SERVICE_URL=http://localhost:4321 + curl --fail --silent --show-error --request PUT \ + "$SERVICE_URL/api/v1/source-connections/bangumi/client-configuration" \ + --header 'content-type: application/json' \ + --data-binary @/path/to/private/bangumi-oauth.json + ``` + + JSON 文件包含应用 `clientId`、`clientSecret` 和登记的 `redirectUri`。不要将密钥写进 `--config-json`、资产库基线或版本控制。用户授权时在来源连接页选择 Bangumi 并按 `Enter`,在自己的浏览器打开终端显示的授权地址,返回终端等待结果。 + +- FANBOX:按 `f` 输入创作者编号并发起限时配对;需在已登录 FANBOX 的浏览器扩展中完成配对。创作者编号不是登录凭据。 + +来源连接密钥与授权保存在服务端的私密状态中,与普通环境配置和初始资产库基线分开。普通停止和启动会保留连接;显式重建会清除授权,之后须在对应来源重新连接。来源连接撤销也不会改变已配置的资产库目录。 diff --git a/packages/cli-delivery/package.json b/apps/tui/package.json similarity index 57% rename from packages/cli-delivery/package.json rename to apps/tui/package.json index 92379e8..f7b34df 100644 --- a/packages/cli-delivery/package.json +++ b/apps/tui/package.json @@ -1,37 +1,32 @@ { - "name": "@digital/cli-delivery", + "name": "@digital/tui", "version": "0.0.0", "private": true, + "bin": { + "digital-tui": "./dist/cli.js" + }, "files": [ "dist" ], "type": "module", - "exports": { - ".": { - "digital-source": "./src/main.ts", - "digital-development": "./.turbo/development/dist/main.js", - "types": "./dist/main.d.ts", - "default": "./dist/main.js" - } - }, "scripts": { - "build": "tsc -p tsconfig.build.json --pretty false", + "build": "tsc -p tsconfig.build.json --pretty false && node -e \"if (process.platform !== 'win32') require('node:fs').chmodSync('dist/cli.js', 0o755)\"", "format:check": "oxfmt --list-different --config ../../oxfmt.config.ts .", "format:write": "oxfmt --write --config ../../oxfmt.config.ts .", "lint": "oxlint --quiet --format=unix --config ../../oxlint.config.ts --ignore-pattern node_modules .", "lint:fix": "oxlint --format=unix --config ../../oxlint.config.ts . --fix", "test": "vitest run --reporter=verbose --passWithNoTests", - "typecheck": "tsc -p tsconfig.json --noEmit --pretty false", - "dev:build": "node --conditions=digital-source ../../scripts/build-development.ts" + "typecheck": "tsc -p tsconfig.json --noEmit --pretty false" }, "dependencies": { - "@digital/application": "workspace:*", - "@digital/product-application": "workspace:*", - "commander": "catalog:" + "@digital/public-api": "workspace:*", + "ink": "6.3.1", + "react": "19.3.1" }, "devDependencies": { "@digital/typescript-config": "workspace:*", "@types/node": "catalog:", + "@types/react": "^19.2.0", "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", @@ -39,6 +34,6 @@ "vitest": "catalog:" }, "engines": { - "node": "24" + "node": ">=22" } } diff --git a/apps/tui/src/asset-inspection.tsx b/apps/tui/src/asset-inspection.tsx new file mode 100644 index 0000000..43da478 --- /dev/null +++ b/apps/tui/src/asset-inspection.tsx @@ -0,0 +1,2091 @@ +import type { + AssetEvidenceKind, + AssetEvidencePage, + AssetInspectionOverview, + AssetSourceRecordChunk, + CurrentAttentionPage, + InspectionPageRequest, + ListAssetEvidenceRequest, + MetadataDecisionPreview, + PublicChangeImpactPreview, + PublicApiClient, + PublicExecutionInspection, + PublicSourceInputRequirement, + SourceAssociationDecisionInspection, + SourceEnrichmentInspection, + WorkflowHistoryPage, +} from "@digital/public-api"; +import { Box, Text, useStdout } from "ink"; +import { useEffect, useState } from "react"; + +import { useCurrentInput } from "./use-current-input.ts"; + +const evidenceLabels: Record = { + "local-observations": "本地观察", + "format-results": "格式读取结果", + "source-records": "来源记录", + "interpreted-facts": "解释事实", + "current-values": "当前采用值及依据", + "metadata-decisions": "人工属性修订记录", +}; + +type InspectionClient = Pick< + PublicApiClient, + | "inspectAssetEvidence" + | "listAssetEvidence" + | "readAssetSourceRecord" + | "listCurrentAttention" + | "listWorkflowHistory" + | "inspectExecution" + | "prepareMetadataDecision" + | "confirmMetadataDecision" + | "cancelMetadataDecision" + | "listSourceInputRequirements" + | "startSourceEnrichment" + | "inspectSourceEnrichment" + | "listRetryableSourceEnrichments" + | "retrySourceEnrichment" + | "inspectSourceAssociationDecision" + | "selectSourceAssociationCandidate" + | "retrySourceAssociationDecision" + | "confirmChangeImpactPreview" + | "rejectChangeImpactPreview" + | "reviewSourceRefreshChangeSet" + | "inspectChangeImpactPreview" +>; + +type AssetInspectionPanelProps = { + readonly client: InspectionClient; + readonly assetId: string; + readonly onBack: () => void; +}; + +type WorkItemsPanelProps = { + readonly client: InspectionClient; + readonly kind: "attention" | "history"; + readonly onBack: () => void; + readonly onOpenAsset?: ((assetId: string) => void) | undefined; +}; + +type CursorState = { + readonly cursor?: string | undefined; + readonly previous: readonly (string | undefined)[]; +}; + +type MetadataDecisionInteraction = + | { + readonly kind: "input"; + readonly property: string; + readonly metadataUsageContext: string; + readonly value: string; + } + | { + readonly kind: "candidates"; + readonly property: string; + readonly metadataUsageContext: string; + readonly candidates: readonly { + readonly reference: string; + readonly value: string; + readonly sourceIdentity: string; + }[]; + readonly selected: number; + } + | { readonly kind: "review"; readonly preview: MetadataDecisionPreview } + | { readonly kind: "error"; readonly message: string }; + +type SourceEnrichmentInteraction = + | { readonly kind: "loading" } + | { + readonly kind: "form"; + readonly requirements: readonly PublicSourceInputRequirement[]; + readonly selectedSource: number; + readonly selectedField: number; + readonly values: Readonly>; + readonly error?: string | undefined; + } + | { readonly kind: "running"; readonly executionId: string } + | { + readonly kind: "result"; + readonly executionId: string; + readonly inspection: SourceEnrichmentInspection; + } + | { readonly kind: "error"; readonly message: string }; + +export function AssetInspectionPanel({ + client, + assetId, + onBack, +}: AssetInspectionPanelProps) { + const { stdout } = useStdout(); + const availableRows = Math.max(1, (stdout.rows ?? 24) - 8); + const sourceChunkLimit = Math.max( + 40, + Math.min( + 600, + availableRows * Math.max(16, Math.floor((stdout.columns ?? 80) / 2)), + ), + ); + const [overview, setOverview] = useState(); + const [overviewError, setOverviewError] = useState(); + const [sectionIndex, setSectionIndex] = useState(0); + const [page, setPage] = useState(); + const [pageError, setPageError] = useState(); + const [pageCursor, setPageCursor] = useState({ previous: [] }); + const [selected, setSelected] = useState(0); + const [sourceChunk, setSourceChunk] = useState(); + const [sourceOffsets, setSourceOffsets] = useState([]); + const [sourceError, setSourceError] = useState(); + const [busy, setBusy] = useState(false); + const [reloadVersion, setReloadVersion] = useState(0); + const [decisionInteraction, setDecisionInteraction] = + useState(); + const [sourceInteraction, setSourceInteraction] = + useState(); + + useEffect(() => { + let active = true; + setOverview(undefined); + setOverviewError(undefined); + void client + .inspectAssetEvidence(assetId) + .then((result) => { + if (active) setOverview(result); + }) + .catch((error: unknown) => { + if (active) + setOverviewError( + publicErrorMessage(error, "暂时无法读取这项资产的资料"), + ); + }); + return () => { + active = false; + }; + }, [assetId, client, reloadVersion]); + + useEffect(() => { + if (sourceInteraction?.kind !== "running") return; + const executionId = sourceInteraction.executionId; + let active = true; + let timer: ReturnType | undefined; + const inspect = async () => { + try { + const inspection = await client.inspectSourceEnrichment(executionId); + if (!active) return; + if (inspection.status === "running") { + timer = setTimeout(() => void inspect(), 500); + return; + } + setSourceInteraction({ kind: "result", executionId, inspection }); + if (inspection.status === "completed") + setReloadVersion((version) => version + 1); + } catch (cause) { + if (active) + setSourceInteraction({ + kind: "error", + message: publicErrorMessage(cause, "暂时无法读取来源补充结果"), + }); + } + }; + void inspect(); + return () => { + active = false; + if (timer !== undefined) clearTimeout(timer); + }; + }, [client, sourceInteraction]); + + const selectedKind = overview?.sections[sectionIndex]?.kind; + + const loadEvidencePage = async ( + kind: AssetEvidenceKind, + cursor: string | undefined, + ) => { + setBusy(true); + setPageError(undefined); + setSourceChunk(undefined); + setSourceError(undefined); + try { + const request: ListAssetEvidenceRequest = { + kind, + limit: 24, + ...(cursor === undefined ? {} : { cursor }), + }; + setPage(await client.listAssetEvidence(assetId, request)); + setPageCursor((current) => ({ ...current, cursor })); + setSelected(0); + } catch (error) { + setPage(undefined); + setPageError(publicErrorMessage(error, "暂时无法读取这一层资料")); + } finally { + setBusy(false); + } + }; + + const loadSourceChunk = async (recordId: string, offset: number) => { + setBusy(true); + setSourceError(undefined); + try { + setSourceChunk( + await client.readAssetSourceRecord(assetId, recordId, { + offset, + limit: sourceChunkLimit, + }), + ); + } catch (error) { + setSourceChunk(undefined); + setSourceError(publicErrorMessage(error, "暂时无法读取这份来源资料")); + } finally { + setBusy(false); + } + }; + + const evidenceItem = page?.items[selected]; + const sourceId = + evidenceItem?.kind === "source-record" && + evidenceItem.status === "available" + ? evidenceItem.id + : undefined; + + const prepareDecision = async ( + request: + | { + readonly kind: "pin"; + readonly property: string; + readonly metadataUsageContext: string; + readonly value: string; + } + | { + readonly kind: "adopt"; + readonly property: string; + readonly metadataUsageContext: string; + readonly candidateReference: string; + } + | { + readonly kind: "clear"; + readonly property: string; + readonly metadataUsageContext: string; + }, + ) => { + setBusy(true); + setDecisionInteraction(undefined); + try { + const preview = await client.prepareMetadataDecision(assetId, request); + setDecisionInteraction({ kind: "review", preview }); + } catch (error) { + setDecisionInteraction({ + kind: "error", + message: publicErrorMessage(error, "暂时无法形成修订预演"), + }); + } finally { + setBusy(false); + } + }; + + const openSourceEnrichment = async () => { + setSourceInteraction({ kind: "loading" }); + try { + const requirements = await client.listSourceInputRequirements(); + if (requirements.length === 0) { + setSourceInteraction({ + kind: "error", + message: "服务没有提供可用于补充的来源", + }); + return; + } + setSourceInteraction({ + kind: "form", + requirements, + selectedSource: 0, + selectedField: 0, + values: {}, + }); + } catch (cause) { + setSourceInteraction({ + kind: "error", + message: publicErrorMessage(cause, "暂时无法读取来源输入要求"), + }); + } + }; + + const submitSourceEnrichment = async ( + form: Extract, + ) => { + const requirement = form.requirements[form.selectedSource]; + if (requirement === undefined) { + setSourceInteraction({ kind: "error", message: "请选择一个来源" }); + return; + } + const missing = requirement.fields.find( + (field) => field.required && !(form.values[field.key] ?? "").trim(), + ); + if (missing !== undefined) { + const fieldIndex = requirement.fields.findIndex( + ({ key }) => key === missing.key, + ); + setSourceInteraction({ + ...form, + selectedField: Math.max(0, fieldIndex), + error: `请填写${missing.label}`, + }); + return; + } + setBusy(true); + try { + const started = await client.startSourceEnrichment({ + subjectId: assetId, + sourceIdentity: requirement.sourceIdentity, + sourceInput: Object.fromEntries( + requirement.fields.flatMap(({ key }) => { + const value = form.values[key]?.trim(); + return value === undefined || value.length === 0 + ? [] + : [[key, value]]; + }), + ), + }); + setSourceInteraction({ kind: "running", executionId: started.id }); + } catch (cause) { + setSourceInteraction({ + kind: "error", + message: publicErrorMessage(cause, "无法发起来源补充"), + }); + } finally { + setBusy(false); + } + }; + + useCurrentInput((input, key) => { + if (sourceInteraction !== undefined) { + if (sourceInteraction.kind === "form") { + const requirement = + sourceInteraction.requirements[sourceInteraction.selectedSource]; + const fields = requirement?.fields ?? []; + const field = fields[sourceInteraction.selectedField]; + if (key.escape) { + setSourceInteraction(undefined); + return; + } + if (key.leftArrow || key.rightArrow) { + setSourceInteraction({ + ...sourceInteraction, + selectedSource: Math.min( + Math.max( + 0, + sourceInteraction.selectedSource + (key.rightArrow ? 1 : -1), + ), + sourceInteraction.requirements.length - 1, + ), + selectedField: 0, + values: {}, + error: undefined, + }); + return; + } + if (key.tab && fields.length > 0) { + setSourceInteraction({ + ...sourceInteraction, + selectedField: + (sourceInteraction.selectedField + 1) % fields.length, + error: undefined, + }); + return; + } + if (key.return) { + if (sourceInteraction.selectedField < fields.length - 1) { + setSourceInteraction({ + ...sourceInteraction, + selectedField: sourceInteraction.selectedField + 1, + error: undefined, + }); + } else void submitSourceEnrichment(sourceInteraction); + return; + } + if (key.ctrl && input.toLowerCase() === "u" && field !== undefined) { + setSourceInteraction({ + ...sourceInteraction, + values: { ...sourceInteraction.values, [field.key]: "" }, + error: undefined, + }); + return; + } + if ((key.backspace || key.delete) && field !== undefined) { + setSourceInteraction({ + ...sourceInteraction, + values: { + ...sourceInteraction.values, + [field.key]: (sourceInteraction.values[field.key] ?? "").slice( + 0, + -1, + ), + }, + error: undefined, + }); + return; + } + if (input.length > 0 && !key.ctrl && !key.meta && field !== undefined) + setSourceInteraction({ + ...sourceInteraction, + values: { + ...sourceInteraction.values, + [field.key]: `${sourceInteraction.values[field.key] ?? ""}${input}`, + }, + error: undefined, + }); + return; + } + if (sourceInteraction.kind === "result" && input === "r") { + if (sourceInteraction.inspection.status !== "failed") return; + setBusy(true); + void (async () => { + try { + const retryable = + await client.listRetryableSourceEnrichments(assetId); + const item = retryable.find( + ({ executionId }) => + executionId === sourceInteraction.executionId, + ); + if (item === undefined || !item.canRetry) { + setSourceInteraction({ + kind: "error", + message: "这项来源补充已不能重试,请重新发起补充", + }); + return; + } + const retried = await client.retrySourceEnrichment({ + executionId: item.executionId, + subjectId: item.subjectId, + sourceIdentity: item.sourceIdentity, + }); + setSourceInteraction({ kind: "running", executionId: retried.id }); + } catch (cause) { + setSourceInteraction({ + kind: "error", + message: publicErrorMessage(cause, "无法重试来源补充"), + }); + } finally { + setBusy(false); + } + })(); + return; + } + if ( + key.escape || + sourceInteraction.kind === "error" || + sourceInteraction.kind === "result" + ) { + setSourceInteraction(undefined); + } + return; + } + if (decisionInteraction?.kind === "input") { + if (key.escape) { + setDecisionInteraction(undefined); + return; + } + if (key.return) { + if (decisionInteraction.value.trim().length === 0) { + setDecisionInteraction({ kind: "error", message: "请输入修订值" }); + return; + } + void prepareDecision({ + kind: "pin", + property: decisionInteraction.property, + metadataUsageContext: decisionInteraction.metadataUsageContext, + value: decisionInteraction.value, + }); + return; + } + if (key.backspace || key.delete) { + setDecisionInteraction({ + ...decisionInteraction, + value: decisionInteraction.value.slice(0, -1), + }); + return; + } + if (input.length > 0 && !key.ctrl && !key.meta) + setDecisionInteraction({ + ...decisionInteraction, + value: `${decisionInteraction.value}${input}`, + }); + return; + } + if (decisionInteraction?.kind === "candidates") { + if (key.escape) { + setDecisionInteraction(undefined); + return; + } + if (key.downArrow || input === "j") { + setDecisionInteraction({ + ...decisionInteraction, + selected: Math.min( + decisionInteraction.selected + 1, + decisionInteraction.candidates.length - 1, + ), + }); + return; + } + if (key.upArrow || input === "k") { + setDecisionInteraction({ + ...decisionInteraction, + selected: Math.max(decisionInteraction.selected - 1, 0), + }); + return; + } + if (key.return) { + const candidate = + decisionInteraction.candidates[decisionInteraction.selected]; + if (candidate !== undefined) + void prepareDecision({ + kind: "adopt", + property: decisionInteraction.property, + metadataUsageContext: decisionInteraction.metadataUsageContext, + candidateReference: candidate.reference, + }); + return; + } + return; + } + if (decisionInteraction?.kind === "review") { + if ( + decisionInteraction.preview.status !== "awaiting-confirmation" && + (key.escape || key.return || input === "b") + ) { + setDecisionInteraction(undefined); + return; + } + if (input === "y") { + setBusy(true); + void client + .confirmMetadataDecision(decisionInteraction.preview.previewId) + .then((preview) => { + setDecisionInteraction({ kind: "review", preview }); + if (preview.status === "applied") { + setReloadVersion((version) => version + 1); + if (page !== undefined) + void loadEvidencePage(page.kind, pageCursor.cursor); + } + }) + .catch((error: unknown) => + setDecisionInteraction({ + kind: "error", + message: publicErrorMessage( + error, + "属性修订已过期,请重新检查资料", + ), + }), + ) + .finally(() => setBusy(false)); + return; + } + if (input === "n" || key.escape) { + setBusy(true); + void client + .cancelMetadataDecision(decisionInteraction.preview.previewId) + .then((preview) => + setDecisionInteraction({ kind: "review", preview }), + ) + .catch((error: unknown) => + setDecisionInteraction({ + kind: "error", + message: publicErrorMessage(error, "暂时无法取消修订"), + }), + ) + .finally(() => setBusy(false)); + return; + } + return; + } + if (decisionInteraction?.kind === "error") { + if (key.escape || key.return) setDecisionInteraction(undefined); + return; + } + if (key.escape || input === "b") { + if (sourceChunk !== undefined || sourceError !== undefined) { + setSourceChunk(undefined); + setSourceError(undefined); + setSourceOffsets([]); + } else if (page !== undefined || pageError !== undefined) { + setPage(undefined); + setPageError(undefined); + setPageCursor({ previous: [] }); + setSourceChunk(undefined); + } else onBack(); + return; + } + if (busy) return; + if (sourceId !== undefined && (input === " " || input === "n")) { + if (sourceChunk !== undefined && !sourceChunk.complete) { + setSourceOffsets((offsets) => [...offsets, sourceChunk.offset]); + void loadSourceChunk(sourceId, sourceChunk.nextOffset); + } else if (sourceChunk === undefined) { + setSourceOffsets([]); + void loadSourceChunk(sourceId, 0); + } + return; + } + if ( + sourceChunk !== undefined && + input === "p" && + sourceOffsets.length > 0 + ) { + const previous = sourceOffsets.at(-1); + if (previous !== undefined) { + setSourceOffsets((offsets) => offsets.slice(0, -1)); + void loadSourceChunk(sourceChunk.record.id, previous); + } + return; + } + if (page !== undefined && input === "n" && page.nextCursor !== undefined) { + setPageCursor((current) => ({ + cursor: page.nextCursor, + previous: [...current.previous, current.cursor], + })); + void loadEvidencePage(page.kind, page.nextCursor); + return; + } + if (page !== undefined && input === "p" && pageCursor.previous.length > 0) { + const cursor = pageCursor.previous.at(-1); + setPageCursor({ + cursor, + previous: pageCursor.previous.slice(0, -1), + }); + void loadEvidencePage(page.kind, cursor); + return; + } + if (page !== undefined && key.return && sourceId !== undefined) { + setSourceOffsets([]); + void loadSourceChunk(sourceId, 0); + return; + } + if (page !== undefined) { + if ((key.downArrow || input === "j") && selected < page.items.length - 1) + setSelected(selected + 1); + if ((key.upArrow || input === "k") && selected > 0) + setSelected(selected - 1); + if ( + evidenceItem?.kind === "current-value" && + evidenceItem.target.kind === "entity" && + evidenceItem.target.entityId === assetId + ) { + const coordinate = { + property: evidenceItem.evidence.property, + metadataUsageContext: "asset.ordinary", + }; + if (input === "m") + setDecisionInteraction({ kind: "input", ...coordinate, value: "" }); + if (input === "x") + void prepareDecision({ kind: "clear", ...coordinate }); + if ( + input === "a" && + evidenceItem.evidence.result.status === "conflict" + ) { + const candidates = evidenceItem.evidence.result.candidates.flatMap( + (candidate) => + candidate.reference === undefined + ? [] + : [ + { + reference: candidate.reference, + value: candidate.value, + sourceIdentity: candidate.sourceIdentity, + }, + ], + ); + setDecisionInteraction( + candidates.length === 0 + ? { kind: "error", message: "当前冲突没有可采纳的来源候选" } + : { + kind: "candidates", + ...coordinate, + candidates, + selected: 0, + }, + ); + } + } + return; + } + if (overview !== undefined) { + if ( + (key.downArrow || input === "j") && + sectionIndex < overview.sections.length - 1 + ) + setSectionIndex(sectionIndex + 1); + if ((key.upArrow || input === "k") && sectionIndex > 0) + setSectionIndex(sectionIndex - 1); + if (key.return && selectedKind !== undefined) { + setPageCursor({ previous: [] }); + void loadEvidencePage(selectedKind, undefined); + } + if (input === "r") setReloadVersion((version) => version + 1); + if (input === "e") void openSourceEnrichment(); + } + }); + + return ( + + 资产资料检查 + 用 ↑/↓ 选择,Enter 查看,Esc 返回 + {overviewError !== undefined ? ( + {overviewError}(按 r 重试) + ) : overview === undefined ? ( + 正在读取资料… + ) : page !== undefined || pageError !== undefined ? ( + <> + + + + ) : ( + + {overview.sections.map((section, index) => ( + + {index === sectionIndex ? "› " : " "} + {evidenceLabels[section.kind]}: + {section.status === "missing" + ? "暂无资料" + : `${section.count} 项`} + + ))} + + )} + + + {sourceInteraction?.kind === "form" + ? "←/→ 选择来源 · Tab 切换字段 · Enter 继续或提交 · Esc 返回" + : sourceInteraction?.kind === "result" && + sourceInteraction.inspection.status === "failed" + ? "r 重试来源补充 · Esc 返回" + : sourceInteraction !== undefined + ? "Esc 返回" + : sourceChunk === undefined + ? page === undefined + ? "e 补充来源 · b 返回" + : `${evidenceItem?.kind === "current-value" && evidenceItem.target.kind === "entity" && evidenceItem.target.entityId === assetId ? "m 修订 · x 撤回 · a 采纳冲突候选 · " : ""}${page.nextCursor === undefined ? "" : "n 下一页 · "}${sourceId === undefined ? "" : "Enter 展开来源 · "}${pageCursor.previous.length === 0 ? "" : "p 上一页 · "}b 返回` + : `来源资料 ${sourceChunk.offset + 1}–${sourceChunk.nextOffset} 字符`} + + {sourceChunk !== undefined && ( + + {sourceChunk.complete ? "已到末尾" : "空格 / n 继续"} + {sourceOffsets.length > 0 ? " · p 上一段" : ""} + + )} + + ); +} + +function SourceEnrichmentPrompt({ + interaction, +}: { + readonly interaction: SourceEnrichmentInteraction | undefined; +}) { + if (interaction === undefined) return null; + if (interaction.kind === "loading") return 正在读取来源输入要求…; + if (interaction.kind === "error") + return {interaction.message}; + if (interaction.kind === "running") + return ( + + 正在补充来源,请稍候… + + ); + if (interaction.kind === "form") { + const requirement = interaction.requirements[interaction.selectedSource]; + if (requirement === undefined) return null; + const field = requirement.fields[interaction.selectedField]; + return ( + + 补充来源:{requirement.displayName} + {requirement.fields.map((item, index) => ( + + {index === interaction.selectedField ? "› " : " "} + {item.label} + {item.required ? "(必填)" : ""}: + {interaction.values[item.key] ?? ""} + {item.example === undefined ? "" : ` · 示例 ${item.example}`} + + ))} + {field === undefined ? null : ( + + 正在填写:{field.label} · {field.acceptedInput} + + )} + {interaction.error === undefined ? null : ( + {interaction.error} + )} + {interaction.requirements.length > 1 ? ( + + 来源 {interaction.selectedSource + 1}/ + {interaction.requirements.length} · ←/→ 切换 + + ) : null} + + ); + } + return ( + + + 来源补充 · {sourceEnrichmentStatusLabel(interaction.inspection.status)} + + {interaction.inspection.reason === undefined ? null : ( + {interaction.inspection.reason} + )} + {interaction.inspection.items.map((item, index) => ( + + + {item.sourceIdentity} ·{" "} + {item.input === undefined + ? "未显示输入" + : Object.entries(item.input) + .map(([key, value]) => `${key} ${value}`) + .join(",")} + {" · "} + {sourceEnrichmentStatusLabel(item.status)} + + + ))} + {interaction.inspection.sourceAssociations.map((association, index) => ( + + 已关联:{association.sourceIdentity} · {association.nativeAddress} + + ))} + {interaction.inspection.status === "failed" && + interaction.inspection.items.some(({ canRetry }) => canRetry) ? ( + 可按 r 再次获取当前失败项 + ) : null} + + ); +} + +function sourceEnrichmentStatusLabel( + status: SourceEnrichmentInspection["status"], +): string { + switch (status) { + case "running": + return "进行中"; + case "failed": + return "未完成"; + case "completed": + return "已完成"; + } +} + +function MetadataDecisionPrompt({ + interaction, +}: { + readonly interaction: MetadataDecisionInteraction | undefined; +}) { + if (interaction === undefined) return null; + if (interaction.kind === "input") + return ( + + {interaction.property} 新值:{interaction.value} · Enter 预演 · Esc 取消 + + ); + if (interaction.kind === "candidates") + return ( + + 选择要固定的冲突值 · ↑/↓ 选择 · Enter 预演 · Esc 返回 + {interaction.candidates.map((candidate, index) => ( + + {index === interaction.selected ? "› " : " "} + {candidate.value} · {candidate.sourceIdentity} + + ))} + + ); + if (interaction.kind === "error") + return {interaction.message} · Enter 继续; + const preview = interaction.preview; + return ( + + + {preview.property}: + {preview.kind === "clear" ? "撤回修订" : preview.value} + {preview.status === "awaiting-confirmation" + ? ` · ${preview.impact} · y 确认 · n 取消` + : ` · ${decisionStatusLabel(preview.status)}`} + + {preview.blockingConditions.map((condition, index) => ( + + {condition} + + ))} + + ); +} + +function decisionStatusLabel( + status: MetadataDecisionPreview["status"], +): string { + switch (status) { + case "applied": + return "已确认"; + case "stale": + return "预演已过期,没有修改资料"; + case "rejected": + return "已取消,没有修改资料"; + default: + return "等待确认"; + } +} + +export function WorkItemsPanel({ + client, + kind, + onBack, + onOpenAsset, +}: WorkItemsPanelProps) { + const { stdout } = useStdout(); + const pageWindowSize = Math.max(1, (stdout.rows ?? 24) - 6); + const [page, setPage] = useState< + CurrentAttentionPage | WorkflowHistoryPage + >(); + const [cursor, setCursor] = useState({ previous: [] }); + const [error, setError] = useState(); + const [selected, setSelected] = useState(0); + const [selectedCandidate, setSelectedCandidate] = useState(0); + const [busy, setBusy] = useState(false); + const [executionDetail, setExecutionDetail] = useState<{ + readonly id: string; + readonly inspection: PublicExecutionInspection; + }>(); + const [executionDetailError, setExecutionDetailError] = useState(); + const [changePreview, setChangePreview] = + useState(); + const [sourceDecision, setSourceDecision] = + useState(); + const [actionMessage, setActionMessage] = useState(); + + const loadPage = async ( + nextCursor: string | undefined, + preserveReviewResult = false, + ) => { + setBusy(true); + setError(undefined); + try { + const request: InspectionPageRequest = { + limit: 24, + ...(nextCursor === undefined ? {} : { cursor: nextCursor }), + }; + const result = + kind === "attention" + ? await client.listCurrentAttention(request) + : await client.listWorkflowHistory(request); + setPage(result); + setCursor((current) => ({ ...current, cursor: nextCursor })); + setSelected(0); + setExecutionDetail(undefined); + setExecutionDetailError(undefined); + if (!preserveReviewResult) { + setChangePreview(undefined); + setSourceDecision(undefined); + setActionMessage(undefined); + } + } catch (cause) { + setPage(undefined); + setError( + publicErrorMessage( + cause, + kind === "attention" + ? "暂时无法读取待处理事项" + : "暂时无法读取工作流历史", + ), + ); + } finally { + setBusy(false); + } + }; + + const reviewSelectedAttention = async ( + disposition: "approved" | "rejected", + ) => { + if (kind !== "attention" || page === undefined || busy) return; + const item = (page as CurrentAttentionPage).items[selected]; + if (item === undefined) return; + const reviewedDecision = sourceDecision; + setBusy(true); + setActionMessage(undefined); + try { + if (item.kind === "change-impact-review") { + if (item.changeImpactPreviewId === undefined) + throw new Error("当前事项没有关联变更预演"); + const preview = + disposition === "approved" + ? await client.confirmChangeImpactPreview( + item.changeImpactPreviewId, + "tui-user", + ) + : await client.rejectChangeImpactPreview( + item.changeImpactPreviewId, + "tui-user", + ); + setChangePreview(preview); + setActionMessage( + preview.status === "applied" + ? "变更已确认" + : preview.status === "rejected" + ? "已拒绝,资料没有改变" + : preview.status === "stale" + ? "预演已过期,资料没有改变" + : "变更仍待确认", + ); + } else if (item.kind === "source-refresh-review") { + if ( + item.executionId === undefined || + item.sourceRefreshChangeSetId === undefined + ) + throw new Error("当前事项缺少来源更新坐标"); + const execution = await client.inspectExecution(item.executionId); + if (execution.kind !== "execution") + throw new Error("当前来源更新没有可审核的执行结果"); + const run = execution.sourceRefreshRuns.find((candidate) => + candidate.changeSets.some( + ({ id }) => id === item.sourceRefreshChangeSetId, + ), + ); + if (run === undefined) throw new Error("没有找到待审核的来源刷新记录"); + const reviewed = await client.reviewSourceRefreshChangeSet(run.id, { + changeSetId: item.sourceRefreshChangeSetId, + disposition, + }); + const changeSet = reviewed.changeSets.find( + ({ id }) => id === item.sourceRefreshChangeSetId, + ); + setActionMessage( + changeSet?.disposition === "approved" + ? "来源更新已批准并按当前依据应用" + : changeSet?.disposition === "rejected" + ? "来源更新已拒绝,当前资料保持不变" + : "来源更新审核已提交", + ); + } else if ( + item.kind === "recognition-review" && + reviewedDecision !== undefined && + reviewedDecision.executionId === item.executionId && + reviewedDecision.preview !== undefined + ) { + const preview = + disposition === "approved" + ? await client.confirmChangeImpactPreview( + reviewedDecision.preview.previewId, + "tui-user", + ) + : await client.rejectChangeImpactPreview( + reviewedDecision.preview.previewId, + "tui-user", + ); + setChangePreview(preview); + setActionMessage( + preview.status === "applied" + ? "来源关联已确认" + : preview.status === "rejected" + ? "已拒绝来源关联,当前资料保持不变" + : preview.status === "stale" + ? "来源关联预演已过期,当前资料保持不变" + : "来源关联仍待确认", + ); + } else { + throw new Error("请先查看来源候选,并完成候选选择或重新判断"); + } + await loadPage(cursor.cursor, true); + } catch (cause) { + setActionMessage(publicErrorMessage(cause, "无法处理这项待处理事项")); + } finally { + setBusy(false); + } + }; + + useEffect(() => { + void loadPage(undefined); + }, [kind, client]); + + const selectedExecutionId = + page === undefined + ? undefined + : kind === "attention" + ? (page as CurrentAttentionPage).items[selected]?.executionId + : (page as WorkflowHistoryPage).items[selected]?.execution.id; + const loadSourceDecision = async (executionId: string) => { + setBusy(true); + setSourceDecision(undefined); + setExecutionDetail(undefined); + setExecutionDetailError(undefined); + try { + setSourceDecision( + await client.inspectSourceAssociationDecision(executionId), + ); + setSelectedCandidate(0); + } catch { + try { + const inspection = await client.inspectExecution(executionId); + setExecutionDetail({ id: executionId, inspection }); + } catch (cause) { + setExecutionDetailError( + publicErrorMessage(cause, "暂时无法读取执行详情"), + ); + } + } finally { + setBusy(false); + } + }; + + const selectSourceCandidate = async () => { + if (sourceDecision === undefined || busy) return; + const candidate = sourceDecision.candidates[selectedCandidate]; + if (candidate === undefined) return; + setBusy(true); + setActionMessage(undefined); + try { + const updated = await client.selectSourceAssociationCandidate( + sourceDecision.executionId, + candidate.reference, + ); + setSourceDecision(updated); + setChangePreview( + updated.preview === undefined + ? undefined + : await client.inspectChangeImpactPreview(updated.preview.previewId), + ); + setActionMessage( + updated.preview === undefined + ? "已选择来源候选" + : "已选择来源候选,请检查变更影响后确认", + ); + } catch (cause) { + setActionMessage(publicErrorMessage(cause, "无法选择这项来源候选")); + } finally { + setBusy(false); + } + }; + + const retrySourceDecision = async () => { + if (selectedExecutionId === undefined || busy) return; + setBusy(true); + setActionMessage(undefined); + try { + const decision = + sourceDecision?.executionId === selectedExecutionId + ? sourceDecision + : await client.inspectSourceAssociationDecision(selectedExecutionId); + if (!decision.availableActions.includes("retry")) { + setSourceDecision(decision); + setActionMessage("这项来源关联当前不能重新判断"); + return; + } + const retried = + await client.retrySourceAssociationDecision(selectedExecutionId); + setSourceDecision(retried); + setActionMessage("已重新判断来源关联,请检查返回结果"); + } catch (cause) { + setActionMessage(publicErrorMessage(cause, "无法重新判断来源关联")); + } finally { + setBusy(false); + } + }; + + useCurrentInput((input, key) => { + if (key.escape || input === "b") { + if ( + executionDetail !== undefined || + executionDetailError !== undefined || + sourceDecision !== undefined || + changePreview !== undefined + ) { + setExecutionDetail(undefined); + setExecutionDetailError(undefined); + setSourceDecision(undefined); + setChangePreview(undefined); + setActionMessage(undefined); + } else onBack(); + return; + } + if (busy) return; + if (kind === "attention" && input === "a") { + void reviewSelectedAttention("approved"); + return; + } + if (kind === "attention" && input === "x") { + void reviewSelectedAttention("rejected"); + return; + } + if ( + sourceDecision?.state === "awaiting-selection" && + sourceDecision.candidates.length > 0 && + (key.downArrow || input === "j") + ) { + setSelectedCandidate( + Math.min(selectedCandidate + 1, sourceDecision.candidates.length - 1), + ); + return; + } + if ( + sourceDecision?.state === "awaiting-selection" && + sourceDecision.candidates.length > 0 && + (key.upArrow || input === "k") + ) { + setSelectedCandidate(Math.max(0, selectedCandidate - 1)); + return; + } + if ( + kind === "attention" && + input === "s" && + sourceDecision?.availableActions.includes("select") + ) { + void selectSourceCandidate(); + return; + } + if ( + kind === "attention" && + input === "r" && + (page as CurrentAttentionPage | undefined)?.items[selected]?.kind === + "recognition-review" + ) { + void retrySourceDecision(); + return; + } + if (input === "v") { + if (kind === "attention") { + const item = (page as CurrentAttentionPage | undefined)?.items[ + selected + ]; + if ( + item?.kind === "change-impact-review" && + item.changeImpactPreviewId !== undefined + ) { + setBusy(true); + setChangePreview(undefined); + setExecutionDetailError(undefined); + void client + .inspectChangeImpactPreview(item.changeImpactPreviewId) + .then(setChangePreview) + .catch((cause: unknown) => + setExecutionDetailError( + publicErrorMessage(cause, "暂时无法读取变更预演"), + ), + ) + .finally(() => setBusy(false)); + return; + } + if ( + item?.kind === "recognition-review" && + item.executionId !== undefined + ) { + void loadSourceDecision(item.executionId); + return; + } + } + if (selectedExecutionId === undefined) { + setExecutionDetail(undefined); + setExecutionDetailError("当前事项没有关联的执行记录"); + return; + } + setBusy(true); + setExecutionDetail(undefined); + setExecutionDetailError(undefined); + setChangePreview(undefined); + void client + .inspectExecution(selectedExecutionId) + .then((inspection) => + setExecutionDetail({ + id: selectedExecutionId, + inspection, + }), + ) + .catch((cause: unknown) => + setExecutionDetailError( + publicErrorMessage(cause, "暂时无法读取执行详情"), + ), + ) + .finally(() => setBusy(false)); + return; + } + if (input === "r") { + void loadPage(cursor.cursor); + return; + } + if (key.return && onOpenAsset !== undefined && page !== undefined) { + const assetId = + kind === "attention" + ? (page as CurrentAttentionPage).items[selected]?.assetId + : (page as WorkflowHistoryPage).items[selected]?.resultReferences + .acceptedAssetIds[0]; + if (assetId !== undefined) onOpenAsset(assetId); + return; + } + if (page !== undefined && input === "n" && page.nextCursor !== undefined) { + setCursor((current) => ({ + cursor: page.nextCursor, + previous: [...current.previous, current.cursor], + })); + void loadPage(page.nextCursor); + return; + } + if (page !== undefined && input === "p" && cursor.previous.length > 0) { + const previous = cursor.previous.at(-1); + setCursor({ cursor: previous, previous: cursor.previous.slice(0, -1) }); + void loadPage(previous); + return; + } + const rows = page?.items ?? []; + if ((key.downArrow || input === "j") && selected < rows.length - 1) { + setSelected(selected + 1); + setExecutionDetail(undefined); + setExecutionDetailError(undefined); + setChangePreview(undefined); + setSourceDecision(undefined); + setActionMessage(undefined); + } + if ((key.upArrow || input === "k") && selected > 0) { + setSelected(selected - 1); + setExecutionDetail(undefined); + setExecutionDetailError(undefined); + setChangePreview(undefined); + setSourceDecision(undefined); + setActionMessage(undefined); + } + }); + + const heading = kind === "attention" ? "待处理事项" : "工作流历史"; + const pageStart = windowStart( + page?.items.length ?? 0, + selected, + pageWindowSize, + ); + const visibleRows = + page?.items.slice(pageStart, pageStart + pageWindowSize) ?? []; + return ( + + {heading} + + ↑/↓ 选择 · v 查看依据 · a 确认 · x 拒绝 · s 选择来源候选 · r 重新判断 · + n/p 翻页 · Esc 返回 + + {error !== undefined ? ( + {error} + ) : page === undefined ? ( + {busy ? "正在读取…" : "此页没有内容"} + ) : page.items.length === 0 ? ( + + {kind === "attention" ? "目前没有待处理事项" : "暂无工作流历史"} + + ) : kind === "attention" ? ( + visibleRows.map((rawItem, index) => { + const item = rawItem as CurrentAttentionPage["items"][number]; + const absoluteIndex = pageStart + index; + return ( + + + {absoluteIndex === selected ? "› " : " "} + {item.summary} + + {absoluteIndex === selected && ( + {attentionKindLabel(item.kind)} + )} + + ); + }) + ) : ( + visibleRows.map((rawItem, index) => { + const item = rawItem as WorkflowHistoryPage["items"][number]; + const absoluteIndex = pageStart + index; + return ( + + + {absoluteIndex === selected ? "› " : " "} + {operationLabel(item.execution.operationKind)} + {operationLabel(item.execution.operationKind) === "工作流" + ? "" + : " · "} + {hasCancellationOutcome(item.execution.outcome) + ? historyExecutionStatus( + item.execution.status, + item.execution.outcome, + ) + : `${historyStage(item.execution.stage)} · ${historyExecutionStatus( + item.execution.status, + item.execution.outcome, + )}`} + + + {item.resultReferences.acceptedAssetIds.length} 项关联资产 · + {item.recognitionResults.length} 项识别结果 + + {absoluteIndex === selected && + item.recognitionResults.map((result, resultIndex) => ( + + {result.status === "accepted" ? "已采用" : "曾提出"}: + {result.explanation} + + ))} + + ); + }) + )} + {executionDetail !== undefined && + executionDetail.id === selectedExecutionId && ( + + )} + {sourceDecision !== undefined && ( + + )} + {changePreview !== undefined && ( + + )} + {actionMessage !== undefined && ( + {actionMessage} + )} + {executionDetailError !== undefined && ( + {executionDetailError} + )} + {onOpenAsset !== undefined && ( + Enter 打开当前关联资产 + )} + + ); +} + +function SourceAssociationDecisionDetails({ + inspection, + selectedCandidate, +}: { + readonly inspection: SourceAssociationDecisionInspection; + readonly selectedCandidate: number; +}) { + return ( + + + 来源关联 · {sourceDecisionIntentLabel(inspection.intent)} + + + {inspection.failure === undefined + ? `候选 ${inspection.candidates.length} 项 · ${sourceDecisionStateLabel(inspection.state)}` + : `需要处理:${inspection.failure}`} + + {inspection.currentAssociations.map((association) => ( + + 当前关联:{association.sourceIdentity} · {association.nativeAddress} + + ))} + {inspection.candidates.map((candidate, index) => ( + + + {index === selectedCandidate ? "› " : " "} + {candidate.sourceIdentity} ·{" "} + {candidate.outcome === "accepted" ? "可用候选" : "不符合条件"} ·{" "} + {candidate.nativeAddress} + + {candidate.explanation} + + ))} + {inspection.preview !== undefined && ( + + 变更预演: + {inspection.preview.status === "awaiting-confirmation" + ? "等待确认" + : inspection.preview.status === "applied" + ? "已应用" + : inspection.preview.status === "stale" + ? "依据已变化" + : "已拒绝"} + + )} + {inspection.availableActions.includes("select") && ( + 按 s 选择当前候选 + )} + {inspection.availableActions.includes("retry") && ( + 按 r 重新判断来源关联 + )} + + ); +} + +function ChangeImpactReviewDetails({ + preview, +}: { + readonly preview: PublicChangeImpactPreview; +}) { + const stateLabel = (state: string | undefined) => + state === "recognized" + ? "已识别" + : state === "unresolved" + ? "待识别" + : "未改变"; + const associations = ( + values: PublicChangeImpactPreview["current"]["sourceAssociations"], + ) => + values.length === 0 + ? "没有来源关联" + : values.map(({ sourceIdentity }) => sourceIdentity).join("、"); + return ( + + + 变更影响 ·{" "} + {preview.status === "awaiting-confirmation" + ? "等待确认" + : preview.status === "applied" + ? "已应用" + : preview.status === "stale" + ? "依据已变化" + : "已拒绝"} + + + 识别状态:{stateLabel(preview.current.recognitionState)} →{" "} + {stateLabel(preview.projected.recognitionState)} + + + 来源关联:{associations(preview.current.sourceAssociations)} →{" "} + {associations(preview.projected.sourceAssociations)} + + {preview.evidenceConflicts.map((conflict, index) => ( + + 依据冲突:{conflict} + + ))} + {preview.blockingConditions.map((condition, index) => ( + + 暂不能确认:{condition} + + ))} + + ); +} + +function sourceDecisionIntentLabel( + intent: SourceAssociationDecisionInspection["intent"], +): string { + switch (intent) { + case "establish": + return "建立关联"; + case "replace": + return "替换关联"; + case "retract": + return "撤回关联"; + } +} + +function sourceDecisionStateLabel(state: string): string { + switch (state) { + case "awaiting-selection": + return "等待选择候选"; + case "awaiting-confirmation": + return "等待检查并确认"; + case "failed": + return "未完成"; + default: + return "已处理"; + } +} + +function EvidenceView(props: { + readonly page: AssetEvidencePage | undefined; + readonly error: string | undefined; + readonly selected: number; + readonly sourceChunk: AssetSourceRecordChunk | undefined; + readonly sourceError: string | undefined; + readonly busy: boolean; + readonly pageWindowSize: number; +}) { + const { page, error, selected, sourceChunk, sourceError, busy } = props; + if (error !== undefined) return {error}; + if (sourceError !== undefined) return {sourceError}; + if (sourceChunk !== undefined) + return ( + + 来源:{sourceChunk.record.nativeAddress} + {sourceChunk.content} + + ); + if (page === undefined) return {busy ? "正在读取…" : "暂无资料"}; + if (page.items.length === 0) + return ( + + {page.status === "missing" ? "暂无这一层资料" : "这一页没有更多内容"} + + ); + const pageStart = windowStart( + page.items.length, + selected, + props.pageWindowSize, + ); + const visibleItems = page.items.slice( + pageStart, + pageStart + props.pageWindowSize, + ); + return ( + + + {evidenceLabels[page.kind]} · {page.items.length} 项 + + {visibleItems.map((item, index) => { + const absoluteIndex = pageStart + index; + return ( + + + {absoluteIndex === selected ? "› " : " "} + {evidenceTitle(item)} + + {absoluteIndex === selected && } + + ); + })} + + ); +} + +function EvidenceDetails({ + item, +}: { + readonly item: AssetEvidencePage["items"][number]; +}) { + if (item.kind === "current-value") { + const result = item.evidence.result; + return ( + + {result.status === "resolved" ? ( + <> + {result.values.map((value, index) => ( + + {value.property}:{value.value}({reasonLabel(value.reasoning)} + ,来源 {value.sourceIdentity}) + + ))} + {result.notSelected.map((value, index) => ( + + 未采用:{value.property}:{value.value} · {value.sourceIdentity} + + ))} + + ) : result.status === "conflict" ? ( + + + 存在冲突:{result.explanation}({result.candidates.length}{" "} + 个候选) + + {result.candidates.map((candidate, index) => ( + + 候选:{candidate.value} · 来源 {candidate.sourceIdentity} + + ))} + + ) : ( + 当前没有可采用值 + )} + + ); + } + if (item.kind === "interpreted-fact") + return ( + + {item.property}:{item.value} · {item.sourceIdentity ?? "本地解释"} + {item.sourceRecordIds.length === 0 + ? "" + : ` · 依据 ${item.sourceRecordIds.length} 份来源资料`} + + ); + if (item.kind === "metadata-decision") + return ( + + {item.decision === "pin" ? "固定值" : "撤回修订"} + {item.value === undefined ? "" : `:${item.value}`} + {item.adoptedFrom?.sourceIdentity === undefined + ? "" + : ` · 采纳自 ${item.adoptedFrom.sourceIdentity}`} + {item.current ? " · 当前修订" : " · 历史记录"} + + ); + if (item.kind === "source-record") + return ( + + {item.status === "missing" + ? "所引用的来源资料当前不可用" + : `${item.sourceIdentity ?? "来源"} · ${item.mediaType ?? "资料"}${item.acquiredAt === undefined ? "" : ` · 获取于 ${formatDate(item.acquiredAt)}`}${item.coverage === undefined ? "" : ` · ${item.coverage.completeness === "complete" ? "记录完整" : "记录不完整"}`} · 按 Enter 展开正文`} + + ); + if (item.kind === "local-observation") + return {localObservationDetails(item)}; + return {formatResultDetails(item)}; +} + +function evidenceTitle(item: AssetEvidencePage["items"][number]): string { + switch (item.kind) { + case "local-observation": + return item.relativePath || "资产目录"; + case "format-result": + return item.explanation; + case "source-record": + return item.nativeAddress ?? "来源资料"; + case "interpreted-fact": + return `${item.property}:${item.value}`; + case "current-value": + return item.evidence.property; + case "metadata-decision": + return `${item.property} · ${item.decision === "pin" ? "固定值" : "撤回"}`; + } +} + +function reasonLabel(reason: { readonly kind: string }): string { + switch (reason.kind) { + case "source-preference": + return "按来源偏好采用"; + case "preferred-source-absent-fallback": + return "首选来源缺失,采用回退来源"; + case "preferred-sources-absent-unranked": + return "首选来源缺失,未排序采用"; + default: + return "没有来源优先级"; + } +} + +function historyExecutionStatus(status: string, outcome?: string): string { + if (status === "running") return "进行中"; + if (outcome === "cancelled") return "已取消"; + if (outcome === "interrupted") return "已中断"; + if (status === "failed" || outcome === "failed") return "失败"; + return `已完成${outcome === undefined ? "" : `,${historyOutcome(outcome)}`}`; +} + +function hasCancellationOutcome(outcome?: string): boolean { + return outcome === "cancelled" || outcome === "interrupted"; +} + +function ExecutionHistoryDetails({ + inspection, +}: { + readonly inspection: PublicExecutionInspection; +}) { + if (inspection.kind === "asset-library-scan") { + const available = inspection.children.filter( + (child) => child.status === "available", + ); + const missing = inspection.children.length - available.length; + return ( + + 执行详情 · 资料库扫描 + + {historyExecutionStatus(inspection.status, inspection.outcome)} + {inspection.cancellationRequested === undefined + ? "" + : " · 已收到停止请求"} + + + {inspection.children.length} 项目录处理 + {missing === 0 ? "" : ` · ${missing} 项详情暂不可用`} + + {available.length > 0 && ( + + 目录状态: + {summarizeExecutionStatuses( + available.map((child) => child.execution), + )} + + )} + + 来源更新: + {available.reduce( + (count, child) => count + child.execution.sourceRefreshRuns.length, + 0, + )}{" "} + 项 + + + ); + } + + const { progress, sourceRefreshRuns } = inspection; + const dispositions = progress.dispositions; + const workItems = progress.workItems; + const targets = progress.targets; + const changeSets = sourceRefreshRuns.flatMap((run) => run.changeSets); + return ( + + 执行详情 · {operationLabel(inspection.operationKind)} + + {hasCancellationOutcome(inspection.outcome) + ? historyExecutionStatus(inspection.status, inspection.outcome) + : `${historyStage(inspection.stage)} · ${historyExecutionStatus( + inspection.status, + inspection.outcome, + )}`} + {inspection.cancellation === undefined ? "" : " · 已收到停止请求"} + + {dispositions.length > 0 && ( + + 处理步骤: + {summarizeStatuses( + dispositions.map(({ status }) => status), + progressStatusLabel, + )} + + )} + {workItems.length > 0 && ( + + 资料项:{workItems.length} 项 + {workItems.filter( + ({ acceptedAssetId }) => acceptedAssetId !== undefined, + ).length === 0 + ? "" + : ` · ${workItems.filter(({ acceptedAssetId }) => acceptedAssetId !== undefined).length} 项已关联`} + {workItems.filter(({ reason }) => reason !== undefined).length === 0 + ? "" + : ` · ${workItems.filter(({ reason }) => reason !== undefined).length} 项有说明`} + + )} + {targets.length > 0 && ( + + 目标: + {summarizeStatuses( + targets.map(({ status }) => status), + progressStatusLabel, + )} + + )} + {sourceRefreshRuns.length > 0 && ( + + 来源更新:{sourceRefreshRuns.length} 项 · {changeSets.length} 组变更 + {changeSets.filter(({ disposition }) => disposition === "pending") + .length === 0 + ? "" + : ` · ${changeSets.filter(({ disposition }) => disposition === "pending").length} 组待确认`} + + )} + + ); +} + +function summarizeStatuses( + statuses: readonly string[], + label: (status: string) => string, +): string { + const counts = new Map(); + for (const status of statuses) + counts.set(status, (counts.get(status) ?? 0) + 1); + return [...counts] + .map(([status, count]) => `${label(status)} ${count}`) + .join("、"); +} + +function summarizeExecutionStatuses( + executions: readonly { + readonly status: string; + readonly outcome?: string | undefined; + }[], +): string { + const counts = new Map(); + for (const execution of executions) { + const label = historyExecutionStatus(execution.status, execution.outcome); + counts.set(label, (counts.get(label) ?? 0) + 1); + } + return [...counts].map(([label, count]) => `${label} ${count}`).join("、"); +} + +function progressStatusLabel(status: string): string { + switch (status) { + case "running": + return "进行中"; + case "completed": + return "已完成"; + case "failed": + return "失败"; + case "skipped": + return "已跳过"; + case "reused": + return "已复用"; + case "no-change": + return "无变化"; + case "blocked": + return "受阻"; + default: + return "已处理"; + } +} + +function operationLabel(operationKind: string | undefined): string { + switch (operationKind) { + case "scan-filesystem": + return "本地目录扫描"; + case "scan-asset-library": + return "资料库扫描"; + case "refresh-metadata": + return "资料更新"; + case "rescrape": + return "来源复查"; + case "associate-observed-material": + return "资料关联"; + default: + return "工作流"; + } +} + +function historyStage(stage: string): string { + switch (stage) { + case "recognition": + return "识别中"; + case "source-records-retained": + return "来源资料已保存"; + case "source-artifacts-acquired": + case "cover-acquired": + return "正在获取资料文件"; + case "source-artifacts-stored": + case "cover-stored": + return "资料文件已保存"; + case "completed": + return "已结束"; + case "failed": + return "遇到问题"; + default: + return "处理中"; + } +} + +function historyOutcome(outcome: string): string { + switch (outcome) { + case "unresolved": + return "尚未解决"; + case "proposal": + return "有待确认的建议"; + case "accepted": + return "已确认"; + case "partially-succeeded": + return "部分完成"; + case "failed": + return "未完成"; + case "interrupted": + return "已中断"; + case "cancelled": + return "已取消"; + case "no-change": + return "没有变化"; + default: + return "已处理"; + } +} + +function attentionKindLabel( + kind: CurrentAttentionPage["items"][number]["kind"], +): string { + switch (kind) { + case "recognition-review": + return "需要确认识别结果"; + case "change-impact-review": + return "需要查看变更影响"; + case "source-refresh-review": + return "需要查看来源更新"; + case "prerequisite-unavailable": + return "所需资料暂不可用"; + } +} + +function localObservationDetails( + item: Extract< + AssetEvidencePage["items"][number], + { kind: "local-observation" } + >, +): string { + const entry = item.entry; + const observedAt = formatDate(item.observedAt); + if (entry.kind === "file") + return `${item.relativePath} · 文件 · ${entry.size} 字节 · 修改 ${formatDate(entry.modifiedAt)} · 观察 ${observedAt}`; + if (entry.kind === "directory") + return `${item.relativePath} · 目录 · 修改 ${formatDate(entry.modifiedAt)} · 观察 ${observedAt}`; + return `${item.relativePath} · 未纳入 · ${observationExclusionLabel(entry.reason)} · 观察 ${observedAt}`; +} + +function observationExclusionLabel( + reason: "symbolic-link" | "special" | "unavailable", +): string { + switch (reason) { + case "symbolic-link": + return "链接未纳入"; + case "special": + return "特殊文件未纳入"; + case "unavailable": + return "当前不可读取"; + } +} + +function formatDate(value: string): string { + const date = new Date(value); + return Number.isNaN(date.valueOf()) ? value : date.toLocaleString("zh-CN"); +} + +function formatResultDetails( + item: Extract, +): string { + switch (item.detail.kind) { + case "result": + return `读取结果 · ${item.detail.byteLength} 字节 · ${mediaTypeLabel(item.detail.mediaType)}`; + case "identifier": + return `标识 ${identifierSchemeLabel(item.detail.scheme)}:${item.detail.value || "(空)"}`; + case "metadata": + return `${formatMetadataPropertyLabel(item.detail.property)}:${item.detail.value || "(空)"}`; + case "part": + return `${formatPartRoleLabel(item.detail.role)} · ${mediaTypeLabel(item.detail.mediaType)}`; + case "entity-graph": + return `检查到 ${item.detail.entityCount} 个条目、${item.detail.mentionCount} 处提及、${item.detail.relationshipCount} 项关联`; + } +} + +function mediaTypeLabel(mediaType: string): string { + switch (mediaType) { + case "application/pdf": + return "PDF 文档"; + case "application/epub+zip": + return "EPUB 电子书"; + case "image/jpeg": + return "JPEG 图像"; + case "image/png": + return "PNG 图像"; + case "audio/mpeg": + return "MP3 音频"; + default: + return mediaType; + } +} + +function identifierSchemeLabel(scheme: string): string { + return scheme.toLocaleLowerCase() === "isbn-13" ? "ISBN-13" : scheme; +} + +function formatMetadataPropertyLabel(property: string): string { + switch (property) { + case "pdf.page-count": + return "页数"; + case "pdf.document-author": + return "作者"; + default: + return property; + } +} + +function formatPartRoleLabel(role: string): string { + switch (role) { + case "asset.material-image": + return "页面图像"; + case "asset.primary-cover": + return "封面"; + default: + return role; + } +} + +function windowStart(length: number, selected: number, size: number): number { + return Math.min(Math.max(0, selected - size + 1), Math.max(0, length - size)); +} + +function publicErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : fallback; +} diff --git a/apps/tui/src/automation.tsx b/apps/tui/src/automation.tsx new file mode 100644 index 0000000..7013602 --- /dev/null +++ b/apps/tui/src/automation.tsx @@ -0,0 +1,476 @@ +import { + PublicApiClientError, + PublicExecutionClientError, +} from "@digital/public-api"; +import type { + PublicApiClient, + PublicAssetLibrary, + PublicAutomationIntent, + PublicExecutionInspection, + WorkflowHistoryPage, +} from "@digital/public-api"; +import { Box, Text } from "ink"; +import { useEffect, useMemo, useState } from "react"; + +import { useCurrentInput } from "./use-current-input.ts"; + +type AutomationClient = Pick< + PublicApiClient, + | "listAutomationIntents" + | "enableAutomationIntent" + | "disableAutomationIntent" + | "triggerAutomationIntent" + | "listWorkflowHistory" + | "inspectExecution" + | "cancelExecution" +>; + +type AutomationPanelProps = { + readonly client: AutomationClient; + readonly library: PublicAssetLibrary; + readonly onExit: () => void; +}; + +const intervalOptions = [ + { milliseconds: 3_600_000, label: "1 小时" }, + { milliseconds: 21_600_000, label: "6 小时" }, + { milliseconds: 86_400_000, label: "24 小时" }, +] as const; + +export function AutomationPanel({ + client, + library, + onExit, +}: AutomationPanelProps) { + const [intents, setIntents] = useState([]); + const [history, setHistory] = useState({ items: [] }); + const [selected, setSelected] = useState(0); + const [intervalOverride, setIntervalOverride] = useState(); + const [acceptTrustedOverride, setAcceptTrustedOverride] = useState(); + const [relativeScopeOverride, setRelativeScopeOverride] = useState(); + const [scopeEditing, setScopeEditing] = useState(false); + const [scopeInput, setScopeInput] = useState(""); + const [message, setMessage] = useState("正在读取自动刷新设置…"); + const [readFailed, setReadFailed] = useState(false); + const [busy, setBusy] = useState(false); + const [reloadVersion, setReloadVersion] = useState(0); + const [activeExecutionId, setActiveExecutionId] = useState(); + const [activeExecution, setActiveExecution] = + useState(); + + const directories = library.directories; + const activeDirectory = directories[selected]; + const libraryIntents = useMemo( + () => intents.filter(({ scope }) => scope.assetLibraryId === library.id), + [intents, library.id], + ); + const selectedIntents = libraryIntents.filter( + ({ scope }) => scope.directoryId === activeDirectory?.id, + ); + const enabledIntent = selectedIntents.findLast(({ enabled }) => enabled); + const latestSelectedIntent = enabledIntent ?? selectedIntents.at(-1); + const configuredIntervalIndex = Math.max( + 0, + intervalOptions.findIndex( + ({ milliseconds }) => + milliseconds === latestSelectedIntent?.intervalMilliseconds, + ), + ); + const intervalIndex = intervalOverride ?? configuredIntervalIndex; + const acceptTrusted = + acceptTrustedOverride ?? latestSelectedIntent?.acceptTrusted ?? true; + const relativeScope = + relativeScopeOverride ?? latestSelectedIntent?.scope.relativeScope ?? "."; + useEffect(() => { + let active = true; + void Promise.all([ + client.listAutomationIntents(), + client.listWorkflowHistory({ limit: 100 }), + ]) + .then(([result, workflowHistory]) => { + if (!active) return; + setIntents(result.items); + setHistory(workflowHistory); + const intentIds = new Set( + result.items + .filter(({ scope }) => scope.assetLibraryId === library.id) + .map(({ id }) => id), + ); + const runningExecution = workflowHistory.items.find( + ({ execution }) => + execution.status === "running" && + execution.automationIntentId !== undefined && + intentIds.has(execution.automationIntentId), + ); + if (runningExecution !== undefined) + setActiveExecutionId(runningExecution.execution.id); + setReadFailed(false); + setMessage(""); + }) + .catch(() => { + if (!active) return; + setReadFailed(true); + setMessage("暂时无法读取自动刷新状态,请按 r 重试"); + }); + return () => { + active = false; + }; + }, [client, library.id, reloadVersion]); + + useEffect(() => { + if (activeExecutionId === undefined) return; + let active = true; + let timer: ReturnType | undefined; + const inspect = async () => { + try { + const result = await client.inspectExecution(activeExecutionId); + if (!active) return; + setActiveExecution(result); + if (executionStatus(result) === "进行中") { + timer = setTimeout(() => void inspect(), 500); + } else { + setMessage(`自动刷新${executionStatus(result)}`); + setReloadVersion((version) => version + 1); + } + } catch { + if (!active) return; + setMessage("暂时无法读取自动刷新进度"); + timer = setTimeout(() => void inspect(), 1_500); + } + }; + void inspect(); + return () => { + active = false; + if (timer !== undefined) clearTimeout(timer); + }; + }, [activeExecutionId, client]); + + const refresh = () => setReloadVersion((version) => version + 1); + + const enable = async () => { + if (activeDirectory === undefined) { + setMessage("此资产库没有可配置目录"); + return; + } + if (activeDirectory.status !== "active" || !activeDirectory.reachable) { + setMessage("所选目录当前不可用,请检查资产库设置"); + return; + } + if (enabledIntent !== undefined) { + setMessage("此目录已有启用设置;停用后可重新配置"); + return; + } + setBusy(true); + setMessage("正在保存自动刷新设置并执行首次扫描…"); + try { + const interval = intervalOptions[intervalIndex]; + if (interval === undefined) throw new Error("自动刷新间隔无效"); + const result = await client.enableAutomationIntent({ + assetLibraryId: library.id, + directoryId: activeDirectory.id, + relativeScope, + intervalMilliseconds: interval.milliseconds, + acceptTrusted, + }); + if (result.initial?.status === "started") { + setActiveExecutionId(result.initial.executionId); + } else if (result.initial?.status === "skipped") { + setMessage(`设置已启用;首次刷新未启动:${result.initial.reason}`); + } else if (result.initial?.status === "disabled") { + setMessage(`设置已保存,但自动刷新已停用:${result.initial.reason}`); + } else setMessage("自动刷新设置已启用"); + refresh(); + } catch (error) { + setMessage(operationFailureMessage(error, "启用")); + } finally { + setBusy(false); + } + }; + + const disable = async () => { + if (enabledIntent === undefined) { + setMessage("所选目录没有启用中的自动刷新设置"); + return; + } + setBusy(true); + try { + await client.disableAutomationIntent(enabledIntent.id); + setMessage("自动刷新已停用;正在运行的执行会按执行状态结束"); + refresh(); + } catch (error) { + setMessage(operationFailureMessage(error, "停用")); + } finally { + setBusy(false); + } + }; + + const trigger = async () => { + const intent = latestSelectedIntent; + if (intent === undefined) { + setMessage("请先为所选目录启用自动刷新"); + return; + } + setBusy(true); + setMessage("正在提交立即扫描…"); + try { + const result = await client.triggerAutomationIntent(intent.id); + if (result.status === "started") { + setActiveExecutionId(result.executionId); + setActiveExecution(undefined); + setMessage("立即扫描已提交,正在查询实际执行状态"); + } else if (result.status === "disabled") { + setMessage("自动刷新设置已停用"); + } else { + setMessage("已有自动刷新正在执行,本次未重复启动"); + } + refresh(); + } catch (error) { + setMessage(operationFailureMessage(error, "立即扫描")); + } finally { + setBusy(false); + } + }; + + const cancel = async () => { + if (activeExecutionId === undefined) return; + setBusy(true); + try { + const result = await client.cancelExecution(activeExecutionId); + setActiveExecution(result.inspection); + setMessage( + result.requestStatus === "requested" + ? "取消请求已提交,仍在等待实际结局" + : `执行${executionStatus(result.inspection)}`, + ); + } catch (error) { + setMessage(operationFailureMessage(error, "取消")); + } finally { + setBusy(false); + } + }; + + useCurrentInput((input, key) => { + if (scopeEditing) { + if (key.escape) { + setScopeEditing(false); + return; + } + if (key.return) { + const value = scopeInput.trim(); + if (value.length === 0) { + setMessage("请输入目录下的相对范围,例如 . 或 子目录"); + return; + } + setRelativeScopeOverride(value); + setScopeEditing(false); + setMessage("扫描范围已更新"); + return; + } + if (key.ctrl && input.toLowerCase() === "u") { + setScopeInput(""); + return; + } + if (key.backspace || key.delete) { + setScopeInput((value) => Array.from(value).slice(0, -1).join("")); + return; + } + if (input.length > 0 && !key.ctrl && !key.meta) + setScopeInput((value) => value + input); + return; + } + if (input === "q" || key.escape) { + onExit(); + return; + } + if (busy) return; + if (input === "r") { + setMessage("正在重新读取自动刷新状态…"); + refresh(); + return; + } + if (input === "i") { + setIntervalOverride( + (index) => + ((index ?? configuredIntervalIndex) + 1) % intervalOptions.length, + ); + return; + } + if (input === "t") { + setAcceptTrustedOverride( + (value) => !(value ?? latestSelectedIntent?.acceptTrusted ?? true), + ); + return; + } + if (input === "f") { + setScopeInput(relativeScope); + setScopeEditing(true); + setMessage("输入资产库目录下的相对范围,Enter 保存,Esc 取消"); + return; + } + if ((key.downArrow || input === "j") && selected < directories.length - 1) { + setSelected((index) => index + 1); + setIntervalOverride(undefined); + setAcceptTrustedOverride(undefined); + setRelativeScopeOverride(undefined); + } else if ((key.upArrow || input === "k") && selected > 0) { + setSelected((index) => index - 1); + setIntervalOverride(undefined); + setAcceptTrustedOverride(undefined); + setRelativeScopeOverride(undefined); + } else if (input === "e") void enable(); + else if (input === "d") void disable(); + else if (input === "s") void trigger(); + else if (input === "c") void cancel(); + }); + + const interval = intervalOptions[intervalIndex]; + return ( + + + 自动刷新:{library.displayName} + + 目录仅显示名称;产品服务按已保存设置独立运行。 + {directories.map((directory, index) => { + const directoryIntents = libraryIntents.filter( + ({ scope }) => scope.directoryId === directory.id, + ); + const enabled = directoryIntents.find(({ enabled: value }) => value); + const latest = directoryIntents.at(-1); + const current = enabled ?? latest; + const latestRun = + current === undefined + ? undefined + : history.items.find( + ({ execution }) => execution.automationIntentId === current.id, + )?.execution; + const availability = + directory.status !== "active" + ? "目录已移除" + : !directory.reachable + ? "目录不可用" + : "未设置"; + const rowStatus = + current === undefined + ? availability + : `${current.enabled ? "已启用" : disabledReasonLabel(current.disabledReason)} · ${intervalLabel(current.intervalMilliseconds)} · ${current.acceptTrusted ? "信任结果可自动应用" : "信任结果需审阅"}`; + return ( + + + {index === selected ? "› " : " "} + {directory.displayName} {rowStatus} + + {latestRun !== undefined ? ( + + {index === selected ? " " : " "}最近执行: + {executionHistoryStatus(latestRun.status, latestRun.outcome)} + + ) : null} + + ); + })} + {directories.length === 0 ? ( + 此资产库没有目录设置 + ) : null} + + 扫描范围(相对目录): + {scopeEditing ? `编辑中:${scopeInput}` : relativeScope} + + + 新设置:{interval?.label ?? "未知"} 信任结果: + {acceptTrusted ? "自动应用" : "需审阅"} + + {activeExecution !== undefined ? ( + + 当前执行:{executionStatus(activeExecution)} + {executionProgress(activeExecution)} + + ) : null} + {message.length > 0 ? ( + {message} + ) : null} + + {scopeEditing + ? "输入范围 Enter 保存 Esc 取消 Ctrl+U 清空" + : "↑↓/j k 选择 f 编辑范围 i 间隔 t 信任策略 e 启用 d 停用 s 立即扫描 c 请求取消 r 重读 q 返回"} + + + ); +} + +function disabledReasonLabel( + reason: PublicAutomationIntent["disabledReason"], +): string { + switch (reason) { + case "observed-root-revoked": + return "目录授权已撤销"; + case "observed-root-unavailable": + return "目录当前不可用"; + case "prerequisite-unavailable": + return "所需配置不可用"; + case "invalid-configuration": + return "设置需要检查"; + default: + return "已停用"; + } +} + +function intervalLabel(milliseconds: number): string { + const hours = milliseconds / 3_600_000; + return Number.isInteger(hours) ? `${hours} 小时` : "自定义间隔"; +} + +function executionStatus(execution: PublicExecutionInspection): string { + const status = execution.status; + const outcome = execution.outcome; + if (status === "running") return "进行中"; + return executionHistoryStatus(status, outcome); +} + +function executionHistoryStatus( + status: "running" | "completed" | "failed", + outcome: string | undefined, +): string { + if (status === "running") return "进行中"; + if (status === "failed" || outcome === "failed") return "失败"; + if (outcome === "cancelled") return "已取消"; + if (outcome === "interrupted") return "已中断"; + if (outcome === "partially-succeeded") return "部分完成"; + if (outcome === "no-change") return "无变更"; + if (outcome === "accepted") return "已识别"; + if (outcome === "unresolved") return "未识别"; + if (outcome === "proposal") return "待确认"; + return "已完成"; +} + +function executionProgress(execution: PublicExecutionInspection): string { + if (execution.kind !== "asset-library-scan") return ""; + const completed = execution.children.filter( + (child) => + child.status === "available" && child.execution.status !== "running", + ).length; + return `(${completed}/${execution.children.length} 个目录项结束)`; +} + +function operationFailureMessage(error: unknown, action: string): string { + if (error instanceof PublicApiClientError) { + switch (error.kind) { + case "invalid-request": + return `自动刷新${action}失败:设置无效`; + case "conflict": + return `自动刷新${action}失败:资产库或目录设置已变化,请重新读取`; + case "not-found": + return `自动刷新${action}失败:设置不存在或已移除`; + case "not-ready": + return `自动刷新${action}失败:目录当前不可用`; + default: + return `自动刷新${action}失败:产品服务暂时不可用`; + } + } + if (error instanceof PublicExecutionClientError) { + if (error.kind === "not-found") return "执行记录不存在或已移除"; + if (error.kind === "conflict") return "执行状态已变化,请重新查询"; + } + return `自动刷新${action}失败,请稍后重试`; +} diff --git a/apps/tui/src/cli.tsx b/apps/tui/src/cli.tsx new file mode 100644 index 0000000..4d23fdf --- /dev/null +++ b/apps/tui/src/cli.tsx @@ -0,0 +1,1632 @@ +#!/usr/bin/env node +import { + createPublicApiClient, + PublicApiClientError, + PublicExecutionClientError, +} from "@digital/public-api"; +import type { + PublicExecutionInspection, + PublicAssetLibrary, + PublicAssetLibraryOptions, + PublicAssetLibraryScan, + PublicCurrentAsset, + PublicPage, + PublicUnrecognizedMaterial, +} from "@digital/public-api"; +import { Box, render, Text, useApp, useStdout } from "ink"; +import { useEffect, useMemo, useState } from "react"; + +import { AssetInspectionPanel, WorkItemsPanel } from "./asset-inspection.tsx"; +import { AutomationPanel } from "./automation.tsx"; +import { SourceConnectionsPanel } from "./source-connections.tsx"; +import { useCurrentInput } from "./use-current-input.ts"; + +type RefreshKind = "library-scan" | "rescrape" | "metadata-refresh" | "unknown"; + +type Mode = + | "libraries" + | "assets" + | "unrecognized" + | "scan" + | "inspection" + | "attention" + | "history" + | "connections" + | "automation"; +type Row = PublicAssetLibrary | PublicCurrentAsset | PublicUnrecognizedMaterial; +type EditorStage = "name" | "directories" | "profiles" | "sources"; +type LibraryEditor = { + readonly id?: string | undefined; + readonly expectedVersion?: number | undefined; + readonly stage: EditorStage; + readonly displayName: string; + readonly directories: readonly string[]; + readonly recognitionProfileIds: readonly string[]; + readonly sourceIdentities: readonly string[]; + readonly input: string; + readonly selectedChoice: number; + readonly error?: string | undefined; +}; + +function argumentValue( + arguments_: readonly string[], + name: string, +): string | undefined { + const index = arguments_.findIndex((argument) => argument === name); + return index < 0 ? undefined : arguments_[index + 1]; +} + +function argumentUrl(arguments_: readonly string[]): string { + const value = argumentValue(arguments_, "--url"); + if (value !== undefined) return value; + if (arguments_.includes("--url")) + throw new Error("请在 --url 后提供服务地址"); + return process.env.DIGITAL_PRODUCT_URL ?? "http://127.0.0.1:3000"; +} + +function App({ + baseUrl, + initialExecutionId, +}: { + readonly baseUrl: string; + readonly initialExecutionId?: string | undefined; +}) { + const { exit } = useApp(); + const { stdout } = useStdout(); + const sourceConnectionClient = useMemo( + () => createPublicApiClient({ baseUrl }), + [baseUrl], + ); + const [mode, setMode] = useState( + initialExecutionId === undefined ? "libraries" : "scan", + ); + const inspectionClient = useMemo( + () => createPublicApiClient({ baseUrl }), + [baseUrl], + ); + const [page, setPage] = useState>({ items: [] }); + const [libraryId, setLibraryId] = useState(); + const [libraryName, setLibraryName] = useState(); + const [automationLibrary, setAutomationLibrary] = + useState(); + const [cursor, setCursor] = useState(); + const [previousCursors, setPreviousCursors] = useState< + readonly (string | undefined)[] + >([]); + const [selected, setSelected] = useState(0); + const [filterText, setFilterText] = useState(""); + const [filterEditing, setFilterEditing] = useState(false); + const [readFailed, setReadFailed] = useState(false); + const [message, setMessage] = useState("正在读取…"); + const [reloadVersion, setReloadVersion] = useState(0); + const [editor, setEditor] = useState(); + const [editorLoading, setEditorLoading] = useState(false); + const [libraryOptions, setLibraryOptions] = + useState(); + const [pendingRemoval, setPendingRemoval] = useState(); + const [scanExecutionId, setScanExecutionId] = useState( + initialExecutionId, + ); + const [scanResult, setScanResult] = useState(); + const [executionInspection, setExecutionInspection] = + useState(); + const [refreshKind, setRefreshKind] = useState("unknown"); + const [cancelRequestStatus, setCancelRequestStatus] = useState< + "requested" | "already-finished" + >(); + const [scanError, setScanError] = useState(); + const [scanReloadVersion, setScanReloadVersion] = useState(0); + const [actionBusy, setActionBusy] = useState(false); + const [inspectionAssetId, setInspectionAssetId] = useState(); + const [inspectionReturnMode, setInspectionReturnMode] = + useState("assets"); + const [workItemsReturnMode, setWorkItemsReturnMode] = + useState("libraries"); + const [terminalSize, setTerminalSize] = useState({ + columns: stdout.columns ?? 80, + rows: stdout.rows ?? 24, + }); + + useEffect(() => { + const updateSize = () => + setTerminalSize({ + columns: stdout.columns ?? 80, + rows: stdout.rows ?? 24, + }); + stdout.on("resize", updateSize); + updateSize(); + return () => { + stdout.off("resize", updateSize); + }; + }, [stdout]); + + useEffect(() => { + let active = true; + const client = createPublicApiClient({ baseUrl }); + if ( + mode === "scan" || + isInspectionMode(mode) || + mode === "connections" || + mode === "automation" + ) + return; + void (async () => { + try { + const input = { + ...(libraryId === undefined ? {} : { assetLibraryId: libraryId }), + ...(cursor === undefined ? {} : { cursor }), + limit: 20, + }; + const result = + mode === "libraries" + ? await client.listAssetLibraries(input) + : mode === "assets" + ? await client.listAssets(input) + : await client.listUnrecognizedMaterials(input); + if (active) { + setPage(result); + setReadFailed(false); + setMessage( + result.items.length === 0 + ? mode === "libraries" + ? "还没有资产库" + : "此页没有内容" + : "", + ); + } + } catch (error) { + if (!active) return; + setPage({ items: [] }); + setReadFailed(true); + setMessage( + error instanceof PublicApiClientError + ? error.message + : "暂时无法读取资料", + ); + } + })(); + return () => { + active = false; + }; + }, [baseUrl, cursor, libraryId, mode, reloadVersion]); + + useEffect(() => { + if (mode !== "scan" || scanExecutionId === undefined) return; + const client = createPublicApiClient({ baseUrl }); + let active = true; + let timer: ReturnType | undefined; + const inspect = async () => { + try { + const execution = await client.inspectExecution(scanExecutionId); + if (!active) return; + setExecutionInspection(execution); + setScanError(undefined); + if (execution.kind === "asset-library-scan") { + setRefreshKind("library-scan"); + const result = await client.inspectAssetLibraryScan(scanExecutionId); + if (!active) return; + setScanResult(result); + if (execution.status === "running") + timer = setTimeout(() => void inspect(), 500); + else { + setMessage( + executionResultMessage("扫描", result.status, result.outcome), + ); + setReloadVersion((version) => version + 1); + } + return; + } + setScanResult(undefined); + setRefreshKind( + execution.operationKind === "rescrape" + ? "rescrape" + : execution.operationKind === "refresh-metadata" + ? "metadata-refresh" + : "unknown", + ); + if (execution.status === "running") + timer = setTimeout(() => void inspect(), 500); + else { + setMessage( + executionResultMessage("刷新", execution.status, execution.outcome), + ); + setReloadVersion((version) => version + 1); + } + } catch (error) { + if (!active) return; + setScanError( + error instanceof PublicApiClientError || + error instanceof PublicExecutionClientError + ? error.message + : "暂时无法读取执行结果", + ); + timer = setTimeout(() => void inspect(), 1500); + } + }; + void inspect(); + return () => { + active = false; + if (timer !== undefined) clearTimeout(timer); + }; + }, [baseUrl, mode, scanExecutionId, scanReloadVersion]); + + const openLibraryEditor = (library?: PublicAssetLibrary) => { + setEditorLoading(true); + setMessage("正在读取识别能力与来源…"); + void createPublicApiClient({ baseUrl }) + .listAssetLibraryOptions() + .then((options) => { + setLibraryOptions(options); + setEditor({ + ...(library === undefined + ? {} + : { id: library.id, expectedVersion: library.configVersion }), + stage: "name", + displayName: library?.displayName ?? "", + directories: + library?.directories + .filter(({ status }) => status !== "removed") + .map(({ path: directoryPath }) => directoryPath) ?? [], + recognitionProfileIds: library?.recognitionProfileIds ?? [], + sourceIdentities: library?.sourceIdentities ?? [], + input: library?.displayName ?? "", + selectedChoice: 0, + }); + setMessage(""); + }) + .catch((error: unknown) => { + setMessage(publicErrorMessage(error, "暂时无法读取资产库选项")); + setReadFailed(true); + }) + .finally(() => setEditorLoading(false)); + }; + + const refreshLibraries = async (preferredId?: string) => { + const result = await createPublicApiClient({ baseUrl }).listAssetLibraries({ + limit: 20, + }); + setMode("libraries"); + setCursor(undefined); + setPreviousCursors([]); + setPage(result); + setSelected( + preferredId === undefined + ? 0 + : Math.max( + 0, + result.items.findIndex((item) => item.id === preferredId), + ), + ); + setReadFailed(false); + }; + + const saveEditor = async (current: LibraryEditor) => { + if (actionBusy) return; + setActionBusy(true); + setMessage("正在保存资产库…"); + try { + const client = createPublicApiClient({ baseUrl }); + const saved = await client.saveAssetLibrary({ + ...(current.id === undefined || current.expectedVersion === undefined + ? {} + : { id: current.id, expectedVersion: current.expectedVersion }), + displayName: current.displayName, + directories: [...current.directories], + recognitionProfileIds: [...current.recognitionProfileIds], + sourceIdentities: [...current.sourceIdentities], + }); + const result = await client.listAssetLibraries({ limit: 20 }); + setPage(result); + setMode("libraries"); + setCursor(undefined); + setPreviousCursors([]); + setSelected( + Math.max( + 0, + result.items.findIndex(({ id }) => id === saved.id), + ), + ); + setEditor(undefined); + setReadFailed(false); + setMessage( + `已保存「${saved.displayName}」。目录路径由服务所在机器访问,不会上传客户端文件。`, + ); + } catch (error) { + setEditor({ + ...current, + error: publicErrorMessage(error, "资产库保存失败"), + }); + setMessage(""); + } finally { + setActionBusy(false); + } + }; + + const removeLibrary = async (library: PublicAssetLibrary) => { + if (actionBusy) return; + setActionBusy(true); + setMessage(`正在移除「${library.displayName}」…`); + try { + await createPublicApiClient({ baseUrl }).removeAssetLibrary({ + id: library.id, + expectedVersion: library.configVersion, + }); + await refreshLibraries(); + setMessage(`已移除「${library.displayName}」的资产库配置`); + } catch (error) { + setMessage(publicErrorMessage(error, "资产库移除失败")); + } finally { + setActionBusy(false); + setPendingRemoval(undefined); + } + }; + + const startLibraryScan = async (library: PublicAssetLibrary) => { + if (actionBusy) return; + setActionBusy(true); + setLibraryId(library.id); + setLibraryName(library.displayName); + setScanResult(undefined); + setExecutionInspection(undefined); + setScanExecutionId(undefined); + setRefreshKind("library-scan"); + setCancelRequestStatus(undefined); + setScanError(undefined); + setMode("scan"); + setMessage("正在发起扫描…"); + try { + const started = await createPublicApiClient({ + baseUrl, + }).startAssetLibraryScan(library.id); + setScanExecutionId(started.parentExecutionId); + setMessage("已发起扫描"); + } catch (error) { + setMode("libraries"); + setMessage(publicErrorMessage(error, "无法发起资产库扫描")); + } finally { + setActionBusy(false); + } + }; + + const startSubjectRefresh = async ( + subjectId: string, + kind: "rescrape" | "metadata-refresh", + ) => { + if (actionBusy) return; + setActionBusy(true); + setScanResult(undefined); + setExecutionInspection(undefined); + setScanExecutionId(undefined); + setRefreshKind(kind); + setCancelRequestStatus(undefined); + setScanError(undefined); + setMode("scan"); + setMessage( + kind === "rescrape" ? "正在发起重新刮削…" : "正在发起元数据刷新…", + ); + try { + const client = createPublicApiClient({ baseUrl }); + const started = + kind === "rescrape" + ? await client.startCurrentScopeRescrape({ subjectId }) + : await client.startMetadataRefresh({ subjectId }); + setScanExecutionId(started.id); + setMessage("已发起刷新"); + } catch (error) { + setMode("assets"); + setMessage(publicErrorMessage(error, "无法发起刷新")); + } finally { + setActionBusy(false); + } + }; + + const cancelCurrentExecution = async () => { + if (actionBusy || scanExecutionId === undefined) return; + setActionBusy(true); + setScanError(undefined); + setMessage("正在请求取消…"); + try { + const result = await createPublicApiClient({ baseUrl }).cancelExecution( + scanExecutionId, + ); + setCancelRequestStatus(result.requestStatus); + setExecutionInspection(result.inspection); + setMessage( + result.requestStatus === "requested" + ? "取消请求已提交,仍在等待实际结局" + : "执行已结束,显示持久化结局", + ); + if (result.inspection.kind === "asset-library-scan") { + setRefreshKind("library-scan"); + const scan = await createPublicApiClient({ + baseUrl, + }).inspectAssetLibraryScan(scanExecutionId); + setScanResult(scan); + } else { + setScanResult(undefined); + setRefreshKind( + result.inspection.operationKind === "rescrape" + ? "rescrape" + : result.inspection.operationKind === "refresh-metadata" + ? "metadata-refresh" + : "unknown", + ); + } + } catch (error) { + setScanError(publicErrorMessage(error, "取消请求失败;执行状态仍需检查")); + setMessage("取消请求失败,执行仍可能继续"); + } finally { + setActionBusy(false); + } + }; + + const rows: readonly Row[] = page.items; + const narrow = terminalSize.columns < 40; + const filteredRows = + filterText.length === 0 + ? rows + : rows.filter((row) => + rowSearchText(row, mode) + .toLocaleLowerCase() + .includes(filterText.toLocaleLowerCase()), + ); + const rowViewportHeight = Math.max( + 1, + terminalSize.rows - (filterEditing || filterText.length > 0 ? 8 : 7), + ); + const viewportStart = Math.min( + Math.max(0, selected - rowViewportHeight + 1), + Math.max(0, filteredRows.length - rowViewportHeight), + ); + const visibleRows = filteredRows.slice( + viewportStart, + viewportStart + rowViewportHeight, + ); + + useCurrentInput((input, key) => { + if (mode === "connections" || mode === "automation") return; + if (editor !== undefined) { + if (key.escape) { + setEditor(undefined); + setMessage("已取消资产库编辑"); + return; + } + if (editor.stage === "profiles" || editor.stage === "sources") { + const options = + editor.stage === "profiles" + ? (libraryOptions?.recognitionProfiles ?? []) + : (libraryOptions?.sources ?? []); + if (key.upArrow || input === "k") { + setEditor({ + ...editor, + selectedChoice: Math.max(0, editor.selectedChoice - 1), + error: undefined, + }); + return; + } + if (key.downArrow || input === "j") { + setEditor({ + ...editor, + selectedChoice: Math.min( + Math.max(0, options.length - 1), + editor.selectedChoice + 1, + ), + error: undefined, + }); + return; + } + if (input === " ") { + const option = options[editor.selectedChoice]; + if (option === undefined) return; + const values = + editor.stage === "profiles" + ? editor.recognitionProfileIds + : editor.sourceIdentities; + const updated = values.includes(option.id) + ? values.filter((value) => value !== option.id) + : [...values, option.id]; + setEditor({ + ...editor, + ...(editor.stage === "profiles" + ? { recognitionProfileIds: updated } + : { sourceIdentities: updated }), + error: undefined, + }); + return; + } + if (input === "b") { + setEditor({ + ...editor, + stage: editor.stage === "sources" ? "profiles" : "directories", + selectedChoice: 0, + input: "", + error: undefined, + }); + return; + } + if (key.return) { + if (editor.stage === "profiles") + setEditor({ + ...editor, + stage: "sources", + selectedChoice: 0, + error: undefined, + }); + else void saveEditor(editor); + } + return; + } + if (input === "b" && editor.stage === "directories") { + setEditor({ + ...editor, + stage: "name", + input: editor.displayName, + error: undefined, + }); + return; + } + if (key.ctrl && input.toLowerCase() === "u") { + setEditor({ ...editor, input: "", error: undefined }); + return; + } + if (key.backspace || key.delete) { + setEditor({ + ...editor, + input: Array.from(editor.input).slice(0, -1).join(""), + error: undefined, + }); + return; + } + if (key.return) { + const value = editor.input.trim(); + if (editor.stage === "name") { + if (value.length === 0) { + setEditor({ ...editor, error: "请填写资产库名称" }); + return; + } + setEditor({ + ...editor, + stage: "directories", + displayName: value, + input: "", + error: undefined, + }); + return; + } + if (editor.stage === "directories") { + if (value.length === 0) { + if (editor.directories.length === 0) { + setEditor({ ...editor, error: "至少添加一个服务端目录" }); + return; + } + setEditor({ + ...editor, + stage: "profiles", + selectedChoice: 0, + input: "", + error: undefined, + }); + return; + } + if (value.startsWith("-")) { + const removePath = value.slice(1).trim(); + if (!editor.directories.includes(removePath)) { + setEditor({ ...editor, input: "", error: "没有这条服务端目录" }); + return; + } + setEditor({ + ...editor, + directories: editor.directories.filter( + (item) => item !== removePath, + ), + input: "", + error: undefined, + }); + return; + } + if (editor.directories.includes(value)) { + setEditor({ ...editor, input: "", error: "这条服务端目录已添加" }); + return; + } + setEditor({ + ...editor, + directories: [...editor.directories, value], + input: "", + error: undefined, + }); + return; + } + } + if (input.length > 0 && !key.ctrl && !key.meta) + setEditor({ ...editor, input: editor.input + input, error: undefined }); + return; + } + if (editorLoading || actionBusy) return; + if (pendingRemoval !== undefined) { + if (input.toLowerCase() === "y") void removeLibrary(pendingRemoval); + else if (input.toLowerCase() === "n" || key.escape) { + setPendingRemoval(undefined); + setMessage("已取消移除"); + } + return; + } + if (filterEditing) { + if (key.return) { + setFilterEditing(false); + return; + } + if (key.escape) { + setFilterEditing(false); + setFilterText(""); + setSelected(0); + return; + } + if (key.backspace || key.delete) { + setFilterText((value) => Array.from(value).slice(0, -1).join("")); + setSelected(0); + return; + } + if (input.length > 0 && !key.ctrl && !key.meta) { + setFilterText((value) => value + input); + setSelected(0); + } + return; + } + if (input === "q") { + exit(); + return; + } + if (isInspectionMode(mode)) return; + if (key.escape) { + exit(); + return; + } + if ( + input === "1" || + input === "2" || + input === "3" || + input === "4" || + input === "5" || + input === "6" || + input === "7" + ) { + const nextMode: Mode = + input === "1" + ? "libraries" + : input === "2" + ? "assets" + : input === "3" + ? "unrecognized" + : input === "4" + ? "scan" + : input === "5" + ? "attention" + : input === "6" + ? "history" + : "connections"; + if ( + nextMode === "scan" && + scanResult === undefined && + scanExecutionId === undefined + ) + return; + if (nextMode === "attention" || nextMode === "history") + setWorkItemsReturnMode(mode); + setMode(nextMode); + setCursor(undefined); + setPreviousCursors([]); + setSelected(0); + setFilterText(""); + setFilterEditing(false); + return; + } + if (input === "/") { + setFilterText(""); + setFilterEditing(true); + setSelected(0); + return; + } + if (input === "r") { + if (mode === "scan") { + setScanError(undefined); + setScanReloadVersion((version) => version + 1); + } else { + setMessage("正在重新读取…"); + setReadFailed(false); + setReloadVersion((version) => version + 1); + } + return; + } + if (mode === "scan" && input === "c") { + void cancelCurrentExecution(); + return; + } + if (input === "l" && libraryId !== undefined) { + setMode("libraries"); + setCursor(undefined); + setPreviousCursors([]); + setSelected(0); + return; + } + if (mode === "libraries" && input === "c") { + openLibraryEditor(); + return; + } + if (input === "n" && page.nextCursor !== undefined) { + setPreviousCursors((values) => [...values, cursor]); + setCursor(page.nextCursor); + setSelected(0); + setFilterText(""); + return; + } + if (input === "p" && previousCursors.length > 0) { + setCursor(previousCursors.at(-1)); + setPreviousCursors((values) => values.slice(0, -1)); + setSelected(0); + setFilterText(""); + return; + } + if ((key.downArrow || input === "j") && selected < filteredRows.length - 1) + setSelected(selected + 1); + if ((key.upArrow || input === "k") && selected > 0) + setSelected(selected - 1); + const selectedRow = filteredRows[selected]; + if ( + (input === "i" || key.return) && + mode === "assets" && + selectedRow !== undefined && + "descriptions" in selectedRow + ) { + setInspectionAssetId(selectedRow.id); + setInspectionReturnMode("assets"); + setMode("inspection"); + return; + } + if ( + mode === "libraries" && + selectedRow !== undefined && + "displayName" in selectedRow + ) { + const library = selectedRow as PublicAssetLibrary; + if (input === "e") { + openLibraryEditor(library); + return; + } + if (input === "d") { + setPendingRemoval(library); + setMessage(`确认移除「${library.displayName}」?按 y 确认,n 取消`); + return; + } + if (input === "s") { + void startLibraryScan(library); + return; + } + if (input === "a") { + setAutomationLibrary(library); + setMode("automation"); + return; + } + } + if ( + mode === "assets" && + selectedRow !== undefined && + "descriptions" in selectedRow + ) { + if (input === "x") { + void startSubjectRefresh(selectedRow.id, "rescrape"); + return; + } + if (input === "m") { + void startSubjectRefresh(selectedRow.id, "metadata-refresh"); + return; + } + } + if (key.return && mode === "libraries" && selectedRow !== undefined) { + const library = selectedRow as PublicAssetLibrary; + setLibraryId(library.id); + setLibraryName(library.displayName); + setMode("assets"); + setCursor(undefined); + setPreviousCursors([]); + setSelected(0); + setFilterText(""); + } + }); + + return ( + + + 数字资料库 + + {mode === "automation" ? null : ( + + {navigationLabel(narrow, terminalSize.columns, libraryName, mode)} + + )} + {mode === "inspection" && inspectionAssetId !== undefined ? ( + setMode(inspectionReturnMode)} + /> + ) : null} + {mode === "attention" || mode === "history" ? ( + setMode(workItemsReturnMode)} + onOpenAsset={(assetId) => { + setInspectionAssetId(assetId); + setInspectionReturnMode(mode); + setMode("inspection"); + }} + /> + ) : null} + {mode === "connections" ? ( + setMode("libraries")} + /> + ) : null} + {mode === "automation" && automationLibrary !== undefined ? ( + { + setAutomationLibrary(undefined); + setMode("libraries"); + setReloadVersion((version) => version + 1); + }} + /> + ) : null} + {mode === "libraries" && editor === undefined ? ( + + {narrow + ? "c新建 e修改 d移除 s扫描 a自动刷新 Enter资产" + : "c 新建 e 修改 d 移除 s 扫描 a 自动刷新 Enter 查看资产"} + + ) : null} + {mode === "assets" && editor === undefined ? ( + + {narrow + ? "x刮削 m元数据 i/Enter检查" + : "x 重新刮削 m 元数据刷新 i / Enter 检查资料"} + + ) : null} + {!isInspectionMode(mode) && + mode !== "connections" && + mode !== "automation" && + message.length > 0 ? ( + + {message} + + ) : null} + {!isInspectionMode(mode) && editorLoading ? ( + 正在读取配置选项… + ) : null} + {editor !== undefined ? ( + + ) : null} + {!isInspectionMode(mode) && pendingRemoval !== undefined ? ( + + 将移除「{pendingRemoval.displayName}」的配置;原素材不会被删除。 + + ) : null} + {!isInspectionMode(mode) && (filterEditing || filterText.length > 0) ? ( + + 筛选本页:{truncateToWidth(filterText, terminalSize.columns - 12)} + {filterEditing + ? "(输入后回车,退格可修正)" + : `(${filteredRows.length}/${rows.length})`} + + ) : null} + + {mode === "scan" ? ( + + ) : null} + {editor === undefined && + mode !== "scan" && + !isInspectionMode(mode) && + mode !== "connections" && + mode !== "automation" + ? visibleRows.map((row, visibleIndex) => { + const index = viewportStart + visibleIndex; + return ( + + {index === selected ? "› " : " "} + {truncateToWidth( + rowLabel(row, mode, narrow, filterText), + terminalSize.columns - 4, + )} + + ); + }) + : null} + {editor === undefined && + mode !== "scan" && + !isInspectionMode(mode) && + mode !== "connections" && + mode !== "automation" && + rows.length > 0 && + filteredRows.length === 0 ? ( + 当前页没有匹配内容 + ) : null} + {editor === undefined && + mode !== "scan" && + !isInspectionMode(mode) && + mode !== "connections" && + mode !== "automation" && + rows.length === 0 && + message.length > 0 ? ( + {message} + ) : null} + + {isInspectionMode(mode) ? null : editor !== undefined ? ( + Esc 取消 b 返回 Ctrl+U 清空输入 + ) : mode === "connections" || mode === "automation" ? null : narrow ? ( + <> + {mode === "scan" && scanExecutionId !== undefined ? ( + c请求取消 r重新查询 q退出 + ) : null} + + {page.nextCursor === undefined + ? "" + : previousCursors.length === 0 + ? "n下一页 " + : "n/p翻页 "} + {page.nextCursor === undefined && previousCursors.length > 0 + ? "p上一页 " + : ""} + ↑↓移动 r重读 q退出 /筛选 + + + {filteredRows.length > 0 + ? `第 ${viewportStart + 1}-${viewportStart + visibleRows.length}/${filteredRows.length} 项` + : ""} + + + ) : ( + + {mode === "scan" && scanExecutionId !== undefined + ? "c 请求取消 " + : ""} + {page.nextCursor === undefined ? "" : "n 下一页 "} + {previousCursors.length === 0 ? "" : "p 上一页 "}↑↓/j k 移动 r + 重读 q 退出 /筛选本页  + {filteredRows.length > 0 + ? `第 ${viewportStart + 1}-${viewportStart + visibleRows.length}/${filteredRows.length} 项` + : ""} + + )} + + ); +} + +function navigationLabel( + narrow: boolean, + columns: number, + libraryName: string | undefined, + mode: Mode, +): string { + const entries: readonly [Mode, string, string][] = narrow + ? [ + ["libraries", "1", "库"], + ["assets", "2", "资产"], + ["unrecognized", "3", "未识别"], + ["scan", "4", "扫描"], + ["attention", "5", "待办"], + ["history", "6", "历史"], + ["connections", "7", "连接"], + ] + : [ + ["libraries", "1", "资产库"], + ["assets", "2", "资产"], + ["unrecognized", "3", "未识别材料"], + ["scan", "4", "执行"], + ["attention", "5", "待处理"], + ["history", "6", "历史"], + ["connections", "7", "来源连接"], + ]; + const menu = entries + .map( + ([entryMode, number, label]) => + `${mode === entryMode ? "›" : ""}${number} ${label}`, + ) + .join(" "); + if (libraryName === undefined) return menu; + const separator = narrow ? " " : " 当前库:"; + return `${menu}${separator}${truncateToWidth( + libraryName, + columns - terminalWidth(menu) - terminalWidth(separator), + )}`; +} + +function isInspectionMode(mode: Mode): boolean { + return mode === "inspection" || mode === "attention" || mode === "history"; +} + +function LibraryEditorView({ + editor, + options, + narrow, + columns, + saving, +}: { + readonly editor: LibraryEditor; + readonly options: PublicAssetLibraryOptions | undefined; + readonly narrow: boolean; + readonly columns: number; + readonly saving: boolean; +}) { + const selectionStage = + editor.stage === "profiles" || editor.stage === "sources"; + const choices = + editor.stage === "profiles" + ? (options?.recognitionProfiles ?? []) + : editor.stage === "sources" + ? (options?.sources ?? []) + : []; + const selectedIds = + editor.stage === "profiles" + ? editor.recognitionProfileIds + : editor.sourceIdentities; + const sectionLabel = + editor.stage === "name" + ? "资产库名称" + : editor.stage === "directories" + ? "服务端目录" + : editor.stage === "profiles" + ? "识别能力" + : "来源"; + return ( + + + {editor.id === undefined ? "新建资产库" : "修改资产库"}:{sectionLabel} + + {editor.stage === "name" ? ( + <> + 名称:{truncateToWidth(editor.input, columns - 5)} + 输入名称后按 Enter。Ctrl+U 清空当前输入。 + + ) : null} + {editor.stage === "directories" ? ( + <> + + {narrow + ? "服务端绝对路径;Enter 添加,-路径 移除。" + : "目录由服务所在机器访问;输入绝对路径并按 Enter 添加,-路径 移除。"} + + {editor.directories.length === 0 ? ( + 尚未添加目录 + ) : ( + editor.directories.map((directory) => ( + + · {truncatePathToWidth(directory, columns - 5)} + + )) + )} + 路径输入:{truncateToWidth(editor.input, columns - 8)} + 空行继续;不会上传、修改或删除原素材。 + + ) : null} + {selectionStage ? ( + <> + + ↑↓ 移动 Space 选择 Enter 继续 + {editor.stage === "sources" ? "并保存" : ""} + + {choices.length === 0 ? ( + 服务没有提供可选{sectionLabel} + ) : ( + choices.map((choice, index) => ( + + {index === editor.selectedChoice ? "› " : " "} + {selectedIds.includes(choice.id) ? "[x] " : "[ ] "} + {truncateToWidth(choice.displayName, columns - 12)} + + )) + )} + + ) : null} + {editor.error !== undefined ? ( + {editor.error} + ) : null} + {saving ? 正在保存… : null} + + ); +} + +function ScanResultView({ + scan, + executionId, + execution, + refreshKind, + error, + message, + cancelRequestStatus, + narrow, + columns, +}: { + readonly scan: PublicAssetLibraryScan | undefined; + readonly executionId: string | undefined; + readonly execution: PublicExecutionInspection | undefined; + readonly refreshKind: RefreshKind; + readonly error: string | undefined; + readonly message: string; + readonly cancelRequestStatus: "requested" | "already-finished" | undefined; + readonly narrow: boolean; + readonly columns: number; +}) { + if (execution?.kind === "execution") + return ( + + 刷新执行编号:{execution.id} + + 状态: + {executionResultStatusLabel(execution.status, execution.outcome)} + {execution.status === "running" + ? ` / ${executionStageLabel(execution.stage)}` + : ""} + + {execution.reason === undefined ? null : ( + {execution.reason} + )} + {execution.cancellation === undefined ? null : ( + 取消请求已记录:{execution.cancellation.requestedBy} + )} + {cancelRequestStatus === "requested" && + execution.status === "running" ? ( + 取消请求已提交,仍在等待实际结局 + ) : null} + {execution.progress.dispositions.map((disposition) => ( + + 处理项: + {executionProgressStatusLabel(disposition.status)} + {disposition.reason === undefined ? "" : `,${disposition.reason}`} + + ))} + {execution.progress.workItems.map((item) => ( + + {truncateToWidth( + `${item.relativePaths.join("、")} → ${scanOutcomeLabel(item.outcome)}${item.acceptedAssetId === undefined ? "" : `,资产 ${item.acceptedAssetId}`}`, + columns - (narrow ? 3 : 4), + )} + + ))} + {execution.sourceRefreshRuns.map((run) => { + const line = `来源刷新 ${run.id}:${sourceRefreshStatusLabel(run.phase, run.outcome)};变更集 ${ + run.changeSets + .map( + ({ id, disposition }) => + `${id}(${changeSetDispositionLabel(disposition)})`, + ) + .join("、") || "尚无" + }`; + return ( + + {truncateToWidth(line, columns - (narrow ? 3 : 4))} + + ); + })} + {error !== undefined ? {error} : null} + {message.length > 0 ? {message} : null} + + ); + if (scan === undefined) + return ( + + {executionId !== undefined ? ( + <> + + {refreshKind === "library-scan" ? "扫描" : "刷新"}执行编号: + + {executionId} + + ) : null} + {error !== undefined ? {error} : null} + {message.length > 0 ? {message} : null} + {executionId !== undefined ? ( + + c 请求取消 r 重新查询;可用 --execution 指定编号恢复 + + ) : null} + + ); + const fileItems = scan.directories.flatMap((directory) => + directory.files.map((file) => ({ directory, file })), + ); + return ( + + 扫描执行编号: + {scan.parentExecutionId} + + 结果:{executionResultStatusLabel(scan.status, scan.outcome)} + {`;目录 ${scan.directories.length},资料 ${fileItems.length}`} + + {execution?.kind === "asset-library-scan" ? ( + <> + {execution.cancellationRequested === undefined ? null : ( + + 取消请求已记录:{execution.cancellationRequested.requestedBy} + {execution.status === "running" ? ";仍在等待实际结局" : ""} + + )} + {execution.children.map((child, index) => ( + + 子任务 {index + 1}: + {child.status === "missing" + ? "当前不可读取" + : `${executionResultStatusLabel(child.execution.status, child.execution.outcome)}${child.execution.status === "running" ? ` / ${executionStageLabel(child.execution.stage)}` : ""}`} + + ))} + {execution.children.flatMap((child) => + child.status === "available" + ? child.execution.sourceRefreshRuns.map((run) => { + const line = `来源刷新 ${run.id}:${sourceRefreshStatusLabel(run.phase, run.outcome)};变更集 ${ + run.changeSets + .map( + ({ id, disposition }) => + `${id}(${changeSetDispositionLabel(disposition)})`, + ) + .join("、") || "尚无" + }`; + return ( + + {truncateToWidth(line, columns - (narrow ? 3 : 4))} + + ); + }) + : [], + )} + + ) : null} + {cancelRequestStatus === "requested" && scan.status === "running" ? ( + 取消请求已提交,扫描父执行仍在等待实际结局 + ) : null} + {scan.directories.map((directory, index) => ( + + {directory.displayName}:{scanDirectoryStageLabel(directory.stage)} /{" "} + {scanDirectoryModeLabel(directory.mode)}, + {`资料 ${directory.workItemCount},待确认 ${directory.unresolvedCount},失败 ${directory.failedCount}`} + {directory.outcome === undefined + ? "" + : `,${scanOutcomeLabel(directory.outcome)}`} + {directory.reason === undefined ? "" : `(${directory.reason})`} + + ))} + {fileItems.map(({ directory, file }, index) => ( + + {truncateToWidth( + `${file.relativePaths.join("、")} → ${scanOutcomeLabel(file.outcome)}${file.assetId === undefined ? "" : `,资产 ${file.assetId}`}`, + columns - (narrow ? 3 : 4), + )} + + ))} + {fileItems.length === 0 ? 没有可列出的资料项 : null} + {error !== undefined ? {error} : null} + {message.length > 0 ? {message} : null} + + ); +} + +function executionResultMessage( + name: string, + status: PublicExecutionInspection["status"], + outcome: PublicExecutionInspection["outcome"] | undefined, +): string { + if (outcome === "cancelled") return `${name}已取消`; + if (outcome === "interrupted") return `${name}已中断`; + if (status === "failed" || outcome === "failed") return `${name}失败`; + return `${name}已完成${outcome === undefined ? "" : `:${scanOutcomeLabel(outcome)}`}`; +} + +function executionResultStatusLabel( + status: PublicExecutionInspection["status"], + outcome: PublicExecutionInspection["outcome"] | undefined, +): string { + if (status === "running") return "进行中"; + if (outcome === "cancelled") return "已取消"; + if (outcome === "interrupted") return "已中断"; + if (status === "failed" || outcome === "failed") return "失败"; + return `已完成${outcome === undefined ? "" : `,${scanOutcomeLabel(outcome)}`}`; +} + +function executionProgressStatusLabel( + status: + | "running" + | "completed" + | "failed" + | "skipped" + | "reused" + | "no-change", +): string { + switch (status) { + case "running": + return "进行中"; + case "completed": + return "已完成"; + case "failed": + return "失败"; + case "skipped": + return "已跳过"; + case "reused": + return "已复用"; + case "no-change": + return "无变化"; + } +} + +function executionStageLabel( + stage: + | "recognition" + | "source-records-retained" + | "source-artifacts-acquired" + | "source-artifacts-stored" + | "cover-acquired" + | "cover-stored" + | "completed" + | "failed", +): string { + switch (stage) { + case "recognition": + return "识别"; + case "source-records-retained": + return "已保留来源记录"; + case "source-artifacts-acquired": + return "正在获取来源资料"; + case "source-artifacts-stored": + return "已保存来源资料"; + case "cover-acquired": + return "已获取封面"; + case "cover-stored": + return "已保存封面"; + case "completed": + return "已结束"; + case "failed": + return "失败"; + } +} + +function sourceRefreshStatusLabel( + phase: "planning" | "acquiring" | "reviewing" | "applying" | "terminal", + outcome: + | "succeeded" + | "partially-succeeded" + | "failed" + | "cancelled" + | undefined, +): string { + if (outcome === "succeeded") return "已完成"; + if (outcome === "partially-succeeded") return "部分完成"; + if (outcome === "failed") return "失败"; + if (outcome === "cancelled") return "已取消"; + switch (phase) { + case "planning": + return "正在规划"; + case "acquiring": + return "正在获取"; + case "reviewing": + return "等待审阅"; + case "applying": + return "正在应用"; + case "terminal": + return "已结束"; + } +} + +function changeSetDispositionLabel( + disposition: "automatic" | "pending" | "approved" | "rejected", +): string { + switch (disposition) { + case "automatic": + return "自动应用"; + case "pending": + return "待审阅"; + case "approved": + return "已批准"; + case "rejected": + return "已拒绝"; + } +} + +function scanDirectoryStageLabel( + stage: PublicAssetLibraryScan["directories"][number]["stage"], +): string { + switch (stage) { + case "queued": + return "等待扫描"; + case "running": + return "扫描中"; + case "settled": + return "已结束"; + case "cancelled": + return "已取消"; + default: + return "状态未知"; + } +} + +function scanDirectoryModeLabel( + mode: PublicAssetLibraryScan["directories"][number]["mode"], +): string { + return mode === "execute" + ? "独立扫描" + : mode === "covered" + ? "由父目录覆盖" + : "状态未知"; +} + +function scanOutcomeLabel( + outcome: PublicAssetLibraryScan["outcome"] | undefined, +): string { + switch (outcome) { + case "accepted": + return "已识别"; + case "unresolved": + return "未识别"; + case "proposal": + return "待确认"; + case "partially-succeeded": + return "部分完成"; + case "failed": + return "失败"; + case "interrupted": + return "已中断"; + case "cancelled": + return "已取消"; + case "no-change": + return "无变更"; + default: + return "待查看"; + } +} + +function publicErrorMessage(error: unknown, fallback: string): string { + return error instanceof PublicApiClientError || + error instanceof PublicExecutionClientError + ? error.message + : fallback; +} + +function truncateToWidth(value: string, maximumWidth: number): string { + const limit = Math.max(1, maximumWidth); + let width = 0; + let result = ""; + for (const character of value) { + const characterWidth = terminalWidth(character); + if (width + characterWidth > limit) return `${result}…`; + result += character; + width += characterWidth; + } + return result; +} + +function truncatePathToWidth(value: string, maximumWidth: number): string { + const limit = Math.max(1, maximumWidth); + if (terminalWidth(value) <= limit) return value; + + let suffix = ""; + let width = 0; + for (const character of Array.from(value).reverse()) { + const characterWidth = terminalWidth(character); + if (width + characterWidth > limit - 1) break; + suffix = character + suffix; + width += characterWidth; + } + return `…${suffix}`; +} + +function terminalWidth(value: string): number { + let width = 0; + for (const character of value) { + if (/\p{Mark}/u.test(character) || character === "\u200d") continue; + width += + /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Extended_Pictographic}\u3000-\u303f\uff01-\uff60]/u.test( + character, + ) + ? 2 + : 1; + } + return width; +} + +function rowKey(row: Row): string { + if ("displayName" in row) return row.id; + if ("parentExecutionId" in row) + return row.kind === "work-item" + ? `${row.parentExecutionId}:${row.workItemKey}` + : `${row.parentExecutionId}:${row.observationId}`; + return row.id; +} + +function rowLabel( + row: Row, + mode: Mode, + narrow: boolean, + filterText: string, +): string { + if (mode === "libraries" && "displayName" in row) { + const hasUnavailableDirectory = row.directories.some( + ({ status, reachable }) => status === "active" && reachable === false, + ); + return `${hasUnavailableDirectory ? "目录不可用 · " : ""}${row.displayName} (${row.directories.length} 个目录)`; + } + if (mode === "unrecognized" && "parentExecutionId" in row) { + const matchingPaths = row.relativePaths.filter((relativePath) => + relativePath.toLocaleLowerCase().includes(filterText.toLocaleLowerCase()), + ); + const paths = filterText.length > 0 ? matchingPaths : row.relativePaths; + return narrow ? (paths[0] ?? "") : paths.join("、"); + } + if ("descriptions" in row) { + const title = row.descriptions + .filter( + (description) => + description.target.kind === "entity" && + description.target.entityId === row.id, + ) + .flatMap((description) => description.metadata) + .find( + (metadata) => + metadata.property === "asset.title" && + metadata.result.status === "resolved", + )?.result; + const titleText = + title?.status === "resolved" ? title.values[0]?.value : undefined; + return ( + titleText ?? + row.relatedEntities.find((entity) => entity.id === row.id)?.label ?? + "未命名资产" + ); + } + return "未命名资料"; +} + +function rowSearchText(row: Row, mode: Mode): string { + if (mode === "unrecognized" && "parentExecutionId" in row) + return row.relativePaths.join("\n"); + return rowLabel(row, mode, false, ""); +} + +try { + const arguments_ = process.argv.slice(2); + const baseUrl = argumentUrl(arguments_); + new URL(baseUrl); + const initialExecutionId = argumentValue(arguments_, "--execution"); + if (arguments_.includes("--execution") && initialExecutionId === undefined) + throw new Error("请在 --execution 后提供执行编号"); + render( + , + ); +} catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : "服务地址无效"}\n`, + ); + process.exitCode = 2; +} diff --git a/apps/tui/src/source-connections.tsx b/apps/tui/src/source-connections.tsx new file mode 100644 index 0000000..0e7ff82 --- /dev/null +++ b/apps/tui/src/source-connections.tsx @@ -0,0 +1,680 @@ +import { PublicApiClientError } from "@digital/public-api"; +import type { + BangumiAuthorizationStart, + KeySourceConnectionId, + PublicBangumiAuthorizationAttempt, + PublicFanboxConnectionRequest, + PublicSourceConnection, + PublicSourceConnectionClientMethods, +} from "@digital/public-api/source-connection-contract"; +import { Box, Text } from "ink"; +import { useEffect, useState } from "react"; + +import { useCurrentInput } from "./use-current-input.ts"; + +type SourceConnectionsPanelProps = { + readonly client: PublicSourceConnectionClientMethods; + readonly serviceUrl: string; + readonly onExit: () => void; +}; + +type PanelMode = + | "list" + | "secret" + | "revoke-confirm" + | "fanbox-creator" + | "fanbox-request" + | "bangumi-authorization"; + +type BangumiAuthorization = BangumiAuthorizationStart & { + readonly status: PublicBangumiAuthorizationAttempt["status"]; +}; + +/** TMDB 与 OMDb 的私密密钥连接入口;其他来源由各自连接流程负责。 */ +export function SourceConnectionsPanel({ + client, + serviceUrl, + onExit, +}: SourceConnectionsPanelProps) { + const [connections, setConnections] = useState< + readonly PublicSourceConnection[] + >([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [mode, setMode] = useState("list"); + const [secret, setSecret] = useState(""); + const [creatorId, setCreatorId] = useState(""); + const [pairing, setPairing] = useState<{ + readonly request: PublicFanboxConnectionRequest; + readonly pairingCode: string; + }>(); + const [bangumiAuthorization, setBangumiAuthorization] = + useState(); + const [message, setMessage] = useState("正在读取来源连接…"); + const [readFailed, setReadFailed] = useState(false); + const [busy, setBusy] = useState(false); + const [reloadVersion, setReloadVersion] = useState(0); + + useEffect(() => { + let active = true; + void client + .listSourceConnections() + .then(({ items }) => { + if (!active) return; + setConnections( + items.filter( + ({ id }) => + id === "tmdb" || + id === "omdb" || + id === "bangumi" || + id.startsWith("fanbox/"), + ), + ); + setReadFailed(false); + setMessage(""); + }) + .catch(() => { + if (!active) return; + setReadFailed(true); + setMessage("暂时无法读取来源连接,请按 r 重试"); + }); + return () => { + active = false; + }; + }, [client, reloadVersion]); + + useEffect(() => { + if (mode !== "fanbox-request" || pairing === undefined) return; + let active = true; + let timer: ReturnType | undefined; + const poll = async () => { + try { + const request = await client.getFanboxConnectionRequest( + pairing.request.id, + ); + if (!active) return; + setPairing((current) => + current === undefined ? current : { ...current, request }, + ); + if (request.status === "waiting" || request.status === "verifying") + timer = setTimeout(() => void poll(), 1_000); + else setReloadVersion((value) => value + 1); + } catch { + if (!active) return; + timer = setTimeout(() => void poll(), 2_000); + } + }; + timer = setTimeout(() => void poll(), 1_000); + return () => { + active = false; + if (timer !== undefined) clearTimeout(timer); + }; + }, [client, mode, pairing?.request.id, pairing?.request.status]); + + useEffect(() => { + if ( + mode !== "bangumi-authorization" || + bangumiAuthorization === undefined || + bangumiAuthorization.status === "succeeded" || + bangumiAuthorization.status === "failed" || + bangumiAuthorization.status === "cancelled" || + bangumiAuthorization.status === "expired" || + bangumiAuthorization.status === "superseded" + ) { + return; + } + let active = true; + let timer: ReturnType | undefined; + const poll = async () => { + try { + const attempt = await client.getBangumiAuthorizationAttempt( + bangumiAuthorization.attemptId, + ); + if (!active) return; + setBangumiAuthorization((current) => + current === undefined + ? current + : { ...current, status: attempt.status }, + ); + if (attempt.status === "waiting" || attempt.status === "processing") { + timer = setTimeout(() => void poll(), 1_000); + } else { + setReloadVersion((value) => value + 1); + setMessage(bangumiAuthorizationMessage(attempt.status)); + } + } catch { + if (!active) return; + setMessage("暂时无法读取授权结果,正在重试…"); + timer = setTimeout(() => void poll(), 2_000); + } + }; + timer = setTimeout(() => void poll(), 750); + return () => { + active = false; + if (timer !== undefined) clearTimeout(timer); + }; + }, [ + bangumiAuthorization?.attemptId, + bangumiAuthorization?.status, + client, + mode, + ]); + + const selected = connections[selectedIndex]; + + const configure = async () => { + if (selected === undefined) return; + if (secret.trim().length === 0) { + setMessage("请输入密钥"); + return; + } + const sourceId = selected.id as KeySourceConnectionId; + setBusy(true); + setMessage("正在验证密钥…"); + try { + const input = { + sourceId, + expectedVersion: selected.version, + secret, + }; + const result = + selected.credentialStatus === "configured" + ? await client.replaceSourceConnection(input) + : await client.configureSourceConnection(input); + setConnections((current) => + current.map((connection) => + connection.id === result.connection.id + ? result.connection + : connection, + ), + ); + setMessage(verificationMessage(result.verification)); + } catch (error) { + setMessage(operationFailureMessage(error, "配置")); + } finally { + setSecret(""); + setMode("list"); + setBusy(false); + } + }; + + const beginBangumiAuthorization = async () => { + setBusy(true); + setMessage("正在发起 Bangumi 连接授权…"); + try { + const started = await client.beginBangumiAuthorization(); + setBangumiAuthorization({ ...started, status: "waiting" }); + setMode("bangumi-authorization"); + setMessage(""); + } catch (error) { + setMessage( + error instanceof PublicApiClientError && error.kind === "conflict" + ? "Bangumi 授权正在处理,请稍后重试。" + : "暂时无法发起 Bangumi 授权,请稍后重试。", + ); + } finally { + setBusy(false); + } + }; + + const cancelBangumiAuthorization = async () => { + if (bangumiAuthorization === undefined) return; + setBusy(true); + try { + const attempt = await client.cancelBangumiAuthorization( + bangumiAuthorization.attemptId, + ); + setBangumiAuthorization((current) => + current === undefined + ? current + : { ...current, status: attempt.status }, + ); + setMessage( + attempt.status === "processing" + ? "授权回调正在处理,当前无法取消;正在等待结果。" + : bangumiAuthorizationMessage(attempt.status), + ); + if ( + attempt.status === "succeeded" || + attempt.status === "failed" || + attempt.status === "cancelled" || + attempt.status === "expired" || + attempt.status === "superseded" + ) { + setReloadVersion((value) => value + 1); + } + } catch { + setMessage("暂时无法取消 Bangumi 授权,请稍后重试。"); + } finally { + setBusy(false); + } + }; + + const revoke = async () => { + if (selected === undefined) return; + setBusy(true); + try { + const result = selected.id.startsWith("fanbox/") + ? await client.revokeFanboxSourceConnection({ + creatorId: selected.id.slice("fanbox/".length), + expectedVersion: selected.version, + }) + : selected.id === "bangumi" + ? await client.revokeBangumiSourceConnection({ + expectedVersion: selected.version, + }) + : await client.revokeSourceConnection({ + sourceId: selected.id as KeySourceConnectionId, + expectedVersion: selected.version, + }); + setConnections((current) => + current.map((connection) => + connection.id === result.connection.id + ? result.connection + : connection, + ), + ); + setMessage("来源连接已撤销"); + } catch (error) { + setMessage(operationFailureMessage(error, "撤销")); + } finally { + setMode("list"); + setBusy(false); + } + }; + + const createFanboxRequest = async () => { + if (!/^[a-zA-Z0-9_-]{1,128}$/u.test(creatorId)) { + setMessage("请输入有效的 FANBOX 创作者编号"); + return; + } + setBusy(true); + setMessage("正在发起 FANBOX 连接…"); + try { + const result = await client.createFanboxConnectionRequest({ creatorId }); + setPairing(result); + setMode("fanbox-request"); + setMessage("请在已登录 FANBOX 的 Chromium 扩展中完成配对"); + } catch (error) { + setMessage(operationFailureMessage(error, "连接")); + } finally { + setBusy(false); + } + }; + + const cancelFanboxRequest = async () => { + if (pairing === undefined) return; + setBusy(true); + try { + const request = await client.cancelFanboxConnectionRequest({ + requestId: pairing.request.id, + pairingCode: pairing.pairingCode, + }); + setPairing({ ...pairing, request }); + setMessage("FANBOX 连接请求已取消"); + setReloadVersion((value) => value + 1); + } catch { + setMessage("FANBOX 连接请求取消失败,请按 r 重读状态"); + } finally { + setBusy(false); + } + }; + + useCurrentInput((input, key) => { + if (busy) return; + if (mode === "fanbox-creator") { + if (key.escape) { + setCreatorId(""); + setMode("list"); + setMessage("已取消 FANBOX 连接"); + } else if (key.return) { + void createFanboxRequest(); + } else if (key.backspace || key.delete) { + setCreatorId((value) => value.slice(0, -1)); + } else if (input.length > 0 && !key.ctrl && !key.meta) { + setCreatorId((value) => value + input); + } + return; + } + if (mode === "fanbox-request") { + if ( + input === "x" && + pairing !== undefined && + (pairing.request.status === "waiting" || + pairing.request.status === "verifying") + ) { + void cancelFanboxRequest(); + } else if (input === "q" || key.escape) { + setPairing(undefined); + setMode("list"); + setReloadVersion((value) => value + 1); + setMessage("FANBOX 连接状态已返回列表"); + } + return; + } + if (mode === "bangumi-authorization") { + if (input === "x" || key.escape) { + if (bangumiAuthorization?.status === "waiting") + void cancelBangumiAuthorization(); + else if (bangumiAuthorization?.status === "processing") + setMessage("授权回调正在处理,当前无法取消;正在等待结果。"); + else if (bangumiAuthorization !== undefined) { + setBangumiAuthorization(undefined); + setMode("list"); + setReloadVersion((value) => value + 1); + } + } else if (input === "q" && bangumiAuthorization !== undefined) { + if (bangumiAuthorization.status === "waiting") + void cancelBangumiAuthorization(); + else if (bangumiAuthorization.status === "processing") + setMessage("授权回调正在处理,完成后按 q 返回来源连接。"); + else { + setBangumiAuthorization(undefined); + setMode("list"); + setReloadVersion((value) => value + 1); + } + } + return; + } + if (mode === "secret") { + if (key.escape) { + setSecret(""); + setMode("list"); + setMessage("已取消密钥输入"); + } else if (key.return) { + void configure(); + } else if (key.backspace || key.delete) { + setSecret((value) => value.slice(0, -1)); + } else if (input.length > 0 && !key.ctrl && !key.meta) { + setSecret((value) => value + input); + } + return; + } + if (mode === "revoke-confirm") { + if (input.toLowerCase() === "y") void revoke(); + else if (input.toLowerCase() === "n" || key.escape) { + setMode("list"); + setMessage("已取消撤销"); + } + return; + } + if (input === "q" || key.escape) { + onExit(); + } else if (input === "r") { + setReadFailed(false); + setMessage("正在读取来源连接…"); + setReloadVersion((value) => value + 1); + } else if (key.upArrow) { + setSelectedIndex((value) => Math.max(0, value - 1)); + } else if (key.downArrow) { + setSelectedIndex((value) => Math.min(connections.length - 1, value + 1)); + } else if (input === "f") { + setCreatorId( + selected?.id.startsWith("fanbox/") + ? selected.id.slice("fanbox/".length) + : "", + ); + setMode("fanbox-creator"); + setMessage(""); + } else if ((input === "c" || key.return) && selected !== undefined) { + if (selected.id === "bangumi") { + void beginBangumiAuthorization(); + return; + } else if (selected.id.startsWith("fanbox/")) { + setCreatorId(selected.id.slice("fanbox/".length)); + setMode("fanbox-creator"); + setMessage(""); + return; + } + setSecret(""); + setMode("secret"); + setMessage(""); + } else if (input === "x" && selected !== undefined) { + if (selected.credentialStatus === "unconfigured") + setMessage("此来源还未配置"); + else { + setMode("revoke-confirm"); + setMessage(`确认撤销${sourceName(selected.id)}连接?按 y 确认,n 取消`); + } + } + }); + + if (mode === "fanbox-creator") + return ( + + 连接 FANBOX 创作者 + 创作者编号(不是 FANBOX 登录凭据) + {creatorId.length === 0 ? "(空)" : creatorId} + Enter 发起限时配对,Esc 取消 + {message.length > 0 ? {message} : null} + + ); + + if (mode === "fanbox-request" && pairing !== undefined) + return ( + + FANBOX 浏览器连接 + 服务地址:{serviceUrl} + 创作者范围:{pairing.request.creatorId} + 目标实例:{pairing.request.instanceId} + 请求编号:{pairing.request.id} + 限时配对码:{pairing.pairingCode} + + 当前状态:{fanboxRequestStatusLabel(pairing.request.status)} + + {pairing.request.status === "waiting" || + pairing.request.status === "verifying" ? ( + x 取消请求 q 返回列表 + ) : ( + q 返回列表 + )} + {message.length > 0 ? {message} : null} + + ); + + if (mode === "bangumi-authorization" && bangumiAuthorization !== undefined) { + const target = bangumiAuthorizationTarget( + bangumiAuthorization.authorizationUrl, + serviceUrl, + ); + return ( + + Bangumi 连接授权 + 目标实例:{new URL(serviceUrl).origin} + 授权回调目标:{target} + 授权地址:{bangumiAuthorization.authorizationUrl} + 请在你自己的浏览器打开授权地址,完成后返回此终端查看结果。 + + 当前状态:{bangumiAuthorizationLabel(bangumiAuthorization.status)} + + {bangumiAuthorization.status === "waiting" ? ( + x/Esc 取消授权 q 取消并查看结果 + ) : bangumiAuthorization.status === "processing" ? ( + 正在确认授权,暂时无法取消或返回 + ) : ( + q/Esc 返回来源连接 + )} + {message.length > 0 ? {message} : null} + + ); + } + + if (mode === "secret") + return ( + + 设置{sourceName(selected?.id ?? "")}密钥 + + {selected?.id === "tmdb" ? "访问令牌" : "API 密钥"}(输入已隐藏) + + + {secret.length === 0 + ? "(空)" + : "•".repeat(Math.min(24, secret.length))} + + Enter 验证并保存,Esc 取消 + {message.length > 0 ? {message} : null} + + ); + + return ( + + 来源连接 + {connections.length === 0 && !readFailed ? ( + 当前没有来源连接 + ) : ( + + )} + {message.length > 0 ? ( + {message} + ) : null} + {mode === "revoke-confirm" ? null : ( + ↑↓选择 Enter/c 配置 f FANBOX 连接 x 撤销 r 重读 q 返回 + )} + + ); +} + +export function SourceConnectionStatusList({ + connections, + selectedIndex, + sourceName: displayName = sourceName, +}: { + readonly connections: readonly PublicSourceConnection[]; + readonly selectedIndex?: number | undefined; + readonly sourceName?: ((id: string) => string) | undefined; +}) { + return ( + + {connections.map((connection, index) => ( + + {selectedIndex === index ? "› " : " "} + {displayName(connection.id)}: + {sourceConnectionStatusLabel(connection)} + + ))} + + ); +} + +function sourceName(id: string): string { + switch (id) { + case "bangumi": + return "Bangumi"; + case "tmdb": + return "TMDB"; + case "omdb": + return "OMDb"; + default: + return id.startsWith("fanbox/") ? `FANBOX ${id.slice(7)}` : "来源"; + } +} + +function bangumiAuthorizationTarget( + authorizationUrl: string, + fallback: string, +): string { + const redirectUri = new URL(authorizationUrl).searchParams.get( + "redirect_uri", + ); + return redirectUri === null ? fallback : new URL(redirectUri).origin; +} + +function bangumiAuthorizationLabel( + status: PublicBangumiAuthorizationAttempt["status"], +): string { + switch (status) { + case "waiting": + return "等待浏览器授权"; + case "processing": + return "正在确认授权"; + case "succeeded": + return "连接已可用"; + case "failed": + return "授权失败"; + case "cancelled": + return "授权已取消"; + case "expired": + return "授权已过期"; + case "superseded": + return "授权请求已失效"; + } +} + +function bangumiAuthorizationMessage( + status: PublicBangumiAuthorizationAttempt["status"], +): string { + switch (status) { + case "waiting": + return "等待浏览器完成授权。"; + case "processing": + return "授权回调正在处理,当前无法取消;正在等待结果。"; + case "succeeded": + return "Bangumi 连接已可用。"; + case "failed": + return "Bangumi 授权失败,请重新发起。"; + case "cancelled": + return "Bangumi 授权已取消。"; + case "expired": + return "Bangumi 授权已过期,请重新发起。"; + case "superseded": + return "Bangumi 授权请求已失效,请重新发起。"; + } +} + +function fanboxRequestStatusLabel( + status: PublicFanboxConnectionRequest["status"], +): string { + switch (status) { + case "waiting": + return "等待浏览器配对"; + case "verifying": + return "正在验证 FANBOX 登录状态"; + case "succeeded": + return "连接成功"; + case "failed": + return "验证失败"; + case "cancelled": + return "已取消"; + case "expired": + return "已过期"; + case "superseded": + return "已由新连接请求替代"; + } +} + +export function sourceConnectionStatusLabel( + connection: PublicSourceConnection, +): string { + if (connection.reason === "verification-pending") return "待验证"; + if (connection.credentialStatus === "unconfigured") return "待配置"; + if (connection.credentialStatus === "revoked") return "已撤销"; + if (connection.credentialStatus === "invalid") return "状态不可用"; + if (connection.availability === "available") return "可用"; + if (connection.reason === "verification-failed") return "验证未通过"; + return "当前不可用"; +} + +function verificationMessage( + verification: "verified" | "failed" | "superseded", +): string { + switch (verification) { + case "verified": + return "验证成功,来源连接已可用"; + case "failed": + return "验证未通过,来源连接当前不可用"; + case "superseded": + return "连接已由其他操作更改,请重新读取状态"; + } +} + +function operationFailureMessage( + error: unknown, + action: "配置" | "撤销" | "连接", +) { + if (error instanceof PublicApiClientError && error.kind === "conflict") + return "来源连接已被其他操作更改,请按 r 重读后重试"; + return `来源连接${action}未完成,请按 r 重读后重试`; +} diff --git a/apps/tui/src/use-current-input.ts b/apps/tui/src/use-current-input.ts new file mode 100644 index 0000000..4be93af --- /dev/null +++ b/apps/tui/src/use-current-input.ts @@ -0,0 +1,16 @@ +import { useInput } from "ink"; +import { useCallback, useLayoutEffect, useRef } from "react"; + +type InputHandler = Parameters[0]; + +export function useCurrentInput(inputHandler: InputHandler): void { + const inputHandlerRef = useRef(inputHandler); + useLayoutEffect(() => { + inputHandlerRef.current = inputHandler; + }, [inputHandler]); + const dispatchInput = useCallback( + (input, key) => inputHandlerRef.current(input, key), + [], + ); + useInput(dispatchInput); +} diff --git a/apps/tui/test/cli.test.ts b/apps/tui/test/cli.test.ts new file mode 100644 index 0000000..0f03359 --- /dev/null +++ b/apps/tui/test/cli.test.ts @@ -0,0 +1,1063 @@ +import { spawn } from "node:child_process"; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; + +import { afterEach, expect, it } from "vitest"; + +const servers: ReturnType[] = []; +const children: ReturnType[] = []; + +function interactivePtyEnvironment( + columns: number, + rows: number, +): NodeJS.ProcessEnv { + const environment: NodeJS.ProcessEnv = { + ...process.env, + TERM: "xterm-256color", + COLUMNS: String(columns), + LINES: String(rows), + }; + delete environment.CI; + delete environment.CONTINUOUS_INTEGRATION; + return environment; +} + +function startInteractivePty( + terminalCommand: string, + columns: number, + rows: number, + timeoutMs: number, +) { + const child = spawn( + "bash", + ["-lc", `script -qfec ${shellQuote(terminalCommand)} /dev/null`], + { + cwd: process.cwd(), + env: interactivePtyEnvironment(columns, rows), + stdio: ["pipe", "pipe", "pipe"], + detached: process.platform !== "win32", + }, + ); + children.push(child); + + const output: Buffer[] = []; + const errors: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => output.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => errors.push(chunk)); + const exitCode = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve(code)); + }); + const processTimeout = setTimeout(() => { + if (child.pid === undefined) return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + }, timeoutMs).unref(); + void exitCode.then( + () => clearTimeout(processTimeout), + () => clearTimeout(processTimeout), + ); + + const currentOutput = () => Buffer.concat(output); + const currentOutputText = () => currentOutput().toString("utf8"); + const currentErrors = () => Buffer.concat(errors).toString("utf8"); + const waitForOutputSince = ( + needles: string | readonly string[], + textOffset: number, + ) => + new Promise((resolve, reject) => { + const markers = typeof needles === "string" ? [needles] : needles; + const check = () => { + const outputSince = currentOutputText().slice(textOffset); + return markers.every((needle) => outputSince.includes(needle)); + }; + let finished = false; + const cleanup = () => { + clearTimeout(waitTimeout); + child.stdout.off("data", onData); + child.off("close", onClose); + }; + const complete = () => { + if (finished || !check()) return; + finished = true; + cleanup(); + resolve(); + }; + const onData = () => complete(); + const onClose = (code: number | null) => { + if (finished) return; + finished = true; + cleanup(); + reject( + new Error( + `PTY 在等待新输出 ${JSON.stringify(markers)} 前已退出(退出码 ${String(code)});\n${currentErrors()}\n${currentOutput().toString("utf8")}`, + ), + ); + }; + const waitTimeout = setTimeout(() => { + if (finished) return; + finished = true; + cleanup(); + reject( + new Error( + `PTY 等待的新输出 ${JSON.stringify(markers)} 未在期限内出现;\n${currentErrors()}\n${currentOutput().toString("utf8")}`, + ), + ); + }, 5_000); + child.stdout.on("data", onData); + child.once("close", onClose); + complete(); + }); + + return { + get output() { + return currentOutputText(); + }, + get errors() { + return currentErrors(); + }, + waitForOutput(needles: string | readonly string[]) { + return waitForOutputSince(needles, 0); + }, + async sendAndWait(input: string, needles: string | readonly string[]) { + const textOffset = currentOutputText().length; + if (!child.stdin.write(input)) + await new Promise((resolve) => + child.stdin.once("drain", resolve), + ); + await waitForOutputSince(needles, textOffset); + }, + async closeAndWait() { + child.stdin.end(); + return exitCode; + }, + }; +} + +afterEach(async () => { + for (const child of children.splice(0).reverse()) { + if (child.exitCode === null && child.signalCode === null) { + if (child.pid !== undefined && process.platform !== "win32") { + try { + process.kill(-child.pid, "SIGTERM"); + } catch { + child.kill("SIGTERM"); + } + } else child.kill("SIGTERM"); + } + } + for (const server of servers.splice(0).reverse()) { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +it("运行安装入口、处理分页键,并在窄终端退出时恢复输入状态", async () => { + const requests: string[] = []; + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://localhost"); + requests.push(`${url.pathname}${url.search}`); + servePublicPage(url, response); + }); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + const terminalCommand = `stty cols 34 rows 10 -icanon -echo; terminalModeBefore=$(stty -g); (sleep 5; stty cols 46 rows 14 < /dev/tty) & ./dist/cli.js --url http://127.0.0.1:${address.port}; terminalModeAfter=$(stty -g); if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi`; + const pty = startInteractivePty(terminalCommand, 34, 10, 15_000); + await pty.waitForOutput("本地资料库"); + await pty.sendAndWait("n", "第二页资料库"); + await pty.sendAndWait("p", "本地资料库"); + await pty.sendAndWait("/", "筛选本页:"); + await pty.sendAndWait("本地资料库x", "筛选本页:本地资料库x"); + await pty.sendAndWait("\u007f", "筛选本页:本地资料库(输入后回车"); + await pty.sendAndWait("\r", "筛选本页:本地资料库(1/1)"); + await pty.sendAndWait("\r", "第一页资产"); + await pty.sendAndWait("n", "第二页资产01"); + await pty.sendAndWait("j", "› 第二页资产02"); + await pty.sendAndWait("j", "› 第二页资产03"); + await pty.sendAndWait("j", "› 第二页资产04"); + await pty.sendAndWait("j", "› 第二页资产05"); + await pty.sendAndWait("j", "› 第二页资产06"); + await pty.sendAndWait("j", "› 第二页资产07"); + await pty.sendAndWait("j", "› 第二页资产08"); + await pty.sendAndWait("j", ["› 第二页资产09", "第 3-9/20 项"]); + await pty.sendAndWait("p", "第一页资产"); + await pty.sendAndWait("3", "未识别样本.pdf"); + await pty.sendAndWait("q", "TERMINAL_RESTORED"); + const exitCode = await pty.closeAndWait(); + const terminalOutput = pty.output; + const errorOutput = pty.errors; + + expect(exitCode, `${errorOutput}\n${terminalOutput}`).toBe(0); + expect(terminalOutput).toContain("数字资料库"); + expect(terminalOutput).toContain("本地资料库"); + expect(terminalOutput).toContain("第二页资料库"); + expect(terminalOutput).toContain("筛选本页:本地资料库x"); + expect(terminalOutput).toContain("当前页没有匹配内容"); + expect(terminalOutput).toContain("筛选本页:本地资料库(1/1)"); + expect(terminalOutput).toContain("第一页资产"); + expect(terminalOutput).toContain("第二页资产"); + expect(terminalOutput).toContain("未识别材料"); + expect(terminalOutput).toContain("未识别样本.pdf"); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + expect(terminalOutput).toContain("↑↓移动 r重读 q退出 /筛选"); + expect(terminalOutput).toContain("p上一页"); + expect(terminalOutput).toContain("第 1-3/20 项"); + expect(terminalOutput).toContain("第 3-9/20 项"); + expect(terminalOutput).not.toContain("asset-id-"); + expect(requests).toContain("/api/v1/asset-libraries?limit=20"); + expect(requests).toContain( + "/api/v1/asset-libraries?limit=20&cursor=library-page-2", + ); + expect(requests).toContain( + "/api/v1/assets?assetLibraryId=library-1&limit=20&cursor=page-2", + ); + expect( + requests.filter( + (request) => + request === "/api/v1/assets?assetLibraryId=library-1&limit=20", + ).length, + ).toBeGreaterThan(1); + expect(requests).toContain( + "/api/v1/unrecognized-materials?assetLibraryId=library-1&limit=20", + ); +}, 15_000); + +it("安装入口可补充来源、检查失败依据并重试", async () => { + const requests: Array<{ method: string; path: string; body?: unknown }> = []; + let sourceEnrichmentInspections = 0; + const technicalFailureReason = + "DLsite official source request failed: 受控来源拒绝:边界 ordinary-request 没有匹配条目(请求键 GET https://www.dlsite.com/maniax/product/info/ajax)"; + const server = createServer((request, response) => { + void (async () => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = + chunks.length === 0 + ? undefined + : (JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown); + const url = new URL(request.url ?? "/", "http://localhost"); + requests.push({ + method: request.method ?? "GET", + path: `${url.pathname}${url.search}`, + ...(body === undefined ? {} : { body }), + }); + + if (url.pathname === "/api/v1/asset-libraries") + return json(response, { + items: [ + { + id: "library-1", + displayName: "来源补充库", + configVersion: 1, + directories: [], + recognitionProfileIds: [], + sourceIdentities: ["nbinet"], + }, + ], + }); + if (url.pathname === "/api/v1/assets") + return json(response, { items: [asset("待补来源的作品", 0)] }); + if (url.pathname === "/api/v1/assets/asset-id-0/inspection") + return json(response, { + assetId: "asset-id-0", + entity: { id: "asset-id-0", kind: "digital-asset" }, + sections: [ + { kind: "source-records", count: 0, status: "missing" }, + { kind: "current-values", count: 1, status: "available" }, + ], + }); + if (url.pathname === "/api/v1/source-input-requirements") + return json(response, [ + { + sourceIdentity: "nbinet", + displayName: "國家圖書館", + fields: [ + { + key: "isbn", + label: "ISBN", + required: true, + acceptedInput: "ISBN-13", + example: "9789864738281", + }, + ], + }, + ]); + if ( + request.method === "POST" && + url.pathname === "/api/v1/source-enrichments" + ) + return json(response, { executionId: "enrichment-1" }, 202); + if ( + request.method === "GET" && + url.pathname === "/api/v1/source-enrichments/enrichment-1" + ) { + sourceEnrichmentInspections += 1; + if (sourceEnrichmentInspections === 1) + return json(response, { + executionId: "enrichment-1", + status: "running", + outcome: "running", + items: [], + sourceAssociations: [], + }); + return json(response, { + executionId: "enrichment-1", + status: "failed", + outcome: "failed", + reason: "受控来源当前没有可用资料", + items: [ + { + subjectId: "asset-id-0", + sourceIdentity: "nbinet", + sourceAddress: "https://data.ncl.edu.tw/isbn/9789864738281", + input: { isbn: "9789864738281" }, + status: "failed", + reason: technicalFailureReason, + canRetry: true, + }, + ], + sourceAssociations: [], + }); + } + if ( + url.pathname === "/api/v1/assets/asset-id-0/source-enrichment-retries" + ) + return json(response, [ + { + executionId: "enrichment-1", + subjectId: "asset-id-0", + sourceIdentity: "nbinet", + canRetry: true, + }, + ]); + if ( + request.method === "POST" && + url.pathname === "/api/v1/source-enrichment-retries" + ) + return json(response, { executionId: "enrichment-2" }, 202); + if ( + request.method === "GET" && + url.pathname === "/api/v1/source-enrichments/enrichment-2" + ) + return json(response, { + executionId: "enrichment-2", + status: "completed", + outcome: "accepted", + items: [ + { + subjectId: "asset-id-0", + sourceIdentity: "nbinet", + sourceAddress: "https://data.ncl.edu.tw/isbn/9789864738281", + input: { isbn: "9789864738281" }, + status: "completed", + canRetry: false, + }, + ], + sourceAssociations: [ + { + sourceIdentity: "nbinet", + nativeAddress: "https://data.ncl.edu.tw/isbn/9789864738281", + }, + ], + }); + return json( + response, + { error: { code: "not-found", message: "没有此页面" } }, + 404, + ); + })().catch((cause: unknown) => { + json( + response, + { + error: { + code: "internal", + message: cause instanceof Error ? cause.message : "响应失败", + }, + }, + 500, + ); + }); + }); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + const terminalCommand = `stty cols 54 rows 16 -icanon -echo; terminalModeBefore=$(stty -g); ./dist/cli.js --url http://127.0.0.1:${address.port}; terminalModeAfter=$(stty -g); if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi`; + const pty = startInteractivePty(terminalCommand, 54, 16, 15_000); + await pty.waitForOutput("来源补充库"); + await pty.sendAndWait("\r", "待补来源的作品"); + await pty.sendAndWait("i", "来源记录:暂无资料"); + await pty.sendAndWait("e", "补充来源:國家圖書館"); + await pty.sendAndWait("9789864738281", "9789864738281"); + await pty.sendAndWait("\r", [ + "正在补充来源,请稍候", + "可按 r 再次获取当前失败项", + ]); + await pty.sendAndWait("r", ["来源补充 · 已完成", "已关联:nbinet"]); + await pty.sendAndWait("\u001b", "来源记录:暂无资料"); + await pty.sendAndWait("q", "TERMINAL_RESTORED"); + const exitCode = await pty.closeAndWait(); + const terminalOutput = pty.output; + const errorOutput = pty.errors; + + expect(exitCode, `${errorOutput}\n${terminalOutput}`).toBe(0); + expect(terminalOutput).toContain("来源补充"); + expect(terminalOutput).toContain("正在补充来源,请稍候"); + expect(terminalOutput).not.toContain("enrichment-1"); + expect(terminalOutput).toContain("nbinet"); + expect(terminalOutput).toContain("isbn 9789864738281"); + expect(terminalOutput).toContain("nbinet · isbn 9789864738281 · 未完成"); + expect(terminalOutput).toContain("来源补充 · 未完成"); + expect(terminalOutput).toContain("可按 r 再次获取当前失败项"); + expect(terminalOutput).not.toContain("DLsite official source request failed"); + expect(terminalOutput).not.toContain("ordinary-request"); + expect(terminalOutput).not.toContain( + "https://www.dlsite.com/maniax/product/info/ajax", + ); + expect(terminalOutput).toContain("受控来源当前没有可用资料"); + expect(terminalOutput).toContain("已关联:nbinet"); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + expect(requests).toContainEqual({ + method: "POST", + path: "/api/v1/source-enrichments", + body: { + subjectId: "asset-id-0", + sourceIdentity: "nbinet", + sourceInput: { isbn: "9789864738281" }, + }, + }); + expect(requests).toContainEqual({ + method: "POST", + path: "/api/v1/source-enrichment-retries", + body: { + executionId: "enrichment-1", + subjectId: "asset-id-0", + sourceIdentity: "nbinet", + }, + }); +}, 15_000); + +it("安装入口可检查待处理变更的影响并明确确认", async () => { + const requests: Array<{ method: string; path: string; body?: unknown }> = []; + let confirmed = false; + const preview = (status: "awaiting-confirmation" | "applied") => ({ + previewId: "preview-1", + executionId: "execution-1", + assetId: "asset-1", + status, + current: { + recognitionState: "recognized", + recognitionDecisionId: "decision-1", + sourceAssociations: [ + { sourceIdentity: "tmdb", nativeAddress: "https://movie/1" }, + ], + }, + projected: { + recognitionState: "unresolved", + sourceAssociations: [], + }, + evidenceConflicts: ["撤回后该内容等待重新识别"], + blockingConditions: [], + }); + const server = createServer((request, response) => { + void (async () => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = + chunks.length === 0 + ? undefined + : (JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown); + const url = new URL(request.url ?? "/", "http://localhost"); + requests.push({ + method: request.method ?? "GET", + path: `${url.pathname}${url.search}`, + ...(body === undefined ? {} : { body }), + }); + if (url.pathname === "/api/v1/asset-libraries") + return json(response, { items: [] }); + if (url.pathname === "/api/v1/attention") + return json(response, { + items: confirmed + ? [] + : [ + { + id: "review-1", + kind: "change-impact-review", + executionId: "execution-1", + assetId: "asset-1", + summary: "撤回来源识别后需要检查影响", + changeImpactPreviewId: "preview-1", + }, + ], + }); + if ( + request.method === "GET" && + url.pathname === "/api/v1/change-impact-previews/preview-1" + ) + return json(response, preview("awaiting-confirmation")); + if ( + request.method === "POST" && + url.pathname === "/api/v1/change-impact-previews/preview-1/confirm" + ) { + confirmed = true; + return json(response, preview("applied")); + } + return json( + response, + { error: { code: "not-found", message: "没有此页面" } }, + 404, + ); + })().catch((cause: unknown) => { + json( + response, + { + error: { + code: "internal", + message: cause instanceof Error ? cause.message : "响应失败", + }, + }, + 500, + ); + }); + }); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + const terminalCommand = `stty cols 64 rows 16 -icanon -echo; terminalModeBefore=$(stty -g); ./dist/cli.js --url http://127.0.0.1:${address.port}; terminalModeAfter=$(stty -g); if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi`; + const pty = startInteractivePty(terminalCommand, 64, 16, 15_000); + await pty.waitForOutput("数字资料库"); + await pty.sendAndWait("5", "撤回来源识别后需要检查影响"); + await pty.sendAndWait("v", "变更影响 · 等待确认"); + await pty.sendAndWait("a", "变更已确认"); + await pty.sendAndWait("\u001b", "目前没有待处理事项"); + await pty.sendAndWait("q", "TERMINAL_RESTORED"); + const exitCode = await pty.closeAndWait(); + const terminalOutput = pty.output; + const errorOutput = pty.errors; + + expect(exitCode, `${errorOutput}\n${terminalOutput}`).toBe(0); + expect(terminalOutput).toContain("变更影响"); + expect(terminalOutput).toContain("已识别 → 待识别"); + expect(terminalOutput).toContain("撤回后该内容等待重新识别"); + expect(terminalOutput).toContain("变更已确认"); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + expect(confirmed).toBe(true); + expect(requests).toContainEqual({ + method: "POST", + path: "/api/v1/change-impact-previews/preview-1/confirm", + body: { confirmedBy: "tui-user" }, + }); +}, 15_000); + +it("显示服务端读取错误并可重新读取", async () => { + let libraryRequests = 0; + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://localhost"); + if (url.pathname === "/api/v1/asset-libraries") { + libraryRequests += 1; + if (libraryRequests === 1) + return json( + response, + { + error: { + code: "unavailable", + message: "资产库暂时不可用", + }, + }, + 503, + ); + return json(response, { + items: [ + { + id: "library-1", + displayName: "恢复后的资料库", + configVersion: 1, + directories: [], + recognitionProfileIds: [], + sourceIdentities: [], + }, + ], + }); + } + return json( + response, + { error: { code: "not-found", message: "没有此页面" } }, + 404, + ); + }); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + const terminalCommand = `stty cols 48 rows 12 -icanon -echo; terminalModeBefore=$(stty -g); ./dist/cli.js --url http://127.0.0.1:${address.port}; terminalModeAfter=$(stty -g); if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi`; + const pty = startInteractivePty(terminalCommand, 48, 12, 15_000); + await pty.waitForOutput("资产库暂时不可用"); + await pty.sendAndWait("r", "恢复后的资料库"); + await pty.sendAndWait("q", "TERMINAL_RESTORED"); + const exitCode = await pty.closeAndWait(); + const terminalOutput = pty.output; + const errorOutput = pty.errors; + + expect(exitCode, `${errorOutput}\n${terminalOutput}`).toBe(0); + expect(terminalOutput).toContain("资产库暂时不可用"); + expect(terminalOutput).toContain( + `${String.fromCharCode(27)}[31m资产库暂时不可用`, + ); + expect(terminalOutput).toContain("恢复后的资料库"); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + expect(libraryRequests).toBe(2); +}, 15_000); + +it("安装入口可跟踪三种操作、请求取消并展示同一执行的最终来源结果", async () => { + const requests: Array<{ method: string; path: string; body?: unknown }> = []; + let rescrapeCancelRequested = false; + const server = createServer((request, response) => { + void (async () => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = + chunks.length === 0 + ? undefined + : (JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown); + const url = new URL(request.url ?? "/", "http://localhost"); + requests.push({ + method: request.method ?? "GET", + path: url.pathname, + ...(body === undefined ? {} : { body }), + }); + + if (url.pathname === "/api/v1/asset-libraries") + return json(response, { + items: [ + { + id: "library-1", + displayName: "执行流程资料库", + configVersion: 1, + directories: [], + recognitionProfileIds: [], + sourceIdentities: [], + }, + ], + }); + if (url.pathname === "/api/v1/assets") + return json(response, { items: [asset("受控作品", 1)] }); + if (request.method === "POST" && url.pathname.endsWith("/scans")) + return json(response, { parentExecutionId: "execution-scan" }, 202); + if (request.method === "POST" && url.pathname === "/api/v1/refreshes") { + const operation = (body as { operation: string }).operation; + return json( + response, + { + id: + operation === "rescrape" + ? "execution-rescrape" + : "execution-metadata", + }, + 202, + ); + } + if ( + request.method === "POST" && + url.pathname === "/api/v1/executions/execution-rescrape/cancel" + ) { + rescrapeCancelRequested = true; + return json(response, { + requestStatus: "requested", + inspection: execution("execution-rescrape", { + operationKind: "rescrape", + status: "running", + cancellation: { + requestedBy: "public-api", + requestedAt: "2026-10-16T00:00:00.000Z", + }, + }), + }); + } + if (url.pathname === "/api/v1/executions/execution-scan") + return json(response, { + kind: "asset-library-scan", + id: "execution-scan", + status: "completed", + outcome: "no-change", + children: [], + }); + if (url.pathname === "/api/v1/asset-library-scans/execution-scan") + return json(response, { + parentExecutionId: "execution-scan", + assetLibraryId: "library-1", + configVersion: 1, + status: "completed", + outcome: "no-change", + directories: [], + }); + if (url.pathname === "/api/v1/executions/execution-rescrape") + return json( + response, + rescrapeCancelRequested + ? execution("execution-rescrape", { + operationKind: "rescrape", + status: "failed", + outcome: "cancelled", + cancellation: { + requestedBy: "public-api", + requestedAt: "2026-10-16T00:00:00.000Z", + }, + }) + : execution("execution-rescrape", { + operationKind: "rescrape", + status: "running", + stage: "recognition", + }), + ); + if (url.pathname === "/api/v1/executions/execution-metadata") + return json( + response, + execution("execution-metadata", { + operationKind: "refresh-metadata", + status: "completed", + outcome: "accepted", + sourceRefreshRuns: [sourceRefreshRun()], + }), + ); + return json( + response, + { error: { code: "not-found", message: "没有此页面" } }, + 404, + ); + })().catch((error: unknown) => { + if (!response.destroyed) response.destroy(error as Error); + }); + }); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + const terminalCommand = `stty cols 58 rows 16 -icanon -echo; terminalModeBefore=$(stty -g); ./dist/cli.js --url http://127.0.0.1:${address.port}; terminalModeAfter=$(stty -g); if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi`; + const pty = startInteractivePty(terminalCommand, 58, 16, 15_000); + await pty.waitForOutput("执行流程资料库"); + await pty.sendAndWait("s", "execution-scan"); + await pty.sendAndWait("l", "执行流程资料库"); + await pty.sendAndWait("2", "受控作品"); + await pty.sendAndWait("x", "execution-rescrape"); + await pty.sendAndWait("c", ["取消请求已提交", "刷新已取消"]); + await pty.sendAndWait("l", "执行流程资料库"); + await pty.sendAndWait("2", "受控作品"); + await pty.sendAndWait("m", ["execution-metadata", "刷新已完成:已识别"]); + await pty.sendAndWait("q", "TERMINAL_RESTORED"); + const exitCode = await pty.closeAndWait(); + const terminalOutput = pty.output; + const errorOutput = pty.errors; + + expect(exitCode, `${errorOutput}\n${terminalOutput}`).toBe(0); + expect(terminalOutput).toContain("execution-scan"); + expect(terminalOutput).toContain("execution-rescrape"); + expect(terminalOutput).toContain("execution-metadata"); + expect(terminalOutput).toContain("取消请求已提交"); + expect(terminalOutput).toContain("刷新已取消"); + expect(terminalOutput).toContain("刷新已完成:已识别"); + expect(terminalOutput).not.toContain("accepted"); + expect(terminalOutput).not.toContain("状态:失败 /"); + expect(terminalOutput).toContain("change-set-1"); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + expect( + requests + .filter( + ({ method, path }) => method === "POST" && path === "/api/v1/refreshes", + ) + .map(({ body }) => body), + ).toEqual([ + { operation: "rescrape", subjectId: "asset-id-1" }, + { operation: "metadata-refresh", subjectId: "asset-id-1" }, + ]); + expect( + requests.filter( + ({ method, path }) => + method === "POST" && + path === "/api/v1/executions/execution-rescrape/cancel", + ), + ).toHaveLength(1); + expect( + requests.filter( + ({ method, path }) => + method === "POST" && path === "/api/v1/asset-libraries/library-1/scans", + ), + ).toHaveLength(1); +}, 15_000); + +it("读取连接中断后保留原执行身份并自动恢复查询,不重提刷新", async () => { + let executionReads = 0; + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://localhost"); + if (url.pathname === "/api/v1/executions/execution-resume") { + executionReads += 1; + if (executionReads === 1) { + response.socket?.resetAndDestroy(); + return; + } + return json( + response, + execution("execution-resume", { + operationKind: "refresh-metadata", + status: "completed", + outcome: "accepted", + sourceRefreshRuns: [sourceRefreshRun()], + }), + ); + } + return json( + response, + { error: { code: "not-found", message: "没有此页面" } }, + 404, + ); + }); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + const terminalCommand = `stty cols 58 rows 16 -icanon -echo; terminalModeBefore=$(stty -g); ./dist/cli.js --url http://127.0.0.1:${address.port} --execution execution-resume; terminalModeAfter=$(stty -g); if [ "$terminalModeBefore" = "$terminalModeAfter" ]; then echo TERMINAL_RESTORED; else echo TERMINAL_NOT_RESTORED; fi`; + const pty = startInteractivePty(terminalCommand, 58, 16, 15_000); + await pty.waitForOutput("change-set-1"); + await pty.sendAndWait("q", "TERMINAL_RESTORED"); + const exitCode = await pty.closeAndWait(); + const terminalOutput = pty.output; + const errorOutput = pty.errors; + + expect(exitCode, `${errorOutput}\n${terminalOutput}`).toBe(0); + expect(terminalOutput).toContain("execution-resume"); + expect(terminalOutput).toContain("执行结局仍未知"); + expect(terminalOutput).toContain("change-set-1"); + expect(terminalOutput).toContain("TERMINAL_RESTORED"); + expect(executionReads).toBeGreaterThanOrEqual(2); +}); + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function servePublicPage( + url: URL, + response: ServerResponse, +): void { + if (url.pathname === "/api/v1/asset-libraries") + return json( + response, + url.searchParams.get("cursor") === "library-page-2" + ? { + items: [ + { + id: "library-2", + displayName: "第二页资料库", + configVersion: 1, + directories: [ + { + id: "root-2", + displayName: "书架二", + path: "/ordinary/library-2", + status: "active", + reachable: true, + }, + ], + recognitionProfileIds: [], + sourceIdentities: [], + }, + ], + } + : { + items: [ + { + id: "library-1", + displayName: "本地资料库", + configVersion: 1, + directories: [ + { + id: "root-1", + displayName: "书架", + path: "/ordinary/library-1", + status: "active", + reachable: true, + }, + ], + recognitionProfileIds: [], + sourceIdentities: [], + }, + ], + nextCursor: "library-page-2", + }, + ); + if (url.pathname === "/api/v1/assets") { + const next = url.searchParams.get("cursor") === "page-2"; + return json(response, { + items: next + ? Array.from({ length: 20 }, (_, index) => + asset(`第二页资产${String(index + 1).padStart(2, "0")}`, index), + ) + : [asset("第一页资产", 0)], + ...(!next ? { nextCursor: "page-2" } : {}), + }); + } + if (url.pathname === "/api/v1/unrecognized-materials") + return json(response, { + items: [ + { + kind: "work-item", + parentExecutionId: "parent-1", + directoryKey: "书架", + childExecutionId: "child-1", + workItemKey: "item-1", + observedRootId: "root-1", + relativeScope: ".", + relativePaths: ["未识别样本.pdf"], + }, + ], + }); + return json( + response, + { error: { code: "not-found", message: "没有此页面" } }, + 404, + ); +} + +function asset(title: string, index: number) { + const id = `asset-id-${index}`; + return { + id, + sourceAssociations: [], + descriptions: [ + { + target: { kind: "entity", entityId: id }, + metadataUsageContext: "public", + metadata: [ + { + property: "asset.title", + result: { + status: "resolved", + values: [ + { + value: title, + sourceIdentity: "fixture", + sourceSlot: "asset.title", + sourceRecordIds: [], + reasoning: {}, + assertions: [], + }, + ], + }, + }, + ], + metadataScope: {}, + artifactRoles: [], + missingArtifactRoles: [], + conflicts: [], + }, + ], + relatedEntities: [], + relationships: [], + locations: [], + }; +} + +function execution( + id: string, + options: { + readonly operationKind?: "rescrape" | "refresh-metadata"; + readonly status?: "running" | "completed" | "failed"; + readonly stage?: "recognition" | "completed" | "failed"; + readonly outcome?: "accepted" | "cancelled"; + readonly cancellation?: { + readonly requestedBy: string; + readonly requestedAt: string; + }; + readonly sourceRefreshRuns?: readonly unknown[]; + } = {}, +) { + const status = options.status ?? "completed"; + return { + kind: "execution", + id, + operationKind: options.operationKind ?? "rescrape", + status, + stage: + options.stage ?? (status === "running" ? "recognition" : "completed"), + ...(options.outcome === undefined ? {} : { outcome: options.outcome }), + ...(options.cancellation === undefined + ? {} + : { cancellation: options.cancellation }), + progress: { dispositions: [], workItems: [], targets: [] }, + sourceRefreshRuns: options.sourceRefreshRuns ?? [], + }; +} + +function sourceRefreshRun() { + const coverage = { + sourceIdentity: "source-fixture", + nativeAddress: "https://source.invalid/item/1", + coverageId: "coverage-1", + }; + return { + id: "source-run-1", + initiatedBy: "public-api", + initiatedAt: "2026-10-16T00:00:00.000Z", + requestedSelection: { + subjectId: "asset-id-1", + sourceAssociationId: "association-1", + sourceIdentity: "source-fixture", + nativeAddress: "https://source.invalid/item/1", + selectors: [{ kind: "metadata-property", property: "asset.title" }], + }, + reviewPolicy: "automatic", + phase: "terminal", + outcome: "succeeded", + scopes: [ + { + coverage, + state: "terminal", + acquisitionAttempt: 1, + changeSetId: "change-set-1", + candidateSourceRecordId: "source-record-1", + outcome: "applied", + }, + ], + changeSets: [ + { + id: "change-set-1", + runId: "source-run-1", + coverage, + candidateSourceRecordId: "source-record-1", + changes: [{ kind: "established", factReference: "fact-1" }], + requiresReview: false, + disposition: "automatic", + }, + ], + }; +} + +function json( + response: ServerResponse, + body: unknown, + status = 200, +): void { + response.writeHead(status, { "content-type": "application/json" }); + response.end(JSON.stringify(body)); +} diff --git a/apps/tui/tsconfig.build.json b/apps/tui/tsconfig.build.json new file mode 100644 index 0000000..c4b04cf --- /dev/null +++ b/apps/tui/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "customConditions": [], + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src/**/*.tsx", "src/**/*.ts"] +} diff --git a/apps/tui/tsconfig.json b/apps/tui/tsconfig.json new file mode 100644 index 0000000..6203046 --- /dev/null +++ b/apps/tui/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@digital/typescript-config/base.json", + "compilerOptions": { + "customConditions": ["digital-source"], + "erasableSyntaxOnly": true, + "jsx": "react-jsx", + "module": "nodenext", + "moduleResolution": "nodenext", + "rewriteRelativeImportExtensions": true, + "skipLibCheck": true, + "target": "es2023", + "types": ["node"] + }, + "include": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"] +} diff --git a/apps/tui/turbo.json b/apps/tui/turbo.json new file mode 100644 index 0000000..c6f34ec --- /dev/null +++ b/apps/tui/turbo.json @@ -0,0 +1,29 @@ +{ + "extends": ["//"], + "tags": ["client"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "tags": ["quality:light"] + }, + "format:check": { "outputs": [], "tags": ["quality:light"] }, + "format:write": { + "dependsOn": ["lint:fix"], + "cache": false, + "tags": ["work:repair"] + }, + "lint": { "outputs": [], "tags": ["quality:light"] }, + "lint:fix": { "cache": false, "tags": ["work:repair"] }, + "test": { + "dependsOn": ["build"], + "outputs": [], + "tags": ["quality:light"] + }, + "typecheck": { + "dependsOn": ["^typecheck"], + "outputs": [], + "tags": ["quality:light"] + } + } +} diff --git a/apps/web/+server.ts b/apps/web/+server.ts deleted file mode 100644 index 1d67d67..0000000 --- a/apps/web/+server.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { resolveSourceIoFromEnvironment } from "@digital/test-support/source-io"; -import type { Server } from "vike/types"; - -import type { WebAppBootstrap } from "#/server/app"; -import { - resolveReadyIdentity, - type ReadyIdentity, -} from "#/server/environment-ready-identity"; -// 只为了在任何启动步骤之前接管信号:环境的停止只发出一次 SIGTERM,收尾必须走产品 -// 既有生命周期,而不是 Node 的默认终止。 -import "#/server/process-lifecycle"; -import { claimWebProcessTemporaryRoot } from "#/server/process-temporary-root"; - -/** 后端更新由 Turbo 重启进程;同一开发进程的 SSR 求值共用产品所有者。 */ -async function bootstrapServer(): Promise { - const readyIdentityResolution = resolveReadyIdentity(); - const readyIdentity: ReadyIdentity | undefined = - "identity" in readyIdentityResolution - ? readyIdentityResolution.identity - : undefined; - - // 来源装配选择在任何产品准入之前解析:显式受控而清单缺失或无效时进程直接失败, - // 不带着真实默认值继续,也永不把受控启动降级。 - const sourceIo = await resolveSourceIoFromEnvironment(process.env); - - const processTemporaryRootClaim = await claimWebProcessTemporaryRoot(); - let app; - let bootstrap: WebAppBootstrap | undefined; - let shouldRetainProcessTemporaryRoot: - | ((error: unknown) => boolean) - | undefined; - let combineAbortFailure: - | ((bootstrapError: unknown, abortError: unknown) => unknown) - | undefined; - try { - const { - combineWebBootstrapAbortFailure, - createWebServer, - shouldRetainWebProcessTemporaryRoot, - } = await import("#/server/app"); - shouldRetainProcessTemporaryRoot = shouldRetainWebProcessTemporaryRoot; - combineAbortFailure = combineWebBootstrapAbortFailure; - processTemporaryRootClaim.beginInitialization(); - if ("problem" in readyIdentityResolution) - // 身份无法建立时如实报告,服务照常提供,但环境的认证探测会看到缺少证明。 - console.error(readyIdentityResolution.problem); - bootstrap = await createWebServer( - { - productDataDirectory: process.env.DIGITAL_PRODUCT_DATA, - environment: process.env, - sourceIo, - }, - readyIdentity === undefined ? {} : { readyIdentity }, - ); - bootstrap.publish(); - processTemporaryRootClaim.commit(); - app = bootstrap.app; - } catch (caught) { - let error = caught; - if (bootstrap !== undefined) { - try { - await bootstrap.abort(); - } catch (abortError) { - error = combineAbortFailure?.(error, abortError) ?? abortError; - } - } - if (shouldRetainProcessTemporaryRoot?.(error)) - processTemporaryRootClaim.retain(); - else await processTemporaryRootClaim.rollback().catch(() => undefined); - throw error; - } - - const port = Number.parseInt(process.env.PORT ?? "3000", 10); - - return { - fetch: app.fetch, - prod: { - port, - }, - } satisfies Server; -} - -const developmentBootstrap = Symbol.for("digital.web.development-bootstrap"); -const processGlobal = globalThis as typeof globalThis & { - [developmentBootstrap]?: Promise; -}; - -export default await (process.env.DIGITAL_DEVELOPMENT === "1" - ? (processGlobal[developmentBootstrap] ??= bootstrapServer()) - : bootstrapServer()); diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index e665b7e..0000000 --- a/apps/web/.env.example +++ /dev/null @@ -1 +0,0 @@ -DATABASE_FILE=./data/app.sqlite diff --git a/apps/web/Dockerfile.playwright-client b/apps/web/Dockerfile.playwright-client deleted file mode 100644 index 9b2543e..0000000 --- a/apps/web/Dockerfile.playwright-client +++ /dev/null @@ -1,11 +0,0 @@ -FROM node:24-bookworm-slim -ARG PACKAGE_MANAGER_PIN="pnpm@12.3.4" -RUN corepack enable \ - && corepack prepare "$PACKAGE_MANAGER_PIN" --activate \ - && test "$(pnpm --version)" = "12.3.4" -RUN apt-get update && apt-get install -y --no-install-recommends \ - libnspr4 libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ - libdbus-1-3 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 \ - libxrandr2 libgbm1 libasound2 libpangocairo-1.0-0 libcairo2 \ - libpango-1.0-0 libatspi2.0-0 libgtk-3-0 \ - && rm -rf /var/lib/apt/lists/* diff --git a/apps/web/assets/logo.svg b/apps/web/assets/logo.svg deleted file mode 100644 index a1a1b2c..0000000 --- a/apps/web/assets/logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/apps/web/components.json b/apps/web/components.json deleted file mode 100644 index 464f25f..0000000 --- a/apps/web/components.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "$schema": "https://shadcn-vue.com/schema.json", - "style": "new-york", - "typescript": true, - "tailwind": { - "config": "", - "css": "pages/tailwind.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "#/components", - "composables": "#/composables", - "utils": "#/lib/utils", - "ui": "#/components/ui", - "lib": "#/lib" - }, - "iconLibrary": "lucide", - "pointer": false, - "rtl": false -} diff --git a/apps/web/components/CounterButton.vue b/apps/web/components/CounterButton.vue deleted file mode 100644 index 0ccbb66..0000000 --- a/apps/web/components/CounterButton.vue +++ /dev/null @@ -1,18 +0,0 @@ - - - diff --git a/apps/web/components/PageShell.vue b/apps/web/components/PageShell.vue deleted file mode 100644 index 9747e23..0000000 --- a/apps/web/components/PageShell.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/apps/web/components/formal/AppShell.vue b/apps/web/components/formal/AppShell.vue deleted file mode 100644 index 56543d0..0000000 --- a/apps/web/components/formal/AppShell.vue +++ /dev/null @@ -1,73 +0,0 @@ - - - - - diff --git a/apps/web/components/formal/AppSidebar.vue b/apps/web/components/formal/AppSidebar.vue deleted file mode 100644 index 8d3a670..0000000 --- a/apps/web/components/formal/AppSidebar.vue +++ /dev/null @@ -1,171 +0,0 @@ - - - diff --git a/apps/web/components/formal/AssetActions.vue b/apps/web/components/formal/AssetActions.vue deleted file mode 100644 index 1cd397b..0000000 --- a/apps/web/components/formal/AssetActions.vue +++ /dev/null @@ -1,78 +0,0 @@ - - - diff --git a/apps/web/components/formal/AssetCard.vue b/apps/web/components/formal/AssetCard.vue deleted file mode 100644 index ace5b11..0000000 --- a/apps/web/components/formal/AssetCard.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/apps/web/components/formal/AssetCover.vue b/apps/web/components/formal/AssetCover.vue deleted file mode 100644 index dc2ea05..0000000 --- a/apps/web/components/formal/AssetCover.vue +++ /dev/null @@ -1,31 +0,0 @@ - - - diff --git a/apps/web/components/formal/AssetCoverChoice.vue b/apps/web/components/formal/AssetCoverChoice.vue deleted file mode 100644 index d967614..0000000 --- a/apps/web/components/formal/AssetCoverChoice.vue +++ /dev/null @@ -1,171 +0,0 @@ - - - diff --git a/apps/web/components/formal/AssetDetail.vue b/apps/web/components/formal/AssetDetail.vue deleted file mode 100644 index 0d5e6ff..0000000 --- a/apps/web/components/formal/AssetDetail.vue +++ /dev/null @@ -1,99 +0,0 @@ - - - diff --git a/apps/web/components/formal/AssetFileTree.vue b/apps/web/components/formal/AssetFileTree.vue deleted file mode 100644 index 6b869ac..0000000 --- a/apps/web/components/formal/AssetFileTree.vue +++ /dev/null @@ -1,109 +0,0 @@ - - - diff --git a/apps/web/components/formal/AssetInfoNotice.vue b/apps/web/components/formal/AssetInfoNotice.vue deleted file mode 100644 index e8414ea..0000000 --- a/apps/web/components/formal/AssetInfoNotice.vue +++ /dev/null @@ -1,117 +0,0 @@ - - - diff --git a/apps/web/components/formal/AutomationSettingsDialog.vue b/apps/web/components/formal/AutomationSettingsDialog.vue deleted file mode 100644 index 299b07f..0000000 --- a/apps/web/components/formal/AutomationSettingsDialog.vue +++ /dev/null @@ -1,157 +0,0 @@ - - diff --git a/apps/web/components/formal/BangumiAssetCard.vue b/apps/web/components/formal/BangumiAssetCard.vue deleted file mode 100644 index 3ade7db..0000000 --- a/apps/web/components/formal/BangumiAssetCard.vue +++ /dev/null @@ -1,32 +0,0 @@ - - - diff --git a/apps/web/components/formal/BangumiAssetDetail.vue b/apps/web/components/formal/BangumiAssetDetail.vue deleted file mode 100644 index b8fa5eb..0000000 --- a/apps/web/components/formal/BangumiAssetDetail.vue +++ /dev/null @@ -1,172 +0,0 @@ - - - diff --git a/apps/web/components/formal/BangumiEpisodeDetail.vue b/apps/web/components/formal/BangumiEpisodeDetail.vue deleted file mode 100644 index 95e1067..0000000 --- a/apps/web/components/formal/BangumiEpisodeDetail.vue +++ /dev/null @@ -1,113 +0,0 @@ - - - diff --git a/apps/web/components/formal/BangumiEpisodeList.vue b/apps/web/components/formal/BangumiEpisodeList.vue deleted file mode 100644 index a82fcd3..0000000 --- a/apps/web/components/formal/BangumiEpisodeList.vue +++ /dev/null @@ -1,23 +0,0 @@ - - - diff --git a/apps/web/components/formal/BangumiEpisodeRow.vue b/apps/web/components/formal/BangumiEpisodeRow.vue deleted file mode 100644 index e72d106..0000000 --- a/apps/web/components/formal/BangumiEpisodeRow.vue +++ /dev/null @@ -1,55 +0,0 @@ - - - diff --git a/apps/web/components/formal/BangumiWorkNav.vue b/apps/web/components/formal/BangumiWorkNav.vue deleted file mode 100644 index f166a51..0000000 --- a/apps/web/components/formal/BangumiWorkNav.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/apps/web/components/formal/BookAssetCard.vue b/apps/web/components/formal/BookAssetCard.vue deleted file mode 100644 index 5f83028..0000000 --- a/apps/web/components/formal/BookAssetCard.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - diff --git a/apps/web/components/formal/BookAssetDetail.vue b/apps/web/components/formal/BookAssetDetail.vue deleted file mode 100644 index 49a0872..0000000 --- a/apps/web/components/formal/BookAssetDetail.vue +++ /dev/null @@ -1,206 +0,0 @@ - - - diff --git a/apps/web/components/formal/ContentEnrichmentPage.vue b/apps/web/components/formal/ContentEnrichmentPage.vue deleted file mode 100644 index f753805..0000000 --- a/apps/web/components/formal/ContentEnrichmentPage.vue +++ /dev/null @@ -1,1489 +0,0 @@ - - - diff --git a/apps/web/components/formal/DlsiteAssetCard.vue b/apps/web/components/formal/DlsiteAssetCard.vue deleted file mode 100644 index 8030821..0000000 --- a/apps/web/components/formal/DlsiteAssetCard.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/apps/web/components/formal/DlsiteAssetDetail.vue b/apps/web/components/formal/DlsiteAssetDetail.vue deleted file mode 100644 index 988a162..0000000 --- a/apps/web/components/formal/DlsiteAssetDetail.vue +++ /dev/null @@ -1,702 +0,0 @@ - - - diff --git a/apps/web/components/formal/DlsiteFileTree.vue b/apps/web/components/formal/DlsiteFileTree.vue deleted file mode 100644 index 022beaf..0000000 --- a/apps/web/components/formal/DlsiteFileTree.vue +++ /dev/null @@ -1,180 +0,0 @@ - - - diff --git a/apps/web/components/formal/ExecutionInspection.vue b/apps/web/components/formal/ExecutionInspection.vue deleted file mode 100644 index 7bfd11f..0000000 --- a/apps/web/components/formal/ExecutionInspection.vue +++ /dev/null @@ -1,154 +0,0 @@ - - - diff --git a/apps/web/components/formal/ExecutionList.vue b/apps/web/components/formal/ExecutionList.vue deleted file mode 100644 index 5b715ca..0000000 --- a/apps/web/components/formal/ExecutionList.vue +++ /dev/null @@ -1,85 +0,0 @@ - - - diff --git a/apps/web/components/formal/FanboxAssetCard.vue b/apps/web/components/formal/FanboxAssetCard.vue deleted file mode 100644 index 5f6032d..0000000 --- a/apps/web/components/formal/FanboxAssetCard.vue +++ /dev/null @@ -1,66 +0,0 @@ - - - diff --git a/apps/web/components/formal/FanboxAssetDetail.vue b/apps/web/components/formal/FanboxAssetDetail.vue deleted file mode 100644 index 40f421a..0000000 --- a/apps/web/components/formal/FanboxAssetDetail.vue +++ /dev/null @@ -1,182 +0,0 @@ - - - diff --git a/apps/web/components/formal/FilmAssetCard.vue b/apps/web/components/formal/FilmAssetCard.vue deleted file mode 100644 index 5d5d94e..0000000 --- a/apps/web/components/formal/FilmAssetCard.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - diff --git a/apps/web/components/formal/FilmAssetDetail.vue b/apps/web/components/formal/FilmAssetDetail.vue deleted file mode 100644 index 5652ee3..0000000 --- a/apps/web/components/formal/FilmAssetDetail.vue +++ /dev/null @@ -1,213 +0,0 @@ - - - diff --git a/apps/web/components/formal/GenericAssetCard.vue b/apps/web/components/formal/GenericAssetCard.vue deleted file mode 100644 index 3dd5ab2..0000000 --- a/apps/web/components/formal/GenericAssetCard.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/apps/web/components/formal/GenericAssetDetail.vue b/apps/web/components/formal/GenericAssetDetail.vue deleted file mode 100644 index ca1f746..0000000 --- a/apps/web/components/formal/GenericAssetDetail.vue +++ /dev/null @@ -1,131 +0,0 @@ - - - diff --git a/apps/web/components/formal/IntakeFileTree.vue b/apps/web/components/formal/IntakeFileTree.vue deleted file mode 100644 index 70a8e02..0000000 --- a/apps/web/components/formal/IntakeFileTree.vue +++ /dev/null @@ -1,152 +0,0 @@ - - - diff --git a/apps/web/components/formal/IntakePage.vue b/apps/web/components/formal/IntakePage.vue deleted file mode 100644 index 3ec6d07..0000000 --- a/apps/web/components/formal/IntakePage.vue +++ /dev/null @@ -1,446 +0,0 @@ - - - diff --git a/apps/web/components/formal/LibraryCover.vue b/apps/web/components/formal/LibraryCover.vue deleted file mode 100644 index 51b92aa..0000000 --- a/apps/web/components/formal/LibraryCover.vue +++ /dev/null @@ -1,45 +0,0 @@ - - - diff --git a/apps/web/components/formal/LibraryList.vue b/apps/web/components/formal/LibraryList.vue deleted file mode 100644 index a1352db..0000000 --- a/apps/web/components/formal/LibraryList.vue +++ /dev/null @@ -1,155 +0,0 @@ - - - diff --git a/apps/web/components/formal/LibraryMasonry.vue b/apps/web/components/formal/LibraryMasonry.vue deleted file mode 100644 index fa5cd11..0000000 --- a/apps/web/components/formal/LibraryMasonry.vue +++ /dev/null @@ -1,102 +0,0 @@ - - - diff --git a/apps/web/components/formal/LibraryPreview.vue b/apps/web/components/formal/LibraryPreview.vue deleted file mode 100644 index a568f21..0000000 --- a/apps/web/components/formal/LibraryPreview.vue +++ /dev/null @@ -1,52 +0,0 @@ - - - diff --git a/apps/web/components/formal/LibrarySettingsDialog.vue b/apps/web/components/formal/LibrarySettingsDialog.vue deleted file mode 100644 index 1333925..0000000 --- a/apps/web/components/formal/LibrarySettingsDialog.vue +++ /dev/null @@ -1,142 +0,0 @@ - -